From 942a9431ccd445a2fcb25082c834e4efcb65ec2b Mon Sep 17 00:00:00 2001 From: luozhou Date: Tue, 25 Aug 2026 19:52:50 +0800 Subject: [PATCH] refactor: publish current SpatialCF architecture --- README.md | 4 +- README_EN.md | 5 +- src/spatialcf/adapters/ai2thor.py | 4891 ----------------- src/spatialcf/adapters/ai2thor/__init__.py | 161 + src/spatialcf/adapters/ai2thor/adapter.py | 354 ++ src/spatialcf/adapters/ai2thor/camera.py | 873 +++ src/spatialcf/adapters/ai2thor/capture.py | 1095 ++++ src/spatialcf/adapters/ai2thor/conversion.py | 195 + src/spatialcf/adapters/ai2thor/execution.py | 1199 ++++ src/spatialcf/adapters/ai2thor/models.py | 1220 ++++ src/spatialcf/adapters/ai2thor/support.py | 1202 ++++ src/spatialcf/adapters/ai2thor/validation.py | 141 + src/spatialcf/adapters/ai2thor_execution.py | 680 --- src/spatialcf/adapters/ai2thor_validation.py | 647 --- src/spatialcf/adapters/base.py | 1447 ++++- src/spatialcf/adapters/canonical_v2_1.py | 40 - .../adapters/canonical_v2_1_binding.py | 44 - .../adapters/canonical_v2_1_models.py | 16 - .../adapters/canonical_v2_binding.py | 14 +- src/spatialcf/adapters/canonical_v2_models.py | 58 +- .../{solver => adapters}/execution.py | 24 +- src/spatialcf/adapters/in_memory.py | 483 ++ src/spatialcf/adapters/json_scene.py | 28 +- src/spatialcf/composition.py | 7 + src/spatialcf/core/__init__.py | 14 + .../core/_internal/compilation/__init__.py | 1 + .../compilation/candidate_cells.py} | 81 +- .../core/_internal/compilation/collision.py | 2505 +++++++++ .../core/_internal/compilation/support.py | 1973 +++++++ .../compilation/target.py} | 625 ++- .../compilation/visibility.py} | 933 +++- .../core/_internal/kernels/__init__.py | 1 + .../kernels/convex_partition.py} | 14 +- .../kernels/convex_translation.py} | 17 +- .../kernels/projected_visibility.py} | 0 .../kernels/rect.py} | 12 +- .../kernels/rectilinear.py} | 8 +- .../kernels/so2.py} | 0 .../kernels/strict_convex.py} | 14 +- .../kernels/upright_box.py} | 11 +- .../core/_internal/objective/__init__.py | 1 + .../objective/base.py} | 52 +- .../objective/numeric.py} | 12 +- .../objective/relation.py} | 120 +- .../objective/relation_damage.py} | 94 +- .../objective/relation_partition.py} | 144 +- .../objective/safety.py} | 130 +- .../objective/safety_bounds.py} | 108 +- .../objective/visibility.py} | 118 +- .../objective/visibility_objective.py} | 34 +- .../resources.py} | 0 src/spatialcf/core/candidate.py | 103 + ...yaw_certificate_v2_9.py => certificate.py} | 6 +- .../feasible.py => core/feasibility.py} | 114 +- ...ous_yaw_objective_v2_9.py => objective.py} | 74 +- ...tinuous_yaw_camera_frame.py => problem.py} | 50 +- ...ontinuous_yaw_solver_v2_9.py => solver.py} | 125 +- src/spatialcf/core/v2/__init__.py | 5 - src/spatialcf/core/v2/_internal/__init__.py | 1 - .../core/v2/_internal/boundary/__init__.py | 19 - .../core/v2/_internal/boundary/errors.py | 21 - .../core/v2/_internal/boundary/strict.py | 83 - .../v2/_internal/certification/__init__.py | 1 - .../_internal/certification/solve_replay.py | 236 - .../v2/_internal/orchestration/__init__.py | 1 - .../_internal/orchestration/capabilities.py | 186 - .../v2/_internal/orchestration/registry.py | 235 - .../core/v2/_internal/orchestration/solve.py | 142 - .../core/v2/_internal/orchestration/stages.py | 734 --- .../core/v2/_internal/resources/__init__.py | 1 - src/spatialcf/core/v2/artifact_verifier.py | 421 -- .../core/v2/camera_cardinal_rebase.py | 448 -- .../core/v2/camera_cardinal_result.py | 518 -- src/spatialcf/core/v2/camera_translation.py | 501 -- src/spatialcf/core/v2/cardinal_yaw.py | 463 -- src/spatialcf/core/v2/certificate_builder.py | 1752 ------ src/spatialcf/core/v2/collision_domain.py | 682 --- .../core/v2/continuous_yaw_certificate.py | 169 - .../core/v2/continuous_yaw_solve_verifier.py | 151 - .../core/v2/continuous_yaw_solver.py | 482 -- .../v2/continuous_yaw_support_projection.py | 753 --- src/spatialcf/core/v2/edit_feasibility.py | 611 -- src/spatialcf/core/v2/minimum_cost_solver.py | 793 --- .../core/v2/minimum_cost_solver_v2_1.py | 288 - .../core/v2/minimum_cost_solver_v2_2.py | 67 - .../core/v2/minimum_cost_solver_v2_3.py | 67 - .../core/v2/minimum_cost_solver_v2_4.py | 44 - ...obstacle_strict_convex_candidate_domain.py | 953 ---- src/spatialcf/core/v2/objective_partition.py | 1058 ---- src/spatialcf/core/v2/point_objective.py | 1333 ----- src/spatialcf/core/v2/solve_verifier.py | 189 - src/spatialcf/core/v2/solve_verifier_v2_1.py | 68 - src/spatialcf/core/v2/solve_verifier_v2_2.py | 68 - src/spatialcf/core/v2/solve_verifier_v2_3.py | 68 - src/spatialcf/core/v2/solve_verifier_v2_4.py | 68 - .../core/v2/strict_convex_candidate_domain.py | 858 --- src/spatialcf/core/v2/support_domain.py | 526 -- .../support_strict_convex_candidate_domain.py | 686 --- .../core/v2/target_relation_domain.py | 530 -- src/spatialcf/core/v2/visibility_domain.py | 816 --- src/spatialcf/core/v2/zero_distortion.py | 252 - ...solve_verifier_v2_9.py => verification.py} | 37 +- src/spatialcf/domain/{v2 => }/artifacts.py | 310 +- src/spatialcf/domain/{v2 => }/base.py | 36 +- src/spatialcf/domain/{v2 => }/certificate.py | 16 +- src/spatialcf/domain/{v2 => }/constraints.py | 276 +- src/spatialcf/domain/{v2 => }/edit.py | 18 +- src/spatialcf/domain/{v2 => }/evidence.py | 186 +- src/spatialcf/domain/{v2 => }/geometry.py | 63 +- src/spatialcf/domain/models.py | 234 - src/spatialcf/domain/{v2 => }/objective.py | 38 +- src/spatialcf/domain/{v2 => }/problem.py | 182 +- src/spatialcf/domain/{enums.py => request.py} | 26 + src/spatialcf/domain/{v2 => }/result.py | 26 +- src/spatialcf/domain/{v2 => }/scene.py | 310 +- .../domain/{v2 => }/serialization.py | 10 +- ...ontinuous_yaw_solver_v2_9.py => solver.py} | 602 +- src/spatialcf/domain/source.py | 147 + src/spatialcf/domain/v2/__init__.py | 6 - src/spatialcf/domain/v2/cardinal.py | 274 - src/spatialcf/domain/v2/continuous_yaw.py | 44 - .../domain/v2/continuous_yaw_camera.py | 87 - .../domain/v2/continuous_yaw_candidate.py | 293 - .../domain/v2/continuous_yaw_solver.py | 580 -- src/spatialcf/domain/v2/publication.py | 787 --- .../generation/_internal/evidence/__init__.py | 1 - .../generation/_internal/evidence/camera.py | 2023 ------- .../generation/_internal/evidence/surface.py | 521 -- .../_internal/execution/__init__.py | 27 - .../generation/_internal/execution/audit.py | 974 ---- .../generation/_internal/planning/__init__.py | 33 - .../generation/_internal/source_manifest.py | 168 +- .../_internal/source_observation.py | 1396 ----- src/spatialcf/generation/capture/__init__.py | 14 +- src/spatialcf/generation/capture/compiler.py | 92 +- src/spatialcf/generation/capture/models.py | 2605 ++++++++- src/spatialcf/generation/capture/plan.py | 47 +- .../evidence => capture}/reachability.py | 144 +- src/spatialcf/generation/capture/source.py | 256 +- src/spatialcf/generation/capture/storage.py | 26 +- src/spatialcf/generation/dataset.py | 1393 +---- .../{execution.py => execution/__init__.py} | 64 +- .../execution/run.py => execution/audit.py} | 1257 ++++- src/spatialcf/generation/execution/batch.py | 36 + .../{_internal => }/execution/campaign.py | 34 +- .../execution/correspondence.py | 86 +- .../{planning.py => planning/__init__.py} | 29 +- .../{_internal => }/planning/campaign.py | 151 +- .../{_internal => }/planning/endpoint.py | 197 +- .../{_internal => }/planning/models.py | 24 +- .../planning/proxy.py => planning/problem.py} | 96 +- .../__init__.py} | 2 +- .../{_internal => publication}/assets.py | 254 +- .../generation/workflows/__init__.py | 3 + src/spatialcf/generation/workflows/capture.py | 1231 +++++ .../generation/workflows/contracts.py | 243 + src/spatialcf/generation/workflows/dataset.py | 1568 ++++++ .../batch.py => workflows/execution.py} | 69 +- src/spatialcf/geometry/obb.py | 16 +- src/spatialcf/geometry/regions.py | 18 +- src/spatialcf/geometry/transforms.py | 6 +- src/spatialcf/relations/engine.py | 24 +- src/spatialcf/solver/__init__.py | 54 - src/spatialcf/solver/analytic_motion.py | 204 - src/spatialcf/solver/certified_constraints.py | 1043 ---- src/spatialcf/solver/certified_geometry.py | 139 - src/spatialcf/solver/certified_models.py | 360 -- src/spatialcf/solver/challenge_validation.py | 1054 ---- src/spatialcf/solver/continuous.py | 1297 ----- src/spatialcf/solver/objective.py | 213 - src/spatialcf/solver/search.py | 901 --- src/spatialcf/solver/stress/__init__.py | 35 - src/spatialcf/solver/stress/cases.py | 265 - src/spatialcf/solver/stress/families_depth.py | 524 -- .../solver/stress/families_distance.py | 424 -- .../solver/stress/families_horizontal.py | 520 -- src/spatialcf/solver/stress/models.py | 215 - src/spatialcf/solver/stress/oracles.py | 1796 ------ src/spatialcf/solver/stress/profiles.py | 109 - src/spatialcf/solver/stress/sampling.py | 32 - src/spatialcf/solver/stress/scene_factory.py | 470 -- src/spatialcf/solver/stress/transforms.py | 235 - src/spatialcf/solver/validation.py | 546 -- src/spatialcf/verification/__init__.py | 19 +- src/spatialcf/verification/artifacts.py | 182 + src/spatialcf/verification/dataset.py | 3650 ++++++++++++ src/spatialcf/verification/integrity.py | 403 ++ src/spatialcf/verification/manual_review.py | 515 ++ src/spatialcf/verification/profile.py | 45 + src/spatialcf/verification/provenance.py | 320 ++ src/spatialcf/verification/split.py | 65 + src/spatialcf/verification/verifier.py | 59 +- tests/public_smoke/_fake_runtime.py | 12 +- tests/public_smoke/test_readme.py | 4 +- 194 files changed, 29611 insertions(+), 45485 deletions(-) delete mode 100644 src/spatialcf/adapters/ai2thor.py create mode 100644 src/spatialcf/adapters/ai2thor/__init__.py create mode 100644 src/spatialcf/adapters/ai2thor/adapter.py create mode 100644 src/spatialcf/adapters/ai2thor/camera.py create mode 100644 src/spatialcf/adapters/ai2thor/capture.py create mode 100644 src/spatialcf/adapters/ai2thor/conversion.py create mode 100644 src/spatialcf/adapters/ai2thor/execution.py create mode 100644 src/spatialcf/adapters/ai2thor/models.py create mode 100644 src/spatialcf/adapters/ai2thor/support.py create mode 100644 src/spatialcf/adapters/ai2thor/validation.py delete mode 100644 src/spatialcf/adapters/ai2thor_execution.py delete mode 100644 src/spatialcf/adapters/ai2thor_validation.py delete mode 100644 src/spatialcf/adapters/canonical_v2_1.py delete mode 100644 src/spatialcf/adapters/canonical_v2_1_binding.py delete mode 100644 src/spatialcf/adapters/canonical_v2_1_models.py rename src/spatialcf/{solver => adapters}/execution.py (90%) create mode 100644 src/spatialcf/adapters/in_memory.py create mode 100644 src/spatialcf/composition.py create mode 100644 src/spatialcf/core/_internal/compilation/__init__.py rename src/spatialcf/core/{v2/candidate_domain.py => _internal/compilation/candidate_cells.py} (97%) create mode 100644 src/spatialcf/core/_internal/compilation/collision.py create mode 100644 src/spatialcf/core/_internal/compilation/support.py rename src/spatialcf/core/{v2/continuous_yaw_target_relation.py => _internal/compilation/target.py} (61%) rename src/spatialcf/core/{v2/continuous_yaw_visibility.py => _internal/compilation/visibility.py} (50%) create mode 100644 src/spatialcf/core/_internal/kernels/__init__.py rename src/spatialcf/core/{v2/convex_translation_partition.py => _internal/kernels/convex_partition.py} (98%) rename src/spatialcf/core/{v2/convex_translation_domain.py => _internal/kernels/convex_translation.py} (99%) rename src/spatialcf/core/{v2/projected_bounding_box_visibility.py => _internal/kernels/projected_visibility.py} (100%) rename src/spatialcf/core/{v2/rect_kernel.py => _internal/kernels/rect.py} (98%) rename src/spatialcf/core/{v2/rectilinear_kernel.py => _internal/kernels/rectilinear.py} (99%) rename src/spatialcf/core/{v2/so2_interval.py => _internal/kernels/so2.py} (100%) rename src/spatialcf/core/{v2/strict_convex_intersection.py => _internal/kernels/strict_convex.py} (99%) rename src/spatialcf/core/{v2/oriented_upright_box.py => _internal/kernels/upright_box.py} (98%) create mode 100644 src/spatialcf/core/_internal/objective/__init__.py rename src/spatialcf/core/{v2/continuous_yaw_objective.py => _internal/objective/base.py} (96%) rename src/spatialcf/core/{v2/objective_numeric.py => _internal/objective/numeric.py} (99%) rename src/spatialcf/core/{v2/continuous_yaw_directional_relation.py => _internal/objective/relation.py} (92%) rename src/spatialcf/core/{v2/continuous_yaw_relation_damage.py => _internal/objective/relation_damage.py} (94%) rename src/spatialcf/core/{v2/relation_cost_partition.py => _internal/objective/relation_partition.py} (93%) rename src/spatialcf/core/{v2/continuous_yaw_safety_v2_9.py => _internal/objective/safety.py} (95%) rename src/spatialcf/core/{v2/objective_safety_bounds.py => _internal/objective/safety_bounds.py} (95%) rename src/spatialcf/core/{v2/continuous_yaw_visibility_v2_9.py => _internal/objective/visibility.py} (95%) rename src/spatialcf/core/{v2/continuous_yaw_visibility_objective_v2_9.py => _internal/objective/visibility_objective.py} (97%) rename src/spatialcf/core/{v2/_internal/resources/domain_operations.py => _internal/resources.py} (100%) create mode 100644 src/spatialcf/core/candidate.py rename src/spatialcf/core/{v2/continuous_yaw_certificate_v2_9.py => certificate.py} (95%) rename src/spatialcf/{solver/feasible.py => core/feasibility.py} (81%) rename src/spatialcf/core/{v2/continuous_yaw_objective_v2_9.py => objective.py} (94%) rename src/spatialcf/core/{v2/continuous_yaw_camera_frame.py => problem.py} (91%) rename src/spatialcf/core/{v2/continuous_yaw_solver_v2_9.py => solver.py} (85%) delete mode 100644 src/spatialcf/core/v2/__init__.py delete mode 100644 src/spatialcf/core/v2/_internal/__init__.py delete mode 100644 src/spatialcf/core/v2/_internal/boundary/__init__.py delete mode 100644 src/spatialcf/core/v2/_internal/boundary/errors.py delete mode 100644 src/spatialcf/core/v2/_internal/boundary/strict.py delete mode 100644 src/spatialcf/core/v2/_internal/certification/__init__.py delete mode 100644 src/spatialcf/core/v2/_internal/certification/solve_replay.py delete mode 100644 src/spatialcf/core/v2/_internal/orchestration/__init__.py delete mode 100644 src/spatialcf/core/v2/_internal/orchestration/capabilities.py delete mode 100644 src/spatialcf/core/v2/_internal/orchestration/registry.py delete mode 100644 src/spatialcf/core/v2/_internal/orchestration/solve.py delete mode 100644 src/spatialcf/core/v2/_internal/orchestration/stages.py delete mode 100644 src/spatialcf/core/v2/_internal/resources/__init__.py delete mode 100644 src/spatialcf/core/v2/artifact_verifier.py delete mode 100644 src/spatialcf/core/v2/camera_cardinal_rebase.py delete mode 100644 src/spatialcf/core/v2/camera_cardinal_result.py delete mode 100644 src/spatialcf/core/v2/camera_translation.py delete mode 100644 src/spatialcf/core/v2/cardinal_yaw.py delete mode 100644 src/spatialcf/core/v2/certificate_builder.py delete mode 100644 src/spatialcf/core/v2/collision_domain.py delete mode 100644 src/spatialcf/core/v2/continuous_yaw_certificate.py delete mode 100644 src/spatialcf/core/v2/continuous_yaw_solve_verifier.py delete mode 100644 src/spatialcf/core/v2/continuous_yaw_solver.py delete mode 100644 src/spatialcf/core/v2/continuous_yaw_support_projection.py delete mode 100644 src/spatialcf/core/v2/edit_feasibility.py delete mode 100644 src/spatialcf/core/v2/minimum_cost_solver.py delete mode 100644 src/spatialcf/core/v2/minimum_cost_solver_v2_1.py delete mode 100644 src/spatialcf/core/v2/minimum_cost_solver_v2_2.py delete mode 100644 src/spatialcf/core/v2/minimum_cost_solver_v2_3.py delete mode 100644 src/spatialcf/core/v2/minimum_cost_solver_v2_4.py delete mode 100644 src/spatialcf/core/v2/multi_obstacle_strict_convex_candidate_domain.py delete mode 100644 src/spatialcf/core/v2/objective_partition.py delete mode 100644 src/spatialcf/core/v2/point_objective.py delete mode 100644 src/spatialcf/core/v2/solve_verifier.py delete mode 100644 src/spatialcf/core/v2/solve_verifier_v2_1.py delete mode 100644 src/spatialcf/core/v2/solve_verifier_v2_2.py delete mode 100644 src/spatialcf/core/v2/solve_verifier_v2_3.py delete mode 100644 src/spatialcf/core/v2/solve_verifier_v2_4.py delete mode 100644 src/spatialcf/core/v2/strict_convex_candidate_domain.py delete mode 100644 src/spatialcf/core/v2/support_domain.py delete mode 100644 src/spatialcf/core/v2/support_strict_convex_candidate_domain.py delete mode 100644 src/spatialcf/core/v2/target_relation_domain.py delete mode 100644 src/spatialcf/core/v2/visibility_domain.py delete mode 100644 src/spatialcf/core/v2/zero_distortion.py rename src/spatialcf/core/{v2/continuous_yaw_solve_verifier_v2_9.py => verification.py} (81%) rename src/spatialcf/domain/{v2 => }/artifacts.py (72%) rename src/spatialcf/domain/{v2 => }/base.py (91%) rename src/spatialcf/domain/{v2 => }/certificate.py (93%) rename src/spatialcf/domain/{v2 => }/constraints.py (77%) rename src/spatialcf/domain/{v2 => }/edit.py (75%) rename src/spatialcf/domain/{v2 => }/evidence.py (83%) rename src/spatialcf/domain/{v2 => }/geometry.py (85%) delete mode 100644 src/spatialcf/domain/models.py rename src/spatialcf/domain/{v2 => }/objective.py (93%) rename src/spatialcf/domain/{v2 => }/problem.py (89%) rename src/spatialcf/domain/{enums.py => request.py} (65%) rename src/spatialcf/domain/{v2 => }/result.py (96%) rename src/spatialcf/domain/{v2 => }/scene.py (66%) rename src/spatialcf/domain/{v2 => }/serialization.py (91%) rename src/spatialcf/domain/{v2/continuous_yaw_solver_v2_9.py => solver.py} (50%) create mode 100644 src/spatialcf/domain/source.py delete mode 100644 src/spatialcf/domain/v2/__init__.py delete mode 100644 src/spatialcf/domain/v2/cardinal.py delete mode 100644 src/spatialcf/domain/v2/continuous_yaw.py delete mode 100644 src/spatialcf/domain/v2/continuous_yaw_camera.py delete mode 100644 src/spatialcf/domain/v2/continuous_yaw_candidate.py delete mode 100644 src/spatialcf/domain/v2/continuous_yaw_solver.py delete mode 100644 src/spatialcf/domain/v2/publication.py delete mode 100644 src/spatialcf/generation/_internal/evidence/__init__.py delete mode 100644 src/spatialcf/generation/_internal/evidence/camera.py delete mode 100644 src/spatialcf/generation/_internal/evidence/surface.py delete mode 100644 src/spatialcf/generation/_internal/execution/__init__.py delete mode 100644 src/spatialcf/generation/_internal/execution/audit.py delete mode 100644 src/spatialcf/generation/_internal/planning/__init__.py delete mode 100644 src/spatialcf/generation/_internal/source_observation.py rename src/spatialcf/generation/{_internal/evidence => capture}/reachability.py (89%) rename src/spatialcf/generation/{execution.py => execution/__init__.py} (51%) rename src/spatialcf/generation/{_internal/execution/run.py => execution/audit.py} (50%) create mode 100644 src/spatialcf/generation/execution/batch.py rename src/spatialcf/generation/{_internal => }/execution/campaign.py (96%) rename src/spatialcf/generation/{_internal => }/execution/correspondence.py (89%) rename src/spatialcf/generation/{planning.py => planning/__init__.py} (83%) rename src/spatialcf/generation/{_internal => }/planning/campaign.py (95%) rename src/spatialcf/generation/{_internal => }/planning/endpoint.py (84%) rename src/spatialcf/generation/{_internal => }/planning/models.py (96%) rename src/spatialcf/generation/{_internal/planning/proxy.py => planning/problem.py} (96%) rename src/spatialcf/generation/{publication.py => publication/__init__.py} (89%) rename src/spatialcf/generation/{_internal => publication}/assets.py (78%) create mode 100644 src/spatialcf/generation/workflows/__init__.py create mode 100644 src/spatialcf/generation/workflows/capture.py create mode 100644 src/spatialcf/generation/workflows/contracts.py create mode 100644 src/spatialcf/generation/workflows/dataset.py rename src/spatialcf/generation/{_internal/execution/batch.py => workflows/execution.py} (96%) delete mode 100644 src/spatialcf/solver/__init__.py delete mode 100644 src/spatialcf/solver/analytic_motion.py delete mode 100644 src/spatialcf/solver/certified_constraints.py delete mode 100644 src/spatialcf/solver/certified_geometry.py delete mode 100644 src/spatialcf/solver/certified_models.py delete mode 100644 src/spatialcf/solver/challenge_validation.py delete mode 100644 src/spatialcf/solver/continuous.py delete mode 100644 src/spatialcf/solver/objective.py delete mode 100644 src/spatialcf/solver/search.py delete mode 100644 src/spatialcf/solver/stress/__init__.py delete mode 100644 src/spatialcf/solver/stress/cases.py delete mode 100644 src/spatialcf/solver/stress/families_depth.py delete mode 100644 src/spatialcf/solver/stress/families_distance.py delete mode 100644 src/spatialcf/solver/stress/families_horizontal.py delete mode 100644 src/spatialcf/solver/stress/models.py delete mode 100644 src/spatialcf/solver/stress/oracles.py delete mode 100644 src/spatialcf/solver/stress/profiles.py delete mode 100644 src/spatialcf/solver/stress/sampling.py delete mode 100644 src/spatialcf/solver/stress/scene_factory.py delete mode 100644 src/spatialcf/solver/stress/transforms.py delete mode 100644 src/spatialcf/solver/validation.py create mode 100644 src/spatialcf/verification/artifacts.py create mode 100644 src/spatialcf/verification/dataset.py create mode 100644 src/spatialcf/verification/integrity.py create mode 100644 src/spatialcf/verification/manual_review.py create mode 100644 src/spatialcf/verification/profile.py create mode 100644 src/spatialcf/verification/provenance.py create mode 100644 src/spatialcf/verification/split.py diff --git a/README.md b/README.md index bfa6256..93943b6 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ # SpatialCF -SpatialCF 用于生成经过验证的空间反事实数据集。它从场景观测中冻结请求,使用最小代价 -求解器规划单物体平面移动,通过 Adapter 在原生环境执行编辑,再对结果和数据集文件进行 -fresh verification。 +SpatialCF 用于生成经过验证的空间反事实数据集。当前链路:domain/core → adapter protocol → generation → fresh verification。它从场景观测中冻结请求,使用最小代价求解器规划单物体平面移动,由 Adapter protocol 将平台事实和 Canonical Edit 接入 generation,再对结果和数据集文件重新验证。 Schema、求解器和验证逻辑均为平台无关设计。Unity/AI2-THOR 是首个 Adapter,只负责把 平台事实和原生操作连接到这条公共生成链。 diff --git a/README_EN.md b/README_EN.md index 7c8ce6c..b1edce9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -2,10 +2,7 @@ # SpatialCF -SpatialCF generates verified spatial counterfactual datasets. It freezes requests -from scene observations, plans single-object planar moves with a minimum-cost -solver, executes edits through an Adapter, and freshly verifies the results and -dataset files. +SpatialCF generates verified spatial counterfactual datasets. Current chain: domain/core → adapter protocol → generation → fresh verification. It freezes requests from scene observations, plans single-object planar moves with a minimum-cost solver, connects platform facts and Canonical Edits through the Adapter protocol, and freshly verifies the results and dataset files. The Schema, solver, and verification logic are platform-neutral. Unity/AI2-THOR is the first Adapter and connects platform facts and native operations to the diff --git a/src/spatialcf/adapters/ai2thor.py b/src/spatialcf/adapters/ai2thor.py deleted file mode 100644 index f890578..0000000 --- a/src/spatialcf/adapters/ai2thor.py +++ /dev/null @@ -1,4891 +0,0 @@ -from __future__ import annotations - -import json -import math -import warnings -from collections.abc import Callable, Iterator, Mapping -from contextlib import contextmanager -from copy import deepcopy -from dataclasses import asdict, dataclass -from enum import StrEnum -from hashlib import sha256 -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as package_version -from io import BytesIO -from pathlib import Path -from types import MappingProxyType -from typing import Any -from weakref import ReferenceType, ref - -import numpy as np -from PIL import Image - -from spatialcf.adapters.base import RenderedAssets -from spatialcf.domain.models import ( - OBB, - BBox2D, - Camera, - CollisionObstacle, - ObjectView, - Quaternion, - Scene, - SceneObject, - SubjectPositionRegion, - Vec2, - Vec3, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 -from spatialcf.geometry.regions import ( - conservative_navigation_position_geometry, - conservative_receptacle_position_geometry, - planar_polygon_payloads, -) -from spatialcf.geometry.transforms import ( - ai2thor_position_to_world, - ai2thor_rotation_to_world, - matrix4, - transform_point, -) - -ControllerFactory = Callable[..., Any] -_EPSILON = 1e-6 -_ANGLE_TOLERANCE_DEGREES = 1e-4 -# Unity round-trips placed-object Euler angles at about 3e-4 degrees while the -# independently checked quaternion geometry remains stable within 1e-5. -_OBJECT_ROTATION_TOLERANCE_DEGREES = 1e-3 -_CAMERA_POSITION_TOLERANCE_M = 1e-5 -_OBJECT_GEOMETRY_TOLERANCE_M = 1e-5 -_NATIVE_NAVIGATION_GRID_SIZE_M = 0.05 -_REACHABLE_POSITION_QUANTIZATION_M = 1e-6 -_RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M = 1e-5 -_RECEPTACLE_TRIGGER_GRID_SIDE = 21 -_RECEPTACLE_TRIGGER_GRID_SIZE = _RECEPTACLE_TRIGGER_GRID_SIDE**2 -_TELEPORT_VERTICAL_GUARD_M = 1e-6 -_RUNTIME_RECEPTACLE_POSITION_RESIDUAL_M = 1e-4 -_STRUCTURAL_OBJECT_TYPES = frozenset({"Ceiling", "Floor", "Wall"}) - - -def _camera_position_residual_m( - requested: AI2ThorNativePosition, - observed: AI2ThorNativePosition, -) -> float: - """Return the total native-coordinate residual for one camera pose.""" - return math.dist( - (requested.x, requested.y, requested.z), - (observed.x, observed.y, observed.z), - ) - - -def _camera_position_residual_within_tolerance( - requested: AI2ThorNativePosition, - observed: AI2ThorNativePosition, -) -> tuple[float, bool]: - """Apply the shared total-position camera contract with ULP allowance.""" - residual_m = _camera_position_residual_m(requested, observed) - rounding_allowance_m = 4.0 * max( - math.ulp(value) - for value in ( - requested.x, - requested.y, - requested.z, - observed.x, - observed.y, - observed.z, - ) - ) - return ( - residual_m, - residual_m <= _CAMERA_POSITION_TOLERANCE_M + rounding_allowance_m, - ) - - -def ai2thor_camera_world_to_camera( - position: Vec3, - *, - yaw_degrees: float, - horizon_degrees: float, -) -> tuple[float, ...]: - """Build AI2-THOR's canonical world-to-camera extrinsic matrix.""" - - yaw = math.radians(yaw_degrees) - pitch = math.radians(horizon_degrees) - right = np.asarray([math.cos(yaw), -math.sin(yaw), 0.0]) - forward = np.asarray( - [ - math.sin(yaw) * math.cos(pitch), - math.cos(yaw) * math.cos(pitch), - -math.sin(pitch), - ] - ) - up = np.cross(right, forward) - rotation = np.stack([right, up, forward]) - translation = -rotation @ np.asarray([position.x, position.y, position.z]) - world_to_camera = np.eye(4) - world_to_camera[:3, :3] = rotation - world_to_camera[:3, 3] = translation - return tuple(float(value) for value in world_to_camera.reshape(-1)) - - -class AI2ThorRuntimeError(RuntimeError): - """Expected controller transport/launcher failure.""" - - -class AI2ThorNativeReturnError(ValueError): - """A successful native action returned a structurally invalid state.""" - - -class AI2ThorSettlementTimeout(RuntimeError): - """A bounded native settlement loop exhausted its explicit Pass budget.""" - - -class AI2ThorNativeSupportKind(StrEnum): - """Closed classification of one native parent lineage.""" - - FLOOR = "FLOOR" - RECEPTACLE = "RECEPTACLE" - UNKNOWN = "UNKNOWN" - MULTIPLE_AMBIGUOUS = "MULTIPLE_AMBIGUOUS" - CYCLIC = "CYCLIC" - - -@dataclass(frozen=True) -class AI2ThorNativeSupportFact: - """Read-only native parent evidence bound to one stable scene object.""" - - scene_id: str - object_id: str - object_name: str - native_object_id: str - raw_parent_object_ids: tuple[str, ...] - structural_parent_object_ids: tuple[str, ...] - domain_parent_object_ids: tuple[str, ...] - support_kind: AI2ThorNativeSupportKind - support_object_id: str | None - floor_object_id: str | None - - def __post_init__(self) -> None: - for name in ( - "scene_id", - "object_id", - "object_name", - "native_object_id", - ): - _nonempty_text(getattr(self, name), f"native support {name}") - for name in ( - "raw_parent_object_ids", - "structural_parent_object_ids", - "domain_parent_object_ids", - ): - values = getattr(self, name) - if type(values) is not tuple or any( - type(value) is not str or not value.strip() for value in values - ): - raise ValueError(f"native support {name} must be a text tuple") - if values != tuple(sorted(set(values))): - raise ValueError(f"native support {name} must be unique and sorted") - raw = set(self.raw_parent_object_ids) - structural = set(self.structural_parent_object_ids) - domain = set(self.domain_parent_object_ids) - if not structural.issubset(raw) or not domain.issubset(raw): - raise ValueError("native support parent partitions must be raw subsets") - if structural.intersection(domain): - raise ValueError("native support parent partitions must be disjoint") - if type(self.support_kind) is not AI2ThorNativeSupportKind: - raise ValueError("native support kind has invalid type") - if self.support_kind is AI2ThorNativeSupportKind.RECEPTACLE: - if ( - len(domain) != 1 - or structural - or self.support_object_id != self.domain_parent_object_ids[0] - or self.floor_object_id is not None - ): - raise ValueError("receptacle support fact is not closed") - elif self.support_kind is AI2ThorNativeSupportKind.FLOOR: - if ( - len(structural) != 1 - or domain - or self.floor_object_id != self.structural_parent_object_ids[0] - or self.support_object_id is not None - ): - raise ValueError("floor support fact is not closed") - elif self.support_kind is AI2ThorNativeSupportKind.UNKNOWN: - if domain or self.support_object_id is not None or self.floor_object_id is not None: - raise ValueError("unknown support fact resolved an unusable parent") - elif self.support_kind is AI2ThorNativeSupportKind.MULTIPLE_AMBIGUOUS: - if ( - len(domain) + len(structural) <= 1 - or self.support_object_id is not None - or self.floor_object_id is not None - ): - raise ValueError("ambiguous support fact requires multiple parents") - elif ( - not domain - or self.support_object_id is not None - or self.floor_object_id is not None - ): - raise ValueError("cyclic support fact is not closed") - - -def _strict_finite_float(value: Any, label: str) -> float: - if type(value) not in (int, float) or not math.isfinite(float(value)): - raise ValueError(f"{label} must be a finite real number") - return float(value) - - -def _nonempty_text(value: Any, label: str) -> str: - if type(value) is not str or not value.strip(): - raise ValueError(f"{label} must be non-empty text") - return value - - -def _full_commit_sha(value: Any, label: str) -> str: - text = _nonempty_text(value, label) - if len(text) != 40 or any(character not in "0123456789abcdef" for character in text): - raise ValueError(f"{label} must be a complete lowercase commit SHA") - return text - - -def _validate_json_tree( - value: Any, - *, - active_containers: set[int] | None = None, -) -> None: - if value is None or type(value) in (bool, int, str): - return - if type(value) is float: - if not math.isfinite(value): - raise ValueError("house JSON numbers must be finite") - return - if type(value) not in (dict, list): - raise ValueError("house must contain exact JSON values") - active = set() if active_containers is None else active_containers - identity = id(value) - if identity in active: - raise ValueError("house JSON must not contain cycles") - active.add(identity) - try: - if type(value) is dict: - if any(type(key) is not str for key in value): - raise ValueError("house JSON objects must have string keys") - for item in value.values(): - _validate_json_tree(item, active_containers=active) - else: - for item in value: - _validate_json_tree(item, active_containers=active) - finally: - active.remove(identity) - - -def _canonical_house_json_bytes(house: Any) -> bytes: - if type(house) is not dict: - raise ValueError("house must be an exact dict") - _validate_json_tree(house) - try: - return ( - json.dumps( - house, - allow_nan=False, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ) - + "\n" - ).encode("utf-8") - except (RecursionError, TypeError, UnicodeEncodeError, ValueError) as error: - raise ValueError("house must be finite canonical UTF-8 JSON") from error - - -def canonical_procedural_house_sha256(house: dict[str, Any]) -> str: - """Return the canonical source digest without imposing a room policy.""" - if type(house) is not dict: - raise ValueError("procedural house root must be an exact dict") - return sha256(_canonical_house_json_bytes(house)).hexdigest() - - -def _procedural_room_identity( - house: dict[str, Any], -) -> tuple[str, tuple[float, float, float, float]]: - rooms = house.get("rooms") - if type(rooms) is not list or len(rooms) != 1: - raise ValueError("procedural house must contain exactly one room") - room = rooms[0] - if type(room) is not dict: - raise ValueError("procedural room must be an exact dict") - room_id = _nonempty_text(room.get("id"), "room id") - polygon = room.get("floorPolygon") - if type(polygon) is not list or len(polygon) != 4: - raise ValueError("room floorPolygon must contain exactly four points") - points: list[tuple[float, float]] = [] - elevations: list[float] = [] - for point in polygon: - if type(point) is not dict: - raise ValueError("room floorPolygon points must be exact dicts") - coordinates = tuple(point.get(axis) for axis in ("x", "y", "z")) - if any( - type(coordinate) not in (int, float) - or not math.isfinite(float(coordinate)) - for coordinate in coordinates - ): - raise ValueError("room floorPolygon points must be finite") - points.append((float(coordinates[0]), float(coordinates[2]))) - elevations.append(float(coordinates[1])) - if any( - not math.isclose(value, elevations[0], rel_tol=0.0, abs_tol=1e-9) - for value in elevations[1:] - ): - raise ValueError("room floorPolygon must lie on one horizontal plane") - twice_area = sum( - points[index][0] * points[(index + 1) % 4][1] - - points[(index + 1) % 4][0] * points[index][1] - for index in range(4) - ) - if abs(twice_area) <= 1e-12: - raise ValueError("room floorPolygon must have positive area") - minimum_x = min(point[0] for point in points) - maximum_x = max(point[0] for point in points) - minimum_z = min(point[1] for point in points) - maximum_z = max(point[1] for point in points) - if maximum_x - minimum_x <= 1e-12 or maximum_z - minimum_z <= 1e-12: - raise ValueError("room floorPolygon must have positive area") - expected_corners = { - (minimum_x, minimum_z), - (maximum_x, minimum_z), - (maximum_x, maximum_z), - (minimum_x, maximum_z), - } - actual_corners = set(points) - edges_are_axis_aligned = all( - ( - math.isclose(points[index][0], points[(index + 1) % 4][0], abs_tol=1e-12) - != math.isclose( - points[index][1], - points[(index + 1) % 4][1], - abs_tol=1e-12, - ) - ) - for index in range(4) - ) - if actual_corners != expected_corners or not edges_are_axis_aligned: - raise ValueError( - "room floorPolygon must be a convex axis-aligned rectangle" - ) - return room_id, (minimum_x, minimum_z, maximum_x, maximum_z) - - -@dataclass(frozen=True) -class AI2ThorProceduralScene: - """Immutable provenance plus canonical source bytes for one ProcTHOR house.""" - - dataset_id: str - revision: str - split: str - index: int - source_loader_id: str - source_loader_version: str - canonical_house_json: bytes - house_sha256: str - room_id: str - floor_xz_bounds: tuple[float, float, float, float] - - def __post_init__(self) -> None: - _nonempty_text(self.dataset_id, "dataset_id") - _full_commit_sha(self.revision, "revision") - if type(self.split) is not str or self.split not in {"train", "val", "test"}: - raise ValueError("split must be exactly train, val, or test") - if type(self.index) is not int or self.index < 0: - raise ValueError("index must be an exact non-negative integer") - _nonempty_text(self.source_loader_id, "source_loader_id") - _nonempty_text(self.source_loader_version, "source_loader_version") - if type(self.canonical_house_json) is not bytes: - raise ValueError("canonical_house_json must be exact bytes") - try: - decoded = json.loads(self.canonical_house_json) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise ValueError("canonical_house_json must be valid UTF-8 JSON") from error - if type(decoded) is not dict: - raise ValueError("canonical_house_json root must be an exact dict") - room_id, floor_xz_bounds = _procedural_room_identity(decoded) - if _canonical_house_json_bytes(decoded) != self.canonical_house_json: - raise ValueError("canonical_house_json is not canonical") - expected_sha256 = sha256(self.canonical_house_json).hexdigest() - if type(self.house_sha256) is not str or self.house_sha256 != expected_sha256: - raise ValueError("house_sha256 does not match canonical_house_json") - if self.room_id != room_id: - raise ValueError("room_id does not match canonical_house_json") - if ( - type(self.floor_xz_bounds) is not tuple - or self.floor_xz_bounds != floor_xz_bounds - ): - raise ValueError("floor_xz_bounds do not match canonical_house_json") - - @classmethod - def create( - cls, - *, - dataset_id: str, - revision: str, - split: str, - index: int, - source_loader_id: str, - source_loader_version: str, - house: dict[str, Any], - ) -> AI2ThorProceduralScene: - canonical = _canonical_house_json_bytes(house) - room_id, floor_xz_bounds = _procedural_room_identity(house) - return cls( - dataset_id=dataset_id, - revision=revision, - split=split, - index=index, - source_loader_id=source_loader_id, - source_loader_version=source_loader_version, - canonical_house_json=canonical, - house_sha256=sha256(canonical).hexdigest(), - room_id=room_id, - floor_xz_bounds=floor_xz_bounds, - ) - - def decode_house(self) -> dict[str, Any]: - """Return a fresh mutable decoding; callers never receive retained state.""" - decoded = json.loads(self.canonical_house_json) - if type(decoded) is not dict: # Defends the public contract after construction. - raise RuntimeError("procedural house root changed from an exact dict") - return decoded - -@dataclass(frozen=True) -class AI2ThorNativePosition: - """A finite position in AI2-THOR's native X/Y/Z coordinate system.""" - - x: float - y: float - z: float - - def __post_init__(self) -> None: - for axis in ("x", "y", "z"): - object.__setattr__( - self, - axis, - _strict_finite_float( - getattr(self, axis), - f"native position {axis}", - ), - ) - - -def canonicalize_ai2thor_reachable_positions( - positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - """Return stable 1-micrometre identities for one native navigation grid.""" - - if ( - type(positions) is not tuple - or not positions - or any(type(item) is not AI2ThorNativePosition for item in positions) - ): - raise TypeError("reachable positions must be a non-empty exact tuple") - keyed: list[tuple[tuple[int, int, int], AI2ThorNativePosition]] = [] - seen: set[tuple[int, int, int]] = set() - for position in positions: - key = tuple( - round(value / _REACHABLE_POSITION_QUANTIZATION_M) - for value in (position.x, position.z, position.y) - ) - if key in seen: - raise ValueError("reachable positions contain a duplicate canonical point") - seen.add(key) - keyed.append( - ( - key, - AI2ThorNativePosition( - x=round(key[0] * _REACHABLE_POSITION_QUANTIZATION_M, 6), - y=round(key[2] * _REACHABLE_POSITION_QUANTIZATION_M, 6), - z=round(key[1] * _REACHABLE_POSITION_QUANTIZATION_M, 6), - ), - ) - ) - keyed.sort(key=lambda item: item[0]) - return tuple(position for _, position in keyed) - - -def bind_ai2thor_reachable_positions( - reference_positions: tuple[AI2ThorNativePosition, ...], - observed_positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - """Bind one noisy replay to a frozen native navigation-grid roster.""" - - frozen = canonicalize_ai2thor_reachable_positions(reference_positions) - if ( - type(observed_positions) is not tuple - or not observed_positions - or any( - type(item) is not AI2ThorNativePosition for item in observed_positions - ) - ): - raise TypeError("observed reachable positions must be a non-empty exact tuple") - if len(observed_positions) != len(reference_positions): - raise ValueError("reachable position roster changed") - maximum_coordinate_ulp_m = max( - math.ulp(value) - for position in (*reference_positions, *observed_positions) - for value in (position.x, position.y, position.z) - ) - maximum_tolerance_m = _REACHABLE_POSITION_QUANTIZATION_M + 4.0 * max( - maximum_coordinate_ulp_m, - math.ulp(_REACHABLE_POSITION_QUANTIZATION_M), - ) - bucket_span = math.ceil( - maximum_tolerance_m / _REACHABLE_POSITION_QUANTIZATION_M - ) - - def bucket(position: AI2ThorNativePosition) -> tuple[int, int, int]: - return tuple( - math.floor(value / _REACHABLE_POSITION_QUANTIZATION_M) - for value in (position.x, position.y, position.z) - ) - - reference_buckets: dict[tuple[int, int, int], list[int]] = {} - for index, reference in enumerate(reference_positions): - reference_buckets.setdefault(bucket(reference), []).append(index) - - matched: set[int] = set() - for observed in observed_positions: - center = bucket(observed) - candidates: list[int] = [] - if bucket_span <= 8: - for x_offset in range(-bucket_span, bucket_span + 1): - for y_offset in range(-bucket_span, bucket_span + 1): - for z_offset in range(-bucket_span, bucket_span + 1): - candidates.extend( - reference_buckets.get( - ( - center[0] + x_offset, - center[1] + y_offset, - center[2] + z_offset, - ), - (), - ) - ) - else: - candidates.extend(range(len(reference_positions))) - within_tolerance = tuple( - index - for index in candidates - if math.dist( - ( - reference_positions[index].x, - reference_positions[index].y, - reference_positions[index].z, - ), - (observed.x, observed.y, observed.z), - ) - <= _REACHABLE_POSITION_QUANTIZATION_M - + 4.0 - * max( - *( - math.ulp(value) - for value in ( - reference_positions[index].x, - reference_positions[index].y, - reference_positions[index].z, - observed.x, - observed.y, - observed.z, - ) - ), - math.ulp(_REACHABLE_POSITION_QUANTIZATION_M), - ) - ) - if len(within_tolerance) != 1 or within_tolerance[0] in matched: - raise ValueError("reachable position roster changed") - matched.add(within_tolerance[0]) - if len(matched) != len(reference_positions): - raise ValueError("reachable position roster changed") - return frozen - - -@dataclass(frozen=True) -class AI2ThorAgentPose: - """A complete deterministic TeleportFull request in native coordinates.""" - - position: AI2ThorNativePosition - yaw_degrees: float - horizon_degrees: float - standing: bool - - def __post_init__(self) -> None: - if type(self.position) is not AI2ThorNativePosition: - raise ValueError("agent pose position must be an AI2ThorNativePosition") - object.__setattr__( - self, - "yaw_degrees", - _strict_finite_float(self.yaw_degrees, "agent yaw"), - ) - object.__setattr__( - self, - "horizon_degrees", - _strict_finite_float(self.horizon_degrees, "camera horizon"), - ) - if type(self.standing) is not bool: - raise ValueError("agent standing must be an exact boolean") - - -@dataclass(frozen=True) -class AI2ThorRuntimeIdentity: - """Exact package, Unity build and controller contract for one run.""" - - ai2thor_version: str - unity_commit_id: str - native_scene_name: str - width: int - height: int - seed: int - render_depth_image: bool = True - render_instance_segmentation: bool = True - grid_size_m: float = 0.05 - snap_to_grid: bool = True - rotate_step_degrees: int = 90 - coordinate_transform_version: str = "ai2thor-native-xzy-to-rh-z-up-v1" - source_dataset_id: str | None = None - source_revision: str | None = None - source_split: str | None = None - source_index: int | None = None - source_sha256: str | None = None - source_scene_alias: str | None = None - source_loader_id: str | None = None - source_loader_version: str | None = None - source_room_id: str | None = None - source_floor_xz_bounds: tuple[float, float, float, float] | None = None - teleport_vertical_guard_m: float = 0.0 - - def __post_init__(self) -> None: - for name in ( - "ai2thor_version", - "unity_commit_id", - "native_scene_name", - "coordinate_transform_version", - ): - if type(getattr(self, name)) is not str or not getattr(self, name): - raise ValueError(f"{name} must be non-empty text") - for name in ("width", "height", "seed", "rotate_step_degrees"): - if type(getattr(self, name)) is not int: - raise ValueError(f"{name} must be an exact integer") - if self.width <= 0 or self.height <= 0 or self.rotate_step_degrees <= 0: - raise ValueError("runtime dimensions and rotation step must be positive") - for name in ( - "render_depth_image", - "render_instance_segmentation", - "snap_to_grid", - ): - if type(getattr(self, name)) is not bool: - raise ValueError(f"{name} must be an exact boolean") - if ( - isinstance(self.grid_size_m, bool) - or not isinstance(self.grid_size_m, (int, float)) - or not math.isfinite(float(self.grid_size_m)) - or self.grid_size_m <= 0.0 - ): - raise ValueError("grid_size_m must be finite and positive") - object.__setattr__(self, "grid_size_m", float(self.grid_size_m)) - if ( - type(self.teleport_vertical_guard_m) not in (int, float) - or not math.isfinite(float(self.teleport_vertical_guard_m)) - or float(self.teleport_vertical_guard_m) not in {0.0, 1e-6} - ): - raise ValueError("teleport_vertical_guard_m must be exactly 0 or 1e-6") - object.__setattr__( - self, - "teleport_vertical_guard_m", - float(self.teleport_vertical_guard_m), - ) - source_values = ( - self.source_dataset_id, - self.source_revision, - self.source_split, - self.source_index, - self.source_sha256, - self.source_scene_alias, - self.source_loader_id, - self.source_loader_version, - self.source_room_id, - self.source_floor_xz_bounds, - ) - if any(value is not None for value in source_values): - if any(value is None for value in source_values): - raise ValueError("procedural source provenance must be complete") - _nonempty_text(self.source_dataset_id, "source_dataset_id") - _full_commit_sha(self.source_revision, "source_revision") - _nonempty_text(self.source_scene_alias, "source_scene_alias") - _nonempty_text(self.source_loader_id, "source_loader_id") - _nonempty_text(self.source_loader_version, "source_loader_version") - _nonempty_text(self.source_room_id, "source_room_id") - if self.source_split not in {"train", "val", "test"}: - raise ValueError("source_split must be exactly train, val, or test") - if type(self.source_index) is not int or self.source_index < 0: - raise ValueError("source_index must be an exact non-negative integer") - if ( - type(self.source_sha256) is not str - or len(self.source_sha256) != 64 - or any(character not in "0123456789abcdef" for character in self.source_sha256) - ): - raise ValueError("source_sha256 must be lowercase SHA-256 hex") - bounds = self.source_floor_xz_bounds - if ( - type(bounds) is not tuple - or len(bounds) != 4 - or any( - type(value) not in (int, float) - or not math.isfinite(float(value)) - for value in bounds - ) - or not float(bounds[0]) < float(bounds[2]) - or not float(bounds[1]) < float(bounds[3]) - ): - raise ValueError( - "source_floor_xz_bounds must be a finite positive rectangle" - ) - object.__setattr__( - self, - "source_floor_xz_bounds", - tuple(float(value) for value in bounds), - ) - if self.native_scene_name != "Procedural": - raise ValueError("procedural provenance requires native Procedural scene") - if self.teleport_vertical_guard_m != 1e-6: - raise ValueError("procedural provenance requires 1e-6 teleport guard") - elif self.native_scene_name == "Procedural": - raise ValueError("native Procedural scene requires source provenance") - elif self.teleport_vertical_guard_m not in { - 0.0, - _TELEPORT_VERTICAL_GUARD_M, - }: - raise ValueError("legacy scene has an unsupported teleport guard") - - -@dataclass(frozen=True) -class AI2ThorObservation: - """Immutable, same-event frames and scene state returned by AI2-THOR.""" - - scene: Scene - rgb_png: bytes - depth_npy: bytes - instance_png: bytes - pointcloud_ply: bytes - rgb_png_sha256: str - depth_npy_sha256: str - instance_png_sha256: str - pointcloud_ply_sha256: str - instance_pixel_counts: Mapping[str, int] - is_scene_at_rest: bool - - @classmethod - def create( - cls, - *, - scene: Scene, - rgb_png: bytes, - depth_npy: bytes, - instance_png: bytes, - pointcloud_ply: bytes, - instance_pixel_counts: Mapping[str, int], - is_scene_at_rest: bool, - ) -> AI2ThorObservation: - return cls( - scene=scene, - rgb_png=rgb_png, - depth_npy=depth_npy, - instance_png=instance_png, - pointcloud_ply=pointcloud_ply, - rgb_png_sha256=sha256(rgb_png).hexdigest(), - depth_npy_sha256=sha256(depth_npy).hexdigest(), - instance_png_sha256=sha256(instance_png).hexdigest(), - pointcloud_ply_sha256=sha256(pointcloud_ply).hexdigest(), - instance_pixel_counts=MappingProxyType( - dict(sorted(instance_pixel_counts.items())) - ), - is_scene_at_rest=is_scene_at_rest, - ) - - -@dataclass(frozen=True) -class AI2ThorCameraApplication: - """A requested camera pose and its immutable same-event observation.""" - - requested_pose: AI2ThorAgentPose - observed_pose: AI2ThorAgentPose - observed_camera_position: AI2ThorNativePosition - observed_scene: Scene - observation: AI2ThorObservation - position_residual_m: float - yaw_residual_degrees: float - horizon_residual_degrees: float - - -@dataclass(frozen=True) -class AI2ThorSettledCameraApplication: - """One post-unpause camera application plus its final settlement count.""" - - application: AI2ThorCameraApplication - settlement_pass_steps: int - - def __post_init__(self) -> None: - if type(self.application) is not AI2ThorCameraApplication: - raise TypeError("settled camera application must be exact") - if type(self.settlement_pass_steps) is not int: - raise TypeError("settled camera pass count must be an exact integer") - if self.settlement_pass_steps < 0: - raise ValueError("settled camera pass count must be non-negative") - - -@dataclass(frozen=True) -class AI2ThorSceneSettlement: - """A fully still source baseline captured from one final native event.""" - - observed_scene: Scene - observation: AI2ThorObservation - pass_steps: int - - -@dataclass(frozen=True) -class AI2ThorIsolatedEpisode: - """One fresh controller plus its immutable same-event source baseline.""" - - adapter: AI2ThorAdapter - baseline_settlement: AI2ThorSceneSettlement - - -@dataclass(frozen=True) -class AI2ThorPoseApplication: - """Keep the commanded canonical state separate from native observation.""" - - commanded_scene: Scene - observed_scene: Scene - commanded_position: Vec3 - observed_position: Vec3 - position_residual_m: float - observation: AI2ThorObservation - is_scene_at_rest: bool - subject_is_moving: bool - - -@dataclass(frozen=True) -class AI2ThorFloorEnvelope: - """Conservative convex floor evidence derived from one native floor AABB.""" - - scene_id: str - floor_object_id: str - floor_name: str - native_aabb: OBB - floor_top_z: float - clearance_m: float - polygon_xy: tuple[Vec2, ...] - - -@dataclass(frozen=True) -class AI2ThorNativeFeasibilityMap: - """Conservative native collision envelopes for one exact source event.""" - - scene_id: str - subject_object_id: str - clearance_m: float - obstacles: tuple[CollisionObstacle, ...] - - def __post_init__(self) -> None: - _nonempty_text(self.scene_id, "feasibility scene_id") - _nonempty_text(self.subject_object_id, "feasibility subject_object_id") - if ( - type(self.clearance_m) not in (int, float) - or not math.isfinite(float(self.clearance_m)) - or float(self.clearance_m) <= 0.0 - ): - raise ValueError("collision clearance must be finite and positive") - object.__setattr__(self, "clearance_m", float(self.clearance_m)) - if type(self.obstacles) is not tuple or any( - type(item) is not CollisionObstacle for item in self.obstacles - ): - raise ValueError("feasibility obstacles must be a CollisionObstacle tuple") - obstacle_ids = tuple(item.obstacle_id for item in self.obstacles) - source_ids = tuple(item.source_object_id for item in self.obstacles) - if len(set(obstacle_ids)) != len(obstacle_ids): - raise ValueError("feasibility obstacle IDs must be unique") - if len(set(source_ids)) != len(source_ids): - raise ValueError("feasibility source object IDs must be unique") - if any(item.clearance_m != self.clearance_m for item in self.obstacles): - raise ValueError("feasibility obstacle clearance mismatch") - - -@dataclass(frozen=True) -class AI2ThorReceptacleSurfacePatch: - """One complete native 21-by-21 receptacle trigger grid.""" - - x_min: float - x_max: float - native_y: float - z_min: float - z_max: float - - def __post_init__(self) -> None: - values = (self.x_min, self.x_max, self.native_y, self.z_min, self.z_max) - if any( - type(value) not in (int, float) or not math.isfinite(float(value)) - for value in values - ): - raise ValueError("receptacle surface patch values must be finite") - for name, value in zip( - ("x_min", "x_max", "native_y", "z_min", "z_max"), values - ): - object.__setattr__(self, name, float(value)) - if self.x_min >= self.x_max or self.z_min >= self.z_max: - raise ValueError("receptacle surface patch must have positive area") - - -def _grid_axis( - values: tuple[float, ...], -) -> tuple[tuple[int, ...], dict[int, tuple[float, ...]]] | None: - grouped: dict[int, list[float]] = {} - for value in values: - key = round(value / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M) - grouped.setdefault(key, []).append(value) - keys = tuple(sorted(grouped)) - if len(keys) != _RECEPTACLE_TRIGGER_GRID_SIDE: - return None - actual_min = min(values) - actual_max = max(values) - if actual_min >= actual_max: - return None - tolerance = 2.0 * _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M - for index, key in enumerate(keys): - expected = actual_min + (actual_max - actual_min) * index / 20.0 - if any(abs(value - expected) > tolerance for value in grouped[key]): - return None - return keys, {key: tuple(grouped[key]) for key in keys} - - -def build_ai2thor_receptacle_surface_patches( - raw_positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorReceptacleSurfacePatch, ...]: - """Fail closed unless every raw contiguous block is one complete grid.""" - - if ( - type(raw_positions) is not tuple - or not raw_positions - or len(raw_positions) % _RECEPTACLE_TRIGGER_GRID_SIZE - or any(type(item) is not AI2ThorNativePosition for item in raw_positions) - ): - return () - patches: list[AI2ThorReceptacleSurfacePatch] = [] - for start in range(0, len(raw_positions), _RECEPTACLE_TRIGGER_GRID_SIZE): - block = raw_positions[start : start + _RECEPTACLE_TRIGGER_GRID_SIZE] - y_keys = { - round(item.y / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M) - for item in block - } - x_axis = _grid_axis(tuple(item.x for item in block)) - z_axis = _grid_axis(tuple(item.z for item in block)) - if len(y_keys) != 1 or x_axis is None or z_axis is None: - return () - x_keys, _ = x_axis - z_keys, _ = z_axis - cells = tuple( - ( - round(item.x / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M), - round(item.z / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M), - ) - for item in block - ) - expected_cells = {(x_key, z_key) for x_key in x_keys for z_key in z_keys} - if len(set(cells)) != _RECEPTACLE_TRIGGER_GRID_SIZE or set(cells) != expected_cells: - return () - patches.append( - AI2ThorReceptacleSurfacePatch( - x_min=min(item.x for item in block), - x_max=max(item.x for item in block), - native_y=( - min(item.y for item in block) + max(item.y for item in block) - ) - / 2.0, - z_min=min(item.z for item in block), - z_max=max(item.z for item in block), - ) - ) - return tuple( - sorted( - patches, - key=lambda item: ( - item.native_y, - item.x_min, - item.z_min, - item.x_max, - item.z_max, - ), - ) - ) - - -@dataclass(frozen=True) -class AI2ThorReceptacleSpawnMap: - """Source-bound receptacle coordinates without a feasibility claim. - - AI2-THOR's ``GetSpawnCoordinatesAboveReceptacle`` action describes native - receptacle coordinates. It does not prove that a particular subject fits - at every returned coordinate, so this value deliberately avoids the word - ``feasible`` and remains adapter evidence rather than a solver domain. - """ - - scene_id: str - subject_object_id: str - support_object_id: str - native_subject_object_id: str - native_support_object_id: str - runtime_identity: AI2ThorRuntimeIdentity - positions: tuple[AI2ThorNativePosition, ...] - positions_sha256: str - scene_sha256: str - source_sha256: str - surface_patches: tuple[AI2ThorReceptacleSurfacePatch, ...] = () - - def __post_init__(self) -> None: - for field_name in ( - "scene_id", - "subject_object_id", - "support_object_id", - "native_subject_object_id", - "native_support_object_id", - ): - _nonempty_text(getattr(self, field_name), field_name) - if self.subject_object_id == self.support_object_id: - raise ValueError("receptacle spawn subject and support must differ") - if type(self.runtime_identity) is not AI2ThorRuntimeIdentity: - raise ValueError("receptacle spawn runtime identity has invalid type") - checked_runtime = AI2ThorRuntimeIdentity(**asdict(self.runtime_identity)) - object.__setattr__(self, "runtime_identity", checked_runtime) - if type(self.surface_patches) is not tuple or any( - type(item) is not AI2ThorReceptacleSurfacePatch - for item in self.surface_patches - ): - raise ValueError("receptacle surface patches must be an exact tuple") - checked_patches = tuple( - AI2ThorReceptacleSurfacePatch(**asdict(item)) - for item in self.surface_patches - ) - if checked_patches != tuple( - sorted( - set(checked_patches), - key=lambda item: ( - item.native_y, - item.x_min, - item.z_min, - item.x_max, - item.z_max, - ), - ) - ): - raise ValueError("receptacle surface patches must be unique and canonical") - object.__setattr__(self, "surface_patches", checked_patches) - if type(self.positions) is not tuple or not self.positions: - raise ValueError("receptacle spawn positions must be a non-empty tuple") - if any(type(item) is not AI2ThorNativePosition for item in self.positions): - raise ValueError("receptacle spawn positions must be native positions") - keys = tuple((item.x, item.z, item.y) for item in self.positions) - if keys != tuple(sorted(keys)) or len(keys) != len(set(keys)): - raise ValueError("receptacle spawn positions must be unique and sorted") - for field_name in ("positions_sha256", "scene_sha256", "source_sha256"): - digest = getattr(self, field_name) - if ( - type(digest) is not str - or len(digest) != 64 - or any(character not in "0123456789abcdef" for character in digest) - ): - raise ValueError(f"{field_name} must be lowercase SHA-256 hex") - if self.positions_sha256 != _native_positions_sha256(self.positions): - raise ValueError("receptacle spawn positions digest mismatch") - if self.surface_patches: - expected_positions = tuple( - sorted( - ( - patch.x_min - + (patch.x_max - patch.x_min) * x_index / 20.0, - patch.z_min - + (patch.z_max - patch.z_min) * z_index / 20.0, - patch.native_y, - ) - for patch in self.surface_patches - for x_index in range(_RECEPTACLE_TRIGGER_GRID_SIDE) - for z_index in range(_RECEPTACLE_TRIGGER_GRID_SIDE) - ) - ) - actual_positions = tuple( - (item.x, item.z, item.y) for item in self.positions - ) - tolerance = 2.0 * _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M - expected_count = ( - len(self.surface_patches) * _RECEPTACLE_TRIGGER_GRID_SIZE - ) - if ( - len(expected_positions) != expected_count - or len(actual_positions) != expected_count - or any( - abs(expected - actual) > tolerance - for expected_position, actual_position in zip( - expected_positions, - actual_positions, - strict=True, - ) - for expected, actual in zip( - expected_position, - actual_position, - strict=True, - ) - ) - ): - raise ValueError( - "receptacle surface patches do not close the position grid" - ) - if self.source_sha256 != _receptacle_spawn_source_sha256( - scene_id=self.scene_id, - subject_object_id=self.subject_object_id, - support_object_id=self.support_object_id, - native_subject_object_id=self.native_subject_object_id, - native_support_object_id=self.native_support_object_id, - runtime_identity=self.runtime_identity, - positions_sha256=self.positions_sha256, - scene_sha256=self.scene_sha256, - surface_patches=self.surface_patches, - ): - raise ValueError("receptacle spawn source digest mismatch") - - -def _canonical_json_sha256(payload: object) -> str: - encoded = json.dumps( - payload, - allow_nan=False, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - return sha256(encoded).hexdigest() - - -def _canonical_scene_sha256(scene: Scene) -> str: - """Hash source facts independently of unordered roster presentation.""" - - if type(scene) is not Scene: - raise TypeError("canonical scene digest requires an exact Scene") - normalized = scene.model_copy( - update={ - "cameras": tuple(sorted(scene.cameras, key=lambda item: item.camera_id)), - "objects": tuple(sorted(scene.objects, key=lambda item: item.object_id)), - "collision_obstacles": tuple( - sorted(scene.collision_obstacles, key=lambda item: item.obstacle_id) - ), - "subject_position_regions": tuple( - sorted( - scene.subject_position_regions, - key=lambda item: item.region_id, - ) - ), - } - ) - payload = normalized.model_dump(mode="python", warnings="error") - payload["pinned_object_ids"] = tuple(sorted(scene.pinned_object_ids)) - return sha256(canonical_json_bytes_v2(payload)).hexdigest() - - -def _receptacle_scene_sha256( - scene: Scene, - surface_patches: tuple[AI2ThorReceptacleSurfacePatch, ...], -) -> str: - """Preserve the legacy digest unless the new grid contract is active.""" - - if surface_patches: - return _canonical_scene_sha256(scene) - return _canonical_json_sha256(scene.model_dump(mode="json", warnings="error")) - - -def _native_positions_sha256( - positions: tuple[AI2ThorNativePosition, ...], -) -> str: - return _canonical_json_sha256( - tuple({"x": item.x, "y": item.y, "z": item.z} for item in positions) - ) - - -def _receptacle_spawn_source_sha256( - *, - scene_id: str, - subject_object_id: str, - support_object_id: str, - native_subject_object_id: str, - native_support_object_id: str, - runtime_identity: AI2ThorRuntimeIdentity, - positions_sha256: str, - scene_sha256: str, - surface_patches: tuple[AI2ThorReceptacleSurfacePatch, ...] = (), -) -> str: - payload: dict[str, object] = { - "action": "GetSpawnCoordinatesAboveReceptacle", - "anywhere": True, - "method": ( - "ai2thor-receptacle-trigger-grid-v1" - if surface_patches - else "ai2thor-receptacle-spawn-map-v1" - ), - "native_subject_object_id": native_subject_object_id, - "native_support_object_id": native_support_object_id, - "positions_sha256": positions_sha256, - "runtime_identity": asdict(runtime_identity), - "scene_id": scene_id, - "scene_sha256": scene_sha256, - "subject_object_id": subject_object_id, - "support_object_id": support_object_id, - } - if surface_patches: - payload["surface_patches"] = tuple(asdict(item) for item in surface_patches) - return _canonical_json_sha256(payload) - - -def _strict_native_position( - value: object, - label: str, -) -> AI2ThorNativePosition: - if type(value) is not AI2ThorNativePosition: - raise TypeError(f"{label} must be an exact AI2ThorNativePosition") - return AI2ThorNativePosition(x=value.x, y=value.y, z=value.z) - - -def _strict_receptacle_spawn_map( - value: object, -) -> AI2ThorReceptacleSpawnMap: - if type(value) is not AI2ThorReceptacleSpawnMap: - raise TypeError("spawn_map must be an exact AI2ThorReceptacleSpawnMap") - if type(value.positions) is not tuple: - raise TypeError("spawn map positions must be an exact tuple") - positions = tuple( - _strict_native_position(item, "spawn map position") for item in value.positions - ) - if type(value.runtime_identity) is not AI2ThorRuntimeIdentity: - raise TypeError("spawn map runtime identity has invalid type") - return AI2ThorReceptacleSpawnMap( - scene_id=value.scene_id, - subject_object_id=value.subject_object_id, - support_object_id=value.support_object_id, - native_subject_object_id=value.native_subject_object_id, - native_support_object_id=value.native_support_object_id, - runtime_identity=AI2ThorRuntimeIdentity(**asdict(value.runtime_identity)), - positions=positions, - positions_sha256=value.positions_sha256, - scene_sha256=value.scene_sha256, - source_sha256=value.source_sha256, - surface_patches=value.surface_patches, - ) - - -def capture_bound_ai2thor_receptacle_spawn_map( - spawn_map: AI2ThorReceptacleSpawnMap, - *, - fresh_scene: Scene, - frozen_scene: Scene, -) -> AI2ThorReceptacleSpawnMap: - """Rebind one exact fresh native query to a validated frozen scene digest. - - Only the scene digest is substituted. Runtime identity, native IDs, exact - native positions and trigger-grid patches all remain those returned by the - fresh query, so drift in any native evidence still changes the source - digest and fails its upstream lineage comparison. - """ - - checked = _strict_receptacle_spawn_map(spawn_map) - if type(fresh_scene) is not Scene or type(frozen_scene) is not Scene: - raise TypeError("capture-bound spawn scenes must be exact Scene values") - expected_fresh_scene_sha256 = _receptacle_scene_sha256( - fresh_scene, - checked.surface_patches, - ) - if ( - checked.scene_id != fresh_scene.scene_id - or checked.scene_sha256 != expected_fresh_scene_sha256 - or frozen_scene.scene_id != fresh_scene.scene_id - ): - raise ValueError("capture-bound spawn map does not bind the fresh scene") - frozen_scene_sha256 = _receptacle_scene_sha256( - frozen_scene, - checked.surface_patches, - ) - source_sha256 = _receptacle_spawn_source_sha256( - scene_id=checked.scene_id, - subject_object_id=checked.subject_object_id, - support_object_id=checked.support_object_id, - native_subject_object_id=checked.native_subject_object_id, - native_support_object_id=checked.native_support_object_id, - runtime_identity=checked.runtime_identity, - positions_sha256=checked.positions_sha256, - scene_sha256=frozen_scene_sha256, - surface_patches=checked.surface_patches, - ) - return AI2ThorReceptacleSpawnMap( - scene_id=checked.scene_id, - subject_object_id=checked.subject_object_id, - support_object_id=checked.support_object_id, - native_subject_object_id=checked.native_subject_object_id, - native_support_object_id=checked.native_support_object_id, - runtime_identity=checked.runtime_identity, - positions=checked.positions, - positions_sha256=checked.positions_sha256, - scene_sha256=frozen_scene_sha256, - source_sha256=source_sha256, - surface_patches=checked.surface_patches, - ) - - -def build_receptacle_support_position_region( - scene: Scene, - spawn_map: AI2ThorReceptacleSpawnMap, -) -> SubjectPositionRegion: - """Build the fixed-pose subject-anchor locus from trigger-grid evidence.""" - - checked = _strict_receptacle_spawn_map(spawn_map) - subject = scene.object_by_id(checked.subject_object_id) - scene_sha256 = _receptacle_scene_sha256(scene, checked.surface_patches) - if ( - checked.scene_id != scene.scene_id - or checked.scene_sha256 != scene_sha256 - or subject.support_object_id != checked.support_object_id - or not checked.surface_patches - ): - raise ValueError("receptacle surface patches do not bind the scene subject") - geometry = conservative_receptacle_position_geometry( - surface_patch_bounds_xy=tuple( - (item.x_min, item.z_min, item.x_max, item.z_max) - for item in checked.surface_patches - ), - subject=subject, - ) - return SubjectPositionRegion( - region_id=( - f"native-receptacle-trigger-grid:{subject.object_id}:" - f"{checked.source_sha256[:16]}" - ), - subject_object_id=subject.object_id, - source_kind="ai2thor-receptacle-trigger-grid-v1", - source_sha256=checked.source_sha256, - components=planar_polygon_payloads(geometry), - ) - - -@dataclass(frozen=True) -class AI2ThorNavigationFeasibilityMap: - """Source-bound conservative position region from one native nav grid.""" - - scene_id: str - subject_object_id: str - agent_radius_m: float - clearance_m: float - reachable_positions: tuple[AI2ThorNativePosition, ...] - reachable_positions_sha256: str - source_sha256: str - position_region: SubjectPositionRegion - - def __post_init__(self) -> None: - _nonempty_text(self.scene_id, "navigation scene_id") - _nonempty_text(self.subject_object_id, "navigation subject_object_id") - radius = _strict_finite_float(self.agent_radius_m, "navigation agent radius") - clearance = _strict_finite_float( - self.clearance_m, - "navigation clearance", - ) - if radius <= 0.0 or clearance < 0.0 or clearance >= radius: - raise ValueError( - "navigation agent radius must be positive and clearance smaller" - ) - object.__setattr__(self, "agent_radius_m", radius) - object.__setattr__(self, "clearance_m", clearance) - if type(self.reachable_positions) is not tuple or not self.reachable_positions: - raise ValueError("navigation reachable positions must be a non-empty tuple") - if any( - type(position) is not AI2ThorNativePosition - for position in self.reachable_positions - ): - raise ValueError("navigation positions must be native positions") - keys = tuple( - (position.x, position.z, position.y) - for position in self.reachable_positions - ) - if keys != tuple(sorted(keys)) or len(set(keys)) != len(keys): - raise ValueError("navigation positions must be unique and sorted") - for name in ("reachable_positions_sha256", "source_sha256"): - digest = getattr(self, name) - if ( - type(digest) is not str - or len(digest) != 64 - or any(character not in "0123456789abcdef" for character in digest) - ): - raise ValueError(f"{name} must be lowercase SHA-256 hex") - if type(self.position_region) is not SubjectPositionRegion: - raise ValueError("navigation position region has invalid type") - if ( - self.position_region.subject_object_id != self.subject_object_id - or self.position_region.source_sha256 != self.source_sha256 - ): - raise ValueError("navigation position region identity mismatch") - - -def build_navigation_feasibility_map( - scene: Scene, - *, - subject_object_id: str, - room_polygon_xy: tuple[Vec2, ...], - reachable_positions: tuple[AI2ThorNativePosition, ...], - agent_radius_m: float, - clearance_m: float, -) -> AI2ThorNavigationFeasibilityMap: - """Deterministically bind native navigation evidence to one Scene.""" - if ( - type(agent_radius_m) not in (int, float) - or not math.isfinite(float(agent_radius_m)) - or float(agent_radius_m) <= 0.0 - or type(clearance_m) not in (int, float) - or not math.isfinite(float(clearance_m)) - or float(clearance_m) < 0.0 - or float(clearance_m) >= float(agent_radius_m) - ): - raise ValueError( - "navigation radius must be positive and clearance non-negative " - "and smaller than the radius" - ) - subject = scene.object_by_id(subject_object_id) - positions_payload = [ - {"x": item.x, "y": item.y, "z": item.z} - for item in reachable_positions - ] - positions_bytes = json.dumps( - positions_payload, - allow_nan=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - positions_digest = sha256(positions_bytes).hexdigest() - source_payload = { - "agent_radius_m": float(agent_radius_m), - "clearance_m": float(clearance_m), - "method": "ai2thor-navigation-v1", - "reachable_positions_sha256": positions_digest, - "room_polygon_xy": [ - {"x": point.x, "y": point.y} for point in room_polygon_xy - ], - "scene_id": scene.scene_id, - "subject_object_id": subject.object_id, - "subject_obb": subject.obb.model_dump(mode="json"), - "subject_position": subject.position.model_dump(mode="json"), - } - source_digest = sha256( - json.dumps( - source_payload, - allow_nan=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - ).hexdigest() - geometry = conservative_navigation_position_geometry( - room_polygon_xy=room_polygon_xy, - reachable_positions_xy=tuple( - Vec2(x=position.x, y=position.z) for position in reachable_positions - ), - subject=subject, - agent_radius_m=float(agent_radius_m), - clearance_m=float(clearance_m), - ) - position_region = SubjectPositionRegion( - region_id=f"native-navigation:{subject.object_id}:{source_digest[:16]}", - subject_object_id=subject.object_id, - source_kind="ai2thor-navigation-v1", - source_sha256=source_digest, - components=planar_polygon_payloads(geometry), - ) - return AI2ThorNavigationFeasibilityMap( - scene_id=scene.scene_id, - subject_object_id=subject.object_id, - agent_radius_m=float(agent_radius_m), - clearance_m=float(clearance_m), - reachable_positions=reachable_positions, - reachable_positions_sha256=positions_digest, - source_sha256=source_digest, - position_region=position_region, - ) - - -def _domain_object_metadata(raw_objects: list[Any]) -> list[Any]: - return [ - item - for item in raw_objects - if ( - not isinstance(item, dict) - or item.get("objectType") not in _STRUCTURAL_OBJECT_TYPES - ) - ] - - -def _validated_native_object_metadata( - raw_objects: list[Any], -) -> list[dict[str, Any]]: - validated_objects: list[dict[str, Any]] = [] - object_ids: set[str] = set() - object_names: set[str] = set() - for item in raw_objects: - if type(item) is not dict: - raise AI2ThorNativeReturnError( - "AI2-THOR object collection entries must be exact dictionaries" - ) - object_id = item.get("objectId") - name = item.get("name") - category = item.get("objectType") - if any( - type(value) is not str or not value.strip() - for value in (object_id, name, category) - ): - raise AI2ThorNativeReturnError( - "AI2-THOR object ID, name, and type must be non-empty text" - ) - if object_id in object_ids or name in object_names: - raise AI2ThorNativeReturnError( - "AI2-THOR object IDs and names must be unique" - ) - object_ids.add(object_id) - object_names.add(name) - if any( - field in item and type(item[field]) is not bool - for field in ("moveable", "pickupable") - ): - raise AI2ThorNativeReturnError( - "AI2-THOR object mobility fields must be exact booleans" - ) - parents = item.get("parentReceptacles") - if parents is not None and ( - type(parents) is not list - or any(type(parent) is not str or not parent.strip() for parent in parents) - ): - raise AI2ThorNativeReturnError( - "AI2-THOR parent receptacles must be null or a list of " - "non-empty text IDs" - ) - validated_objects.append(item) - - for item in validated_objects: - for parent in item.get("parentReceptacles") or []: - if parent not in object_ids: - raise AI2ThorNativeReturnError( - f"observed support {parent!r} has no stable object identity" - ) - - return [ - { - **item, - "parentReceptacles": list(item.get("parentReceptacles") or []), - } - for item in validated_objects - ] - - -def _cyclic_domain_object_ids( - graph: dict[str, tuple[str, ...]], -) -> frozenset[str]: - """Return every member of every directed cycle, including self-loops.""" - - index = 0 - indices: dict[str, int] = {} - lowlinks: dict[str, int] = {} - stack: list[str] = [] - active: set[str] = set() - cyclic: set[str] = set() - - def visit(node: str) -> None: - nonlocal index - indices[node] = index - lowlinks[node] = index - index += 1 - stack.append(node) - active.add(node) - for parent in graph.get(node, ()): - if parent not in graph: - continue - if parent not in indices: - visit(parent) - lowlinks[node] = min(lowlinks[node], lowlinks[parent]) - elif parent in active: - lowlinks[node] = min(lowlinks[node], indices[parent]) - if lowlinks[node] != indices[node]: - return - component: list[str] = [] - while True: - member = stack.pop() - active.remove(member) - component.append(member) - if member == node: - break - if len(component) > 1 or node in graph.get(node, ()): - cyclic.update(component) - - for node in sorted(graph): - if node not in indices: - visit(node) - return frozenset(cyclic) - - -def build_ai2thor_native_support_facts( - scene: Scene, - raw_objects: list[object], -) -> tuple[AI2ThorNativeSupportFact, ...]: - """Validate and normalize native parent lineage without a platform action.""" - - if type(scene) is not Scene: - raise TypeError("native support scene must be an exact Scene") - if type(raw_objects) is not list: - raise TypeError("native support raw_objects must be an exact list") - validated = _validated_native_object_metadata(raw_objects) - raw_by_id = { - item["objectId"]: (item["name"], item["objectType"]) for item in validated - } - scene_ids = tuple(item.object_id for item in scene.objects) - scene_names = tuple(item.name for item in scene.objects) - if len(scene_ids) != len(set(scene_ids)) or len(scene_names) != len(set(scene_names)): - raise AI2ThorNativeReturnError( - "stable scene object IDs and names must be unique" - ) - scene_by_name = {item.name: item for item in scene.objects} - raw_domain_names = { - item["name"] for item in validated if item["objectType"] not in _STRUCTURAL_OBJECT_TYPES - } - if set(scene_by_name) != raw_domain_names: - raise AI2ThorNativeReturnError( - "native and stable scene object name rosters must match" - ) - native_by_name = {item["name"]: item for item in validated} - - normalized: dict[ - str, - tuple[str, str, tuple[str, ...], tuple[str, ...], tuple[str, ...]], - ] = {} - for object_name, scene_object in scene_by_name.items(): - native = native_by_name[object_name] - raw_parents = tuple(sorted(set(native["parentReceptacles"]))) - structural: list[str] = [] - domain: list[str] = [] - normalized_raw: list[str] = [] - for raw_parent in raw_parents: - parent_name, parent_type = raw_by_id[raw_parent] - if parent_type in _STRUCTURAL_OBJECT_TYPES: - structural.append(raw_parent) - normalized_raw.append(raw_parent) - else: - try: - stable_parent = scene_by_name[parent_name].object_id - except KeyError as error: - raise AI2ThorNativeReturnError( - f"observed support {raw_parent!r} has no stable object identity" - ) from error - domain.append(stable_parent) - normalized_raw.append(stable_parent) - normalized[scene_object.object_id] = ( - object_name, - native["objectId"], - tuple(sorted(set(normalized_raw))), - tuple(sorted(set(structural))), - tuple(sorted(set(domain))), - ) - - graph = {object_id: values[4] for object_id, values in normalized.items()} - cyclic = _cyclic_domain_object_ids(graph) - facts: list[AI2ThorNativeSupportFact] = [] - for object_id in sorted(normalized): - object_name, native_object_id, raw, structural, domain = normalized[object_id] - floor_parents = tuple( - parent for parent in structural if raw_by_id[parent][1] == "Floor" - ) - plausible_count = len(domain) + len(floor_parents) - if object_id in cyclic: - kind = AI2ThorNativeSupportKind.CYCLIC - support_object_id = None - floor_object_id = None - elif plausible_count > 1: - kind = AI2ThorNativeSupportKind.MULTIPLE_AMBIGUOUS - support_object_id = None - floor_object_id = None - elif len(domain) == 1 and not structural: - kind = AI2ThorNativeSupportKind.RECEPTACLE - support_object_id = domain[0] - floor_object_id = None - elif len(floor_parents) == 1 and not domain and len(structural) == 1: - kind = AI2ThorNativeSupportKind.FLOOR - support_object_id = None - floor_object_id = floor_parents[0] - else: - kind = AI2ThorNativeSupportKind.UNKNOWN - support_object_id = None - floor_object_id = None - # Structural parents other than Floor are raw provenance, not usable - # support. Keep them out of the structural support partition so the - # UNKNOWN invariant remains explicit. - structural = () - facts.append( - AI2ThorNativeSupportFact( - scene_id=scene.scene_id, - object_id=object_id, - object_name=object_name, - native_object_id=native_object_id, - raw_parent_object_ids=raw, - structural_parent_object_ids=structural, - domain_parent_object_ids=domain, - support_kind=kind, - support_object_id=support_object_id, - floor_object_id=floor_object_id, - ) - ) - return tuple(facts) - - -def _load_default_controller_type() -> type[Any]: - try: - with warnings.catch_warnings(): - # AI2-THOR 5.0.0 contains escaped spaces in diagnostic-only string - # literals. Import it inside callers' fail-closed warning scopes - # without weakening warnings emitted by our code or at runtime. - warnings.filterwarnings( - "ignore", - message=r"invalid escape sequence '\\ '", - category=DeprecationWarning, - ) - from ai2thor.controller import Controller - except ImportError as exc: - raise RuntimeError( - "AI2-THOR is not installed. Install spatialcf[sim] with Python 3.11." - ) from exc - return Controller - - -def _default_controller_factory(**kwargs: Any) -> Any: - return _load_default_controller_type()(**kwargs) - - -def _quaternion_yaw(rotation: Quaternion) -> float: - norm = math.sqrt( - rotation.x**2 + rotation.y**2 + rotation.z**2 + rotation.w**2 - ) - if not math.isfinite(norm) or norm <= _EPSILON: - raise ValueError("object rotation must be a finite non-zero quaternion") - x, y, z, w = ( - rotation.x / norm, - rotation.y / norm, - rotation.z / norm, - rotation.w / norm, - ) - return math.atan2( - 2.0 * (w * z + x * y), - 1.0 - 2.0 * (y * y + z * z), - ) - - -def _rotation_matrix(rotation: Quaternion) -> np.ndarray: - norm = math.sqrt( - rotation.x**2 + rotation.y**2 + rotation.z**2 + rotation.w**2 - ) - if not math.isfinite(norm) or norm <= _EPSILON: - raise ValueError("rotation must be a finite non-zero quaternion") - x, y, z, w = ( - rotation.x / norm, - rotation.y / norm, - rotation.z / norm, - rotation.w / norm, - ) - return np.asarray( - [ - [ - 1.0 - 2.0 * (y * y + z * z), - 2.0 * (x * y - z * w), - 2.0 * (x * z + y * w), - ], - [ - 2.0 * (x * y + z * w), - 1.0 - 2.0 * (x * x + z * z), - 2.0 * (y * z - x * w), - ], - [ - 2.0 * (x * z - y * w), - 2.0 * (y * z + x * w), - 1.0 - 2.0 * (x * x + y * y), - ], - ], - dtype=float, - ) - - -def _quaternions_close(left: Quaternion, right: Quaternion) -> bool: - a = np.asarray([left.x, left.y, left.z, left.w], dtype=float) - b = np.asarray([right.x, right.y, right.z, right.w], dtype=float) - a /= np.linalg.norm(a) - b /= np.linalg.norm(b) - return bool( - np.allclose(a, b, atol=1e-5, rtol=0.0) - or np.allclose(a, -b, atol=1e-5, rtol=0.0) - ) - - -class AI2ThorAdapter: - def __init__( - self, - scene_names: list[str], - width: int, - height: int, - seed: int, - *, - controller_factory: ControllerFactory | None = None, - allow_source_pose_drift: bool = False, - procedural_scenes: Mapping[str, AI2ThorProceduralScene] | None = None, - ) -> None: - if type(scene_names) is not list or any( - type(name) is not str for name in scene_names - ): - raise ValueError("scene_names must be an exact string list") - if type(width) is not int or type(height) is not int: - raise ValueError("render dimensions must be exact integers") - if type(seed) is not int: - raise ValueError("seed must be an exact integer") - if type(allow_source_pose_drift) is not bool: - raise ValueError("allow_source_pose_drift must be an exact boolean") - if not scene_names: - raise ValueError("at least one scene is required") - if len(set(scene_names)) != len(scene_names): - raise ValueError("scene names must be unique") - if width <= 0 or height <= 0: - raise ValueError("render dimensions must be positive") - if procedural_scenes is None: - procedural_by_alias: dict[str, AI2ThorProceduralScene] = {} - else: - if not isinstance(procedural_scenes, Mapping): - raise ValueError("procedural_scenes must be a mapping") - procedural_by_alias = {} - for alias, source in procedural_scenes.items(): - if type(alias) is not str: - raise ValueError("procedural_scenes keys must be exact strings") - if type(source) is not AI2ThorProceduralScene: - raise ValueError( - "procedural_scenes values must be AI2ThorProceduralScene" - ) - procedural_by_alias[alias] = source - if not set(procedural_by_alias).issubset(scene_names): - raise ValueError("procedural_scenes keys must be a scene_names subset") - self.scene_names = list(scene_names) - self.scene_name = self.scene_names[0] - self.width = width - self.height = height - self.seed = seed - self.allow_source_pose_drift = allow_source_pose_drift - self.procedural_scenes: Mapping[str, AI2ThorProceduralScene] = ( - MappingProxyType(dict(procedural_by_alias)) - ) - self.controller: Any | None = None - self._controller_factory = controller_factory or _default_controller_factory - self._event: Any | None = None - self._latest_event: Any | None = None - self._stopped = False - self._current_scene: Scene | None = None - self._camera_states: dict[ - tuple[str, tuple[float, ...], tuple[float, ...]], dict[str, Any] - ] = {} - self._native_rotations: dict[tuple[str, str], dict[str, float]] = {} - self._isolated_controller_refs: list[ReferenceType[Any]] = [] - - def __enter__(self) -> AI2ThorAdapter: - if self.controller is not None: - raise RuntimeError("adapter context is already active") - self._stopped = False - self.scene_name = self.scene_names[0] - self._latest_event = None - self._camera_states.clear() - self._native_rotations.clear() - try: - self.controller = self._start_controller( - scene=self._native_scene_input(self.scene_name), - width=self.width, - height=self.height, - renderDepthImage=True, - renderInstanceSegmentation=True, - gridSize=_NATIVE_NAVIGATION_GRID_SIZE_M, - snapToGrid=True, - rotateStepDegrees=90, - ) - self._validate_controller_source_or_poison( - self.controller, self.scene_name - ) - event = self._step(self.controller, "Pass", action="Pass") - self._event = event - self._current_scene = None - self._event = self._checked_scene_event( - self.controller, - event, - "Pass", - self.scene_name, - ) - return self - except BaseException as error: - try: - self._stop() - except AI2ThorRuntimeError as cleanup_error: - error.add_note(f"AI2-THOR cleanup also failed: {cleanup_error}") - raise - - def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None: - try: - self._stop() - except AI2ThorRuntimeError as cleanup_error: - if exc is None: - raise - exc.add_note(f"AI2-THOR cleanup also failed: {cleanup_error}") - - def _stop(self) -> None: - if self.controller is not None and not self._stopped: - self._stopped = True - controller = self.controller - self.controller = None - self._event = None - self._latest_event = None - self._current_scene = None - try: - controller.stop() - except OSError as error: - raise AI2ThorRuntimeError( - f"AI2-THOR controller stop I/O failed: {error}" - ) from error - except MemoryError: - raise - except Exception as error: - raise AI2ThorRuntimeError( - f"AI2-THOR controller stop failed: {error}" - ) from error - else: - self.controller = None - self._event = None - self._latest_event = None - self._current_scene = None - - def _start_controller(self, **kwargs: Any) -> Any: - try: - return self._controller_factory(**kwargs) - except OSError as error: - raise AI2ThorRuntimeError( - f"AI2-THOR controller startup I/O failed: {error}" - ) from error - except MemoryError: - raise - except Exception as error: - raise AI2ThorRuntimeError( - f"AI2-THOR controller startup failed: {error}" - ) from error - - @staticmethod - def _step(controller: Any, label: str, **kwargs: Any) -> Any: - try: - return controller.step(**kwargs) - except OSError as error: - raise AI2ThorRuntimeError( - f"AI2-THOR {label} step I/O failed: {error}" - ) from error - except MemoryError: - raise - except Exception as error: - raise AI2ThorRuntimeError( - f"AI2-THOR {label} step failed: {error}" - ) from error - - def _native_scene_input(self, scene_id: str) -> str | dict[str, Any]: - source = self.procedural_scenes.get(scene_id) - return scene_id if source is None else source.decode_house() - - def _poison_scene_state(self) -> None: - self._event = None - self._current_scene = None - - def _validate_controller_source( - self, - controller: Any, - scene_id: str, - ) -> None: - source = self.procedural_scenes.get(scene_id) - if source is None: - controller_scene = getattr(controller, "scene", None) - if type(controller_scene) is dict: - raise RuntimeError("legacy source cannot use a controller house dict") - if controller_scene not in {scene_id, f"{scene_id}_physics"}: - raise RuntimeError( - "legacy controller scene does not match the registered scene" - ) - return - try: - canonical = _canonical_house_json_bytes(controller.scene) - except (AttributeError, ValueError) as error: - raise RuntimeError( - "controller procedural source SHA-256 cannot be verified" - ) from error - if sha256(canonical).hexdigest() != source.house_sha256: - raise RuntimeError("controller procedural source SHA-256 changed") - - def _validate_controller_source_or_poison( - self, - controller: Any, - scene_id: str, - ) -> None: - try: - self._validate_controller_source(controller, scene_id) - except BaseException: - self._poison_scene_state() - raise - - def _validate_scene_source_or_poison( - self, - controller: Any, - scene_id: str, - event: Any, - ) -> None: - try: - self._validate_controller_source(controller, scene_id) - native_scene_name = self._native_scene_name(event) - if scene_id in self.procedural_scenes: - if native_scene_name != "Procedural": - raise RuntimeError( - "registered procedural source requires native Procedural scene" - ) - else: - if native_scene_name == "Procedural": - raise RuntimeError( - "legacy source cannot use native Procedural scene" - ) - if native_scene_name != controller.scene: - raise RuntimeError( - "legacy controller and event native scene names differ" - ) - except BaseException: - self._poison_scene_state() - raise - - def _checked_scene_event( - self, - controller: Any, - event: Any, - action: str, - scene_id: str, - ) -> Any: - # Keep the raw returned event even when action or identity validation - # fails. Candidate rejection classification may inspect it later, but - # that path does not treat it as trusted without independently - # revalidating the relevant fields. - self._latest_event = event - try: - checked = self._checked_event(event, action) - self._validate_scene_source_or_poison( - controller, - scene_id, - checked, - ) - except BaseException: - self._poison_scene_state() - raise - return checked - - def _reset(self, controller: Any, scene_id: str) -> Any: - try: - return controller.reset(scene=self._native_scene_input(scene_id)) - except OSError as error: - raise AI2ThorRuntimeError( - f"AI2-THOR reset {scene_id} I/O failed: {error}" - ) from error - except MemoryError: - raise - except Exception as error: - raise AI2ThorRuntimeError( - f"AI2-THOR reset {scene_id} failed: {error}" - ) from error - - def _require_active(self) -> Any: - if self.controller is None: - raise RuntimeError("adapter must be used as a context manager") - return self.controller - - @staticmethod - def _checked_event(event: Any, action: str) -> Any: - metadata = getattr(event, "metadata", None) - if not isinstance(metadata, dict): - raise RuntimeError(f"{action} returned an event without metadata") - if metadata.get("lastActionSuccess") is not True: - message = metadata.get("errorMessage") or f"{action} failed" - raise RuntimeError(str(message)) - return event - - def list_scene_ids(self) -> list[str]: - return list(self.scene_names) - - def runtime_identity(self) -> AI2ThorRuntimeIdentity: - """Return the active package/build/configuration identity without action.""" - controller = self._require_active() - if self._event is None: - raise RuntimeError("no AI2-THOR event is available") - self._validate_scene_source_or_poison( - controller, - self.scene_name, - self._event, - ) - build = getattr(controller, "_build", None) - commit_id = getattr(build, "commit_id", None) - if type(commit_id) is not str or not commit_id: - raise RuntimeError("AI2-THOR controller has no Unity build identity") - try: - installed_version = package_version("ai2thor") - except PackageNotFoundError as error: - raise RuntimeError("AI2-THOR package identity is unavailable") from error - source = self.procedural_scenes.get(self.scene_name) - return AI2ThorRuntimeIdentity( - ai2thor_version=installed_version, - unity_commit_id=commit_id, - native_scene_name=self._native_scene_name(self._event), - width=self.width, - height=self.height, - seed=self.seed, - source_dataset_id=None if source is None else source.dataset_id, - source_revision=None if source is None else source.revision, - source_split=None if source is None else source.split, - source_index=None if source is None else source.index, - source_sha256=None if source is None else source.house_sha256, - source_scene_alias=None if source is None else self.scene_name, - source_loader_id=None if source is None else source.source_loader_id, - source_loader_version=( - None if source is None else source.source_loader_version - ), - source_room_id=None if source is None else source.room_id, - source_floor_xz_bounds=( - None if source is None else source.floor_xz_bounds - ), - teleport_vertical_guard_m=_TELEPORT_VERTICAL_GUARD_M, - ) - - def latest_native_event(self, scene_id: str) -> Any: - """Return latest raw event after source and native-scene revalidation.""" - controller = self._require_active() - if scene_id not in self.scene_names: - raise KeyError(scene_id) - if self._latest_event is None: - raise RuntimeError("adapter has no latest native event") - self._validate_scene_source_or_poison( - controller, - scene_id, - self._latest_event, - ) - return self._latest_event - - def native_support_facts( - self, - scene: Scene, - ) -> tuple[AI2ThorNativeSupportFact, ...]: - """Read support lineage from the exact current event without an action.""" - - event = self._current_event_for_scene(scene) - raw_objects = event.metadata.get("objects") - if type(raw_objects) is not list: - raise AI2ThorNativeReturnError("invalid AI2-THOR object collection") - return build_ai2thor_native_support_facts(scene, raw_objects) - - def current_agent_pose(self, scene: Scene) -> AI2ThorAgentPose: - """Read the agent pose from the exact current event without an action.""" - - return self._native_agent_pose(self._current_event_for_scene(scene)) - - def _current_event_for_scene(self, scene: Scene) -> Any: - controller = self._require_active() - if ( - self._event is None - or self.scene_name != scene.scene_id - or self._current_scene != scene - ): - raise RuntimeError( - "scene must be the adapter's exact current scene and event" - ) - self._validate_scene_source_or_poison( - controller, - scene.scene_id, - self._event, - ) - return self._event - - @staticmethod - def _is_analysis_overlay( - current: Scene, - requested: Scene, - ) -> bool: - if current == requested: - return False - return ( - requested.model_copy( - update={ - "room_polygon_xy": current.room_polygon_xy, - "collision_obstacles": current.collision_obstacles, - "subject_position_regions": current.subject_position_regions, - } - ) - == current - ) - - @staticmethod - def _native_position( - value: Any, - label: str, - *, - error_type: type[Exception] = ValueError, - ) -> AI2ThorNativePosition: - if type(value) is not dict or set(value) != {"x", "y", "z"}: - raise error_type(f"{label} must have exactly x/y/z keys") - try: - return AI2ThorNativePosition( - x=_strict_finite_float(value["x"], f"{label} x"), - y=_strict_finite_float(value["y"], f"{label} y"), - z=_strict_finite_float(value["z"], f"{label} z"), - ) - except ValueError as error: - if error_type is ValueError: - raise - raise error_type(str(error)) from error - - @staticmethod - def _native_scene_name(event: Any) -> str: - metadata = getattr(event, "metadata", None) - if not isinstance(metadata, dict): - raise TypeError("AI2-THOR event has no metadata") - scene_name = metadata.get("sceneName") - if type(scene_name) is not str or not scene_name: - raise RuntimeError("AI2-THOR event has no valid native scene name") - return scene_name - - @classmethod - def _validate_native_scene_name( - cls, - event: Any, - expected_native_scene_name: str, - ) -> None: - if cls._native_scene_name(event) != expected_native_scene_name: - raise RuntimeError( - "AI2-THOR event scene name changed during camera operation" - ) - - def _validate_native_scene_name_or_poison( - self, - event: Any, - expected_native_scene_name: str, - ) -> None: - try: - self._validate_native_scene_name(event, expected_native_scene_name) - except BaseException: - self._poison_scene_state() - raise - - @classmethod - def _native_agent_pose(cls, event: Any) -> AI2ThorAgentPose: - metadata = event.metadata - agent = metadata.get("agent") - if type(agent) is not dict: - raise ValueError("AI2-THOR event has no valid agent pose") - position = cls._native_position(agent.get("position"), "agent position") - rotation = agent.get("rotation") - if type(rotation) is not dict or set(rotation) != {"x", "y", "z"}: - raise ValueError("agent rotation must have exactly x/y/z keys") - rotation_values = { - axis: _strict_finite_float(rotation[axis], f"agent rotation {axis}") - for axis in ("x", "y", "z") - } - if not cls._angles_close(rotation_values["x"], 0.0) or not cls._angles_close( - rotation_values["z"], 0.0 - ): - raise RuntimeError("camera pose returned non-zero pitch or roll") - - horizons: list[float] = [] - if "cameraHorizon" in metadata: - horizons.append( - _strict_finite_float( - metadata["cameraHorizon"], - "top-level camera horizon", - ) - ) - if "cameraHorizon" in agent: - horizons.append( - _strict_finite_float( - agent["cameraHorizon"], - "agent camera horizon", - ) - ) - if not horizons: - raise ValueError("AI2-THOR event has no camera horizon") - if any(not cls._angles_close(horizons[0], horizon) for horizon in horizons[1:]): - raise RuntimeError("AI2-THOR camera horizon metadata is inconsistent") - standing = agent.get("isStanding") - if type(standing) is not bool: - raise ValueError("AI2-THOR agent standing must be an exact boolean") - return AI2ThorAgentPose( - position=position, - yaw_degrees=rotation_values["y"], - horizon_degrees=horizons[0], - standing=standing, - ) - - @classmethod - def _native_scene_fully_settled( - cls, - event: Any, - expected_categories_by_name: dict[str, str], - ) -> bool: - raw_objects = event.metadata.get("objects") - if type(raw_objects) is not list: - raise AI2ThorNativeReturnError( - "scene settlement returned no object metadata" - ) - raw_objects = _validated_native_object_metadata(raw_objects) - domain_objects = _domain_object_metadata(raw_objects) - categories_by_name: dict[str, str] = {} - any_object_moving = False - for item in domain_objects: - if type(item) is not dict: - raise AI2ThorNativeReturnError( - "scene settlement returned invalid domain object metadata" - ) - name = item.get("name") - category = item.get("objectType") - if ( - type(name) is not str - or not name - or type(category) is not str - or not category - ): - raise AI2ThorNativeReturnError( - "scene settlement returned invalid object name/category metadata" - ) - if name in categories_by_name: - raise AI2ThorNativeReturnError( - "scene settlement returned duplicate stable object names" - ) - categories_by_name[name] = category - is_moving = item.get("isMoving") - if type(is_moving) is not bool: - raise AI2ThorNativeReturnError( - f"AI2-THOR isMoving for object {name!r} must be an exact boolean" - ) - any_object_moving = any_object_moving or is_moving - if categories_by_name != expected_categories_by_name: - raise AI2ThorNativeReturnError( - "object name/category mapping changed during scene settlement" - ) - return cls._native_scene_at_rest(event) and not any_object_moving - - @staticmethod - def _angle_residual_degrees(left: float, right: float) -> float: - return abs((left - right + 180.0) % 360.0 - 180.0) - - @classmethod - def _validate_camera_object_invariants( - cls, - source: Scene, - observed: Scene, - ) -> None: - cls._validate_camera_object_identity_invariants(source, observed) - source_by_name = cls._objects_by_name(source.objects) - observed_by_name = cls._objects_by_name(observed.objects) - - for name, original in source_by_name.items(): - current = observed_by_name[name] - if ( - not np.allclose( - ( - current.position.x, - current.position.y, - current.position.z, - current.obb.center.x, - current.obb.center.y, - current.obb.center.z, - current.obb.extent.x, - current.obb.extent.y, - current.obb.extent.z, - ), - ( - original.position.x, - original.position.y, - original.position.z, - original.obb.center.x, - original.obb.center.y, - original.obb.center.z, - original.obb.extent.x, - original.obb.extent.y, - original.obb.extent.z, - ), - atol=_OBJECT_GEOMETRY_TOLERANCE_M, - rtol=0.0, - ) - or not _quaternions_close( - current.rotation, - original.rotation, - ) - or not _quaternions_close( - current.obb.rotation, - original.obb.rotation, - ) - ): - raise RuntimeError( - f"object {name!r} geometry changed during camera application" - ) - - @classmethod - def _validate_camera_object_identity_invariants( - cls, - source: Scene, - observed: Scene, - ) -> None: - source_by_name = cls._objects_by_name(source.objects) - observed_by_name = cls._objects_by_name(observed.objects) - if set(source_by_name) != set(observed_by_name): - raise RuntimeError("stable object names changed during camera application") - - for name, original in source_by_name.items(): - current = observed_by_name[name] - if ( - current.object_id != original.object_id - or current.name != original.name - or current.category != original.category - or current.movable is not original.movable - or current.request_eligible is not original.request_eligible - or current.support_object_id != original.support_object_id - ): - raise RuntimeError( - f"object {name!r} identity, category, mobility, or support changed " - "during camera application" - ) - - def reachable_agent_positions( - self, - scene: Scene, - ) -> tuple[AI2ThorNativePosition, ...]: - """Return a deterministic, strictly validated native navigation grid.""" - controller = self._require_active() - current_event = self._current_event_for_scene(scene) - expected_native_scene_name = self._native_scene_name(current_event) - expected_positions = {obj.name: obj.position for obj in scene.objects} - expected_rotations = { - obj.name: self._native_rotation_for(scene.scene_id, obj) - for obj in scene.objects - } - try: - event = self._step( - controller, - "GetReachablePositions", - action="GetReachablePositions", - ) - event = self._checked_scene_event( - controller, - event, - "GetReachablePositions", - scene.scene_id, - ) - self._validate_native_scene_name_or_poison( - event, - expected_native_scene_name, - ) - self._validate_returned_state( - scene, - event, - expected_positions, - expected_rotations, - ) - action_return = event.metadata.get("actionReturn") - if type(action_return) is not list: - raise ValueError( - "GetReachablePositions actionReturn must be a list" - ) - keyed_positions: list[ - tuple[tuple[int, int, int], AI2ThorNativePosition] - ] = [] - seen_keys: set[tuple[int, int, int]] = set() - for index, raw_position in enumerate(action_return): - position = self._native_position( - raw_position, - f"reachable position {index}", - ) - quantized_key = tuple( - round(value / _REACHABLE_POSITION_QUANTIZATION_M) - for value in (position.x, position.z, position.y) - ) - if quantized_key in seen_keys: - raise ValueError( - "GetReachablePositions returned a duplicate quantized position" - ) - seen_keys.add(quantized_key) - keyed_positions.append((quantized_key, position)) - except BaseException: - self._poison_scene_state() - raise - keyed_positions.sort(key=lambda item: item[0]) - self._event = event - self._current_scene = scene - return tuple(position for _, position in keyed_positions) - - def receptacle_spawn_map( - self, - scene: Scene, - *, - subject_object_id: str, - ) -> AI2ThorReceptacleSpawnMap: - """Capture deterministic native receptacle coordinates for one subject. - - Returned coordinates are source facts, not collision-free placements. - The query is executed once against the exact current source event and - cannot be used as a per-candidate platform search loop. - """ - - controller = self._require_active() - current_event = self._current_event_for_scene(scene) - subject = scene.object_by_id(subject_object_id) - support_object_id = subject.support_object_id - if support_object_id is None: - raise ValueError("receptacle spawn subject has no declared support") - support = scene.object_by_id(support_object_id) - runtime_identity = self.runtime_identity() - expected_native_scene_name = self._native_scene_name(current_event) - expected_positions = {obj.name: obj.position for obj in scene.objects} - expected_rotations = { - obj.name: self._native_rotation_for(scene.scene_id, obj) - for obj in scene.objects - } - native_subject_object_id = self._native_object_id_for_name( - current_event, - subject.name, - ) - native_support_object_id = self._native_object_id_for_name( - current_event, - support.name, - ) - try: - event = self._step( - controller, - "GetSpawnCoordinatesAboveReceptacle", - action="GetSpawnCoordinatesAboveReceptacle", - objectId=native_support_object_id, - anywhere=True, - ) - event = self._checked_scene_event( - controller, - event, - "GetSpawnCoordinatesAboveReceptacle", - scene.scene_id, - ) - self._validate_native_scene_name_or_poison( - event, - expected_native_scene_name, - ) - self._validate_returned_state( - scene, - event, - expected_positions, - expected_rotations, - ) - action_return = event.metadata.get("actionReturn") - if type(action_return) is not list or not action_return: - raise AI2ThorNativeReturnError( - "GetSpawnCoordinatesAboveReceptacle actionReturn must be " - "a non-empty list" - ) - raw_positions: list[AI2ThorNativePosition] = [] - keyed_positions: list[ - tuple[tuple[int, int, int], AI2ThorNativePosition] - ] = [] - seen_keys: set[tuple[int, int, int]] = set() - for index, raw_position in enumerate(action_return): - position = self._native_position( - raw_position, - f"receptacle spawn position {index}", - error_type=AI2ThorNativeReturnError, - ) - raw_positions.append(position) - quantized_key = tuple( - round(value / _REACHABLE_POSITION_QUANTIZATION_M) - for value in (position.x, position.z, position.y) - ) - if quantized_key in seen_keys: - raise AI2ThorNativeReturnError( - "GetSpawnCoordinatesAboveReceptacle returned a duplicate " - "quantized position" - ) - seen_keys.add(quantized_key) - keyed_positions.append((quantized_key, position)) - except BaseException: - self._poison_scene_state() - raise - positions = tuple( - sorted( - (position for _, position in keyed_positions), - key=lambda item: (item.x, item.z, item.y), - ) - ) - surface_patches = build_ai2thor_receptacle_surface_patches( - tuple(raw_positions) - ) - positions_sha256 = _native_positions_sha256(positions) - scene_sha256 = _receptacle_scene_sha256(scene, surface_patches) - source_sha256 = _receptacle_spawn_source_sha256( - scene_id=scene.scene_id, - subject_object_id=subject.object_id, - support_object_id=support.object_id, - native_subject_object_id=native_subject_object_id, - native_support_object_id=native_support_object_id, - runtime_identity=runtime_identity, - positions_sha256=positions_sha256, - scene_sha256=scene_sha256, - surface_patches=surface_patches, - ) - spawn_map = AI2ThorReceptacleSpawnMap( - scene_id=scene.scene_id, - subject_object_id=subject.object_id, - support_object_id=support.object_id, - native_subject_object_id=native_subject_object_id, - native_support_object_id=native_support_object_id, - runtime_identity=runtime_identity, - positions=positions, - positions_sha256=positions_sha256, - scene_sha256=scene_sha256, - source_sha256=source_sha256, - surface_patches=surface_patches, - ) - self._event = event - self._current_scene = scene - return spawn_map - - def settle_scene_observed( - self, - scene: Scene, - max_pass_steps: int = 30, - ) -> AI2ThorSceneSettlement: - """Advance physics until the scene and every domain object are still.""" - controller = self._require_active() - if type(max_pass_steps) is not int or max_pass_steps <= 0: - raise ValueError("max_pass_steps must be an exact positive integer") - event = self._current_event_for_scene(scene) - expected_categories_by_name = { - name: obj.category - for name, obj in self._objects_by_name(scene.objects).items() - } - previous_camera_states = deepcopy(self._camera_states) - previous_native_rotations = deepcopy(self._native_rotations) - pass_steps = 0 - try: - expected_native_scene_name = self._native_scene_name(event) - while True: - self._validate_native_scene_name_or_poison( - event, - expected_native_scene_name, - ) - settled = self._native_scene_fully_settled( - event, - expected_categories_by_name, - ) - if settled: - native_observed = self._scene_from_event(scene.scene_id, event) - observed_scene = self._stable_observed_scene( - scene, - native_observed, - ) - observation = self._observation_from_event( - observed_scene, - event, - ) - result = AI2ThorSceneSettlement( - observed_scene=observed_scene, - observation=observation, - pass_steps=pass_steps, - ) - self._event = event - self._current_scene = observed_scene - return result - if pass_steps >= max_pass_steps: - raise AI2ThorSettlementTimeout( - "AI2-THOR scene did not settle within " - f"{max_pass_steps} Pass steps" - ) - event = self._step( - controller, - "Pass", - action="Pass", - ) - self._event = event - self._current_scene = None - pass_steps += 1 - event = self._checked_scene_event( - controller, - event, - "Pass", - scene.scene_id, - ) - except BaseException: - self._camera_states = previous_camera_states - self._native_rotations = previous_native_rotations - self._current_scene = None - raise - - @contextmanager - def isolated_scene_observed( - self, - source: Scene, - max_pass_steps: int = 30, - ) -> Iterator[AI2ThorIsolatedEpisode]: - """Open one target-only controller and yield its settled source. - - The parent adapter remains the preparation owner and is never reset or - mutated. Native object IDs may differ in the child, so its observed - baseline is rebound to the frozen source IDs and analysis overlays by - unique object name. Geometry, views, assets, and rest status all come - from the child's same final trusted event. - """ - parent_controller = self._require_active() - if type(source) is not Scene: - raise ValueError("source must be an exact canonical Scene") - if source.scene_id not in self.scene_names: - raise KeyError(source.scene_id) - if type(max_pass_steps) is not int or max_pass_steps <= 0: - raise ValueError("max_pass_steps must be an exact positive integer") - procedural = self.procedural_scenes.get(source.scene_id) - - def isolated_controller_factory(**kwargs: Any) -> Any: - controller = self._controller_factory(**kwargs) - if controller is parent_controller: - raise RuntimeError( - "isolated AI2-THOR episode reused the parent controller" - ) - live_refs: list[ReferenceType[Any]] = [] - for controller_ref in self._isolated_controller_refs: - previous = controller_ref() - if previous is None: - continue - live_refs.append(controller_ref) - if controller is previous: - raise RuntimeError( - "isolated AI2-THOR episode reused a prior child controller" - ) - try: - live_refs.append(ref(controller)) - except TypeError as error: - raise RuntimeError( - "isolated AI2-THOR controller must support weak references" - ) from error - self._isolated_controller_refs = live_refs - return controller - - child = AI2ThorAdapter( - [source.scene_id], - width=self.width, - height=self.height, - seed=self.seed, - controller_factory=isolated_controller_factory, - allow_source_pose_drift=self.allow_source_pose_drift, - procedural_scenes=( - {source.scene_id: procedural} - if procedural is not None - else None - ), - ) - with child: - event = child._activate_scene(source.scene_id) - native_scene = child._scene_from_event(source.scene_id, event) - child._current_scene = native_scene - native_settlement = child.settle_scene_observed( - native_scene, - max_pass_steps=max_pass_steps, - ) - final_event = child._current_event_for_scene( - native_settlement.observed_scene - ) - stable_scene = child._stable_observed_scene( - source, - native_settlement.observed_scene, - ) - observation = child._observation_from_event(stable_scene, final_event) - settlement = AI2ThorSceneSettlement( - observed_scene=stable_scene, - observation=observation, - pass_steps=native_settlement.pass_steps, - ) - child._current_scene = stable_scene - yield AI2ThorIsolatedEpisode( - adapter=child, - baseline_settlement=settlement, - ) - - def apply_camera_pose_observed( - self, - scene: Scene, - pose: AI2ThorAgentPose, - ) -> AI2ThorCameraApplication: - """Apply one unforced TeleportFull and bind its same-event observation.""" - controller = self._require_active() - current_event = self._current_event_for_scene(scene) - expected_native_scene_name = self._native_scene_name(current_event) - if type(pose) is not AI2ThorAgentPose: - raise ValueError("camera pose must be an exact AI2ThorAgentPose") - snapped_x = ( - round(pose.position.x / _NATIVE_NAVIGATION_GRID_SIZE_M) - * _NATIVE_NAVIGATION_GRID_SIZE_M - ) - snapped_z = ( - round(pose.position.z / _NATIVE_NAVIGATION_GRID_SIZE_M) - * _NATIVE_NAVIGATION_GRID_SIZE_M - ) - commanded_position = AI2ThorNativePosition( - x=snapped_x, - y=pose.position.y, - z=snapped_z, - ) - _, is_on_native_grid = _camera_position_residual_within_tolerance( - pose.position, - commanded_position, - ) - if not is_on_native_grid: - raise ValueError("camera pose is not on the native grid") - if commanded_position.x == 0.0: - commanded_position = AI2ThorNativePosition( - x=0.0, - y=commanded_position.y, - z=commanded_position.z, - ) - if commanded_position.z == 0.0: - commanded_position = AI2ThorNativePosition( - x=commanded_position.x, - y=commanded_position.y, - z=0.0, - ) - - previous_camera_states = deepcopy(self._camera_states) - previous_native_rotations = deepcopy(self._native_rotations) - try: - event = self._step( - controller, - "TeleportFull", - action="TeleportFull", - position={ - "x": commanded_position.x, - "y": commanded_position.y, - "z": commanded_position.z, - }, - rotation={"x": 0.0, "y": pose.yaw_degrees, "z": 0.0}, - horizon=pose.horizon_degrees, - standing=pose.standing, - ) - # Unity may have changed even when validation below fails. Retain - # the returned event but invalidate the canonical current scene - # until every invariant and same-event artifact has been checked. - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - "TeleportFull", - scene.scene_id, - ) - self._validate_native_scene_name_or_poison( - event, - expected_native_scene_name, - ) - - observed_pose = self._native_agent_pose(event) - observed_camera_position = self._native_position( - event.metadata.get("cameraPosition"), - "camera position", - ) - position_residual_m, position_within_tolerance = ( - _camera_position_residual_within_tolerance( - pose.position, - observed_pose.position, - ) - ) - yaw_residual_degrees = self._angle_residual_degrees( - observed_pose.yaw_degrees, - pose.yaw_degrees, - ) - horizon_residual_degrees = abs( - observed_pose.horizon_degrees - pose.horizon_degrees - ) - if not position_within_tolerance: - raise RuntimeError("camera position drift exceeds tolerance") - if yaw_residual_degrees > _ANGLE_TOLERANCE_DEGREES: - raise RuntimeError("camera yaw drift exceeds tolerance") - if horizon_residual_degrees > _ANGLE_TOLERANCE_DEGREES: - raise RuntimeError("camera horizon drift exceeds tolerance") - if observed_pose.standing is not pose.standing: - raise RuntimeError("camera standing state differs from request") - - native_observed = self._scene_from_event(scene.scene_id, event) - observed_scene = self._canonical_camera_observed_scene( - scene, - native_observed, - ) - observation = self._observation_from_event(observed_scene, event) - result = AI2ThorCameraApplication( - requested_pose=pose, - observed_pose=observed_pose, - observed_camera_position=observed_camera_position, - observed_scene=observed_scene, - observation=observation, - position_residual_m=position_residual_m, - yaw_residual_degrees=yaw_residual_degrees, - horizon_residual_degrees=horizon_residual_degrees, - ) - self._current_scene = observed_scene - return result - except BaseException: - self._camera_states = previous_camera_states - self._native_rotations = previous_native_rotations - self._poison_scene_state() - raise - - @contextmanager - def paused_camera_observations(self, source: Scene) -> Iterator[Scene]: - """Freeze native physics while yielding camera-only source observations.""" - - with self._paused_camera_observations( - source, - retain_unpaused_scene=False, - ) as paused_source: - yield paused_source - - @contextmanager - def paused_camera_observations_for_settlement( - self, - source: Scene, - ) -> Iterator[Scene]: - """Freeze camera ranking and retain the native scene returned by unpause.""" - - with self._paused_camera_observations( - source, - retain_unpaused_scene=True, - ) as paused_source: - yield paused_source - - @contextmanager - def _paused_camera_observations( - self, - source: Scene, - *, - retain_unpaused_scene: bool, - ) -> Iterator[Scene]: - if type(retain_unpaused_scene) is not bool: - raise TypeError("retain-unpaused-scene flag must be an exact boolean") - controller = self._require_active() - current_event = self._current_event_for_scene(source) - expected_native_scene_name = self._native_scene_name(current_event) - try: - paused_event = self._step( - controller, - "PausePhysicsAutoSim", - action="PausePhysicsAutoSim", - ) - self._event = paused_event - self._current_scene = None - paused_event = self._checked_scene_event( - controller, - paused_event, - "PausePhysicsAutoSim", - source.scene_id, - ) - self._validate_native_scene_name_or_poison( - paused_event, - expected_native_scene_name, - ) - native_paused = self._scene_from_event(source.scene_id, paused_event) - paused_source = self._canonical_camera_observed_scene( - source, - native_paused, - ) - self._current_scene = paused_source - except BaseException: - self._poison_scene_state() - raise - - try: - yield paused_source - except BaseException as error: - try: - self._unpause_camera_observations( - controller, - source.scene_id, - expected_native_scene_name, - retained_source=source if retain_unpaused_scene else None, - ) - except Exception as cleanup_error: # noqa: BLE001 - error.add_note(f"AI2-THOR physics unpause also failed: {cleanup_error}") - raise - else: - self._unpause_camera_observations( - controller, - source.scene_id, - expected_native_scene_name, - retained_source=source if retain_unpaused_scene else None, - ) - - def _unpause_camera_observations( - self, - controller: Any, - scene_id: str, - expected_native_scene_name: str, - *, - retained_source: Scene | None = None, - ) -> None: - try: - event = self._step( - controller, - "UnpausePhysicsAutoSim", - action="UnpausePhysicsAutoSim", - ) - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - "UnpausePhysicsAutoSim", - scene_id, - ) - self._validate_native_scene_name_or_poison( - event, - expected_native_scene_name, - ) - if retained_source is not None: - native_unpaused = self._scene_from_event(scene_id, event) - stable_unpaused = self._stable_observed_scene( - retained_source, - native_unpaused, - ) - self._validate_camera_object_identity_invariants( - retained_source, - stable_unpaused, - ) - self._current_scene = stable_unpaused - self._event = event - except BaseException: - self._poison_scene_state() - raise - - def settle_current_camera_pose_observed( - self, - source: Scene, - pose: AI2ThorAgentPose, - *, - max_pass_steps: int, - ) -> AI2ThorSettledCameraApplication: - """Settle the post-unpause scene and bind its current camera event.""" - - if type(source) is not Scene: - raise TypeError("camera settlement source must be an exact Scene") - if type(pose) is not AI2ThorAgentPose: - raise TypeError("camera settlement pose must be exact") - current_scene = self._current_scene - if current_scene is None or current_scene.scene_id != source.scene_id: - raise RuntimeError("adapter has no current post-unpause camera scene") - settlement = self.settle_scene_observed( - current_scene, - max_pass_steps=max_pass_steps, - ) - event = self._current_event_for_scene(settlement.observed_scene) - observed_pose = self._native_agent_pose(event) - observed_camera_position = self._native_position( - event.metadata.get("cameraPosition"), - "camera position", - ) - position_residual_m, position_within_tolerance = ( - _camera_position_residual_within_tolerance( - pose.position, - observed_pose.position, - ) - ) - yaw_residual_degrees = self._angle_residual_degrees( - observed_pose.yaw_degrees, - pose.yaw_degrees, - ) - horizon_residual_degrees = abs( - observed_pose.horizon_degrees - pose.horizon_degrees - ) - if not position_within_tolerance: - raise RuntimeError("settled camera position drift exceeds tolerance") - if yaw_residual_degrees > _ANGLE_TOLERANCE_DEGREES: - raise RuntimeError("settled camera yaw drift exceeds tolerance") - if horizon_residual_degrees > _ANGLE_TOLERANCE_DEGREES: - raise RuntimeError("settled camera horizon drift exceeds tolerance") - if observed_pose.standing is not pose.standing: - raise RuntimeError("settled camera standing state differs from request") - return AI2ThorSettledCameraApplication( - application=AI2ThorCameraApplication( - requested_pose=pose, - observed_pose=observed_pose, - observed_camera_position=observed_camera_position, - observed_scene=settlement.observed_scene, - observation=settlement.observation, - position_residual_m=position_residual_m, - yaw_residual_degrees=yaw_residual_degrees, - horizon_residual_degrees=horizon_residual_degrees, - ), - settlement_pass_steps=settlement.pass_steps, - ) - - def apply_camera_pose_from_frozen_source_observed( - self, - source: Scene, - pose: AI2ThorAgentPose, - *, - max_pass_steps: int, - ) -> AI2ThorCameraApplication: - """Reset and settle the same frozen source before one camera pose.""" - - if type(source) is not Scene: - raise TypeError("frozen camera source must be an exact Scene") - loaded = self.load_scene(source.scene_id) - settlement = self.settle_scene_observed( - loaded, - max_pass_steps=max_pass_steps, - ) - stable_source = self._canonical_camera_observed_scene( - source, - settlement.observed_scene, - ) - self._current_scene = stable_source - return self.apply_camera_pose_observed(stable_source, pose) - - def conservative_floor_envelope( - self, - scene: Scene, - clearance_m: float, - ) -> AI2ThorFloorEnvelope: - """Derive an inward-offset rectangle from the current native floor AABB.""" - event = self._current_event_for_scene(scene) - if ( - isinstance(clearance_m, bool) - or not isinstance(clearance_m, (int, float)) - or not math.isfinite(float(clearance_m)) - or clearance_m < 0.0 - ): - raise ValueError("floor clearance must be finite and non-negative") - raw_objects = event.metadata.get("objects") - if not isinstance(raw_objects, list): - raise ValueError("current event has no structural Floor collection") - floors: list[dict[str, Any]] = [] - for item in raw_objects: - if not isinstance(item, dict) or item.get("objectType") != "Floor": - continue - bounds = item.get("axisAlignedBoundingBox") - size = bounds.get("size") if isinstance(bounds, dict) else None - if not isinstance(size, dict): - raise ValueError("structural Floor must have a finite positive AABB") - try: - native_size_x = float(size["x"]) - native_size_y = float(size["y"]) - native_size_z = float(size["z"]) - except (KeyError, TypeError, ValueError) as exc: - raise ValueError( - "structural Floor must have a finite positive AABB" - ) from exc - if ( - not all( - math.isfinite(value) - for value in (native_size_x, native_size_y, native_size_z) - ) - or native_size_x <= 0.0 - or native_size_z <= 0.0 - or native_size_y < 0.0 - ): - raise ValueError("structural Floor must have a finite positive AABB") - if native_size_y == 0.0: - continue - floors.append(item) - if len(floors) != 1: - raise ValueError("current event must contain exactly one structural Floor") - floor = floors[0] - bounds = floor.get("axisAlignedBoundingBox") - if not isinstance(bounds, dict): - raise ValueError("structural Floor must have a finite positive AABB") - try: - center = ai2thor_position_to_world(Vec3(**bounds["center"])) - size = bounds["size"] - extent = Vec3( - x=float(size["x"]), - y=float(size["z"]), - z=float(size["y"]), - ) - floor_object_id = str(floor["objectId"]) - floor_name = str(floor["name"]) - except (KeyError, TypeError, ValueError) as exc: - raise ValueError( - "structural Floor must have a finite positive AABB" - ) from exc - geometry_values = ( - center.x, - center.y, - center.z, - extent.x, - extent.y, - extent.z, - ) - if ( - not all(math.isfinite(value) for value in geometry_values) - or min(extent.x, extent.y, extent.z) <= 0.0 - or not floor_object_id - or not floor_name - ): - raise ValueError("structural Floor must have a finite positive AABB") - source = self.procedural_scenes.get(scene.scene_id) - effective_native_bounds: tuple[float, float, float, float] | None = None - if source is not None: - native_center = bounds.get("center") - if not isinstance(native_center, dict): - raise ValueError("structural Floor must have a finite positive AABB") - try: - native_center_x = float(native_center["x"]) - native_center_z = float(native_center["z"]) - except (KeyError, TypeError, ValueError) as error: - raise ValueError( - "structural Floor must have a finite positive AABB" - ) from error - floor_aabb_bounds = ( - native_center_x - extent.x / 2.0, - native_center_z - extent.y / 2.0, - native_center_x + extent.x / 2.0, - native_center_z + extent.y / 2.0, - ) - if not all( - math.isclose(expected, actual, rel_tol=0.0, abs_tol=1e-5) - for expected, actual in zip( - source.floor_xz_bounds, - floor_aabb_bounds, - strict=True, - ) - ): - raise ValueError( - "procedural floorPolygon does not match structural Floor AABB" - ) - effective_native_bounds = ( - max(source.floor_xz_bounds[0], floor_aabb_bounds[0]), - max(source.floor_xz_bounds[1], floor_aabb_bounds[1]), - min(source.floor_xz_bounds[2], floor_aabb_bounds[2]), - min(source.floor_xz_bounds[3], floor_aabb_bounds[3]), - ) - if effective_native_bounds is None: - effective_native_bounds = ( - center.x - extent.x / 2.0, - center.y - extent.y / 2.0, - center.x + extent.x / 2.0, - center.y + extent.y / 2.0, - ) - minimum_x = effective_native_bounds[0] + float(clearance_m) - minimum_y = effective_native_bounds[1] + float(clearance_m) - maximum_x = effective_native_bounds[2] - float(clearance_m) - maximum_y = effective_native_bounds[3] - float(clearance_m) - if maximum_x <= minimum_x or maximum_y <= minimum_y: - raise ValueError("floor clearance leaves no positive envelope") - native_aabb = OBB( - center=center, - extent=extent, - rotation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0), - ) - return AI2ThorFloorEnvelope( - scene_id=scene.scene_id, - floor_object_id=floor_object_id, - floor_name=floor_name, - native_aabb=native_aabb, - floor_top_z=center.z + extent.z / 2.0, - clearance_m=float(clearance_m), - polygon_xy=( - Vec2(x=minimum_x, y=minimum_y), - Vec2(x=maximum_x, y=minimum_y), - Vec2(x=maximum_x, y=maximum_y), - Vec2(x=minimum_x, y=maximum_y), - ), - ) - - def conservative_collision_map( - self, - scene: Scene, - *, - subject_object_id: str, - clearance_m: float, - ) -> AI2ThorNativeFeasibilityMap: - """Expand stationary native OBBs into view-independent obstacles.""" - self._current_event_for_scene(scene) - if ( - isinstance(clearance_m, bool) - or not isinstance(clearance_m, (int, float)) - or not math.isfinite(float(clearance_m)) - or float(clearance_m) <= 0.0 - ): - raise ValueError("collision clearance must be finite and positive") - subject = scene.object_by_id(subject_object_id) - excluded_ids = {subject.object_id, subject.support_object_id} - clearance = float(clearance_m) - obstacles = tuple( - CollisionObstacle( - obstacle_id=f"native-clearance:{obj.object_id}", - source_object_id=obj.object_id, - clearance_m=clearance, - obb=obj.obb, - ) - for obj in sorted(scene.objects, key=lambda item: item.object_id) - if obj.object_id not in excluded_ids - ) - return AI2ThorNativeFeasibilityMap( - scene_id=scene.scene_id, - subject_object_id=subject.object_id, - clearance_m=clearance, - obstacles=obstacles, - ) - - def conservative_navigation_map( - self, - scene: Scene, - *, - subject_object_id: str, - room_polygon_xy: tuple[Vec2, ...], - agent_radius_m: float, - clearance_m: float, - ) -> AI2ThorNavigationFeasibilityMap: - """Bind the current reachable grid to a conservative subject locus.""" - self._current_event_for_scene(scene) - positions = self.reachable_agent_positions(scene) - return build_navigation_feasibility_map( - scene, - subject_object_id=subject_object_id, - room_polygon_xy=room_polygon_xy, - reachable_positions=positions, - agent_radius_m=agent_radius_m, - clearance_m=clearance_m, - ) - - @staticmethod - def _camera_key( - scene_id: str, - camera: Camera, - ) -> tuple[str, tuple[float, ...], tuple[float, ...]]: - return scene_id, camera.intrinsics, camera.world_to_camera - - def _camera(self, metadata: dict[str, Any], scene_id: str) -> Camera: - try: - fov = math.radians(float(metadata["fov"])) - position = ai2thor_position_to_world( - Vec3(**metadata["cameraPosition"]) - ) - agent_metadata = metadata["agent"] - yaw_degrees = float(agent_metadata["rotation"]["y"]) - if "cameraHorizon" in metadata: - horizon = metadata["cameraHorizon"] - else: - horizon = agent_metadata["cameraHorizon"] - horizon_degrees = float(horizon) - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - "invalid AI2-THOR camera metadata" - ) from exc - if not (0.0 < fov < math.pi): - raise AI2ThorNativeReturnError( - "camera field of view must be between 0 and 180 degrees" - ) - focal = self.height / (2.0 * math.tan(fov / 2.0)) - intrinsics = ( - focal, - 0.0, - self.width / 2.0, - 0.0, - focal, - self.height / 2.0, - 0.0, - 0.0, - 1.0, - ) - camera = Camera( - camera_id="main", - width=self.width, - height=self.height, - intrinsics=tuple(float(value) for value in intrinsics), - world_to_camera=ai2thor_camera_world_to_camera( - position, - yaw_degrees=yaw_degrees, - horizon_degrees=horizon_degrees, - ), - ) - agent = metadata.get("agent") - if not isinstance(agent, dict): - raise AI2ThorNativeReturnError("invalid AI2-THOR agent metadata") - try: - state = { - "position": { - axis: float(agent["position"][axis]) for axis in ("x", "y", "z") - }, - "rotation": { - axis: float(agent["rotation"][axis]) for axis in ("x", "y", "z") - }, - "horizon": float(horizon), - "standing": bool(agent.get("isStanding", True)), - } - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - "invalid AI2-THOR agent pose metadata" - ) from exc - self._camera_states[self._camera_key(scene_id, camera)] = state - return camera - - @staticmethod - def _oriented_bounds( - metadata: dict[str, Any], - position: Vec3, - rotation: Quaternion, - ) -> OBB: - oriented = metadata.get("objectOrientedBoundingBox") - corners = oriented.get("cornerPoints") if isinstance(oriented, dict) else None - if corners is not None: - try: - array = np.asarray( - [ - ( - float(point["x"]), - float(point["y"]), - float(point["z"]), - ) - if isinstance(point, dict) - else tuple(float(value) for value in point) - for point in corners - ], - dtype=float, - ) - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - "invalid oriented bounding-box corners" - ) from exc - if array.shape != (8, 3) or not np.isfinite(array).all(): - raise AI2ThorNativeReturnError( - "oriented bounding box must have eight finite corners" - ) - world = array[:, [0, 2, 1]] - center_array = world.mean(axis=0) - yaw = _quaternion_yaw(rotation) - planar_rotation = Quaternion( - x=0.0, - y=0.0, - z=math.sin(yaw / 2.0), - w=math.cos(yaw / 2.0), - ) - # Geometry consumers use an upright Z-up OBB. For tilted objects, - # bound the projected corners in the object's yaw frame so the - # ground footprint is conservative instead of under-estimated. - local = (world - center_array) @ _rotation_matrix(planar_rotation) - extent_array = np.ptp(local, axis=0) - if np.any(extent_array <= _EPSILON): - raise AI2ThorNativeReturnError( - "oriented bounding-box extents must be positive" - ) - return OBB( - center=Vec3( - x=float(center_array[0]), - y=float(center_array[1]), - z=float(center_array[2]), - ), - extent=Vec3( - x=float(extent_array[0]), - y=float(extent_array[1]), - z=float(extent_array[2]), - ), - rotation=planar_rotation, - ) - - bounds = metadata.get("axisAlignedBoundingBox") - if not isinstance(bounds, dict): - raise AI2ThorNativeReturnError( - "object is missing bounding-box metadata" - ) - try: - center = ai2thor_position_to_world(Vec3(**bounds["center"])) - size = bounds["size"] - extent = Vec3( - x=float(size["x"]), - y=float(size["z"]), - z=float(size["y"]), - ) - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - "invalid axis-aligned bounding-box metadata" - ) from exc - if not all( - math.isfinite(value) - for value in ( - center.x, - center.y, - center.z, - extent.x, - extent.y, - extent.z, - ) - ) or min(extent.x, extent.y, extent.z) <= _EPSILON: - raise AI2ThorNativeReturnError( - "axis-aligned bounding-box values must be finite and positive" - ) - return OBB( - center=center, - extent=extent, - rotation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0), - ) - - @staticmethod - def _obb_corners(obb: OBB) -> np.ndarray: - offsets = np.asarray( - [ - [dx * obb.extent.x / 2, dy * obb.extent.y / 2, dz * obb.extent.z / 2] - for dx in (-1.0, 1.0) - for dy in (-1.0, 1.0) - for dz in (-1.0, 1.0) - ], - dtype=float, - ) - center = np.asarray([obb.center.x, obb.center.y, obb.center.z]) - return offsets @ _rotation_matrix(obb.rotation).T + center - - def _projected_bounds( - self, - obb: OBB, - camera: Camera, - ) -> tuple[float, float, float, float]: - projected: list[tuple[float, float]] = [] - extrinsics = matrix4(camera.world_to_camera) - fx, fy = camera.intrinsics[0], camera.intrinsics[4] - cx, cy = camera.intrinsics[2], camera.intrinsics[5] - for point in self._obb_corners(obb): - camera_point = transform_point( - extrinsics, - Vec3(x=float(point[0]), y=float(point[1]), z=float(point[2])), - ) - if camera_point.z <= _EPSILON: - continue - projected.append( - ( - fx * camera_point.x / camera_point.z + cx, - cy - fy * camera_point.y / camera_point.z, - ) - ) - if not projected: - raise AI2ThorNativeReturnError( - "visible object bounds are behind the camera" - ) - xs, ys = zip(*projected) - return min(xs), min(ys), max(xs), max(ys) - - def _view( - self, - metadata: dict[str, Any], - position: Vec3, - obb: OBB, - camera: Camera, - event: Any, - ) -> dict[str, ObjectView]: - object_id = metadata["objectId"] - detections = getattr(event, "instance_detections2D", {}) or {} - masks = getattr(event, "instance_masks", {}) or {} - detection = detections.get(object_id) - mask = masks.get(object_id) - if detection is None or mask is None: - return {} - values = np.asarray(detection, dtype=float) - if values.shape != (4,) or not np.isfinite(values).all(): - raise AI2ThorNativeReturnError(f"invalid detection for {object_id!r}") - xmin, ymin, xmax, ymax = (float(value) for value in values) - if xmax <= xmin or ymax <= ymin: - if metadata.get("visible") is False: - return {} - if ( - type(mask) is not np.ndarray - or mask.shape != (self.height, self.width) - or mask.dtype != np.bool_ - ): - raise AI2ThorNativeReturnError( - f"invalid instance mask for {object_id!r}" - ) - mask_array = mask - pixels = np.argwhere(mask_array) - if pixels.size == 0: - raise AI2ThorNativeReturnError( - f"invalid detection bounds for {object_id!r}" - ) - ymin = float(pixels[:, 0].min()) - ymax = float(pixels[:, 0].max() + 1) - xmin = float(pixels[:, 1].min()) - xmax = float(pixels[:, 1].max() + 1) - else: - mask_array = np.asarray(mask) - if mask_array.shape != (self.height, self.width): - raise AI2ThorNativeReturnError( - f"invalid instance mask shape for {object_id!r}" - ) - camera_point = transform_point(matrix4(camera.world_to_camera), position) - if not math.isfinite(camera_point.z): - raise AI2ThorNativeReturnError( - f"visible object {object_id!r} has non-positive camera depth" - ) - if camera_point.z <= _EPSILON: - # AI2-THOR object anchors are not guaranteed to be OBB centres. - # Large fixtures can therefore cross the camera plane while their - # valid anchor is behind it. They remain scene geometry, but do - # not expose a relation view whose anchor depth is non-positive. - # A box wholly on either side while its anchor disagrees is still - # inconsistent native metadata and fails closed. - extrinsics = matrix4(camera.world_to_camera) - corner_depths = tuple( - transform_point( - extrinsics, - Vec3(x=float(corner[0]), y=float(corner[1]), z=float(corner[2])), - ).z - for corner in self._obb_corners(obb) - ) - if min(corner_depths) < 0.0 < max(corner_depths): - return {} - raise AI2ThorNativeReturnError( - f"visible object {object_id!r} has non-positive camera depth" - ) - - clipped = ( - max(0.0, min(float(self.width), xmin)), - max(0.0, min(float(self.height), ymin)), - max(0.0, min(float(self.width), xmax)), - max(0.0, min(float(self.height), ymax)), - ) - if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]: - raise AI2ThorNativeReturnError( - f"visible detection for {object_id!r} is outside the image" - ) - projected = self._projected_bounds(obb, camera) - projected_area = max( - _EPSILON, - (projected[2] - projected[0]) * (projected[3] - projected[1]), - ) - projected_clipped_width = max( - 0.0, min(projected[2], self.width) - max(projected[0], 0.0) - ) - projected_clipped_height = max( - 0.0, min(projected[3], self.height) - max(projected[1], 0.0) - ) - bbox_area = (clipped[2] - clipped[0]) * (clipped[3] - clipped[1]) - image_area = float(self.width * self.height) - return { - "main": ObjectView( - camera_id="main", - bbox=BBox2D( - xmin=clipped[0], - ymin=clipped[1], - xmax=clipped[2], - ymax=clipped[3], - ), - camera_depth=float(camera_point.z), - visible_fraction=float( - np.clip(np.count_nonzero(mask_array) / projected_area, 0.0, 1.0) - ), - image_area_fraction=float(np.clip(bbox_area / image_area, 0.0, 1.0)), - truncated_fraction=float( - np.clip( - 1.0 - - projected_clipped_width - * projected_clipped_height - / projected_area, - 0.0, - 1.0, - ) - ), - ) - } - - def _object( - self, - metadata: dict[str, Any], - camera: Camera, - event: Any, - structural_object_ids: frozenset[str], - ) -> SceneObject: - try: - position = ai2thor_position_to_world(Vec3(**metadata["position"])) - rotation = ai2thor_rotation_to_world(Vec3(**metadata["rotation"])) - object_id = metadata["objectId"] - name = metadata["name"] - category = metadata["objectType"] - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - "invalid AI2-THOR object metadata" - ) from exc - obb = self._oriented_bounds(metadata, position, rotation) - parents = metadata["parentReceptacles"] - parents = [parent for parent in parents if parent not in structural_object_ids] - return SceneObject( - object_id=object_id, - name=name, - category=category, - movable=(metadata.get("moveable") is True or metadata.get("pickupable") is True), - position=position, - rotation=rotation, - obb=obb, - support_object_id=parents[0] if parents else None, - views=self._view(metadata, position, obb, camera, event), - ) - - @staticmethod - def _without_cyclic_support_assignments( - objects: tuple[SceneObject, ...], - ) -> tuple[SceneObject, ...]: - """Drop every edge in a cyclic native receptacle component. - - AI2-THOR's ``parentReceptacles`` describes receptacle membership, not - a certified physical support tree, and real scenes can report cycles. - A cycle has no honest single supporting parent, so retain the objects - but represent those assignments as unknown instead of guessing an - edge or passing an invalid graph to the solver. - """ - object_ids = {obj.object_id for obj in objects} - parents = { - obj.object_id: ( - obj.support_object_id - if obj.support_object_id in object_ids - else None - ) - for obj in objects - } - cyclic_ids: set[str] = set() - for start in sorted(parents): - path: list[str] = [] - path_index: dict[str, int] = {} - current: str | None = start - while current is not None and current in parents: - if current in path_index: - cyclic_ids.update(path[path_index[current] :]) - break - path_index[current] = len(path) - path.append(current) - current = parents[current] - if not cyclic_ids: - return objects - return tuple( - obj.model_copy(update={"support_object_id": None}) - if obj.object_id in cyclic_ids - else obj - for obj in objects - ) - - def _scene_from_event(self, scene_id: str, event: Any) -> Scene: - metadata = event.metadata - camera = self._camera(metadata, scene_id) - raw_objects = metadata.get("objects") - if not isinstance(raw_objects, list): - raise AI2ThorNativeReturnError("invalid AI2-THOR object collection") - raw_objects = _validated_native_object_metadata(raw_objects) - structural_object_ids = frozenset( - item["objectId"] - for item in raw_objects - if item["objectType"] in _STRUCTURAL_OBJECT_TYPES - ) - raw_objects = _domain_object_metadata(raw_objects) - names = [item["name"] for item in raw_objects] - for item, name in zip(raw_objects, names, strict=True): - try: - rotation = { - axis: float(item["rotation"][axis]) - for axis in ("x", "y", "z") - } - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - f"invalid native rotation for {name!r}" - ) from exc - if not all(math.isfinite(value) for value in rotation.values()): - raise AI2ThorNativeReturnError( - f"invalid native rotation for {name!r}" - ) - self._native_rotations[(scene_id, name)] = rotation - objects = tuple( - self._object( - item, - camera, - event, - structural_object_ids, - ) - for item in raw_objects - ) - objects = self._without_cyclic_support_assignments(objects) - scene_bounds = metadata.get("sceneBounds") - if not isinstance(scene_bounds, dict): - raise AI2ThorNativeReturnError("invalid AI2-THOR scene bounds") - try: - center = ai2thor_position_to_world(Vec3(**scene_bounds["center"])) - size = scene_bounds["size"] - half_x = float(size["x"]) / 2.0 - half_y = float(size["z"]) / 2.0 - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError("invalid AI2-THOR scene bounds") from exc - if not all( - math.isfinite(value) - for value in (center.x, center.y, center.z, half_x, half_y) - ) or half_x <= 0.0 or half_y <= 0.0: - raise AI2ThorNativeReturnError( - "scene ground bounds must be finite and positive" - ) - return Scene( - scene_id=scene_id, - source="ai2thor", - room_polygon_xy=( - Vec2(x=center.x - half_x, y=center.y - half_y), - Vec2(x=center.x + half_x, y=center.y - half_y), - Vec2(x=center.x + half_x, y=center.y + half_y), - Vec2(x=center.x - half_x, y=center.y + half_y), - ), - cameras=(camera,), - objects=objects, - generation_seed=self.seed, - ) - - def _activate_scene( - self, - scene_id: str, - *, - force_reset: bool = False, - ) -> Any: - controller = self._require_active() - if scene_id not in self.scene_names: - raise KeyError(scene_id) - if self._event is None and not force_reset: - raise RuntimeError("no AI2-THOR event is available") - if ( - self._event is not None - and self.scene_name == scene_id - and not force_reset - ): - self._validate_scene_source_or_poison( - controller, - scene_id, - self._event, - ) - return self._event - try: - event = self._reset(controller, scene_id) - except BaseException: - self._poison_scene_state() - raise - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - f"reset {scene_id}", - scene_id, - ) - # Commit native-scene tracking only after reset success. Callers can - # then fail closed without applying saved poses to the wrong scene. - self.scene_name = scene_id - return event - - def load_scene(self, scene_id: str) -> Scene: - # Public load means a deterministic baseline reload, even when Unity - # already has this scene active. Internal restoration uses the - # non-forced activation path to avoid redundant resets. - event = self._activate_scene(scene_id, force_reset=True) - scene = self._scene_from_event(scene_id, event) - self._current_scene = scene - return scene - - @staticmethod - def _objects_by_name( - objects: tuple[SceneObject, ...], - ) -> dict[str, SceneObject]: - by_name = {obj.name: obj for obj in objects} - if len(by_name) != len(objects): - raise ValueError("scene object names must be unique") - return by_name - - @classmethod - def _stable_observed_scene( - cls, - source: Scene, - observed: Scene, - ) -> Scene: - """Map native ID churn back to source IDs through unique object names.""" - source_by_name = cls._objects_by_name(source.objects) - observed_by_name = cls._objects_by_name(observed.objects) - if set(source_by_name) != set(observed_by_name): - raise AI2ThorNativeReturnError( - "stable object names changed during pose application" - ) - - aliases: dict[str, str] = {} - - def register_alias(native_id: str, stable_id: str) -> None: - existing = aliases.get(native_id) - if existing is not None and existing != stable_id: - raise AI2ThorNativeReturnError( - "native object IDs do not map to unique stable IDs" - ) - aliases[native_id] = stable_id - - for name, original in source_by_name.items(): - current = observed_by_name[name] - register_alias(current.object_id, original.object_id) - source_object_ids = {obj.object_id for obj in source.objects} - for original in source.objects: - support_id = original.support_object_id - if support_id is not None and support_id not in source_object_ids: - register_alias(support_id, support_id) - - stable_objects: list[SceneObject] = [] - for original in source.objects: - current = observed_by_name[original.name] - support_id = current.support_object_id - stable_support_id = None - if support_id is not None: - stable_support_id = aliases.get(support_id) - if stable_support_id is None: - raise AI2ThorNativeReturnError( - f"observed support {support_id!r} has no stable object identity" - ) - stable_updates: dict[str, object] = { - "object_id": original.object_id, - "support_object_id": stable_support_id, - } - if "request_eligible" in original.model_fields_set: - stable_updates["request_eligible"] = original.request_eligible - stable_objects.append(current.model_copy(update=stable_updates)) - return source.model_copy( - update={ - "cameras": observed.cameras, - "objects": tuple(stable_objects), - } - ) - - @classmethod - def _canonical_camera_observed_scene( - cls, - source: Scene, - native_observed: Scene, - ) -> Scene: - """Retain fresh camera/views after proving source geometry unchanged.""" - stable = cls._stable_observed_scene(source, native_observed) - cls._validate_camera_object_invariants(source, stable) - stable_by_name = cls._objects_by_name(stable.objects) - return source.model_copy( - update={ - "cameras": stable.cameras, - "objects": tuple( - original.model_copy( - update={"views": stable_by_name[original.name].views} - ) - for original in source.objects - ), - } - ) - - @classmethod - def _commanded_scene( - cls, - source: Scene, - observed: Scene, - subject_id: str, - commanded_position: Vec3, - ) -> Scene: - """Reproduce the legacy canonical command while retaining fresh views.""" - observed_by_name = cls._objects_by_name(observed.objects) - subject = source.object_by_id(subject_id) - delta_x = commanded_position.x - subject.position.x - delta_y = commanded_position.y - subject.position.y - merged: list[SceneObject] = [] - for original in source.objects: - current = observed_by_name[original.name] - if original.object_id == subject_id: - merged.append( - original.model_copy( - update={ - "position": commanded_position, - "obb": original.obb.model_copy( - update={ - "center": Vec3( - x=original.obb.center.x + delta_x, - y=original.obb.center.y + delta_y, - z=original.obb.center.z, - ) - } - ), - "views": current.views, - } - ) - ) - else: - merged.append(original.model_copy(update={"views": current.views})) - return source.model_copy(update={"objects": tuple(merged)}) - - def _native_rotation_for( - self, - scene_id: str, - obj: SceneObject, - ) -> dict[str, float]: - rotation = self._native_rotations.get((scene_id, obj.name)) - if rotation is None: - raise ValueError( - f"native rotation was not captured for object {obj.name!r}" - ) - if not _quaternions_close( - ai2thor_rotation_to_world(Vec3(**rotation)), - obj.rotation, - ) and not self.allow_source_pose_drift: - raise ValueError( - f"scene rotation for object {obj.name!r} differs from captured native pose" - ) - return dict(rotation) - - def _object_pose( - self, - scene_id: str, - obj: SceneObject, - position: Vec3 | None = None, - ) -> dict[str, Any]: - requested_position = position or obj.position - return { - "objectName": obj.name, - "position": { - "x": float(requested_position.x), - "y": float(requested_position.z), - "z": float(requested_position.y), - }, - "rotation": self._native_rotation_for(scene_id, obj), - } - - def _restore_scene_state(self, scene: Scene) -> Any: - controller = self._require_active() - if scene.scene_id not in self.scene_names: - raise KeyError(scene.scene_id) - self._activate_scene(scene.scene_id) - self._objects_by_name(scene.objects) - camera = scene.camera_by_id("main") - state = self._camera_states.get(self._camera_key(scene.scene_id, camera)) - if state is None: - raise ValueError("camera pose was not captured by this adapter") - expected_positions = {obj.name: obj.position for obj in scene.objects} - expected_rotations = { - obj.name: self._native_rotation_for(scene.scene_id, obj) - for obj in scene.objects - } - try: - event = self._step( - controller, - "SetObjectPoses", - action="SetObjectPoses", - objectPoses=[ - self._object_pose(scene.scene_id, obj) - for obj in scene.objects - if obj.movable - ], - placeStationary=True, - ) - except BaseException: - self._poison_scene_state() - raise - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - "SetObjectPoses", - scene.scene_id, - ) - try: - event = self._step( - controller, - "TeleportFull", - action="TeleportFull", - position=dict(state["position"]), - rotation=dict(state["rotation"]), - horizon=state["horizon"], - standing=state["standing"], - forceAction=True, - ) - except BaseException: - self._poison_scene_state() - raise - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - "TeleportFull", - scene.scene_id, - ) - self._validate_returned_state( - scene, - event, - expected_positions, - expected_rotations, - ) - self._current_scene = scene - return event - - @staticmethod - def _angles_close( - left: float, - right: float, - *, - tolerance_degrees: float = _ANGLE_TOLERANCE_DEGREES, - ) -> bool: - difference = (left - right + 180.0) % 360.0 - 180.0 - return abs(difference) <= tolerance_degrees - - def _validate_returned_state( - self, - scene: Scene, - event: Any, - expected_positions: dict[str, Vec3], - expected_rotations: dict[str, dict[str, float]], - *, - total_position_residual_limits_by_name: Mapping[str, float] | None = None, - rotation_residual_limits_by_name: Mapping[str, float] | None = None, - ) -> None: - raw_objects = event.metadata.get("objects") - if not isinstance(raw_objects, list): - raise AI2ThorNativeReturnError( - "pose application returned no object metadata" - ) - raw_objects = _validated_native_object_metadata(raw_objects) - raw_objects = _domain_object_metadata(raw_objects) - names = [item["name"] for item in raw_objects] - by_name = dict(zip(names, raw_objects, strict=True)) - expected_by_name = self._objects_by_name(scene.objects) - if set(by_name) != set(expected_by_name): - raise AI2ThorNativeReturnError( - "stable object names changed during pose application" - ) - for name in expected_by_name: - metadata = by_name[name] - try: - position = ai2thor_position_to_world(Vec3(**metadata["position"])) - native_rotation = { - axis: float(metadata["rotation"][axis]) for axis in ("x", "y", "z") - } - except (KeyError, TypeError, ValueError) as exc: - raise AI2ThorNativeReturnError( - f"object {name!r} returned an invalid pose" - ) from exc - expected_position = expected_positions[name] - expected_rotation = expected_rotations[name] - expected_coordinates = ( - expected_position.x, - expected_position.y, - expected_position.z, - ) - observed_coordinates = (position.x, position.y, position.z) - residual_limit = ( - None - if total_position_residual_limits_by_name is None - else total_position_residual_limits_by_name.get(name) - ) - if residual_limit is None: - position_matches = np.allclose( - observed_coordinates, - expected_coordinates, - atol=1e-5, - rtol=0.0, - ) - else: - rounding_allowance_m = 4.0 * math.ulp(residual_limit) - position_matches = ( - math.dist( - observed_coordinates, - expected_coordinates, - ) - <= residual_limit + rounding_allowance_m - ) - rotation_residual_limit = ( - _OBJECT_ROTATION_TOLERANCE_DEGREES - if rotation_residual_limits_by_name is None - else rotation_residual_limits_by_name.get( - name, - _OBJECT_ROTATION_TOLERANCE_DEGREES, - ) - ) - if not position_matches or not all( - self._angles_close( - native_rotation[axis], - expected_rotation[axis], - tolerance_degrees=rotation_residual_limit, - ) - for axis in ("x", "y", "z") - ): - raise AI2ThorNativeReturnError( - f"object {name!r} pose changed during pose application" - ) - observed_camera = self._camera(event.metadata, scene.scene_id) - self._validate_camera_fixed(scene.camera_by_id("main"), observed_camera) - - @staticmethod - def _native_scene_at_rest(event: Any) -> bool: - value = event.metadata.get("isSceneAtRest") - if type(value) is not bool: - raise AI2ThorNativeReturnError( - "AI2-THOR isSceneAtRest must be an exact boolean" - ) - return value - - @staticmethod - def _native_object_is_moving(event: Any, object_name: str) -> bool: - raw_objects = event.metadata.get("objects") - if not isinstance(raw_objects, list): - raise AI2ThorNativeReturnError( - "pose application returned no object metadata" - ) - matches = [ - item - for item in raw_objects - if isinstance(item, dict) and item.get("name") == object_name - ] - if len(matches) != 1: - raise AI2ThorNativeReturnError( - f"expected one native object named {object_name!r}" - ) - value = matches[0].get("isMoving") - if type(value) is not bool: - raise AI2ThorNativeReturnError( - "AI2-THOR isMoving must be an exact boolean" - ) - return value - - @staticmethod - def _native_object_id_for_name(event: Any, object_name: str) -> str: - raw_objects = event.metadata.get("objects") - if not isinstance(raw_objects, list): - raise RuntimeError("pose application returned no object metadata") - matches = [ - item - for item in raw_objects - if isinstance(item, dict) and item.get("name") == object_name - ] - if len(matches) != 1: - raise RuntimeError( - f"expected one native object named {object_name!r}" - ) - native_object_id = matches[0].get("objectId") - if type(native_object_id) is not str or not native_object_id: - raise RuntimeError( - f"native object named {object_name!r} has no valid objectId" - ) - return native_object_id - - @staticmethod - def _validate_camera_fixed(expected: Camera, observed: Camera) -> None: - if expected.width != observed.width or expected.height != observed.height: - raise AI2ThorNativeReturnError( - "camera dimensions changed during pose application" - ) - if not np.allclose( - expected.intrinsics, observed.intrinsics, atol=1e-8, rtol=0.0 - ) or not np.allclose( - expected.world_to_camera, observed.world_to_camera, atol=1e-5, rtol=0.0 - ): - raise AI2ThorNativeReturnError( - "camera pose or intrinsics changed during pose application" - ) - - def apply_object_xy_observed( - self, - scene: Scene, - object_id: str, - x: float, - y: float, - ) -> AI2ThorPoseApplication: - """Apply one command and retain both canonical and native observations.""" - controller = self._require_active() - target = scene.object_by_id(object_id) - if not target.movable: - raise ValueError(f"object {object_id!r} is not movable") - if not math.isfinite(x) or not math.isfinite(y): - raise ValueError("object X/Y must be finite") - self._objects_by_name(scene.objects) - if self._current_scene != scene: - current_scene = self._current_scene - if current_scene is not None and self._is_analysis_overlay( - current_scene, scene - ): - current_event = self._current_event_for_scene(current_scene) - else: - current_event = self._restore_scene_state(scene) - else: - current_event = self._current_event_for_scene(scene) - expected_positions = { - obj.name: ( - Vec3(x=x, y=y, z=obj.position.z) - if obj.object_id == object_id - else obj.position - ) - for obj in scene.objects - } - expected_rotations = { - obj.name: self._native_rotation_for(scene.scene_id, obj) - for obj in scene.objects - } - native_object_id = self._native_object_id_for_name( - current_event, - target.name, - ) - commanded_position = expected_positions[target.name] - teleport_vertical_guard_m = _TELEPORT_VERTICAL_GUARD_M - # SetObjectPoses removes every movable object omitted from its payload. - # TeleportObject is the native single-object edit and avoids re-emitting - # unrelated poses, while the validation below still checks the full scene. - try: - event = self._step( - controller, - "TeleportObject", - action="TeleportObject", - objectId=native_object_id, - position={ - "x": commanded_position.x, - "y": commanded_position.z + teleport_vertical_guard_m, - "z": commanded_position.y, - }, - rotation=dict(expected_rotations[target.name]), - ) - except BaseException: - self._poison_scene_state() - raise - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - "TeleportObject", - scene.scene_id, - ) - self._validate_returned_state( - scene, - event, - expected_positions, - expected_rotations, - ) - native_observed = self._scene_from_event(scene.scene_id, event) - observed_scene = self._stable_observed_scene(scene, native_observed) - commanded_scene = self._commanded_scene( - scene, - observed_scene, - object_id, - commanded_position, - ) - observed_position = observed_scene.object_by_id(object_id).position - position_residual_m = math.dist( - ( - commanded_position.x, - commanded_position.y, - commanded_position.z, - ), - ( - observed_position.x, - observed_position.y, - observed_position.z, - ), - ) - is_scene_at_rest = self._native_scene_at_rest(event) - subject_is_moving = self._native_object_is_moving(event, target.name) - observation = self._observation_from_event(observed_scene, event) - result = AI2ThorPoseApplication( - commanded_scene=commanded_scene, - observed_scene=observed_scene, - commanded_position=commanded_position, - observed_position=observed_position, - position_residual_m=position_residual_m, - observation=observation, - is_scene_at_rest=is_scene_at_rest, - subject_is_moving=subject_is_moving, - ) - # The retained event contains the observed native pose, not the ideal - # command. Keep that identity exact; legacy callers still receive the - # commanded scene from ``with_object_xy`` and restoration is explicit. - self._current_scene = observed_scene - return result - - def apply_receptacle_spawn_point_observed( - self, - scene: Scene, - spawn_map: AI2ThorReceptacleSpawnMap, - position: AI2ThorNativePosition, - ) -> AI2ThorPoseApplication: - """Audit one externally selected endpoint with native surface placement. - - This method is deliberately not a search API. It accepts exactly one - point from one source-bound map, executes one ``PlaceObjectAtPoint``, - and fails closed if native collision or pose validation rejects it. - """ - - checked_map, subject, native_subject_object_id = ( - self._receptacle_audit_source(scene, spawn_map) - ) - checked_position = _strict_native_position(position, "spawn audit position") - if checked_position not in checked_map.positions: - raise ValueError("spawn audit position is not in the source-bound map") - return self._apply_receptacle_native_position_observed( - scene, - subject, - native_subject_object_id, - checked_position, - ) - - def apply_receptacle_endpoint_observed( - self, - scene: Scene, - spawn_map: AI2ThorReceptacleSpawnMap, - *, - x: float, - y: float, - ) -> AI2ThorPoseApplication: - """Audit one exact world-XY endpoint without snapping or searching. - - The source-bound map contributes only the single exact native support - height required by this horizontal-support API. The endpoint X/Y is - supplied by the platform-neutral solver and is never replaced by a - nearby returned coordinate. Multi-height receptacles are outside this - contract and fail before any native action. - """ - - checked_map, subject, native_subject_object_id = ( - self._receptacle_audit_source(scene, spawn_map) - ) - endpoint_x = _strict_finite_float(x, "receptacle endpoint x") - endpoint_y = _strict_finite_float(y, "receptacle endpoint y") - native_heights = {item.y for item in checked_map.positions} - if len(native_heights) != 1: - raise ValueError( - "exact receptacle endpoint audit requires one native support height" - ) - return self._apply_receptacle_native_position_observed( - scene, - subject, - native_subject_object_id, - AI2ThorNativePosition( - x=endpoint_x, - y=next(iter(native_heights)), - z=endpoint_y, - ), - ) - - def apply_receptacle_endpoint_settled_observed( - self, - scene: Scene, - spawn_map: AI2ThorReceptacleSpawnMap, - *, - x: float, - y: float, - max_pass_steps: int, - max_subject_rotation_residual_degrees: float | None = None, - ) -> AI2ThorPoseApplication: - """Place once, then wait boundedly for the runtime-only endpoint to settle.""" - - if type(max_pass_steps) is not int or max_pass_steps <= 0: - raise ValueError("max_pass_steps must be an exact positive integer") - if max_subject_rotation_residual_degrees is not None: - max_subject_rotation_residual_degrees = _strict_finite_float( - max_subject_rotation_residual_degrees, - "subject rotation residual limit", - ) - if max_subject_rotation_residual_degrees <= 0.0: - raise ValueError("subject rotation residual limit must be positive") - checked_map, subject, native_subject_object_id = self._receptacle_audit_source( - scene, spawn_map - ) - endpoint_x = _strict_finite_float(x, "receptacle endpoint x") - endpoint_y = _strict_finite_float(y, "receptacle endpoint y") - native_heights = {item.y for item in checked_map.positions} - if len(native_heights) != 1: - raise ValueError( - "exact receptacle endpoint audit requires one native support height" - ) - return self._apply_receptacle_native_position_observed( - scene, - subject, - native_subject_object_id, - AI2ThorNativePosition( - x=endpoint_x, - y=next(iter(native_heights)), - z=endpoint_y, - ), - max_pass_steps=max_pass_steps, - max_subject_rotation_residual_degrees=( - max_subject_rotation_residual_degrees - ), - ) - - def _receptacle_audit_source( - self, - scene: Scene, - spawn_map: AI2ThorReceptacleSpawnMap, - ) -> tuple[AI2ThorReceptacleSpawnMap, SceneObject, str]: - current_event = self._current_event_for_scene(scene) - checked_map = _strict_receptacle_spawn_map(spawn_map) - subject = scene.object_by_id(checked_map.subject_object_id) - if subject.support_object_id != checked_map.support_object_id: - raise ValueError("spawn map support does not match the current scene") - runtime_identity = self.runtime_identity() - scene_sha256 = _receptacle_scene_sha256( - scene, - checked_map.surface_patches, - ) - native_subject_object_id = self._native_object_id_for_name( - current_event, - subject.name, - ) - support = scene.object_by_id(checked_map.support_object_id) - native_support_object_id = self._native_object_id_for_name( - current_event, - support.name, - ) - if ( - checked_map.scene_id != scene.scene_id - or checked_map.scene_sha256 != scene_sha256 - or checked_map.runtime_identity != runtime_identity - or checked_map.native_subject_object_id != native_subject_object_id - or checked_map.native_support_object_id != native_support_object_id - ): - raise ValueError("spawn map does not close the exact current source") - return checked_map, subject, native_subject_object_id - - def _apply_receptacle_native_position_observed( - self, - scene: Scene, - subject: SceneObject, - native_subject_object_id: str, - checked_position: AI2ThorNativePosition, - *, - max_pass_steps: int | None = None, - max_subject_rotation_residual_degrees: float | None = None, - ) -> AI2ThorPoseApplication: - controller = self._require_active() - commanded_position = Vec3( - x=checked_position.x, - y=checked_position.z, - z=subject.position.z, - ) - expected_positions = { - obj.name: ( - commanded_position - if obj.object_id == subject.object_id - else obj.position - ) - for obj in scene.objects - } - expected_rotations = { - obj.name: self._native_rotation_for(scene.scene_id, obj) - for obj in scene.objects - } - try: - event = self._step( - controller, - "PlaceObjectAtPoint", - action="PlaceObjectAtPoint", - objectId=native_subject_object_id, - position={ - "x": checked_position.x, - "y": checked_position.y, - "z": checked_position.z, - }, - rotation=dict(expected_rotations[subject.name]), - ) - except BaseException: - self._poison_scene_state() - raise - self._event = event - self._current_scene = None - event = self._checked_scene_event( - controller, - event, - "PlaceObjectAtPoint", - scene.scene_id, - ) - if max_pass_steps is None: - self._validate_returned_state( - scene, - event, - expected_positions, - expected_rotations, - ) - else: - immediate_native = self._scene_from_event(scene.scene_id, event) - immediate_observed = self._stable_observed_scene(scene, immediate_native) - self._current_scene = immediate_observed - settlement = self.settle_scene_observed( - immediate_observed, - max_pass_steps=max_pass_steps, - ) - event = self._current_event_for_scene(settlement.observed_scene) - self._validate_returned_state( - scene, - event, - expected_positions, - expected_rotations, - total_position_residual_limits_by_name={ - subject.name: _RUNTIME_RECEPTACLE_POSITION_RESIDUAL_M - }, - rotation_residual_limits_by_name=( - None - if max_subject_rotation_residual_degrees is None - else { - subject.name: max_subject_rotation_residual_degrees, - } - ), - ) - native_observed = self._scene_from_event(scene.scene_id, event) - observed_scene = self._stable_observed_scene(scene, native_observed) - commanded_scene = self._commanded_scene( - scene, - observed_scene, - subject.object_id, - commanded_position, - ) - observed_position = observed_scene.object_by_id(subject.object_id).position - position_residual_m = math.dist( - ( - commanded_position.x, - commanded_position.y, - commanded_position.z, - ), - ( - observed_position.x, - observed_position.y, - observed_position.z, - ), - ) - result = AI2ThorPoseApplication( - commanded_scene=commanded_scene, - observed_scene=observed_scene, - commanded_position=commanded_position, - observed_position=observed_position, - position_residual_m=position_residual_m, - observation=self._observation_from_event(observed_scene, event), - is_scene_at_rest=self._native_scene_at_rest(event), - subject_is_moving=self._native_object_is_moving(event, subject.name), - ) - self._current_scene = observed_scene - return result - - def with_object_xy( - self, - scene: Scene, - object_id: str, - x: float, - y: float, - ) -> Scene: - return self.apply_object_xy_observed(scene, object_id, x, y).commanded_scene - - @staticmethod - def _png_bytes(frame: np.ndarray) -> bytes: - stream = BytesIO() - Image.fromarray(frame, mode="RGB").save(stream, format="PNG") - return stream.getvalue() - - @staticmethod - def _npy_bytes(array: np.ndarray) -> bytes: - stream = BytesIO() - np.save(stream, array, allow_pickle=False) - return stream.getvalue() - - def _stable_instance_pixel_counts( - self, - scene: Scene, - event: Any, - ) -> dict[str, int]: - raw_objects = event.metadata.get("objects") - if not isinstance(raw_objects, list): - raise AI2ThorNativeReturnError( - "observation returned no object metadata" - ) - native_by_name = { - str(item.get("name")): item - for item in _domain_object_metadata(raw_objects) - if isinstance(item, dict) - } - if len(native_by_name) != len(_domain_object_metadata(raw_objects)): - raise AI2ThorNativeReturnError( - "observation returned duplicate object names" - ) - masks = getattr(event, "instance_masks", None) - if not isinstance(masks, Mapping): - raise AI2ThorNativeReturnError( - "observation returned invalid instance masks" - ) - validated_masks: dict[str, np.ndarray] = {} - for native_id in masks: - if type(native_id) is not str: - raise AI2ThorNativeReturnError( - "observation returned invalid instance masks: keys must be strings" - ) - mask = masks[native_id] - if ( - not isinstance(mask, np.ndarray) - or mask.shape != (self.height, self.width) - or mask.dtype != np.bool_ - ): - raise AI2ThorNativeReturnError( - "observation returned invalid instance masks: " - "values must be boolean HxW numpy arrays" - ) - validated_masks[native_id] = mask - counts: dict[str, int] = {} - for obj in scene.objects: - metadata = native_by_name.get(obj.name) - if metadata is None: - raise AI2ThorNativeReturnError( - f"observation is missing stable object name {obj.name!r}" - ) - native_id = str(metadata.get("objectId")) - mask = validated_masks.get(native_id) - if mask is None: - counts[obj.object_id] = 0 - continue - counts[obj.object_id] = int(np.count_nonzero(mask)) - return counts - - def _observation_from_event( - self, - scene: Scene, - event: Any, - ) -> AI2ThorObservation: - camera = scene.camera_by_id("main") - rgb, depth, instance = self._validated_frames(event) - return AI2ThorObservation.create( - scene=scene, - rgb_png=self._png_bytes(rgb), - depth_npy=self._npy_bytes(depth), - instance_png=self._png_bytes(instance), - pointcloud_ply=self._pointcloud_bytes(camera, depth, rgb), - instance_pixel_counts=self._stable_instance_pixel_counts(scene, event), - is_scene_at_rest=self._native_scene_at_rest(event), - ) - - def capture_current_observation(self, scene: Scene) -> AI2ThorObservation: - """Capture frames from the exact current event without replaying poses.""" - event = self._current_event_for_scene(scene) - return self._observation_from_event(scene, event) - - def _validated_frames( - self, - event: Any | None = None, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - source_event = self._event if event is None else event - if source_event is None: - raise RuntimeError("no AI2-THOR event is available") - rgb = np.asarray(getattr(source_event, "frame", None)) - depth = np.asarray(getattr(source_event, "depth_frame", None)) - instance = np.asarray( - getattr(source_event, "instance_segmentation_frame", None) - ) - expected_color = (self.height, self.width, 3) - expected_depth = (self.height, self.width) - if rgb.shape != expected_color or rgb.dtype != np.uint8: - raise AI2ThorNativeReturnError( - "AI2-THOR RGB frame must be HxWx3 uint8" - ) - if instance.shape != expected_color or instance.dtype != np.uint8: - raise AI2ThorNativeReturnError( - "AI2-THOR instance frame must be HxWx3 uint8" - ) - if depth.shape != expected_depth or not np.issubdtype( - depth.dtype, np.number - ): - raise AI2ThorNativeReturnError( - "AI2-THOR depth frame must be a numeric HxW array" - ) - return rgb, depth.astype(np.float32, copy=False), instance - - @staticmethod - def _validate_stem(stem: str) -> None: - if ( - not stem - or stem.strip() != stem - or stem in {".", ".."} - or Path(stem).name != stem - or "/" in stem - or "\\" in stem - ): - raise ValueError("artifact stem must be a safe filename component") - - def _pointcloud_bytes( - self, - camera: Camera, - depth: np.ndarray, - rgb: np.ndarray, - ) -> bytes: - rows, columns = np.mgrid[0 : self.height : 4, 0 : self.width : 4] - z = depth[rows, columns] - valid = np.isfinite(z) & (z > 0.0) - rows, columns, z = rows[valid], columns[valid], z[valid] - fx, fy = camera.intrinsics[0], camera.intrinsics[4] - cx, cy = camera.intrinsics[2], camera.intrinsics[5] - x = (columns - cx) * z / fx - y = -(rows - cy) * z / fy - camera_points = np.stack([x, y, z, np.ones_like(z)]) - world_points = np.linalg.inv(matrix4(camera.world_to_camera)) @ camera_points - colors = rgb[rows, columns] - lines = [ - "ply", - "format ascii 1.0", - f"element vertex {world_points.shape[1]}", - "property float x", - "property float y", - "property float z", - "property uchar red", - "property uchar green", - "property uchar blue", - "end_header", - ] - lines.extend( - ( - f"{world_points[0, index]:.9g} " - f"{world_points[1, index]:.9g} " - f"{world_points[2, index]:.9g} " - f"{int(colors[index, 0])} " - f"{int(colors[index, 1])} " - f"{int(colors[index, 2])}" - ) - for index in range(world_points.shape[1]) - ) - return ("\n".join(lines) + "\n").encode("ascii") - - def _write_pointcloud( - self, - camera: Camera, - depth: np.ndarray, - rgb: np.ndarray, - destination: Path, - ) -> None: - destination.write_bytes(self._pointcloud_bytes(camera, depth, rgb)) - - def render_assets( - self, - scene: Scene, - camera_id: str, - destination_root: Path, - stem: str, - ) -> RenderedAssets: - self._require_active() - camera = scene.camera_by_id(camera_id) - if camera_id != "main": - raise KeyError(camera_id) - self._validate_stem(stem) - self._restore_scene_state(scene) - rgb, depth, instance = self._validated_frames() - destination_root.mkdir(parents=True, exist_ok=True) - assets = RenderedAssets( - rgb_path=destination_root / f"{stem}-rgb.png", - depth_path=destination_root / f"{stem}-depth.npy", - instance_path=destination_root / f"{stem}-instance.png", - pointcloud_path=destination_root / f"{stem}-pointcloud.ply", - ) - if len( - { - assets.rgb_path, - assets.depth_path, - assets.instance_path, - assets.pointcloud_path, - } - ) != 4: - raise ValueError("artifact paths must be distinct") - Image.fromarray(rgb, mode="RGB").save(assets.rgb_path) - np.save(assets.depth_path, depth, allow_pickle=False) - Image.fromarray(instance, mode="RGB").save(assets.instance_path) - self._write_pointcloud(camera, depth, rgb, assets.pointcloud_path) - return assets diff --git a/src/spatialcf/adapters/ai2thor/__init__.py b/src/spatialcf/adapters/ai2thor/__init__.py new file mode 100644 index 0000000..d1988ec --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/__init__.py @@ -0,0 +1,161 @@ +"""AI2-THOR adapter facade and its final concrete implementation leaves.""" + +from __future__ import annotations + +from spatialcf.adapters.ai2thor.adapter import ( # noqa: F401 + AI2ThorAdapter, + ControllerFactory, + _default_controller_factory, + _load_default_controller_type, +) +from spatialcf.adapters.ai2thor.camera import ( # noqa: F401 + _CAMERA_POSITION_TOLERANCE_M, + _NATIVE_NAVIGATION_GRID_SIZE_M, + _OBJECT_GEOMETRY_TOLERANCE_M, + _camera_position_residual_m, + _camera_position_residual_within_tolerance, +) +from spatialcf.adapters.ai2thor.capture import ( # noqa: F401 + _camera, + _canonical_camera_observed_scene, + _npy_bytes, + _obb_corners, + _object, + _objects_by_name, + _observation_from_event, + _oriented_bounds, + _png_bytes, + _pointcloud_bytes, + _projected_bounds, + _scene_from_event, + _stable_instance_pixel_counts, + _stable_observed_scene, + _validate_stem, + _validated_frames, + _view, + _without_cyclic_support_assignments, + _write_pointcloud, + canonical_procedural_house_sha256, + capture_current_observation, + render_assets, +) +from spatialcf.adapters.ai2thor.conversion import ( # noqa: F401 + _ANGLE_TOLERANCE_DEGREES, + _EPSILON, + _quaternion_yaw, + _quaternions_close, + _rotation_matrix, + ai2thor_camera_world_to_camera, +) +from spatialcf.adapters.ai2thor.execution import ( # noqa: F401 + _OBJECT_ROTATION_TOLERANCE_DEGREES, + _RUNTIME_RECEPTACLE_POSITION_RESIDUAL_M, +) +from spatialcf.adapters.ai2thor.models import ( # noqa: F401 + _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M, + _RECEPTACLE_TRIGGER_GRID_SIDE, + _RECEPTACLE_TRIGGER_GRID_SIZE, + _TELEPORT_VERTICAL_GUARD_M, + AI2ThorAgentPose, + AI2ThorCameraApplication, + AI2ThorFloorEnvelope, + AI2ThorIsolatedEpisode, + AI2ThorNativeFeasibilityMap, + AI2ThorNativePosition, + AI2ThorNativeReturnError, + AI2ThorNativeSupportFact, + AI2ThorNativeSupportKind, + AI2ThorNavigationFeasibilityMap, + AI2ThorObservation, + AI2ThorPoseApplication, + AI2ThorProceduralScene, + AI2ThorReceptacleSpawnMap, + AI2ThorReceptacleSurfacePatch, + AI2ThorRuntimeError, + AI2ThorRuntimeIdentity, + AI2ThorSceneSettlement, + AI2ThorSettledCameraApplication, + AI2ThorSettlementTimeout, + _canonical_house_json_bytes, + _canonical_json_sha256, + _full_commit_sha, + _native_positions_sha256, + _nonempty_text, + _procedural_room_identity, + _receptacle_spawn_source_sha256, + _strict_finite_float, + _validate_json_tree, +) +from spatialcf.adapters.ai2thor.support import ( # noqa: F401 + _REACHABLE_POSITION_QUANTIZATION_M, + _STRUCTURAL_OBJECT_TYPES, + _canonical_scene_sha256, + _cyclic_domain_object_ids, + _domain_object_metadata, + _grid_axis, + _receptacle_scene_sha256, + _strict_native_position, + _strict_receptacle_spawn_map, + _validated_native_object_metadata, + bind_ai2thor_reachable_positions, + build_ai2thor_native_support_facts, + build_ai2thor_receptacle_surface_patches, + build_navigation_feasibility_map, + build_receptacle_support_position_region, + canonicalize_ai2thor_reachable_positions, + capture_bound_ai2thor_receptacle_spawn_map, +) +from spatialcf.adapters.ai2thor.validation import ( + observation_contract_errors, # noqa: F401 +) +from spatialcf.domain.scene import ( # noqa: F401 + CollisionObstacle, + Scene, + SceneObject, + SubjectPositionRegion, + Vec3, +) +from spatialcf.domain.serialization import canonical_json_bytes # noqa: F401 +from spatialcf.geometry.regions import ( # noqa: F401 + conservative_navigation_position_geometry, + conservative_receptacle_position_geometry, + planar_polygon_payloads, +) +from spatialcf.geometry.transforms import ( # noqa: F401 + ai2thor_position_to_world, + ai2thor_rotation_to_world, +) + +__all__ = ( + "AI2ThorAdapter", + "AI2ThorAgentPose", + "AI2ThorCameraApplication", + "AI2ThorFloorEnvelope", + "AI2ThorIsolatedEpisode", + "AI2ThorNativeFeasibilityMap", + "AI2ThorNativePosition", + "AI2ThorNativeReturnError", + "AI2ThorNativeSupportFact", + "AI2ThorNativeSupportKind", + "AI2ThorNavigationFeasibilityMap", + "AI2ThorObservation", + "AI2ThorPoseApplication", + "AI2ThorProceduralScene", + "AI2ThorReceptacleSpawnMap", + "AI2ThorReceptacleSurfacePatch", + "AI2ThorRuntimeError", + "AI2ThorRuntimeIdentity", + "AI2ThorSceneSettlement", + "AI2ThorSettledCameraApplication", + "AI2ThorSettlementTimeout", + "ai2thor_camera_world_to_camera", + "ai2thor_position_to_world", + "bind_ai2thor_reachable_positions", + "build_ai2thor_native_support_facts", + "build_ai2thor_receptacle_surface_patches", + "build_navigation_feasibility_map", + "build_receptacle_support_position_region", + "canonical_procedural_house_sha256", + "canonicalize_ai2thor_reachable_positions", + "capture_bound_ai2thor_receptacle_spawn_map", +) diff --git a/src/spatialcf/adapters/ai2thor/adapter.py b/src/spatialcf/adapters/ai2thor/adapter.py new file mode 100644 index 0000000..cf7de90 --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/adapter.py @@ -0,0 +1,354 @@ +from __future__ import annotations + +import warnings +from collections.abc import Callable, Mapping +from functools import partialmethod +from types import MappingProxyType +from typing import Any, ContextManager # noqa: UP035 +from weakref import ReferenceType + +from spatialcf.adapters.ai2thor.camera import AI2ThorCameraMixin as _AI2ThorCameraMixin +from spatialcf.adapters.ai2thor.capture import ( + AI2ThorCaptureMixin as _AI2ThorCaptureMixin, +) +from spatialcf.adapters.ai2thor.conversion import ( + AI2ThorConversionMixin as _AI2ThorConversionMixin, +) +from spatialcf.adapters.ai2thor.execution import ( + AI2ThorExecutionMixin as _AI2ThorExecutionMixin, +) +from spatialcf.adapters.ai2thor.models import ( + AI2ThorNativeReturnError, + AI2ThorProceduralScene, + AI2ThorRuntimeError, + AI2ThorSettlementTimeout, + adapter_support_fact_from_native, + ai2thor_spawn_map_from_adapter, + applied_certified_edit_from_native, + settled_readback_from_native, +) +from spatialcf.adapters.ai2thor.support import ( + AI2ThorSupportMixin as _AI2ThorSupportMixin, +) +from spatialcf.adapters.base import ( + AdapterActionRejected, + AdapterOperationError, + AdapterProceduralScene, + AdapterReturnRejected, + AdapterSettlementTimeout, + AdapterSupportFact, + AppliedCertifiedEdit, + CameraObservationHandle, + CaptureRequest, + CertifiedEditApplication, + SettledReadback, + SourceCaptureFacts, + SourceCaptureOptions, + capture_source_with_scene_adapter, + capture_support_with_environment_adapter, +) +from spatialcf.domain.scene import Scene + +ControllerFactory = Callable[..., Any] + + +def _load_default_controller_type() -> type[Any]: + try: + with warnings.catch_warnings(): + # AI2-THOR 5.0.0 contains escaped spaces in diagnostic-only string + # literals. Import it inside callers' fail-closed warning scopes + # without weakening warnings emitted by our code or at runtime. + warnings.filterwarnings( + "ignore", + message=r"invalid escape sequence '\\ '", + category=DeprecationWarning, + ) + from ai2thor.controller import Controller + except ImportError as exc: + raise RuntimeError( + "AI2-THOR is not installed. Install spatialcf[sim] with Python 3.11." + ) from exc + return Controller + + +def _default_controller_factory(**kwargs: Any) -> Any: + return _load_default_controller_type()(**kwargs) + + +class AI2ThorAdapter( + _AI2ThorConversionMixin, + _AI2ThorSupportMixin, + _AI2ThorCameraMixin, + _AI2ThorCaptureMixin, + _AI2ThorExecutionMixin, +): + def __init__( + self, + scene_names: list[str], + width: int, + height: int, + seed: int, + *, + controller_factory: ControllerFactory | None = None, + allow_source_pose_drift: bool = False, + procedural_scenes: Mapping[str, AI2ThorProceduralScene | AdapterProceduralScene] + | None = None, + ) -> None: + if type(scene_names) is not list or any( + type(name) is not str for name in scene_names + ): + raise ValueError("scene_names must be an exact string list") + if type(width) is not int or type(height) is not int: + raise ValueError("render dimensions must be exact integers") + if type(seed) is not int: + raise ValueError("seed must be an exact integer") + if type(allow_source_pose_drift) is not bool: + raise ValueError("allow_source_pose_drift must be an exact boolean") + if not scene_names: + raise ValueError("at least one scene is required") + if len(set(scene_names)) != len(scene_names): + raise ValueError("scene names must be unique") + if width <= 0 or height <= 0: + raise ValueError("render dimensions must be positive") + if procedural_scenes is None: + procedural_by_alias: dict[ + str, AI2ThorProceduralScene | AdapterProceduralScene + ] = {} + else: + if not isinstance(procedural_scenes, Mapping): + raise ValueError("procedural_scenes must be a mapping") + procedural_by_alias = {} + for alias, source in procedural_scenes.items(): + if type(alias) is not str: + raise ValueError("procedural_scenes keys must be exact strings") + if type(source) not in (AI2ThorProceduralScene, AdapterProceduralScene): + raise ValueError( + "procedural_scenes values must be exact " + "AI2ThorProceduralScene or AdapterProceduralScene" + ) + procedural_by_alias[alias] = source + if not set(procedural_by_alias).issubset(scene_names): + raise ValueError("procedural_scenes keys must be a scene_names subset") + self.scene_names = list(scene_names) + self.scene_name = self.scene_names[0] + self.width = width + self.height = height + self.seed = seed + self.allow_source_pose_drift = allow_source_pose_drift + self.procedural_scenes: Mapping[ + str, AI2ThorProceduralScene | AdapterProceduralScene + ] = MappingProxyType(dict(procedural_by_alias)) + self.controller: Any | None = None + self._controller_factory = controller_factory or _default_controller_factory + self._event: Any | None = None + self._latest_event: Any | None = None + self._stopped = False + self._current_scene: Scene | None = None + self._pending_protocol_applied: AppliedCertifiedEdit | None = None + self._pending_protocol_scene: Scene | None = None + self._pending_protocol_event: Any | None = None + self._camera_states: dict[ + tuple[str, tuple[float, ...], tuple[float, ...]], dict[str, Any] + ] = {} + self._native_rotations: dict[tuple[str, str], dict[str, float]] = {} + self._isolated_controller_refs: list[ReferenceType[Any]] = [] + self._protocol_camera_handles: dict[ + str, tuple[CameraObservationHandle, ContextManager[Scene]] + ] = {} + self._protocol_camera_resumed: set[str] = set() + self._protocol_camera_token_sequence = 0 + + +def _clear_pending_protocol_application(adapter: AI2ThorAdapter) -> None: + adapter._pending_protocol_applied = None + adapter._pending_protocol_scene = None + adapter._pending_protocol_event = None + + +def _normalized_protocol_error( + adapter: AI2ThorAdapter, + error: Exception, +) -> AdapterOperationError: + """Classify raw controller failures before they cross the protocol edge.""" + if isinstance(error, AdapterSettlementTimeout): + return error + origin = error.__cause__ if isinstance(error.__cause__, Exception) else error + if isinstance(origin, AI2ThorNativeReturnError): + return AdapterReturnRejected( + f"{type(origin).__name__}:{' '.join(str(origin).split())}" + ) + event = adapter._latest_event + metadata = getattr(event, "metadata", None) + if isinstance(metadata, dict) and metadata.get("lastActionSuccess") is False: + message = metadata.get("errorMessage") + if type(message) is str and message.strip(): + return AdapterActionRejected(message) + return AdapterOperationError(str(error)) + + +def _capture_source_protocol( + adapter: AI2ThorAdapter, + request: CaptureRequest, +): + try: + return capture_source_with_scene_adapter(adapter, request) + except ( + AI2ThorNativeReturnError, + AI2ThorRuntimeError, + RuntimeError, + ValueError, + KeyError, + ) as error: + raise _normalized_protocol_error(adapter, error) from error + + +def _observe_source_protocol( + adapter: AI2ThorAdapter, + source, + *, + options: SourceCaptureOptions, + settle: bool, +) -> SourceCaptureFacts: + try: + return _AI2ThorCaptureMixin.observe_source( + adapter, + source, + options=options, + settle=settle, + ) + except AdapterOperationError as error: + raise _normalized_protocol_error(adapter, error) from error + + +def _current_adapter_support_facts( + adapter: AI2ThorAdapter, + facts: SourceCaptureFacts, +) -> tuple[AdapterSupportFact, ...]: + current = tuple( + adapter_support_fact_from_native(item) + for item in adapter.native_support_facts(facts.scene) + ) + by_object_id = {item.object_id: item for item in current} + canonical_object_ids = tuple(item.object_id for item in facts.scene.objects) + if len(by_object_id) != len(current) or set(by_object_id) != set( + canonical_object_ids + ): + raise AdapterOperationError("current support fact roster changed") + return tuple(by_object_id[object_id] for object_id in canonical_object_ids) + + +def _capture_spawn_maps_protocol( + adapter: AI2ThorAdapter, + facts: SourceCaptureFacts, + *, + subject_object_ids: tuple[str, ...], +): + try: + current_support_facts = _current_adapter_support_facts(adapter, facts) + if current_support_facts != facts.support_facts: + raise AdapterOperationError( + "capture support facts changed before spawn-map query" + ) + return _AI2ThorSupportMixin.capture_spawn_maps( + adapter, + facts, + subject_object_ids=subject_object_ids, + ) + except AdapterOperationError as error: + raise _normalized_protocol_error(adapter, error) from error + except ( + AI2ThorNativeReturnError, + AI2ThorRuntimeError, + RuntimeError, + ValueError, + KeyError, + ) as error: + raise _normalized_protocol_error(adapter, error) from error + + +def _apply_certified_edit_observed( + adapter: AI2ThorAdapter, + application: CertifiedEditApplication, +) -> AppliedCertifiedEdit: + if adapter._pending_protocol_applied is not None: + raise AdapterOperationError("pending protocol application must settle first") + _clear_pending_protocol_application(adapter) + try: + source = application.source.scene + subject = source.object_by_id(application.edit.subject_id) + native = adapter.apply_receptacle_endpoint_observed( + source, + ai2thor_spawn_map_from_adapter(application.spawn_map), + x=subject.position.x + application.edit.translation_xy_m.x, + y=subject.position.y + application.edit.translation_xy_m.y, + ) + applied = applied_certified_edit_from_native(native, application=application) + except AI2ThorSettlementTimeout as error: + raise AdapterSettlementTimeout(str(error)) from error + except ( + AI2ThorNativeReturnError, + AI2ThorRuntimeError, + RuntimeError, + ValueError, + KeyError, + ) as error: + raise _normalized_protocol_error(adapter, error) from error + observed_scene = adapter._current_scene + observed_event = adapter._latest_event + if ( + type(observed_scene) is not Scene + or observed_event is None + or adapter._event is not observed_event + ): + raise AdapterOperationError( + "certified edit did not retain an observed adapter state" + ) + adapter._pending_protocol_applied = applied + adapter._pending_protocol_scene = observed_scene + adapter._pending_protocol_event = observed_event + return applied + + +def _settle_readback_observed( + adapter: AI2ThorAdapter, + applied: AppliedCertifiedEdit, +) -> SettledReadback: + pending_scene = adapter._pending_protocol_scene + pending_event = adapter._pending_protocol_event + if ( + adapter._pending_protocol_applied is not applied + or type(pending_scene) is not Scene + or pending_event is None + or adapter._current_scene is not pending_scene + or adapter._event is not pending_event + or adapter._latest_event is not pending_event + ): + _clear_pending_protocol_application(adapter) + raise AdapterOperationError( + "pending protocol application is missing, stale, or consumed" + ) + _clear_pending_protocol_application(adapter) + try: + settlement = adapter.settle_scene_observed( + pending_scene, + max_pass_steps=applied.application.max_settlement_steps, + ) + return settled_readback_from_native(settlement, applied=applied) + except AI2ThorSettlementTimeout as error: + raise AdapterSettlementTimeout(str(error)) from error + except ( + AI2ThorNativeReturnError, + AI2ThorRuntimeError, + RuntimeError, + ValueError, + KeyError, + ) as error: + raise _normalized_protocol_error(adapter, error) from error + + +AI2ThorAdapter.capture_source = _capture_source_protocol +AI2ThorAdapter.capture_support = partialmethod(capture_support_with_environment_adapter) +AI2ThorAdapter.observe_source = _observe_source_protocol +AI2ThorAdapter.capture_spawn_maps = _capture_spawn_maps_protocol +AI2ThorAdapter.apply_certified_edit = _apply_certified_edit_observed +AI2ThorAdapter.settle_readback = _settle_readback_observed diff --git a/src/spatialcf/adapters/ai2thor/camera.py b/src/spatialcf/adapters/ai2thor/camera.py new file mode 100644 index 0000000..7596ea8 --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/camera.py @@ -0,0 +1,873 @@ +"""Native camera control for the staged AI2-THOR adapter.""" + +from __future__ import annotations + +import math +from collections.abc import Iterator +from contextlib import contextmanager +from copy import deepcopy +from typing import Any + +import numpy as np + +from spatialcf.adapters.ai2thor.conversion import ( + _ANGLE_TOLERANCE_DEGREES, + _quaternions_close, +) +from spatialcf.adapters.ai2thor.models import ( + AI2ThorAgentPose, + AI2ThorCameraApplication, + AI2ThorNativePosition, + AI2ThorNativeReturnError, + AI2ThorSettledCameraApplication, + AI2ThorSettlementTimeout, + adapter_camera_application_from_native, + native_pose_from_adapter, +) +from spatialcf.adapters.base import ( + AdapterCameraApplication, + AdapterOperationError, + AdapterPose, + AdapterSettledCameraApplication, + AdapterSettlementTimeout, + CameraObservationHandle, + SourceCaptureFacts, +) +from spatialcf.domain.scene import Camera, Scene + +_CAMERA_POSITION_TOLERANCE_M = 1e-5 +_OBJECT_GEOMETRY_TOLERANCE_M = 1e-5 +_NATIVE_NAVIGATION_GRID_SIZE_M = 0.05 + +CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_5 = 0.2 +CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_6 = 0.21 +CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_7 = 0.25 +CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_8 = 0.25 + math.sqrt(2.0) * 0.5e-6 + + +def _clearance_rotation_matrix( + rotation, +) -> tuple[tuple[float, float, float], ...]: + values = (rotation.x, rotation.y, rotation.z, rotation.w) + if not all(math.isfinite(value) for value in values): + raise ValueError("camera clearance OBB rotation must be finite") + maximum = max(abs(value) for value in values) + if maximum == 0.0: + raise ValueError("camera clearance OBB rotation must be nonzero") + scaled = tuple(value / maximum for value in values) + norm = math.sqrt(sum(value * value for value in scaled)) + x, y, z, w = (value / norm for value in scaled) + return ( + ( + 1.0 - 2.0 * (y * y + z * z), + 2.0 * (x * y - z * w), + 2.0 * (x * z + y * w), + ), + ( + 2.0 * (x * y + z * w), + 1.0 - 2.0 * (x * x + z * z), + 2.0 * (y * z - x * w), + ), + ( + 2.0 * (x * z - y * w), + 2.0 * (y * z + x * w), + 1.0 - 2.0 * (x * x + y * y), + ), + ) + + +def _clearance_projected_corners(obb) -> tuple[tuple[float, float], ...]: + extents = (obb.extent.x, obb.extent.y, obb.extent.z) + centers = (obb.center.x, obb.center.y, obb.center.z) + if not all(math.isfinite(value) and value > 0.0 for value in extents): + raise ValueError("camera clearance OBB extents must be finite and positive") + if not all(math.isfinite(value) for value in centers): + raise ValueError("camera clearance OBB center must be finite") + rotation = _clearance_rotation_matrix(obb.rotation) + points = set() + for x_sign in (-1.0, 1.0): + for y_sign in (-1.0, 1.0): + for z_sign in (-1.0, 1.0): + local = ( + x_sign * extents[0] / 2.0, + y_sign * extents[1] / 2.0, + z_sign * extents[2] / 2.0, + ) + world = tuple( + centers[axis] + + sum(rotation[axis][inner] * local[inner] for inner in range(3)) + for axis in range(3) + ) + points.add((world[0], world[1])) + return tuple(sorted(points)) + + +def _clearance_cross( + origin: tuple[float, float], + left: tuple[float, float], + right: tuple[float, float], +) -> float: + return (left[0] - origin[0]) * (right[1] - origin[1]) - (left[1] - origin[1]) * ( + right[0] - origin[0] + ) + + +def _clearance_convex_hull( + points: tuple[tuple[float, float], ...], +) -> tuple[tuple[float, float], ...]: + unique = tuple(sorted(set(points))) + if len(unique) < 3: + raise ValueError("camera clearance OBB projection must have positive area") + lower: list[tuple[float, float]] = [] + for point in unique: + while len(lower) >= 2 and _clearance_cross(lower[-2], lower[-1], point) <= 0.0: + lower.pop() + lower.append(point) + upper: list[tuple[float, float]] = [] + for point in reversed(unique): + while len(upper) >= 2 and _clearance_cross(upper[-2], upper[-1], point) <= 0.0: + upper.pop() + upper.append(point) + hull = tuple(lower[:-1] + upper[:-1]) + if len(hull) < 3: + raise ValueError("camera clearance OBB projection must have positive area") + return hull + + +def _clearance_point_segment_distance( + point: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], +) -> float: + delta = (end[0] - start[0], end[1] - start[1]) + length_squared = delta[0] * delta[0] + delta[1] * delta[1] + if length_squared == 0.0: + return math.dist(point, start) + fraction = max( + 0.0, + min( + 1.0, + ((point[0] - start[0]) * delta[0] + (point[1] - start[1]) * delta[1]) + / length_squared, + ), + ) + nearest = ( + start[0] + fraction * delta[0], + start[1] + fraction * delta[1], + ) + return math.dist(point, nearest) + + +def _clearance_point_polygon_distance( + point: tuple[float, float], + polygon: tuple[tuple[float, float], ...], +) -> float: + crosses = tuple( + _clearance_cross(polygon[index], polygon[(index + 1) % len(polygon)], point) + for index in range(len(polygon)) + ) + if all(value >= 0.0 for value in crosses) or all(value <= 0.0 for value in crosses): + return 0.0 + return min( + _clearance_point_segment_distance( + point, + polygon[index], + polygon[(index + 1) % len(polygon)], + ) + for index in range(len(polygon)) + ) + + +def _filter_competition_native_camera_positions( + scene: Scene, + positions: tuple[AI2ThorNativePosition, ...], + *, + clearance_radius_m: float, +) -> tuple[AI2ThorNativePosition, ...]: + if type(scene) is not Scene: + raise TypeError("camera clearance scene must be an exact Scene") + checked_scene = Scene.model_validate(scene.model_dump(mode="python"), strict=True) + if type(positions) is not tuple or any( + type(position) is not AI2ThorNativePosition for position in positions + ): + raise TypeError("camera clearance requires an exact position tuple") + if not positions: + raise TypeError("camera clearance requires a non-empty exact position tuple") + if len(set(positions)) != len(positions): + raise ValueError("camera clearance positions must be unique") + footprints = tuple( + _clearance_convex_hull(_clearance_projected_corners(item.obb)) + for item in checked_scene.objects + if item.movable + ) + accepted = tuple( + position + for position in positions + if all( + _clearance_point_polygon_distance((position.x, position.z), footprint) + > clearance_radius_m + for footprint in footprints + ) + ) + return tuple(sorted(accepted, key=lambda item: (item.x, item.z, item.y))) + + +def filter_competition_native_camera_positions_v2_9_5( + scene: Scene, + positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorNativePosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_5, + ) + + +def filter_competition_native_camera_positions_v2_9_6( + scene: Scene, + positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorNativePosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_6, + ) + + +def filter_competition_native_camera_positions_v2_9_7( + scene: Scene, + positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorNativePosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_7, + ) + + +def filter_competition_native_camera_positions_v2_9_8( + scene: Scene, + positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorNativePosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_8, + ) + + +def _camera_position_residual_m( + requested: AI2ThorNativePosition, + observed: AI2ThorNativePosition, +) -> float: + """Return the total native-coordinate residual for one camera pose.""" + return math.dist( + (requested.x, requested.y, requested.z), + (observed.x, observed.y, observed.z), + ) + + +def _camera_position_residual_within_tolerance( + requested: AI2ThorNativePosition, + observed: AI2ThorNativePosition, +) -> tuple[float, bool]: + """Apply the shared total-position camera contract with ULP allowance.""" + residual_m = _camera_position_residual_m(requested, observed) + rounding_allowance_m = 4.0 * max( + math.ulp(value) + for value in ( + requested.x, + requested.y, + requested.z, + observed.x, + observed.y, + observed.z, + ) + ) + return ( + residual_m, + residual_m <= _CAMERA_POSITION_TOLERANCE_M + rounding_allowance_m, + ) + + +class AI2ThorCameraMixin: + def pause_camera_observations( + self, + facts: SourceCaptureFacts, + *, + settle_after_resume: bool, + ) -> CameraObservationHandle: + """Pause one captured source until the matching one-shot resume.""" + + if ( + type(facts) is not SourceCaptureFacts + or type(settle_after_resume) is not bool + ): + raise AdapterOperationError("camera pause arguments must be exact") + token = ( + f"{facts.binding.token}:camera-pause:{self._protocol_camera_token_sequence}" + ) + self._protocol_camera_token_sequence += 1 + if ( + token in self._protocol_camera_handles + or token in self._protocol_camera_resumed + ): + raise AdapterOperationError("camera pause token collision") + manager = ( + self.paused_camera_observations_for_settlement(facts.scene) + if settle_after_resume + else self.paused_camera_observations(facts.scene) + ) + try: + paused_scene = manager.__enter__() + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError) as error: + raise AdapterOperationError(str(error)) from error + handle = CameraObservationHandle( + source=facts.source, + binding=facts.binding, + scene=paused_scene, + token=token, + settle_after_resume=settle_after_resume, + ) + self._protocol_camera_handles[token] = (handle, manager) + return handle + + def resume_camera_observations(self, handle: CameraObservationHandle) -> None: + """Resume a paused source exactly once, even on a workflow exception.""" + + if type(handle) is not CameraObservationHandle: + raise AdapterOperationError("camera resume handle must be exact") + if handle.token in self._protocol_camera_resumed: + raise AdapterOperationError("camera observations already resumed") + try: + issued_handle, manager = self._protocol_camera_handles[handle.token] + except KeyError as error: + raise AdapterOperationError("camera pause handle is missing") from error + if handle is not issued_handle or handle != issued_handle: + raise AdapterOperationError("camera pause handle is not the issued handle") + del self._protocol_camera_handles[handle.token] + try: + manager.__exit__(None, None, None) + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError) as error: + raise AdapterOperationError(str(error)) from error + finally: + self._protocol_camera_resumed.add(handle.token) + + def apply_camera_pose( + self, + facts: SourceCaptureFacts, + pose: AdapterPose, + *, + handle: CameraObservationHandle | None, + source_scene: Scene, + reset_from_source: bool, + max_settlement_steps: int, + ) -> AdapterCameraApplication: + """Apply exactly one requested pose without choosing or replaying it.""" + + if ( + type(facts) is not SourceCaptureFacts + or type(pose) is not AdapterPose + or type(source_scene) is not Scene + or type(reset_from_source) is not bool + or type(max_settlement_steps) is not int + or max_settlement_steps <= 0 + ): + raise AdapterOperationError("camera application arguments must be exact") + if handle is not None and ( + type(handle) is not CameraObservationHandle + or handle.binding != facts.binding + or handle.token not in self._protocol_camera_handles + ): + raise AdapterOperationError("camera application pause handle is invalid") + try: + native_pose = native_pose_from_adapter(pose) + application = ( + self.apply_camera_pose_from_frozen_source_observed( + source_scene, + native_pose, + max_pass_steps=max_settlement_steps, + ) + if reset_from_source + else self.apply_camera_pose_observed(source_scene, native_pose) + ) + return adapter_camera_application_from_native( + application, + source=facts.source, + ) + except AI2ThorSettlementTimeout as error: + raise AdapterSettlementTimeout(str(error)) from error + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError) as error: + raise AdapterOperationError(str(error)) from error + + def settle_camera_pose( + self, + facts: SourceCaptureFacts, + pose: AdapterPose, + *, + source_scene: Scene, + max_settlement_steps: int, + ) -> AdapterSettledCameraApplication: + """Settle one already-selected post-resume pose exactly once.""" + + if ( + type(facts) is not SourceCaptureFacts + or type(pose) is not AdapterPose + or type(source_scene) is not Scene + or type(max_settlement_steps) is not int + or max_settlement_steps <= 0 + ): + raise AdapterOperationError("camera settlement arguments must be exact") + try: + settled = self.settle_current_camera_pose_observed( + source_scene, + native_pose_from_adapter(pose), + max_pass_steps=max_settlement_steps, + ) + return AdapterSettledCameraApplication( + application=adapter_camera_application_from_native( + settled.application, + source=facts.source, + ), + settlement_pass_steps=settled.settlement_pass_steps, + ) + except AI2ThorSettlementTimeout as error: + raise AdapterSettlementTimeout(str(error)) from error + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError) as error: + raise AdapterOperationError(str(error)) from error + + @classmethod + def _validate_camera_object_invariants( + cls, + source: Scene, + observed: Scene, + ) -> None: + cls._validate_camera_object_identity_invariants(source, observed) + source_by_name = cls._objects_by_name(source.objects) + observed_by_name = cls._objects_by_name(observed.objects) + + for name, original in source_by_name.items(): + current = observed_by_name[name] + if ( + not np.allclose( + ( + current.position.x, + current.position.y, + current.position.z, + current.obb.center.x, + current.obb.center.y, + current.obb.center.z, + current.obb.extent.x, + current.obb.extent.y, + current.obb.extent.z, + ), + ( + original.position.x, + original.position.y, + original.position.z, + original.obb.center.x, + original.obb.center.y, + original.obb.center.z, + original.obb.extent.x, + original.obb.extent.y, + original.obb.extent.z, + ), + atol=_OBJECT_GEOMETRY_TOLERANCE_M, + rtol=0.0, + ) + or not _quaternions_close( + current.rotation, + original.rotation, + ) + or not _quaternions_close( + current.obb.rotation, + original.obb.rotation, + ) + ): + raise RuntimeError( + f"object {name!r} geometry changed during camera application" + ) + + @classmethod + def _validate_camera_object_identity_invariants( + cls, + source: Scene, + observed: Scene, + ) -> None: + source_by_name = cls._objects_by_name(source.objects) + observed_by_name = cls._objects_by_name(observed.objects) + if set(source_by_name) != set(observed_by_name): + raise RuntimeError("stable object names changed during camera application") + + for name, original in source_by_name.items(): + current = observed_by_name[name] + if ( + current.object_id != original.object_id + or current.name != original.name + or current.category != original.category + or current.movable is not original.movable + or current.request_eligible is not original.request_eligible + or current.support_object_id != original.support_object_id + ): + raise RuntimeError( + f"object {name!r} identity, category, mobility, or support changed " + "during camera application" + ) + + def apply_camera_pose_observed( + self, + scene: Scene, + pose: AI2ThorAgentPose, + ) -> AI2ThorCameraApplication: + """Apply one unforced TeleportFull and bind its same-event observation.""" + controller = self._require_active() + current_event = self._current_event_for_scene(scene) + expected_native_scene_name = self._native_scene_name(current_event) + if type(pose) is not AI2ThorAgentPose: + raise ValueError("camera pose must be an exact AI2ThorAgentPose") + snapped_x = ( + round(pose.position.x / _NATIVE_NAVIGATION_GRID_SIZE_M) + * _NATIVE_NAVIGATION_GRID_SIZE_M + ) + snapped_z = ( + round(pose.position.z / _NATIVE_NAVIGATION_GRID_SIZE_M) + * _NATIVE_NAVIGATION_GRID_SIZE_M + ) + commanded_position = AI2ThorNativePosition( + x=snapped_x, + y=pose.position.y, + z=snapped_z, + ) + _, is_on_native_grid = _camera_position_residual_within_tolerance( + pose.position, + commanded_position, + ) + if not is_on_native_grid: + raise ValueError("camera pose is not on the native grid") + if commanded_position.x == 0.0: + commanded_position = AI2ThorNativePosition( + x=0.0, + y=commanded_position.y, + z=commanded_position.z, + ) + if commanded_position.z == 0.0: + commanded_position = AI2ThorNativePosition( + x=commanded_position.x, + y=commanded_position.y, + z=0.0, + ) + + previous_camera_states = deepcopy(self._camera_states) + previous_native_rotations = deepcopy(self._native_rotations) + try: + event = self._step( + controller, + "TeleportFull", + action="TeleportFull", + position={ + "x": commanded_position.x, + "y": commanded_position.y, + "z": commanded_position.z, + }, + rotation={"x": 0.0, "y": pose.yaw_degrees, "z": 0.0}, + horizon=pose.horizon_degrees, + standing=pose.standing, + ) + # Unity may have changed even when validation below fails. Retain + # the returned event but invalidate the canonical current scene + # until every invariant and same-event artifact has been checked. + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + "TeleportFull", + scene.scene_id, + ) + self._validate_native_scene_name_or_poison( + event, + expected_native_scene_name, + ) + + observed_pose = self._native_agent_pose(event) + observed_camera_position = self._native_position( + event.metadata.get("cameraPosition"), + "camera position", + ) + position_residual_m, position_within_tolerance = ( + _camera_position_residual_within_tolerance( + pose.position, + observed_pose.position, + ) + ) + yaw_residual_degrees = self._angle_residual_degrees( + observed_pose.yaw_degrees, + pose.yaw_degrees, + ) + horizon_residual_degrees = abs( + observed_pose.horizon_degrees - pose.horizon_degrees + ) + if not position_within_tolerance: + raise RuntimeError("camera position drift exceeds tolerance") + if yaw_residual_degrees > _ANGLE_TOLERANCE_DEGREES: + raise RuntimeError("camera yaw drift exceeds tolerance") + if horizon_residual_degrees > _ANGLE_TOLERANCE_DEGREES: + raise RuntimeError("camera horizon drift exceeds tolerance") + if observed_pose.standing is not pose.standing: + raise RuntimeError("camera standing state differs from request") + + native_observed = self._scene_from_event(scene.scene_id, event) + observed_scene = self._canonical_camera_observed_scene( + scene, + native_observed, + ) + observation = self._observation_from_event(observed_scene, event) + result = AI2ThorCameraApplication( + requested_pose=pose, + observed_pose=observed_pose, + observed_camera_position=observed_camera_position, + observed_scene=observed_scene, + observation=observation, + position_residual_m=position_residual_m, + yaw_residual_degrees=yaw_residual_degrees, + horizon_residual_degrees=horizon_residual_degrees, + ) + self._current_scene = observed_scene + return result + except BaseException: + self._camera_states = previous_camera_states + self._native_rotations = previous_native_rotations + self._poison_scene_state() + raise + + @contextmanager + def paused_camera_observations(self, source: Scene) -> Iterator[Scene]: + """Freeze native physics while yielding camera-only source observations.""" + + with self._paused_camera_observations( + source, + retain_unpaused_scene=False, + ) as paused_source: + yield paused_source + + @contextmanager + def paused_camera_observations_for_settlement( + self, + source: Scene, + ) -> Iterator[Scene]: + """Freeze camera ranking and retain the native scene returned by unpause.""" + + with self._paused_camera_observations( + source, + retain_unpaused_scene=True, + ) as paused_source: + yield paused_source + + @contextmanager + def _paused_camera_observations( + self, + source: Scene, + *, + retain_unpaused_scene: bool, + ) -> Iterator[Scene]: + if type(retain_unpaused_scene) is not bool: + raise TypeError("retain-unpaused-scene flag must be an exact boolean") + controller = self._require_active() + current_event = self._current_event_for_scene(source) + expected_native_scene_name = self._native_scene_name(current_event) + try: + paused_event = self._step( + controller, + "PausePhysicsAutoSim", + action="PausePhysicsAutoSim", + ) + self._event = paused_event + self._current_scene = None + paused_event = self._checked_scene_event( + controller, + paused_event, + "PausePhysicsAutoSim", + source.scene_id, + ) + self._validate_native_scene_name_or_poison( + paused_event, + expected_native_scene_name, + ) + native_paused = self._scene_from_event(source.scene_id, paused_event) + paused_source = self._canonical_camera_observed_scene( + source, + native_paused, + ) + self._current_scene = paused_source + except BaseException: + self._poison_scene_state() + raise + + try: + yield paused_source + except BaseException as error: + try: + self._unpause_camera_observations( + controller, + source.scene_id, + expected_native_scene_name, + retained_source=source if retain_unpaused_scene else None, + ) + except Exception as cleanup_error: # noqa: BLE001 + error.add_note(f"AI2-THOR physics unpause also failed: {cleanup_error}") + raise + else: + self._unpause_camera_observations( + controller, + source.scene_id, + expected_native_scene_name, + retained_source=source if retain_unpaused_scene else None, + ) + + def _unpause_camera_observations( + self, + controller: Any, + scene_id: str, + expected_native_scene_name: str, + *, + retained_source: Scene | None = None, + ) -> None: + try: + event = self._step( + controller, + "UnpausePhysicsAutoSim", + action="UnpausePhysicsAutoSim", + ) + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + "UnpausePhysicsAutoSim", + scene_id, + ) + self._validate_native_scene_name_or_poison( + event, + expected_native_scene_name, + ) + if retained_source is not None: + native_unpaused = self._scene_from_event(scene_id, event) + stable_unpaused = self._stable_observed_scene( + retained_source, + native_unpaused, + ) + self._validate_camera_object_identity_invariants( + retained_source, + stable_unpaused, + ) + self._current_scene = stable_unpaused + self._event = event + except BaseException: + self._poison_scene_state() + raise + + def settle_current_camera_pose_observed( + self, + source: Scene, + pose: AI2ThorAgentPose, + *, + max_pass_steps: int, + ) -> AI2ThorSettledCameraApplication: + """Settle the post-unpause scene and bind its current camera event.""" + + if type(source) is not Scene: + raise TypeError("camera settlement source must be an exact Scene") + if type(pose) is not AI2ThorAgentPose: + raise TypeError("camera settlement pose must be exact") + current_scene = self._current_scene + if current_scene is None or current_scene.scene_id != source.scene_id: + raise RuntimeError("adapter has no current post-unpause camera scene") + settlement = self.settle_scene_observed( + current_scene, + max_pass_steps=max_pass_steps, + ) + event = self._current_event_for_scene(settlement.observed_scene) + observed_pose = self._native_agent_pose(event) + observed_camera_position = self._native_position( + event.metadata.get("cameraPosition"), + "camera position", + ) + position_residual_m, position_within_tolerance = ( + _camera_position_residual_within_tolerance( + pose.position, + observed_pose.position, + ) + ) + yaw_residual_degrees = self._angle_residual_degrees( + observed_pose.yaw_degrees, + pose.yaw_degrees, + ) + horizon_residual_degrees = abs( + observed_pose.horizon_degrees - pose.horizon_degrees + ) + if not position_within_tolerance: + raise RuntimeError("settled camera position drift exceeds tolerance") + if yaw_residual_degrees > _ANGLE_TOLERANCE_DEGREES: + raise RuntimeError("settled camera yaw drift exceeds tolerance") + if horizon_residual_degrees > _ANGLE_TOLERANCE_DEGREES: + raise RuntimeError("settled camera horizon drift exceeds tolerance") + if observed_pose.standing is not pose.standing: + raise RuntimeError("settled camera standing state differs from request") + return AI2ThorSettledCameraApplication( + application=AI2ThorCameraApplication( + requested_pose=pose, + observed_pose=observed_pose, + observed_camera_position=observed_camera_position, + observed_scene=settlement.observed_scene, + observation=settlement.observation, + position_residual_m=position_residual_m, + yaw_residual_degrees=yaw_residual_degrees, + horizon_residual_degrees=horizon_residual_degrees, + ), + settlement_pass_steps=settlement.pass_steps, + ) + + def apply_camera_pose_from_frozen_source_observed( + self, + source: Scene, + pose: AI2ThorAgentPose, + *, + max_pass_steps: int, + ) -> AI2ThorCameraApplication: + """Reset and settle the same frozen source before one camera pose.""" + + if type(source) is not Scene: + raise TypeError("frozen camera source must be an exact Scene") + loaded = self.load_scene(source.scene_id) + settlement = self.settle_scene_observed( + loaded, + max_pass_steps=max_pass_steps, + ) + stable_source = self._canonical_camera_observed_scene( + source, + settlement.observed_scene, + ) + self._current_scene = stable_source + return self.apply_camera_pose_observed(stable_source, pose) + + @staticmethod + def _camera_key( + scene_id: str, + camera: Camera, + ) -> tuple[str, tuple[float, ...], tuple[float, ...]]: + return scene_id, camera.intrinsics, camera.world_to_camera + + @staticmethod + def _validate_camera_fixed(expected: Camera, observed: Camera) -> None: + if expected.width != observed.width or expected.height != observed.height: + raise AI2ThorNativeReturnError( + "camera dimensions changed during pose application" + ) + if not np.allclose( + expected.intrinsics, observed.intrinsics, atol=1e-8, rtol=0.0 + ) or not np.allclose( + expected.world_to_camera, observed.world_to_camera, atol=1e-5, rtol=0.0 + ): + raise AI2ThorNativeReturnError( + "camera pose or intrinsics changed during pose application" + ) diff --git a/src/spatialcf/adapters/ai2thor/capture.py b/src/spatialcf/adapters/ai2thor/capture.py new file mode 100644 index 0000000..28cdc24 --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/capture.py @@ -0,0 +1,1095 @@ +"""Native scene and observation capture for the staged AI2-THOR adapter.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from hashlib import sha256 +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as package_version +from io import BytesIO +from pathlib import Path +from typing import Any + +import numpy as np +from PIL import Image + +from spatialcf.adapters.ai2thor.conversion import ( + _EPSILON, + _quaternion_yaw, + _rotation_matrix, + ai2thor_camera_world_to_camera, +) +from spatialcf.adapters.ai2thor.models import ( + _TELEPORT_VERTICAL_GUARD_M, + AI2ThorAgentPose, + AI2ThorNativeReturnError, + AI2ThorObservation, + AI2ThorRuntimeIdentity, + AI2ThorSettlementTimeout, + _canonical_house_json_bytes, + adapter_floor_envelope_from_native, + adapter_observation_from_native, + adapter_pose_from_native, + adapter_position_from_native, + adapter_runtime_identity_from_native, + adapter_support_fact_from_native, +) +from spatialcf.adapters.ai2thor.support import ( + _STRUCTURAL_OBJECT_TYPES, + _domain_object_metadata, + _validated_native_object_metadata, + build_navigation_feasibility_map, +) +from spatialcf.adapters.base import ( + AdapterOperationError, + AdapterSettlementTimeout, + CapturedSource, + RenderedAssets, + SourceCaptureFacts, + SourceCaptureOptions, +) +from spatialcf.domain.scene import ( + OBB, + BBox2D, + Camera, + ObjectView, + Quaternion, + Scene, + SceneObject, + Vec2, + Vec3, +) +from spatialcf.geometry.transforms import ( + ai2thor_position_to_world, + ai2thor_rotation_to_world, + matrix4, + transform_point, +) + + +# These module-level callables retain the exact frozen method bodies. Formatting +# stays disabled so body-level AST parity also preserves multiline docstrings. +# fmt: off +def canonical_procedural_house_sha256(house: dict[str, Any]) -> str: + """Return the canonical source digest without imposing a room policy.""" + if type(house) is not dict: + raise ValueError("procedural house root must be an exact dict") + return sha256(_canonical_house_json_bytes(house)).hexdigest() + +def _camera(self, metadata: dict[str, Any], scene_id: str) -> Camera: + try: + fov = math.radians(float(metadata["fov"])) + position = ai2thor_position_to_world(Vec3(**metadata["cameraPosition"])) + agent_metadata = metadata["agent"] + yaw_degrees = float(agent_metadata["rotation"]["y"]) + if "cameraHorizon" in metadata: + horizon = metadata["cameraHorizon"] + else: + horizon = agent_metadata["cameraHorizon"] + horizon_degrees = float(horizon) + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError("invalid AI2-THOR camera metadata") from exc + if not (0.0 < fov < math.pi): + raise AI2ThorNativeReturnError( + "camera field of view must be between 0 and 180 degrees" + ) + focal = self.height / (2.0 * math.tan(fov / 2.0)) + intrinsics = ( + focal, + 0.0, + self.width / 2.0, + 0.0, + focal, + self.height / 2.0, + 0.0, + 0.0, + 1.0, + ) + camera = Camera( + camera_id="main", + width=self.width, + height=self.height, + intrinsics=tuple(float(value) for value in intrinsics), + world_to_camera=ai2thor_camera_world_to_camera( + position, + yaw_degrees=yaw_degrees, + horizon_degrees=horizon_degrees, + ), + ) + agent = metadata.get("agent") + if not isinstance(agent, dict): + raise AI2ThorNativeReturnError("invalid AI2-THOR agent metadata") + try: + state = { + "position": { + axis: float(agent["position"][axis]) for axis in ("x", "y", "z") + }, + "rotation": { + axis: float(agent["rotation"][axis]) for axis in ("x", "y", "z") + }, + "horizon": float(horizon), + "standing": bool(agent.get("isStanding", True)), + } + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError( + "invalid AI2-THOR agent pose metadata" + ) from exc + self._camera_states[self._camera_key(scene_id, camera)] = state + return camera + +def _oriented_bounds( + metadata: dict[str, Any], + position: Vec3, + rotation: Quaternion, +) -> OBB: + oriented = metadata.get("objectOrientedBoundingBox") + corners = oriented.get("cornerPoints") if isinstance(oriented, dict) else None + if corners is not None: + try: + array = np.asarray( + [ + ( + float(point["x"]), + float(point["y"]), + float(point["z"]), + ) + if isinstance(point, dict) + else tuple(float(value) for value in point) + for point in corners + ], + dtype=float, + ) + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError( + "invalid oriented bounding-box corners" + ) from exc + if array.shape != (8, 3) or not np.isfinite(array).all(): + raise AI2ThorNativeReturnError( + "oriented bounding box must have eight finite corners" + ) + world = array[:, [0, 2, 1]] + center_array = world.mean(axis=0) + yaw = _quaternion_yaw(rotation) + planar_rotation = Quaternion( + x=0.0, + y=0.0, + z=math.sin(yaw / 2.0), + w=math.cos(yaw / 2.0), + ) + # Geometry consumers use an upright Z-up OBB. For tilted objects, + # bound the projected corners in the object's yaw frame so the + # ground footprint is conservative instead of under-estimated. + local = (world - center_array) @ _rotation_matrix(planar_rotation) + extent_array = np.ptp(local, axis=0) + if np.any(extent_array <= _EPSILON): + raise AI2ThorNativeReturnError( + "oriented bounding-box extents must be positive" + ) + return OBB( + center=Vec3( + x=float(center_array[0]), + y=float(center_array[1]), + z=float(center_array[2]), + ), + extent=Vec3( + x=float(extent_array[0]), + y=float(extent_array[1]), + z=float(extent_array[2]), + ), + rotation=planar_rotation, + ) + + bounds = metadata.get("axisAlignedBoundingBox") + if not isinstance(bounds, dict): + raise AI2ThorNativeReturnError("object is missing bounding-box metadata") + try: + center = ai2thor_position_to_world(Vec3(**bounds["center"])) + size = bounds["size"] + extent = Vec3( + x=float(size["x"]), + y=float(size["z"]), + z=float(size["y"]), + ) + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError( + "invalid axis-aligned bounding-box metadata" + ) from exc + if ( + not all( + math.isfinite(value) + for value in ( + center.x, + center.y, + center.z, + extent.x, + extent.y, + extent.z, + ) + ) + or min(extent.x, extent.y, extent.z) <= _EPSILON + ): + raise AI2ThorNativeReturnError( + "axis-aligned bounding-box values must be finite and positive" + ) + return OBB( + center=center, + extent=extent, + rotation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0), + ) + +def _obb_corners(obb: OBB) -> np.ndarray: + offsets = np.asarray( + [ + [dx * obb.extent.x / 2, dy * obb.extent.y / 2, dz * obb.extent.z / 2] + for dx in (-1.0, 1.0) + for dy in (-1.0, 1.0) + for dz in (-1.0, 1.0) + ], + dtype=float, + ) + center = np.asarray([obb.center.x, obb.center.y, obb.center.z]) + return offsets @ _rotation_matrix(obb.rotation).T + center + +def _projected_bounds( + self, + obb: OBB, + camera: Camera, +) -> tuple[float, float, float, float]: + projected: list[tuple[float, float]] = [] + extrinsics = matrix4(camera.world_to_camera) + fx, fy = camera.intrinsics[0], camera.intrinsics[4] + cx, cy = camera.intrinsics[2], camera.intrinsics[5] + for point in self._obb_corners(obb): + camera_point = transform_point( + extrinsics, + Vec3(x=float(point[0]), y=float(point[1]), z=float(point[2])), + ) + if camera_point.z <= _EPSILON: + continue + projected.append( + ( + fx * camera_point.x / camera_point.z + cx, + cy - fy * camera_point.y / camera_point.z, + ) + ) + if not projected: + raise AI2ThorNativeReturnError( + "visible object bounds are behind the camera" + ) + xs, ys = zip(*projected) + return min(xs), min(ys), max(xs), max(ys) + +def _view( + self, + metadata: dict[str, Any], + position: Vec3, + obb: OBB, + camera: Camera, + event: Any, +) -> dict[str, ObjectView]: + object_id = metadata["objectId"] + detections = getattr(event, "instance_detections2D", {}) or {} + masks = getattr(event, "instance_masks", {}) or {} + detection = detections.get(object_id) + mask = masks.get(object_id) + if detection is None or mask is None: + return {} + values = np.asarray(detection, dtype=float) + if values.shape != (4,) or not np.isfinite(values).all(): + raise AI2ThorNativeReturnError(f"invalid detection for {object_id!r}") + xmin, ymin, xmax, ymax = (float(value) for value in values) + if xmax <= xmin or ymax <= ymin: + if metadata.get("visible") is False: + return {} + if ( + type(mask) is not np.ndarray + or mask.shape != (self.height, self.width) + or mask.dtype != np.bool_ + ): + raise AI2ThorNativeReturnError( + f"invalid instance mask for {object_id!r}" + ) + mask_array = mask + pixels = np.argwhere(mask_array) + if pixels.size == 0: + raise AI2ThorNativeReturnError( + f"invalid detection bounds for {object_id!r}" + ) + ymin = float(pixels[:, 0].min()) + ymax = float(pixels[:, 0].max() + 1) + xmin = float(pixels[:, 1].min()) + xmax = float(pixels[:, 1].max() + 1) + else: + mask_array = np.asarray(mask) + if mask_array.shape != (self.height, self.width): + raise AI2ThorNativeReturnError( + f"invalid instance mask shape for {object_id!r}" + ) + camera_point = transform_point(matrix4(camera.world_to_camera), position) + if not math.isfinite(camera_point.z): + raise AI2ThorNativeReturnError( + f"visible object {object_id!r} has non-positive camera depth" + ) + if camera_point.z <= _EPSILON: + # AI2-THOR object anchors are not guaranteed to be OBB centres. + # Large fixtures can therefore cross the camera plane while their + # valid anchor is behind it. They remain scene geometry, but do + # not expose a relation view whose anchor depth is non-positive. + # A box wholly on either side while its anchor disagrees is still + # inconsistent native metadata and fails closed. + extrinsics = matrix4(camera.world_to_camera) + corner_depths = tuple( + transform_point( + extrinsics, + Vec3(x=float(corner[0]), y=float(corner[1]), z=float(corner[2])), + ).z + for corner in self._obb_corners(obb) + ) + if min(corner_depths) < 0.0 < max(corner_depths): + return {} + raise AI2ThorNativeReturnError( + f"visible object {object_id!r} has non-positive camera depth" + ) + + clipped = ( + max(0.0, min(float(self.width), xmin)), + max(0.0, min(float(self.height), ymin)), + max(0.0, min(float(self.width), xmax)), + max(0.0, min(float(self.height), ymax)), + ) + if clipped[2] <= clipped[0] or clipped[3] <= clipped[1]: + raise AI2ThorNativeReturnError( + f"visible detection for {object_id!r} is outside the image" + ) + projected = self._projected_bounds(obb, camera) + projected_area = max( + _EPSILON, + (projected[2] - projected[0]) * (projected[3] - projected[1]), + ) + projected_clipped_width = max( + 0.0, min(projected[2], self.width) - max(projected[0], 0.0) + ) + projected_clipped_height = max( + 0.0, min(projected[3], self.height) - max(projected[1], 0.0) + ) + bbox_area = (clipped[2] - clipped[0]) * (clipped[3] - clipped[1]) + image_area = float(self.width * self.height) + return { + "main": ObjectView( + camera_id="main", + bbox=BBox2D( + xmin=clipped[0], + ymin=clipped[1], + xmax=clipped[2], + ymax=clipped[3], + ), + camera_depth=float(camera_point.z), + visible_fraction=float( + np.clip(np.count_nonzero(mask_array) / projected_area, 0.0, 1.0) + ), + image_area_fraction=float(np.clip(bbox_area / image_area, 0.0, 1.0)), + truncated_fraction=float( + np.clip( + 1.0 + - projected_clipped_width + * projected_clipped_height + / projected_area, + 0.0, + 1.0, + ) + ), + ) + } + +def _object( + self, + metadata: dict[str, Any], + camera: Camera, + event: Any, + structural_object_ids: frozenset[str], +) -> SceneObject: + try: + position = ai2thor_position_to_world(Vec3(**metadata["position"])) + rotation = ai2thor_rotation_to_world(Vec3(**metadata["rotation"])) + object_id = metadata["objectId"] + name = metadata["name"] + category = metadata["objectType"] + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError("invalid AI2-THOR object metadata") from exc + obb = self._oriented_bounds(metadata, position, rotation) + parents = metadata["parentReceptacles"] + parents = [parent for parent in parents if parent not in structural_object_ids] + return SceneObject( + object_id=object_id, + name=name, + category=category, + movable=( + metadata.get("moveable") is True or metadata.get("pickupable") is True + ), + position=position, + rotation=rotation, + obb=obb, + support_object_id=parents[0] if parents else None, + views=self._view(metadata, position, obb, camera, event), + ) + +def _without_cyclic_support_assignments( + objects: tuple[SceneObject, ...], +) -> tuple[SceneObject, ...]: + """Drop every edge in a cyclic native receptacle component. + + AI2-THOR's ``parentReceptacles`` describes receptacle membership, not + a certified physical support tree, and real scenes can report cycles. + A cycle has no honest single supporting parent, so retain the objects + but represent those assignments as unknown instead of guessing an + edge or passing an invalid graph to the solver. + """ + object_ids = {obj.object_id for obj in objects} + parents = { + obj.object_id: ( + obj.support_object_id if obj.support_object_id in object_ids else None + ) + for obj in objects + } + cyclic_ids: set[str] = set() + for start in sorted(parents): + path: list[str] = [] + path_index: dict[str, int] = {} + current: str | None = start + while current is not None and current in parents: + if current in path_index: + cyclic_ids.update(path[path_index[current] :]) + break + path_index[current] = len(path) + path.append(current) + current = parents[current] + if not cyclic_ids: + return objects + return tuple( + obj.model_copy(update={"support_object_id": None}) + if obj.object_id in cyclic_ids + else obj + for obj in objects + ) + +def _scene_from_event(self, scene_id: str, event: Any) -> Scene: + metadata = event.metadata + camera = self._camera(metadata, scene_id) + raw_objects = metadata.get("objects") + if not isinstance(raw_objects, list): + raise AI2ThorNativeReturnError("invalid AI2-THOR object collection") + raw_objects = _validated_native_object_metadata(raw_objects) + structural_object_ids = frozenset( + item["objectId"] + for item in raw_objects + if item["objectType"] in _STRUCTURAL_OBJECT_TYPES + ) + raw_objects = _domain_object_metadata(raw_objects) + names = [item["name"] for item in raw_objects] + for item, name in zip(raw_objects, names, strict=True): + try: + rotation = { + axis: float(item["rotation"][axis]) for axis in ("x", "y", "z") + } + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError( + f"invalid native rotation for {name!r}" + ) from exc + if not all(math.isfinite(value) for value in rotation.values()): + raise AI2ThorNativeReturnError(f"invalid native rotation for {name!r}") + self._native_rotations[(scene_id, name)] = rotation + objects = tuple( + self._object( + item, + camera, + event, + structural_object_ids, + ) + for item in raw_objects + ) + objects = self._without_cyclic_support_assignments(objects) + scene_bounds = metadata.get("sceneBounds") + if not isinstance(scene_bounds, dict): + raise AI2ThorNativeReturnError("invalid AI2-THOR scene bounds") + try: + center = ai2thor_position_to_world(Vec3(**scene_bounds["center"])) + size = scene_bounds["size"] + half_x = float(size["x"]) / 2.0 + half_y = float(size["z"]) / 2.0 + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError("invalid AI2-THOR scene bounds") from exc + if ( + not all( + math.isfinite(value) + for value in (center.x, center.y, center.z, half_x, half_y) + ) + or half_x <= 0.0 + or half_y <= 0.0 + ): + raise AI2ThorNativeReturnError( + "scene ground bounds must be finite and positive" + ) + return Scene( + scene_id=scene_id, + source="ai2thor", + room_polygon_xy=( + Vec2(x=center.x - half_x, y=center.y - half_y), + Vec2(x=center.x + half_x, y=center.y - half_y), + Vec2(x=center.x + half_x, y=center.y + half_y), + Vec2(x=center.x - half_x, y=center.y + half_y), + ), + cameras=(camera,), + objects=objects, + generation_seed=self.seed, + ) + +def _objects_by_name( + objects: tuple[SceneObject, ...], +) -> dict[str, SceneObject]: + by_name = {obj.name: obj for obj in objects} + if len(by_name) != len(objects): + raise ValueError("scene object names must be unique") + return by_name + +def _stable_observed_scene( + cls, + source: Scene, + observed: Scene, +) -> Scene: + """Map native ID churn back to source IDs through unique object names.""" + source_by_name = cls._objects_by_name(source.objects) + observed_by_name = cls._objects_by_name(observed.objects) + if set(source_by_name) != set(observed_by_name): + raise AI2ThorNativeReturnError( + "stable object names changed during pose application" + ) + + aliases: dict[str, str] = {} + + def register_alias(native_id: str, stable_id: str) -> None: + existing = aliases.get(native_id) + if existing is not None and existing != stable_id: + raise AI2ThorNativeReturnError( + "native object IDs do not map to unique stable IDs" + ) + aliases[native_id] = stable_id + + for name, original in source_by_name.items(): + current = observed_by_name[name] + register_alias(current.object_id, original.object_id) + source_object_ids = {obj.object_id for obj in source.objects} + for original in source.objects: + support_id = original.support_object_id + if support_id is not None and support_id not in source_object_ids: + register_alias(support_id, support_id) + + stable_objects: list[SceneObject] = [] + for original in source.objects: + current = observed_by_name[original.name] + support_id = current.support_object_id + stable_support_id = None + if support_id is not None: + stable_support_id = aliases.get(support_id) + if stable_support_id is None: + raise AI2ThorNativeReturnError( + f"observed support {support_id!r} has no stable object identity" + ) + stable_updates: dict[str, object] = { + "object_id": original.object_id, + "support_object_id": stable_support_id, + } + if "request_eligible" in original.model_fields_set: + stable_updates["request_eligible"] = original.request_eligible + stable_objects.append(current.model_copy(update=stable_updates)) + return source.model_copy( + update={ + "cameras": observed.cameras, + "objects": tuple(stable_objects), + } + ) + +def _canonical_camera_observed_scene( + cls, + source: Scene, + native_observed: Scene, +) -> Scene: + """Retain fresh camera/views after proving source geometry unchanged.""" + stable = cls._stable_observed_scene(source, native_observed) + cls._validate_camera_object_invariants(source, stable) + stable_by_name = cls._objects_by_name(stable.objects) + return source.model_copy( + update={ + "cameras": stable.cameras, + "objects": tuple( + original.model_copy( + update={"views": stable_by_name[original.name].views} + ) + for original in source.objects + ), + } + ) + +def _png_bytes(frame: np.ndarray) -> bytes: + stream = BytesIO() + Image.fromarray(frame, mode="RGB").save(stream, format="PNG") + return stream.getvalue() + +def _npy_bytes(array: np.ndarray) -> bytes: + stream = BytesIO() + np.save(stream, array, allow_pickle=False) + return stream.getvalue() + +def _stable_instance_pixel_counts( + self, + scene: Scene, + event: Any, +) -> dict[str, int]: + raw_objects = event.metadata.get("objects") + if not isinstance(raw_objects, list): + raise AI2ThorNativeReturnError("observation returned no object metadata") + native_by_name = { + str(item.get("name")): item + for item in _domain_object_metadata(raw_objects) + if isinstance(item, dict) + } + if len(native_by_name) != len(_domain_object_metadata(raw_objects)): + raise AI2ThorNativeReturnError( + "observation returned duplicate object names" + ) + masks = getattr(event, "instance_masks", None) + if not isinstance(masks, Mapping): + raise AI2ThorNativeReturnError( + "observation returned invalid instance masks" + ) + validated_masks: dict[str, np.ndarray] = {} + for native_id in masks: + if type(native_id) is not str: + raise AI2ThorNativeReturnError( + "observation returned invalid instance masks: keys must be strings" + ) + mask = masks[native_id] + if ( + not isinstance(mask, np.ndarray) + or mask.shape != (self.height, self.width) + or mask.dtype != np.bool_ + ): + raise AI2ThorNativeReturnError( + "observation returned invalid instance masks: " + "values must be boolean HxW numpy arrays" + ) + validated_masks[native_id] = mask + counts: dict[str, int] = {} + for obj in scene.objects: + metadata = native_by_name.get(obj.name) + if metadata is None: + raise AI2ThorNativeReturnError( + f"observation is missing stable object name {obj.name!r}" + ) + native_id = str(metadata.get("objectId")) + mask = validated_masks.get(native_id) + if mask is None: + counts[obj.object_id] = 0 + continue + counts[obj.object_id] = int(np.count_nonzero(mask)) + return counts + +def _observation_from_event( + self, + scene: Scene, + event: Any, +) -> AI2ThorObservation: + camera = scene.camera_by_id("main") + rgb, depth, instance = self._validated_frames(event) + return AI2ThorObservation.create( + scene=scene, + rgb_png=self._png_bytes(rgb), + depth_npy=self._npy_bytes(depth), + instance_png=self._png_bytes(instance), + pointcloud_ply=self._pointcloud_bytes(camera, depth, rgb), + instance_pixel_counts=self._stable_instance_pixel_counts(scene, event), + is_scene_at_rest=self._native_scene_at_rest(event), + ) + +def capture_current_observation(self, scene: Scene) -> AI2ThorObservation: + """Capture frames from the exact current event without replaying poses.""" + event = self._current_event_for_scene(scene) + return self._observation_from_event(scene, event) + +def _validated_frames( + self, + event: Any | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + source_event = self._event if event is None else event + if source_event is None: + raise RuntimeError("no AI2-THOR event is available") + rgb = np.asarray(getattr(source_event, "frame", None)) + depth = np.asarray(getattr(source_event, "depth_frame", None)) + instance = np.asarray( + getattr(source_event, "instance_segmentation_frame", None) + ) + expected_color = (self.height, self.width, 3) + expected_depth = (self.height, self.width) + if rgb.shape != expected_color or rgb.dtype != np.uint8: + raise AI2ThorNativeReturnError("AI2-THOR RGB frame must be HxWx3 uint8") + if instance.shape != expected_color or instance.dtype != np.uint8: + raise AI2ThorNativeReturnError( + "AI2-THOR instance frame must be HxWx3 uint8" + ) + if depth.shape != expected_depth or not np.issubdtype(depth.dtype, np.number): + raise AI2ThorNativeReturnError( + "AI2-THOR depth frame must be a numeric HxW array" + ) + return rgb, depth.astype(np.float32, copy=False), instance + +def _validate_stem(stem: str) -> None: + if ( + not stem + or stem.strip() != stem + or stem in {".", ".."} + or Path(stem).name != stem + or "/" in stem + or "\\" in stem + ): + raise ValueError("artifact stem must be a safe filename component") + +def _pointcloud_bytes( + self, + camera: Camera, + depth: np.ndarray, + rgb: np.ndarray, +) -> bytes: + rows, columns = np.mgrid[0 : self.height : 4, 0 : self.width : 4] + z = depth[rows, columns] + valid = np.isfinite(z) & (z > 0.0) + rows, columns, z = rows[valid], columns[valid], z[valid] + fx, fy = camera.intrinsics[0], camera.intrinsics[4] + cx, cy = camera.intrinsics[2], camera.intrinsics[5] + x = (columns - cx) * z / fx + y = -(rows - cy) * z / fy + camera_points = np.stack([x, y, z, np.ones_like(z)]) + world_points = np.linalg.inv(matrix4(camera.world_to_camera)) @ camera_points + colors = rgb[rows, columns] + lines = [ + "ply", + "format ascii 1.0", + f"element vertex {world_points.shape[1]}", + "property float x", + "property float y", + "property float z", + "property uchar red", + "property uchar green", + "property uchar blue", + "end_header", + ] + lines.extend( + ( + f"{world_points[0, index]:.9g} " + f"{world_points[1, index]:.9g} " + f"{world_points[2, index]:.9g} " + f"{int(colors[index, 0])} " + f"{int(colors[index, 1])} " + f"{int(colors[index, 2])}" + ) + for index in range(world_points.shape[1]) + ) + return ("\n".join(lines) + "\n").encode("ascii") + +def _write_pointcloud( + self, + camera: Camera, + depth: np.ndarray, + rgb: np.ndarray, + destination: Path, +) -> None: + destination.write_bytes(self._pointcloud_bytes(camera, depth, rgb)) + +def render_assets( + self, + scene: Scene, + camera_id: str, + destination_root: Path, + stem: str, +) -> RenderedAssets: + self._require_active() + camera = scene.camera_by_id(camera_id) + if camera_id != "main": + raise KeyError(camera_id) + self._validate_stem(stem) + self._restore_scene_state(scene) + rgb, depth, instance = self._validated_frames() + destination_root.mkdir(parents=True, exist_ok=True) + assets = RenderedAssets( + rgb_path=destination_root / f"{stem}-rgb.png", + depth_path=destination_root / f"{stem}-depth.npy", + instance_path=destination_root / f"{stem}-instance.png", + pointcloud_path=destination_root / f"{stem}-pointcloud.ply", + ) + if ( + len( + { + assets.rgb_path, + assets.depth_path, + assets.instance_path, + assets.pointcloud_path, + } + ) + != 4 + ): + raise ValueError("artifact paths must be distinct") + Image.fromarray(rgb, mode="RGB").save(assets.rgb_path) + np.save(assets.depth_path, depth, allow_pickle=False) + Image.fromarray(instance, mode="RGB").save(assets.instance_path) + self._write_pointcloud(camera, depth, rgb, assets.pointcloud_path) + return assets + + +# fmt: on +class AI2ThorCaptureMixin: + _camera = _camera + _oriented_bounds = staticmethod(_oriented_bounds) + _obb_corners = staticmethod(_obb_corners) + _projected_bounds = _projected_bounds + _view = _view + _object = _object + _without_cyclic_support_assignments = staticmethod( + _without_cyclic_support_assignments + ) + _scene_from_event = _scene_from_event + _objects_by_name = staticmethod(_objects_by_name) + _stable_observed_scene = classmethod(_stable_observed_scene) + _canonical_camera_observed_scene = classmethod(_canonical_camera_observed_scene) + _png_bytes = staticmethod(_png_bytes) + _npy_bytes = staticmethod(_npy_bytes) + _stable_instance_pixel_counts = _stable_instance_pixel_counts + _observation_from_event = _observation_from_event + capture_current_observation = capture_current_observation + _validated_frames = _validated_frames + _validate_stem = staticmethod(_validate_stem) + _pointcloud_bytes = _pointcloud_bytes + _write_pointcloud = _write_pointcloud + render_assets = render_assets + + def list_scene_ids(self) -> list[str]: + return list(self.scene_names) + + def observe_source( + self, + source: CapturedSource, + *, + options: SourceCaptureOptions, + settle: bool, + ) -> SourceCaptureFacts: + """Capture one settled source through the neutral evidence protocol.""" + + if type(source) is not CapturedSource: + raise AdapterOperationError("source capture requires an exact source") + if type(options) is not SourceCaptureOptions or type(settle) is not bool: + raise AdapterOperationError("source capture options must be exact") + if not settle: + raise AdapterOperationError("source observation must settle") + try: + settlement = self.settle_scene_observed( + source.scene, + max_pass_steps=options.max_settlement_steps, + ) + scene = settlement.observed_scene + support_by_id = { + item.object_id: adapter_support_fact_from_native(item) + for item in self.native_support_facts(scene) + } + if set(support_by_id) != {item.object_id for item in scene.objects}: + raise ValueError("support fact roster does not bind observed scene") + support_facts = tuple( + support_by_id[item.object_id] for item in scene.objects + ) + floor_subjects = tuple( + item + for item in scene.objects + if item.movable + and support_by_id[item.object_id].support_kind == "FLOOR" + ) + floor = None + if floor_subjects: + try: + floor = adapter_floor_envelope_from_native( + self.conservative_floor_envelope( + scene, + clearance_m=options.floor_clearance_m, + ) + ) + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError): + floor = None + native_reachable = self.reachable_agent_positions(scene) + reachable = tuple( + adapter_position_from_native(item) for item in native_reachable + ) + floor_regions = () + if floor is not None: + regions = [] + for item in floor_subjects: + try: + navigation = build_navigation_feasibility_map( + scene, + subject_object_id=item.object_id, + room_polygon_xy=floor.polygon_xy, + reachable_positions=native_reachable, + agent_radius_m=options.navigation_agent_radius_m, + clearance_m=options.navigation_clearance_m, + ) + except ( + AI2ThorNativeReturnError, + RuntimeError, + ValueError, + KeyError, + ): + continue + if navigation.position_region.components: + regions.append((item.object_id, navigation.position_region)) + floor_regions = tuple(sorted(regions, key=lambda item: item[0])) + return SourceCaptureFacts( + source=source, + binding=source.binding, + scene=scene, + runtime_identity=adapter_runtime_identity_from_native( + self.runtime_identity() + ), + observation=adapter_observation_from_native(settlement.observation), + support_facts=support_facts, + floor_envelope=floor, + floor_position_regions=floor_regions, + reachable_positions=reachable, + current_pose=adapter_pose_from_native(self.current_agent_pose(scene)), + settlement_pass_steps=settlement.pass_steps, + ) + except AI2ThorSettlementTimeout as error: + raise AdapterSettlementTimeout(str(error)) from error + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError) as error: + raise AdapterOperationError(str(error)) from error + + def runtime_identity(self) -> AI2ThorRuntimeIdentity: + """Return the active package/build/configuration identity without action.""" + controller = self._require_active() + if self._event is None: + raise RuntimeError("no AI2-THOR event is available") + self._validate_scene_source_or_poison( + controller, + self.scene_name, + self._event, + ) + build = getattr(controller, "_build", None) + commit_id = getattr(build, "commit_id", None) + if type(commit_id) is not str or not commit_id: + raise RuntimeError("AI2-THOR controller has no Unity build identity") + try: + installed_version = package_version("ai2thor") + except PackageNotFoundError as error: + raise RuntimeError("AI2-THOR package identity is unavailable") from error + source = self.procedural_scenes.get(self.scene_name) + return AI2ThorRuntimeIdentity( + ai2thor_version=installed_version, + unity_commit_id=commit_id, + native_scene_name=self._native_scene_name(self._event), + width=self.width, + height=self.height, + seed=self.seed, + source_dataset_id=None if source is None else source.dataset_id, + source_revision=None if source is None else source.revision, + source_split=None if source is None else source.split, + source_index=None if source is None else source.index, + source_sha256=None if source is None else source.house_sha256, + source_scene_alias=None if source is None else self.scene_name, + source_loader_id=None if source is None else source.source_loader_id, + source_loader_version=( + None if source is None else source.source_loader_version + ), + source_room_id=None if source is None else source.room_id, + source_floor_xz_bounds=(None if source is None else source.floor_xz_bounds), + teleport_vertical_guard_m=_TELEPORT_VERTICAL_GUARD_M, + ) + + def latest_native_event(self, scene_id: str) -> Any: + """Return latest raw event after source and native-scene revalidation.""" + controller = self._require_active() + if scene_id not in self.scene_names: + raise KeyError(scene_id) + if self._latest_event is None: + raise RuntimeError("adapter has no latest native event") + self._validate_scene_source_or_poison( + controller, + scene_id, + self._latest_event, + ) + return self._latest_event + + def current_agent_pose(self, scene: Scene) -> AI2ThorAgentPose: + """Read the agent pose from the exact current event without an action.""" + + return self._native_agent_pose(self._current_event_for_scene(scene)) + + def _current_event_for_scene(self, scene: Scene) -> Any: + controller = self._require_active() + if ( + self._event is None + or self.scene_name != scene.scene_id + or self._current_scene != scene + ): + raise RuntimeError( + "scene must be the adapter's exact current scene and event" + ) + self._validate_scene_source_or_poison( + controller, + scene.scene_id, + self._event, + ) + return self._event + + @staticmethod + def _is_analysis_overlay( + current: Scene, + requested: Scene, + ) -> bool: + if current == requested: + return False + return ( + requested.model_copy( + update={ + "room_polygon_xy": current.room_polygon_xy, + "collision_obstacles": current.collision_obstacles, + "subject_position_regions": current.subject_position_regions, + } + ) + == current + ) + + @staticmethod + def _native_scene_name(event: Any) -> str: + metadata = getattr(event, "metadata", None) + if not isinstance(metadata, dict): + raise TypeError("AI2-THOR event has no metadata") + scene_name = metadata.get("sceneName") + if type(scene_name) is not str or not scene_name: + raise RuntimeError("AI2-THOR event has no valid native scene name") + return scene_name + + @classmethod + def _validate_native_scene_name( + cls, + event: Any, + expected_native_scene_name: str, + ) -> None: + if cls._native_scene_name(event) != expected_native_scene_name: + raise RuntimeError( + "AI2-THOR event scene name changed during camera operation" + ) + + def _validate_native_scene_name_or_poison( + self, + event: Any, + expected_native_scene_name: str, + ) -> None: + try: + self._validate_native_scene_name(event, expected_native_scene_name) + except BaseException: + self._poison_scene_state() + raise diff --git a/src/spatialcf/adapters/ai2thor/conversion.py b/src/spatialcf/adapters/ai2thor/conversion.py new file mode 100644 index 0000000..532c02f --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/conversion.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from spatialcf.adapters.ai2thor.models import ( + AI2ThorAgentPose, + AI2ThorNativePosition, + _strict_finite_float, +) +from spatialcf.domain.scene import Quaternion, Vec3 +from spatialcf.geometry.transforms import ( + ai2thor_position_to_world, + ai2thor_rotation_to_world, + matrix4, + transform_point, +) + +_ANGLE_TOLERANCE_DEGREES = 1e-4 +_EPSILON = 1e-6 + +__all__ = ( + "ai2thor_position_to_world", + "ai2thor_rotation_to_world", + "matrix4", + "transform_point", +) + + +def ai2thor_camera_world_to_camera( + position: Vec3, + *, + yaw_degrees: float, + horizon_degrees: float, +) -> tuple[float, ...]: + """Build AI2-THOR's canonical world-to-camera extrinsic matrix.""" + + yaw = math.radians(yaw_degrees) + pitch = math.radians(horizon_degrees) + right = np.asarray([math.cos(yaw), -math.sin(yaw), 0.0]) + forward = np.asarray( + [ + math.sin(yaw) * math.cos(pitch), + math.cos(yaw) * math.cos(pitch), + -math.sin(pitch), + ] + ) + up = np.cross(right, forward) + rotation = np.stack([right, up, forward]) + translation = -rotation @ np.asarray([position.x, position.y, position.z]) + world_to_camera = np.eye(4) + world_to_camera[:3, :3] = rotation + world_to_camera[:3, 3] = translation + return tuple(float(value) for value in world_to_camera.reshape(-1)) + + +def _quaternion_yaw(rotation: Quaternion) -> float: + norm = math.sqrt(rotation.x**2 + rotation.y**2 + rotation.z**2 + rotation.w**2) + if not math.isfinite(norm) or norm <= _EPSILON: + raise ValueError("object rotation must be a finite non-zero quaternion") + x, y, z, w = ( + rotation.x / norm, + rotation.y / norm, + rotation.z / norm, + rotation.w / norm, + ) + return math.atan2( + 2.0 * (w * z + x * y), + 1.0 - 2.0 * (y * y + z * z), + ) + + +def _rotation_matrix(rotation: Quaternion) -> np.ndarray: + norm = math.sqrt(rotation.x**2 + rotation.y**2 + rotation.z**2 + rotation.w**2) + if not math.isfinite(norm) or norm <= _EPSILON: + raise ValueError("rotation must be a finite non-zero quaternion") + x, y, z, w = ( + rotation.x / norm, + rotation.y / norm, + rotation.z / norm, + rotation.w / norm, + ) + return np.asarray( + [ + [ + 1.0 - 2.0 * (y * y + z * z), + 2.0 * (x * y - z * w), + 2.0 * (x * z + y * w), + ], + [ + 2.0 * (x * y + z * w), + 1.0 - 2.0 * (x * x + z * z), + 2.0 * (y * z - x * w), + ], + [ + 2.0 * (x * z - y * w), + 2.0 * (y * z + x * w), + 1.0 - 2.0 * (x * x + y * y), + ], + ], + dtype=float, + ) + + +def _quaternions_close(left: Quaternion, right: Quaternion) -> bool: + a = np.asarray([left.x, left.y, left.z, left.w], dtype=float) + b = np.asarray([right.x, right.y, right.z, right.w], dtype=float) + a /= np.linalg.norm(a) + b /= np.linalg.norm(b) + return bool( + np.allclose(a, b, atol=1e-5, rtol=0.0) + or np.allclose(a, -b, atol=1e-5, rtol=0.0) + ) + + +class AI2ThorConversionMixin: + @staticmethod + def _native_position( + value: Any, + label: str, + *, + error_type: type[Exception] = ValueError, + ) -> AI2ThorNativePosition: + if type(value) is not dict or set(value) != {"x", "y", "z"}: + raise error_type(f"{label} must have exactly x/y/z keys") + try: + return AI2ThorNativePosition( + x=_strict_finite_float(value["x"], f"{label} x"), + y=_strict_finite_float(value["y"], f"{label} y"), + z=_strict_finite_float(value["z"], f"{label} z"), + ) + except ValueError as error: + if error_type is ValueError: + raise + raise error_type(str(error)) from error + + @classmethod + def _native_agent_pose(cls, event: Any) -> AI2ThorAgentPose: + metadata = event.metadata + agent = metadata.get("agent") + if type(agent) is not dict: + raise ValueError("AI2-THOR event has no valid agent pose") + position = cls._native_position(agent.get("position"), "agent position") + rotation = agent.get("rotation") + if type(rotation) is not dict or set(rotation) != {"x", "y", "z"}: + raise ValueError("agent rotation must have exactly x/y/z keys") + rotation_values = { + axis: _strict_finite_float(rotation[axis], f"agent rotation {axis}") + for axis in ("x", "y", "z") + } + if not cls._angles_close(rotation_values["x"], 0.0) or not cls._angles_close( + rotation_values["z"], 0.0 + ): + raise RuntimeError("camera pose returned non-zero pitch or roll") + + horizons: list[float] = [] + if "cameraHorizon" in metadata: + horizons.append( + _strict_finite_float( + metadata["cameraHorizon"], + "top-level camera horizon", + ) + ) + if "cameraHorizon" in agent: + horizons.append( + _strict_finite_float( + agent["cameraHorizon"], + "agent camera horizon", + ) + ) + if not horizons: + raise ValueError("AI2-THOR event has no camera horizon") + if any(not cls._angles_close(horizons[0], horizon) for horizon in horizons[1:]): + raise RuntimeError("AI2-THOR camera horizon metadata is inconsistent") + standing = agent.get("isStanding") + if type(standing) is not bool: + raise ValueError("AI2-THOR agent standing must be an exact boolean") + return AI2ThorAgentPose( + position=position, + yaw_degrees=rotation_values["y"], + horizon_degrees=horizons[0], + standing=standing, + ) + + @staticmethod + def _angles_close( + left: float, + right: float, + *, + tolerance_degrees: float = _ANGLE_TOLERANCE_DEGREES, + ) -> bool: + difference = (left - right + 180.0) % 360.0 - 180.0 + return abs(difference) <= tolerance_degrees diff --git a/src/spatialcf/adapters/ai2thor/execution.py b/src/spatialcf/adapters/ai2thor/execution.py new file mode 100644 index 0000000..7b8189c --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/execution.py @@ -0,0 +1,1199 @@ +from __future__ import annotations + +import math +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from copy import deepcopy +from hashlib import sha256 +from types import TracebackType +from typing import Any, Self +from weakref import ReferenceType, ref + +import numpy as np + +from spatialcf.adapters.ai2thor.camera import _NATIVE_NAVIGATION_GRID_SIZE_M +from spatialcf.adapters.ai2thor.conversion import _quaternions_close +from spatialcf.adapters.ai2thor.models import ( + _TELEPORT_VERTICAL_GUARD_M, + AI2ThorIsolatedEpisode, + AI2ThorNativePosition, + AI2ThorNativeReturnError, + AI2ThorPoseApplication, + AI2ThorReceptacleSpawnMap, + AI2ThorRuntimeError, + AI2ThorSceneSettlement, + AI2ThorSettlementTimeout, + _canonical_house_json_bytes, + _strict_finite_float, +) +from spatialcf.adapters.ai2thor.support import ( + _domain_object_metadata, + _receptacle_scene_sha256, + _strict_native_position, + _strict_receptacle_spawn_map, + _validated_native_object_metadata, +) +from spatialcf.domain.scene import Scene, SceneObject, Vec3 +from spatialcf.geometry.transforms import ( + ai2thor_position_to_world, + ai2thor_rotation_to_world, +) + +# Unity round-trips placed-object Euler angles at about 3e-4 degrees while the +# independently checked quaternion geometry remains stable within 1e-5. +_OBJECT_ROTATION_TOLERANCE_DEGREES = 1e-3 +_RUNTIME_RECEPTACLE_POSITION_RESIDUAL_M = 1e-4 + + +class AI2ThorExecutionMixin: + def __enter__(self) -> Self: + if self.controller is not None: + raise RuntimeError("adapter context is already active") + self._stopped = False + self.scene_name = self.scene_names[0] + self._latest_event = None + self._camera_states.clear() + self._native_rotations.clear() + try: + self.controller = self._start_controller( + scene=self._native_scene_input(self.scene_name), + width=self.width, + height=self.height, + renderDepthImage=True, + renderInstanceSegmentation=True, + gridSize=_NATIVE_NAVIGATION_GRID_SIZE_M, + snapToGrid=True, + rotateStepDegrees=90, + ) + self._validate_controller_source_or_poison(self.controller, self.scene_name) + event = self._step(self.controller, "Pass", action="Pass") + self._event = event + self._current_scene = None + self._event = self._checked_scene_event( + self.controller, + event, + "Pass", + self.scene_name, + ) + return self + except BaseException as error: + try: + self._stop() + except AI2ThorRuntimeError as cleanup_error: + error.add_note(f"AI2-THOR cleanup also failed: {cleanup_error}") + raise + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + try: + self._stop() + except AI2ThorRuntimeError as cleanup_error: + if exc is None: + raise + exc.add_note(f"AI2-THOR cleanup also failed: {cleanup_error}") + + def _stop(self) -> None: + if self.controller is not None and not self._stopped: + self._stopped = True + controller = self.controller + self.controller = None + self._event = None + self._latest_event = None + self._current_scene = None + try: + controller.stop() + except OSError as error: + raise AI2ThorRuntimeError( + f"AI2-THOR controller stop I/O failed: {error}" + ) from error + except MemoryError: + raise + except Exception as error: + raise AI2ThorRuntimeError( + f"AI2-THOR controller stop failed: {error}" + ) from error + else: + self.controller = None + self._event = None + self._latest_event = None + self._current_scene = None + + def _start_controller(self, **kwargs: Any) -> Any: + try: + return self._controller_factory(**kwargs) + except OSError as error: + raise AI2ThorRuntimeError( + f"AI2-THOR controller startup I/O failed: {error}" + ) from error + except MemoryError: + raise + except Exception as error: + raise AI2ThorRuntimeError( + f"AI2-THOR controller startup failed: {error}" + ) from error + + @staticmethod + def _step(controller: Any, label: str, **kwargs: Any) -> Any: + try: + return controller.step(**kwargs) + except OSError as error: + raise AI2ThorRuntimeError( + f"AI2-THOR {label} step I/O failed: {error}" + ) from error + except MemoryError: + raise + except Exception as error: + raise AI2ThorRuntimeError( + f"AI2-THOR {label} step failed: {error}" + ) from error + + def _native_scene_input(self, scene_id: str) -> str | dict[str, Any]: + source = self.procedural_scenes.get(scene_id) + return scene_id if source is None else source.decode_house() + + def _poison_scene_state(self) -> None: + self._event = None + self._current_scene = None + + def _validate_controller_source( + self, + controller: Any, + scene_id: str, + ) -> None: + source = self.procedural_scenes.get(scene_id) + if source is None: + controller_scene = getattr(controller, "scene", None) + if type(controller_scene) is dict: + raise RuntimeError("legacy source cannot use a controller house dict") + if controller_scene not in {scene_id, f"{scene_id}_physics"}: + raise RuntimeError( + "legacy controller scene does not match the registered scene" + ) + return + try: + canonical = _canonical_house_json_bytes(controller.scene) + except (AttributeError, ValueError) as error: + raise RuntimeError( + "controller procedural source SHA-256 cannot be verified" + ) from error + if sha256(canonical).hexdigest() != source.house_sha256: + raise RuntimeError("controller procedural source SHA-256 changed") + + def _validate_controller_source_or_poison( + self, + controller: Any, + scene_id: str, + ) -> None: + try: + self._validate_controller_source(controller, scene_id) + except BaseException: + self._poison_scene_state() + raise + + def _validate_scene_source_or_poison( + self, + controller: Any, + scene_id: str, + event: Any, + ) -> None: + try: + self._validate_controller_source(controller, scene_id) + native_scene_name = self._native_scene_name(event) + if scene_id in self.procedural_scenes: + if native_scene_name != "Procedural": + raise RuntimeError( + "registered procedural source requires native Procedural scene" + ) + else: + if native_scene_name == "Procedural": + raise RuntimeError( + "legacy source cannot use native Procedural scene" + ) + if native_scene_name != controller.scene: + raise RuntimeError( + "legacy controller and event native scene names differ" + ) + except BaseException: + self._poison_scene_state() + raise + + def _checked_scene_event( + self, + controller: Any, + event: Any, + action: str, + scene_id: str, + ) -> Any: + # Keep the raw returned event even when action or identity validation + # fails. Candidate rejection classification may inspect it later, but + # that path does not treat it as trusted without independently + # revalidating the relevant fields. + self._latest_event = event + try: + checked = self._checked_event(event, action) + self._validate_scene_source_or_poison( + controller, + scene_id, + checked, + ) + except BaseException: + self._poison_scene_state() + raise + return checked + + def _reset(self, controller: Any, scene_id: str) -> Any: + try: + return controller.reset(scene=self._native_scene_input(scene_id)) + except OSError as error: + raise AI2ThorRuntimeError( + f"AI2-THOR reset {scene_id} I/O failed: {error}" + ) from error + except MemoryError: + raise + except Exception as error: + raise AI2ThorRuntimeError( + f"AI2-THOR reset {scene_id} failed: {error}" + ) from error + + def _require_active(self) -> Any: + if self.controller is None: + raise RuntimeError("adapter must be used as a context manager") + return self.controller + + @staticmethod + def _checked_event(event: Any, action: str) -> Any: + metadata = getattr(event, "metadata", None) + metadata_is_dict = isinstance(metadata, dict) + if not metadata_is_dict: + raise RuntimeError(f"{action} returned an event without metadata") + if metadata.get("lastActionSuccess") is not True: + message = metadata.get("errorMessage") or f"{action} failed" + raise RuntimeError(str(message)) + return event + + @classmethod + def _native_scene_fully_settled( + cls, + event: Any, + expected_categories_by_name: dict[str, str], + ) -> bool: + raw_objects = event.metadata.get("objects") + if type(raw_objects) is not list: + raise AI2ThorNativeReturnError( + "scene settlement returned no object metadata" + ) + raw_objects = _validated_native_object_metadata(raw_objects) + domain_objects = _domain_object_metadata(raw_objects) + categories_by_name: dict[str, str] = {} + any_object_moving = False + for item in domain_objects: + if type(item) is not dict: + raise AI2ThorNativeReturnError( + "scene settlement returned invalid domain object metadata" + ) + name = item.get("name") + category = item.get("objectType") + if ( + type(name) is not str + or not name + or type(category) is not str + or not category + ): + raise AI2ThorNativeReturnError( + "scene settlement returned invalid object name/category metadata" + ) + if name in categories_by_name: + raise AI2ThorNativeReturnError( + "scene settlement returned duplicate stable object names" + ) + categories_by_name[name] = category + is_moving = item.get("isMoving") + if type(is_moving) is not bool: + raise AI2ThorNativeReturnError( + f"AI2-THOR isMoving for object {name!r} must be an exact boolean" + ) + any_object_moving = any_object_moving or is_moving + if categories_by_name != expected_categories_by_name: + raise AI2ThorNativeReturnError( + "object name/category mapping changed during scene settlement" + ) + return cls._native_scene_at_rest(event) and not any_object_moving + + @staticmethod + def _angle_residual_degrees(left: float, right: float) -> float: + return abs((left - right + 180.0) % 360.0 - 180.0) + + def settle_scene_observed( + self, + scene: Scene, + max_pass_steps: int = 30, + ) -> AI2ThorSceneSettlement: + """Advance physics until the scene and every domain object are still.""" + controller = self._require_active() + if type(max_pass_steps) is not int or max_pass_steps <= 0: + raise ValueError("max_pass_steps must be an exact positive integer") + event = self._current_event_for_scene(scene) + expected_categories_by_name = { + name: obj.category + for name, obj in self._objects_by_name(scene.objects).items() + } + previous_camera_states = deepcopy(self._camera_states) + previous_native_rotations = deepcopy(self._native_rotations) + pass_steps = 0 + try: + expected_native_scene_name = self._native_scene_name(event) + while True: + self._validate_native_scene_name_or_poison( + event, + expected_native_scene_name, + ) + settled = self._native_scene_fully_settled( + event, + expected_categories_by_name, + ) + if settled: + native_observed = self._scene_from_event(scene.scene_id, event) + observed_scene = self._stable_observed_scene( + scene, + native_observed, + ) + observation = self._observation_from_event( + observed_scene, + event, + ) + result = AI2ThorSceneSettlement( + observed_scene=observed_scene, + observation=observation, + pass_steps=pass_steps, + ) + self._event = event + self._current_scene = observed_scene + return result + if pass_steps >= max_pass_steps: + raise AI2ThorSettlementTimeout( + "AI2-THOR scene did not settle within " + f"{max_pass_steps} Pass steps" + ) + event = self._step( + controller, + "Pass", + action="Pass", + ) + self._event = event + self._current_scene = None + pass_steps += 1 + event = self._checked_scene_event( + controller, + event, + "Pass", + scene.scene_id, + ) + except BaseException: + self._camera_states = previous_camera_states + self._native_rotations = previous_native_rotations + self._current_scene = None + raise + + @contextmanager + def isolated_scene_observed( + self, + source: Scene, + max_pass_steps: int = 30, + ) -> Iterator[AI2ThorIsolatedEpisode]: + """Open one target-only controller and yield its settled source. + + The parent adapter remains the preparation owner and is never reset or + mutated. Native object IDs may differ in the child, so its observed + baseline is rebound to the frozen source IDs and analysis overlays by + unique object name. Geometry, views, assets, and rest status all come + from the child's same final trusted event. + """ + parent_controller = self._require_active() + if type(source) is not Scene: + raise ValueError("source must be an exact canonical Scene") + if source.scene_id not in self.scene_names: + raise KeyError(source.scene_id) + if type(max_pass_steps) is not int or max_pass_steps <= 0: + raise ValueError("max_pass_steps must be an exact positive integer") + procedural = self.procedural_scenes.get(source.scene_id) + + def isolated_controller_factory(**kwargs: Any) -> Any: + controller = self._controller_factory(**kwargs) + if controller is parent_controller: + raise RuntimeError( + "isolated AI2-THOR episode reused the parent controller" + ) + live_refs: list[ReferenceType[Any]] = [] + for controller_ref in self._isolated_controller_refs: + previous = controller_ref() + if previous is None: + continue + live_refs.append(controller_ref) + if controller is previous: + raise RuntimeError( + "isolated AI2-THOR episode reused a prior child controller" + ) + try: + live_refs.append(ref(controller)) + except TypeError as error: + raise RuntimeError( + "isolated AI2-THOR controller must support weak references" + ) from error + self._isolated_controller_refs = live_refs + return controller + + child = type(self)( + [source.scene_id], + width=self.width, + height=self.height, + seed=self.seed, + controller_factory=isolated_controller_factory, + allow_source_pose_drift=self.allow_source_pose_drift, + procedural_scenes=( + {source.scene_id: procedural} if procedural is not None else None + ), + ) + with child: + event = child._activate_scene(source.scene_id) + native_scene = child._scene_from_event(source.scene_id, event) + child._current_scene = native_scene + native_settlement = child.settle_scene_observed( + native_scene, + max_pass_steps=max_pass_steps, + ) + final_event = child._current_event_for_scene( + native_settlement.observed_scene + ) + stable_scene = child._stable_observed_scene( + source, + native_settlement.observed_scene, + ) + observation = child._observation_from_event(stable_scene, final_event) + settlement = AI2ThorSceneSettlement( + observed_scene=stable_scene, + observation=observation, + pass_steps=native_settlement.pass_steps, + ) + child._current_scene = stable_scene + yield AI2ThorIsolatedEpisode( + adapter=child, + baseline_settlement=settlement, + ) + + def _activate_scene( + self, + scene_id: str, + *, + force_reset: bool = False, + ) -> Any: + controller = self._require_active() + if scene_id not in self.scene_names: + raise KeyError(scene_id) + if self._event is None and not force_reset: + raise RuntimeError("no AI2-THOR event is available") + if self._event is not None and self.scene_name == scene_id and not force_reset: + self._validate_scene_source_or_poison( + controller, + scene_id, + self._event, + ) + return self._event + try: + event = self._reset(controller, scene_id) + except BaseException: + self._poison_scene_state() + raise + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + f"reset {scene_id}", + scene_id, + ) + # Commit native-scene tracking only after reset success. Callers can + # then fail closed without applying saved poses to the wrong scene. + self.scene_name = scene_id + return event + + def load_scene(self, scene_id: str) -> Scene: + # Public load means a deterministic baseline reload, even when Unity + # already has this scene active. Internal restoration uses the + # non-forced activation path to avoid redundant resets. + event = self._activate_scene(scene_id, force_reset=True) + scene = self._scene_from_event(scene_id, event) + self._current_scene = scene + return scene + + @classmethod + def _commanded_scene( + cls, + source: Scene, + observed: Scene, + subject_id: str, + commanded_position: Vec3, + ) -> Scene: + """Reproduce the legacy canonical command while retaining fresh views.""" + observed_by_name = cls._objects_by_name(observed.objects) + subject = source.object_by_id(subject_id) + delta_x = commanded_position.x - subject.position.x + delta_y = commanded_position.y - subject.position.y + merged: list[SceneObject] = [] + for original in source.objects: + current = observed_by_name[original.name] + if original.object_id == subject_id: + merged.append( + original.model_copy( + update={ + "position": commanded_position, + "obb": original.obb.model_copy( + update={ + "center": Vec3( + x=original.obb.center.x + delta_x, + y=original.obb.center.y + delta_y, + z=original.obb.center.z, + ) + } + ), + "views": current.views, + } + ) + ) + else: + merged.append(original.model_copy(update={"views": current.views})) + return source.model_copy(update={"objects": tuple(merged)}) + + def _native_rotation_for( + self, + scene_id: str, + obj: SceneObject, + ) -> dict[str, float]: + rotation = self._native_rotations.get((scene_id, obj.name)) + if rotation is None: + raise ValueError( + f"native rotation was not captured for object {obj.name!r}" + ) + if ( + not _quaternions_close( + ai2thor_rotation_to_world(Vec3(**rotation)), + obj.rotation, + ) + and not self.allow_source_pose_drift + ): + raise ValueError( + f"scene rotation for object {obj.name!r} differs from captured native pose" + ) + return dict(rotation) + + def _object_pose( + self, + scene_id: str, + obj: SceneObject, + position: Vec3 | None = None, + ) -> dict[str, Any]: + requested_position = position or obj.position + return { + "objectName": obj.name, + "position": { + "x": float(requested_position.x), + "y": float(requested_position.z), + "z": float(requested_position.y), + }, + "rotation": self._native_rotation_for(scene_id, obj), + } + + def _restore_scene_state(self, scene: Scene) -> Any: + controller = self._require_active() + if scene.scene_id not in self.scene_names: + raise KeyError(scene.scene_id) + self._activate_scene(scene.scene_id) + self._objects_by_name(scene.objects) + camera = scene.camera_by_id("main") + state = self._camera_states.get(self._camera_key(scene.scene_id, camera)) + if state is None: + raise ValueError("camera pose was not captured by this adapter") + expected_positions = {obj.name: obj.position for obj in scene.objects} + expected_rotations = { + obj.name: self._native_rotation_for(scene.scene_id, obj) + for obj in scene.objects + } + try: + event = self._step( + controller, + "SetObjectPoses", + action="SetObjectPoses", + objectPoses=[ + self._object_pose(scene.scene_id, obj) + for obj in scene.objects + if obj.movable + ], + placeStationary=True, + ) + except BaseException: + self._poison_scene_state() + raise + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + "SetObjectPoses", + scene.scene_id, + ) + try: + event = self._step( + controller, + "TeleportFull", + action="TeleportFull", + position=dict(state["position"]), + rotation=dict(state["rotation"]), + horizon=state["horizon"], + standing=state["standing"], + forceAction=True, + ) + except BaseException: + self._poison_scene_state() + raise + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + "TeleportFull", + scene.scene_id, + ) + self._validate_returned_state( + scene, + event, + expected_positions, + expected_rotations, + ) + self._current_scene = scene + return event + + def _validate_returned_state( + self, + scene: Scene, + event: Any, + expected_positions: dict[str, Vec3], + expected_rotations: dict[str, dict[str, float]], + *, + total_position_residual_limits_by_name: Mapping[str, float] | None = None, + rotation_residual_limits_by_name: Mapping[str, float] | None = None, + ) -> None: + raw_objects = event.metadata.get("objects") + if not isinstance(raw_objects, list): + raise AI2ThorNativeReturnError( + "pose application returned no object metadata" + ) + raw_objects = _validated_native_object_metadata(raw_objects) + raw_objects = _domain_object_metadata(raw_objects) + names = [item["name"] for item in raw_objects] + by_name = dict(zip(names, raw_objects, strict=True)) + expected_by_name = self._objects_by_name(scene.objects) + if set(by_name) != set(expected_by_name): + raise AI2ThorNativeReturnError( + "stable object names changed during pose application" + ) + for name in expected_by_name: + metadata = by_name[name] + try: + position = ai2thor_position_to_world(Vec3(**metadata["position"])) + native_rotation = { + axis: float(metadata["rotation"][axis]) for axis in ("x", "y", "z") + } + except (KeyError, TypeError, ValueError) as exc: + raise AI2ThorNativeReturnError( + f"object {name!r} returned an invalid pose" + ) from exc + expected_position = expected_positions[name] + expected_rotation = expected_rotations[name] + expected_coordinates = ( + expected_position.x, + expected_position.y, + expected_position.z, + ) + observed_coordinates = (position.x, position.y, position.z) + residual_limit = ( + None + if total_position_residual_limits_by_name is None + else total_position_residual_limits_by_name.get(name) + ) + if residual_limit is None: + position_matches = np.allclose( + observed_coordinates, + expected_coordinates, + atol=1e-5, + rtol=0.0, + ) + else: + rounding_allowance_m = 4.0 * math.ulp(residual_limit) + position_matches = ( + math.dist( + observed_coordinates, + expected_coordinates, + ) + <= residual_limit + rounding_allowance_m + ) + rotation_residual_limit = ( + _OBJECT_ROTATION_TOLERANCE_DEGREES + if rotation_residual_limits_by_name is None + else rotation_residual_limits_by_name.get( + name, + _OBJECT_ROTATION_TOLERANCE_DEGREES, + ) + ) + if not position_matches or not all( + self._angles_close( + native_rotation[axis], + expected_rotation[axis], + tolerance_degrees=rotation_residual_limit, + ) + for axis in ("x", "y", "z") + ): + raise AI2ThorNativeReturnError( + f"object {name!r} pose changed during pose application" + ) + observed_camera = self._camera(event.metadata, scene.scene_id) + self._validate_camera_fixed(scene.camera_by_id("main"), observed_camera) + + @staticmethod + def _native_scene_at_rest(event: Any) -> bool: + value = event.metadata.get("isSceneAtRest") + if type(value) is not bool: + raise AI2ThorNativeReturnError( + "AI2-THOR isSceneAtRest must be an exact boolean" + ) + return value + + @staticmethod + def _native_object_is_moving(event: Any, object_name: str) -> bool: + raw_objects = event.metadata.get("objects") + if not isinstance(raw_objects, list): + raise AI2ThorNativeReturnError( + "pose application returned no object metadata" + ) + matches = [ + item + for item in raw_objects + if isinstance(item, dict) and item.get("name") == object_name + ] + if len(matches) != 1: + raise AI2ThorNativeReturnError( + f"expected one native object named {object_name!r}" + ) + value = matches[0].get("isMoving") + if type(value) is not bool: + raise AI2ThorNativeReturnError("AI2-THOR isMoving must be an exact boolean") + return value + + @staticmethod + def _native_object_id_for_name(event: Any, object_name: str) -> str: + raw_objects = event.metadata.get("objects") + raw_objects_is_list = isinstance(raw_objects, list) + if not raw_objects_is_list: + raise RuntimeError("pose application returned no object metadata") + matches = [ + item + for item in raw_objects + if isinstance(item, dict) and item.get("name") == object_name + ] + if len(matches) != 1: + raise RuntimeError(f"expected one native object named {object_name!r}") + native_object_id = matches[0].get("objectId") + if type(native_object_id) is not str or not native_object_id: + raise RuntimeError( + f"native object named {object_name!r} has no valid objectId" + ) + return native_object_id + + def apply_object_xy_observed( + self, + scene: Scene, + object_id: str, + x: float, + y: float, + ) -> AI2ThorPoseApplication: + """Apply one command and retain both canonical and native observations.""" + controller = self._require_active() + target = scene.object_by_id(object_id) + if not target.movable: + raise ValueError(f"object {object_id!r} is not movable") + if not math.isfinite(x) or not math.isfinite(y): + raise ValueError("object X/Y must be finite") + self._objects_by_name(scene.objects) + if self._current_scene != scene: + current_scene = self._current_scene + if current_scene is not None and self._is_analysis_overlay( + current_scene, scene + ): + current_event = self._current_event_for_scene(current_scene) + else: + current_event = self._restore_scene_state(scene) + else: + current_event = self._current_event_for_scene(scene) + expected_positions = { + obj.name: ( + Vec3(x=x, y=y, z=obj.position.z) + if obj.object_id == object_id + else obj.position + ) + for obj in scene.objects + } + expected_rotations = { + obj.name: self._native_rotation_for(scene.scene_id, obj) + for obj in scene.objects + } + native_object_id = self._native_object_id_for_name( + current_event, + target.name, + ) + commanded_position = expected_positions[target.name] + teleport_vertical_guard_m = _TELEPORT_VERTICAL_GUARD_M + # SetObjectPoses removes every movable object omitted from its payload. + # TeleportObject is the native single-object edit and avoids re-emitting + # unrelated poses, while the validation below still checks the full scene. + try: + event = self._step( + controller, + "TeleportObject", + action="TeleportObject", + objectId=native_object_id, + position={ + "x": commanded_position.x, + "y": commanded_position.z + teleport_vertical_guard_m, + "z": commanded_position.y, + }, + rotation=dict(expected_rotations[target.name]), + ) + except BaseException: + self._poison_scene_state() + raise + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + "TeleportObject", + scene.scene_id, + ) + self._validate_returned_state( + scene, + event, + expected_positions, + expected_rotations, + ) + native_observed = self._scene_from_event(scene.scene_id, event) + observed_scene = self._stable_observed_scene(scene, native_observed) + commanded_scene = self._commanded_scene( + scene, + observed_scene, + object_id, + commanded_position, + ) + observed_position = observed_scene.object_by_id(object_id).position + position_residual_m = math.dist( + ( + commanded_position.x, + commanded_position.y, + commanded_position.z, + ), + ( + observed_position.x, + observed_position.y, + observed_position.z, + ), + ) + is_scene_at_rest = self._native_scene_at_rest(event) + subject_is_moving = self._native_object_is_moving(event, target.name) + observation = self._observation_from_event(observed_scene, event) + result = AI2ThorPoseApplication( + commanded_scene=commanded_scene, + observed_scene=observed_scene, + commanded_position=commanded_position, + observed_position=observed_position, + position_residual_m=position_residual_m, + observation=observation, + is_scene_at_rest=is_scene_at_rest, + subject_is_moving=subject_is_moving, + ) + # The retained event contains the observed native pose, not the ideal + # command. Keep that identity exact; legacy callers still receive the + # commanded scene from ``with_object_xy`` and restoration is explicit. + self._current_scene = observed_scene + return result + + def apply_receptacle_spawn_point_observed( + self, + scene: Scene, + spawn_map: AI2ThorReceptacleSpawnMap, + position: AI2ThorNativePosition, + ) -> AI2ThorPoseApplication: + """Audit one externally selected endpoint with native surface placement. + + This method is deliberately not a search API. It accepts exactly one + point from one source-bound map, executes one ``PlaceObjectAtPoint``, + and fails closed if native collision or pose validation rejects it. + """ + + checked_map, subject, native_subject_object_id = self._receptacle_audit_source( + scene, spawn_map + ) + checked_position = _strict_native_position(position, "spawn audit position") + if checked_position not in checked_map.positions: + raise ValueError("spawn audit position is not in the source-bound map") + return self._apply_receptacle_native_position_observed( + scene, + subject, + native_subject_object_id, + checked_position, + ) + + def apply_receptacle_endpoint_observed( + self, + scene: Scene, + spawn_map: AI2ThorReceptacleSpawnMap, + *, + x: float, + y: float, + ) -> AI2ThorPoseApplication: + """Audit one exact world-XY endpoint without snapping or searching. + + The source-bound map contributes only the single exact native support + height required by this horizontal-support API. The endpoint X/Y is + supplied by the platform-neutral solver and is never replaced by a + nearby returned coordinate. Multi-height receptacles are outside this + contract and fail before any native action. + """ + + checked_map, subject, native_subject_object_id = self._receptacle_audit_source( + scene, spawn_map + ) + endpoint_x = _strict_finite_float(x, "receptacle endpoint x") + endpoint_y = _strict_finite_float(y, "receptacle endpoint y") + native_heights = {item.y for item in checked_map.positions} + if len(native_heights) != 1: + raise ValueError( + "exact receptacle endpoint audit requires one native support height" + ) + return self._apply_receptacle_native_position_observed( + scene, + subject, + native_subject_object_id, + AI2ThorNativePosition( + x=endpoint_x, + y=next(iter(native_heights)), + z=endpoint_y, + ), + ) + + def apply_receptacle_endpoint_settled_observed( + self, + scene: Scene, + spawn_map: AI2ThorReceptacleSpawnMap, + *, + x: float, + y: float, + max_pass_steps: int, + max_subject_rotation_residual_degrees: float | None = None, + ) -> AI2ThorPoseApplication: + """Place once, then wait boundedly for the runtime-only endpoint to settle.""" + + if type(max_pass_steps) is not int or max_pass_steps <= 0: + raise ValueError("max_pass_steps must be an exact positive integer") + if max_subject_rotation_residual_degrees is not None: + max_subject_rotation_residual_degrees = _strict_finite_float( + max_subject_rotation_residual_degrees, + "subject rotation residual limit", + ) + if max_subject_rotation_residual_degrees <= 0.0: + raise ValueError("subject rotation residual limit must be positive") + checked_map, subject, native_subject_object_id = self._receptacle_audit_source( + scene, spawn_map + ) + endpoint_x = _strict_finite_float(x, "receptacle endpoint x") + endpoint_y = _strict_finite_float(y, "receptacle endpoint y") + native_heights = {item.y for item in checked_map.positions} + if len(native_heights) != 1: + raise ValueError( + "exact receptacle endpoint audit requires one native support height" + ) + return self._apply_receptacle_native_position_observed( + scene, + subject, + native_subject_object_id, + AI2ThorNativePosition( + x=endpoint_x, + y=next(iter(native_heights)), + z=endpoint_y, + ), + max_pass_steps=max_pass_steps, + max_subject_rotation_residual_degrees=( + max_subject_rotation_residual_degrees + ), + ) + + def _receptacle_audit_source( + self, + scene: Scene, + spawn_map: AI2ThorReceptacleSpawnMap, + ) -> tuple[AI2ThorReceptacleSpawnMap, SceneObject, str]: + current_event = self._current_event_for_scene(scene) + checked_map = _strict_receptacle_spawn_map(spawn_map) + subject = scene.object_by_id(checked_map.subject_object_id) + if subject.support_object_id != checked_map.support_object_id: + raise ValueError("spawn map support does not match the current scene") + runtime_identity = self.runtime_identity() + scene_sha256 = _receptacle_scene_sha256( + scene, + checked_map.surface_patches, + ) + native_subject_object_id = self._native_object_id_for_name( + current_event, + subject.name, + ) + support = scene.object_by_id(checked_map.support_object_id) + native_support_object_id = self._native_object_id_for_name( + current_event, + support.name, + ) + if ( + checked_map.scene_id != scene.scene_id + or checked_map.scene_sha256 != scene_sha256 + or checked_map.runtime_identity != runtime_identity + or checked_map.native_subject_object_id != native_subject_object_id + or checked_map.native_support_object_id != native_support_object_id + ): + raise ValueError("spawn map does not close the exact current source") + return checked_map, subject, native_subject_object_id + + def _apply_receptacle_native_position_observed( + self, + scene: Scene, + subject: SceneObject, + native_subject_object_id: str, + checked_position: AI2ThorNativePosition, + *, + max_pass_steps: int | None = None, + max_subject_rotation_residual_degrees: float | None = None, + ) -> AI2ThorPoseApplication: + controller = self._require_active() + commanded_position = Vec3( + x=checked_position.x, + y=checked_position.z, + z=subject.position.z, + ) + expected_positions = { + obj.name: ( + commanded_position + if obj.object_id == subject.object_id + else obj.position + ) + for obj in scene.objects + } + expected_rotations = { + obj.name: self._native_rotation_for(scene.scene_id, obj) + for obj in scene.objects + } + try: + event = self._step( + controller, + "PlaceObjectAtPoint", + action="PlaceObjectAtPoint", + objectId=native_subject_object_id, + position={ + "x": checked_position.x, + "y": checked_position.y, + "z": checked_position.z, + }, + rotation=dict(expected_rotations[subject.name]), + ) + except BaseException: + self._poison_scene_state() + raise + self._event = event + self._current_scene = None + event = self._checked_scene_event( + controller, + event, + "PlaceObjectAtPoint", + scene.scene_id, + ) + if max_pass_steps is None: + self._validate_returned_state( + scene, + event, + expected_positions, + expected_rotations, + ) + else: + immediate_native = self._scene_from_event(scene.scene_id, event) + immediate_observed = self._stable_observed_scene(scene, immediate_native) + self._current_scene = immediate_observed + settlement = self.settle_scene_observed( + immediate_observed, + max_pass_steps=max_pass_steps, + ) + event = self._current_event_for_scene(settlement.observed_scene) + self._validate_returned_state( + scene, + event, + expected_positions, + expected_rotations, + total_position_residual_limits_by_name={ + subject.name: _RUNTIME_RECEPTACLE_POSITION_RESIDUAL_M + }, + rotation_residual_limits_by_name=( + None + if max_subject_rotation_residual_degrees is None + else { + subject.name: max_subject_rotation_residual_degrees, + } + ), + ) + native_observed = self._scene_from_event(scene.scene_id, event) + observed_scene = self._stable_observed_scene(scene, native_observed) + commanded_scene = self._commanded_scene( + scene, + observed_scene, + subject.object_id, + commanded_position, + ) + observed_position = observed_scene.object_by_id(subject.object_id).position + position_residual_m = math.dist( + ( + commanded_position.x, + commanded_position.y, + commanded_position.z, + ), + ( + observed_position.x, + observed_position.y, + observed_position.z, + ), + ) + result = AI2ThorPoseApplication( + commanded_scene=commanded_scene, + observed_scene=observed_scene, + commanded_position=commanded_position, + observed_position=observed_position, + position_residual_m=position_residual_m, + observation=self._observation_from_event(observed_scene, event), + is_scene_at_rest=self._native_scene_at_rest(event), + subject_is_moving=self._native_object_is_moving(event, subject.name), + ) + self._current_scene = observed_scene + return result + + def with_object_xy( + self, + scene: Scene, + object_id: str, + x: float, + y: float, + ) -> Scene: + return self.apply_object_xy_observed(scene, object_id, x, y).commanded_scene diff --git a/src/spatialcf/adapters/ai2thor/models.py b/src/spatialcf/adapters/ai2thor/models.py new file mode 100644 index 0000000..50b0394 --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/models.py @@ -0,0 +1,1220 @@ +from __future__ import annotations + +import json +import math +from collections.abc import Mapping +from dataclasses import asdict, dataclass +from enum import StrEnum +from hashlib import sha256 +from types import MappingProxyType +from typing import Any + +from spatialcf.adapters.base import ( + AdapterBinding, + AdapterCameraApplication, + AdapterFloorEnvelope, + AdapterObservation, + AdapterPose, + AdapterPosition, + AdapterRuntimeIdentity, + AdapterSpawnMap, + AdapterSupportFact, + AdapterSurfacePatch, + AppliedCertifiedEdit, + CapturedSource, + CertifiedEditApplication, + SettledReadback, +) +from spatialcf.domain.scene import ( + OBB, + CollisionObstacle, + Scene, + SubjectPositionRegion, + Vec2, + Vec3, +) + +_RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M = 1e-5 +_RECEPTACLE_TRIGGER_GRID_SIDE = 21 +_RECEPTACLE_TRIGGER_GRID_SIZE = _RECEPTACLE_TRIGGER_GRID_SIDE**2 +_TELEPORT_VERTICAL_GUARD_M = 1e-6 + + +def _strict_finite_float(value: Any, label: str) -> float: + if type(value) not in (int, float) or not math.isfinite(float(value)): + raise ValueError(f"{label} must be a finite real number") + return float(value) + + +def _nonempty_text(value: Any, label: str) -> str: + if type(value) is not str or not value.strip(): + raise ValueError(f"{label} must be non-empty text") + return value + + +def _full_commit_sha(value: Any, label: str) -> str: + text = _nonempty_text(value, label) + if len(text) != 40 or any( + character not in "0123456789abcdef" for character in text + ): + raise ValueError(f"{label} must be a complete lowercase commit SHA") + return text + + +def _validate_json_tree( + value: Any, + *, + active_containers: set[int] | None = None, +) -> None: + if value is None or type(value) in (bool, int, str): + return + if type(value) is float: + if not math.isfinite(value): + raise ValueError("house JSON numbers must be finite") + return + if type(value) not in (dict, list): + raise ValueError("house must contain exact JSON values") + active = set() if active_containers is None else active_containers + identity = id(value) + if identity in active: + raise ValueError("house JSON must not contain cycles") + active.add(identity) + try: + if type(value) is dict: + if any(type(key) is not str for key in value): + raise ValueError("house JSON objects must have string keys") + for item in value.values(): + _validate_json_tree(item, active_containers=active) + else: + for item in value: + _validate_json_tree(item, active_containers=active) + finally: + active.remove(identity) + + +def _canonical_house_json_bytes(house: Any) -> bytes: + if type(house) is not dict: + raise ValueError("house must be an exact dict") + _validate_json_tree(house) + try: + return ( + json.dumps( + house, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + except (RecursionError, TypeError, UnicodeEncodeError, ValueError) as error: + raise ValueError("house must be finite canonical UTF-8 JSON") from error + + +def _procedural_room_identity( + house: dict[str, Any], +) -> tuple[str, tuple[float, float, float, float]]: + rooms = house.get("rooms") + if type(rooms) is not list or len(rooms) != 1: + raise ValueError("procedural house must contain exactly one room") + room = rooms[0] + if type(room) is not dict: + raise ValueError("procedural room must be an exact dict") + room_id = _nonempty_text(room.get("id"), "room id") + polygon = room.get("floorPolygon") + if type(polygon) is not list or len(polygon) != 4: + raise ValueError("room floorPolygon must contain exactly four points") + points: list[tuple[float, float]] = [] + elevations: list[float] = [] + for point in polygon: + if type(point) is not dict: + raise ValueError("room floorPolygon points must be exact dicts") + coordinates = tuple(point.get(axis) for axis in ("x", "y", "z")) + if any( + type(coordinate) not in (int, float) or not math.isfinite(float(coordinate)) + for coordinate in coordinates + ): + raise ValueError("room floorPolygon points must be finite") + points.append((float(coordinates[0]), float(coordinates[2]))) + elevations.append(float(coordinates[1])) + if any( + not math.isclose(value, elevations[0], rel_tol=0.0, abs_tol=1e-9) + for value in elevations[1:] + ): + raise ValueError("room floorPolygon must lie on one horizontal plane") + twice_area = sum( + points[index][0] * points[(index + 1) % 4][1] + - points[(index + 1) % 4][0] * points[index][1] + for index in range(4) + ) + if abs(twice_area) <= 1e-12: + raise ValueError("room floorPolygon must have positive area") + minimum_x = min(point[0] for point in points) + maximum_x = max(point[0] for point in points) + minimum_z = min(point[1] for point in points) + maximum_z = max(point[1] for point in points) + if maximum_x - minimum_x <= 1e-12 or maximum_z - minimum_z <= 1e-12: + raise ValueError("room floorPolygon must have positive area") + expected_corners = { + (minimum_x, minimum_z), + (maximum_x, minimum_z), + (maximum_x, maximum_z), + (minimum_x, maximum_z), + } + actual_corners = set(points) + edges_are_axis_aligned = all( + ( + math.isclose(points[index][0], points[(index + 1) % 4][0], abs_tol=1e-12) + != math.isclose( + points[index][1], + points[(index + 1) % 4][1], + abs_tol=1e-12, + ) + ) + for index in range(4) + ) + if actual_corners != expected_corners or not edges_are_axis_aligned: + raise ValueError("room floorPolygon must be a convex axis-aligned rectangle") + return room_id, (minimum_x, minimum_z, maximum_x, maximum_z) + + +def _canonical_json_sha256(payload: object) -> str: + encoded = json.dumps( + payload, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return sha256(encoded).hexdigest() + + +def _native_positions_sha256( + positions: tuple[AI2ThorNativePosition, ...], +) -> str: + return _canonical_json_sha256( + tuple({"x": item.x, "y": item.y, "z": item.z} for item in positions) + ) + + +def _receptacle_spawn_source_sha256( + *, + scene_id: str, + subject_object_id: str, + support_object_id: str, + native_subject_object_id: str, + native_support_object_id: str, + runtime_identity: AI2ThorRuntimeIdentity, + positions_sha256: str, + scene_sha256: str, + surface_patches: tuple[AI2ThorReceptacleSurfacePatch, ...] = (), +) -> str: + payload: dict[str, object] = { + "action": "GetSpawnCoordinatesAboveReceptacle", + "anywhere": True, + "method": ( + "ai2thor-receptacle-trigger-grid-v1" + if surface_patches + else "ai2thor-receptacle-spawn-map-v1" + ), + "native_subject_object_id": native_subject_object_id, + "native_support_object_id": native_support_object_id, + "positions_sha256": positions_sha256, + "runtime_identity": asdict(runtime_identity), + "scene_id": scene_id, + "scene_sha256": scene_sha256, + "subject_object_id": subject_object_id, + "support_object_id": support_object_id, + } + if surface_patches: + payload["surface_patches"] = tuple(asdict(item) for item in surface_patches) + return _canonical_json_sha256(payload) + + +class AI2ThorRuntimeError(RuntimeError): + """Expected controller transport/launcher failure.""" + + +class AI2ThorNativeReturnError(ValueError): + """A successful native action returned a structurally invalid state.""" + + +class AI2ThorSettlementTimeout(RuntimeError): + """A bounded native settlement loop exhausted its explicit Pass budget.""" + + +class AI2ThorNativeSupportKind(StrEnum): + """Closed classification of one native parent lineage.""" + + FLOOR = "FLOOR" + RECEPTACLE = "RECEPTACLE" + UNKNOWN = "UNKNOWN" + MULTIPLE_AMBIGUOUS = "MULTIPLE_AMBIGUOUS" + CYCLIC = "CYCLIC" + + +@dataclass(frozen=True) +class AI2ThorNativeSupportFact: + """Read-only native parent evidence bound to one stable scene object.""" + + scene_id: str + object_id: str + object_name: str + native_object_id: str + raw_parent_object_ids: tuple[str, ...] + structural_parent_object_ids: tuple[str, ...] + domain_parent_object_ids: tuple[str, ...] + support_kind: AI2ThorNativeSupportKind + support_object_id: str | None + floor_object_id: str | None + + def __post_init__(self) -> None: + for name in ( + "scene_id", + "object_id", + "object_name", + "native_object_id", + ): + _nonempty_text(getattr(self, name), f"native support {name}") + for name in ( + "raw_parent_object_ids", + "structural_parent_object_ids", + "domain_parent_object_ids", + ): + values = getattr(self, name) + if type(values) is not tuple or any( + type(value) is not str or not value.strip() for value in values + ): + raise ValueError(f"native support {name} must be a text tuple") + if values != tuple(sorted(set(values))): + raise ValueError(f"native support {name} must be unique and sorted") + raw = set(self.raw_parent_object_ids) + structural = set(self.structural_parent_object_ids) + domain = set(self.domain_parent_object_ids) + if not structural.issubset(raw) or not domain.issubset(raw): + raise ValueError("native support parent partitions must be raw subsets") + if structural.intersection(domain): + raise ValueError("native support parent partitions must be disjoint") + if type(self.support_kind) is not AI2ThorNativeSupportKind: + raise ValueError("native support kind has invalid type") + if self.support_kind is AI2ThorNativeSupportKind.RECEPTACLE: + if ( + len(domain) != 1 + or structural + or self.support_object_id != self.domain_parent_object_ids[0] + or self.floor_object_id is not None + ): + raise ValueError("receptacle support fact is not closed") + elif self.support_kind is AI2ThorNativeSupportKind.FLOOR: + if ( + len(structural) != 1 + or domain + or self.floor_object_id != self.structural_parent_object_ids[0] + or self.support_object_id is not None + ): + raise ValueError("floor support fact is not closed") + elif self.support_kind is AI2ThorNativeSupportKind.UNKNOWN: + if ( + domain + or self.support_object_id is not None + or self.floor_object_id is not None + ): + raise ValueError("unknown support fact resolved an unusable parent") + elif self.support_kind is AI2ThorNativeSupportKind.MULTIPLE_AMBIGUOUS: + if ( + len(domain) + len(structural) <= 1 + or self.support_object_id is not None + or self.floor_object_id is not None + ): + raise ValueError("ambiguous support fact requires multiple parents") + elif ( + not domain + or self.support_object_id is not None + or self.floor_object_id is not None + ): + raise ValueError("cyclic support fact is not closed") + + +@dataclass(frozen=True) +class AI2ThorProceduralScene: + """Immutable provenance plus canonical source bytes for one ProcTHOR house.""" + + dataset_id: str + revision: str + split: str + index: int + source_loader_id: str + source_loader_version: str + canonical_house_json: bytes + house_sha256: str + room_id: str + floor_xz_bounds: tuple[float, float, float, float] + + def __post_init__(self) -> None: + _nonempty_text(self.dataset_id, "dataset_id") + _full_commit_sha(self.revision, "revision") + if type(self.split) is not str or self.split not in {"train", "val", "test"}: + raise ValueError("split must be exactly train, val, or test") + if type(self.index) is not int or self.index < 0: + raise ValueError("index must be an exact non-negative integer") + _nonempty_text(self.source_loader_id, "source_loader_id") + _nonempty_text(self.source_loader_version, "source_loader_version") + if type(self.canonical_house_json) is not bytes: + raise ValueError("canonical_house_json must be exact bytes") + try: + decoded = json.loads(self.canonical_house_json) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("canonical_house_json must be valid UTF-8 JSON") from error + if type(decoded) is not dict: + raise ValueError("canonical_house_json root must be an exact dict") + room_id, floor_xz_bounds = _procedural_room_identity(decoded) + if _canonical_house_json_bytes(decoded) != self.canonical_house_json: + raise ValueError("canonical_house_json is not canonical") + expected_sha256 = sha256(self.canonical_house_json).hexdigest() + if type(self.house_sha256) is not str or self.house_sha256 != expected_sha256: + raise ValueError("house_sha256 does not match canonical_house_json") + if self.room_id != room_id: + raise ValueError("room_id does not match canonical_house_json") + if ( + type(self.floor_xz_bounds) is not tuple + or self.floor_xz_bounds != floor_xz_bounds + ): + raise ValueError("floor_xz_bounds do not match canonical_house_json") + + @classmethod + def create( + cls, + *, + dataset_id: str, + revision: str, + split: str, + index: int, + source_loader_id: str, + source_loader_version: str, + house: dict[str, Any], + ) -> AI2ThorProceduralScene: + canonical = _canonical_house_json_bytes(house) + room_id, floor_xz_bounds = _procedural_room_identity(house) + return cls( + dataset_id=dataset_id, + revision=revision, + split=split, + index=index, + source_loader_id=source_loader_id, + source_loader_version=source_loader_version, + canonical_house_json=canonical, + house_sha256=sha256(canonical).hexdigest(), + room_id=room_id, + floor_xz_bounds=floor_xz_bounds, + ) + + def decode_house(self) -> dict[str, Any]: + """Return a fresh mutable decoding; callers never receive retained state.""" + decoded = json.loads(self.canonical_house_json) + if type(decoded) is not dict: # Defends the public contract after construction. + raise RuntimeError("procedural house root changed from an exact dict") + return decoded + + +@dataclass(frozen=True) +class AI2ThorNativePosition: + """A finite position in AI2-THOR's native X/Y/Z coordinate system.""" + + x: float + y: float + z: float + + def __post_init__(self) -> None: + for axis in ("x", "y", "z"): + object.__setattr__( + self, + axis, + _strict_finite_float( + getattr(self, axis), + f"native position {axis}", + ), + ) + + +@dataclass(frozen=True) +class AI2ThorAgentPose: + """A complete deterministic TeleportFull request in native coordinates.""" + + position: AI2ThorNativePosition + yaw_degrees: float + horizon_degrees: float + standing: bool + + def __post_init__(self) -> None: + if type(self.position) is not AI2ThorNativePosition: + raise ValueError("agent pose position must be an AI2ThorNativePosition") + object.__setattr__( + self, + "yaw_degrees", + _strict_finite_float(self.yaw_degrees, "agent yaw"), + ) + object.__setattr__( + self, + "horizon_degrees", + _strict_finite_float(self.horizon_degrees, "camera horizon"), + ) + if type(self.standing) is not bool: + raise ValueError("agent standing must be an exact boolean") + + +@dataclass(frozen=True) +class AI2ThorRuntimeIdentity: + """Exact package, Unity build and controller contract for one run.""" + + ai2thor_version: str + unity_commit_id: str + native_scene_name: str + width: int + height: int + seed: int + render_depth_image: bool = True + render_instance_segmentation: bool = True + grid_size_m: float = 0.05 + snap_to_grid: bool = True + rotate_step_degrees: int = 90 + coordinate_transform_version: str = "ai2thor-native-xzy-to-rh-z-up-v1" + source_dataset_id: str | None = None + source_revision: str | None = None + source_split: str | None = None + source_index: int | None = None + source_sha256: str | None = None + source_scene_alias: str | None = None + source_loader_id: str | None = None + source_loader_version: str | None = None + source_room_id: str | None = None + source_floor_xz_bounds: tuple[float, float, float, float] | None = None + teleport_vertical_guard_m: float = 0.0 + + def __post_init__(self) -> None: + for name in ( + "ai2thor_version", + "unity_commit_id", + "native_scene_name", + "coordinate_transform_version", + ): + if type(getattr(self, name)) is not str or not getattr(self, name): + raise ValueError(f"{name} must be non-empty text") + for name in ("width", "height", "seed", "rotate_step_degrees"): + if type(getattr(self, name)) is not int: + raise ValueError(f"{name} must be an exact integer") + if self.width <= 0 or self.height <= 0 or self.rotate_step_degrees <= 0: + raise ValueError("runtime dimensions and rotation step must be positive") + for name in ( + "render_depth_image", + "render_instance_segmentation", + "snap_to_grid", + ): + if type(getattr(self, name)) is not bool: + raise ValueError(f"{name} must be an exact boolean") + if ( + isinstance(self.grid_size_m, bool) + or not isinstance(self.grid_size_m, (int, float)) + or not math.isfinite(float(self.grid_size_m)) + or self.grid_size_m <= 0.0 + ): + raise ValueError("grid_size_m must be finite and positive") + object.__setattr__(self, "grid_size_m", float(self.grid_size_m)) + if ( + type(self.teleport_vertical_guard_m) not in (int, float) + or not math.isfinite(float(self.teleport_vertical_guard_m)) + or float(self.teleport_vertical_guard_m) not in {0.0, 1e-6} + ): + raise ValueError("teleport_vertical_guard_m must be exactly 0 or 1e-6") + object.__setattr__( + self, + "teleport_vertical_guard_m", + float(self.teleport_vertical_guard_m), + ) + source_values = ( + self.source_dataset_id, + self.source_revision, + self.source_split, + self.source_index, + self.source_sha256, + self.source_scene_alias, + self.source_loader_id, + self.source_loader_version, + self.source_room_id, + self.source_floor_xz_bounds, + ) + if any(value is not None for value in source_values): + if any(value is None for value in source_values): + raise ValueError("procedural source provenance must be complete") + _nonempty_text(self.source_dataset_id, "source_dataset_id") + _full_commit_sha(self.source_revision, "source_revision") + _nonempty_text(self.source_scene_alias, "source_scene_alias") + _nonempty_text(self.source_loader_id, "source_loader_id") + _nonempty_text(self.source_loader_version, "source_loader_version") + _nonempty_text(self.source_room_id, "source_room_id") + if self.source_split not in {"train", "val", "test"}: + raise ValueError("source_split must be exactly train, val, or test") + if type(self.source_index) is not int or self.source_index < 0: + raise ValueError("source_index must be an exact non-negative integer") + if ( + type(self.source_sha256) is not str + or len(self.source_sha256) != 64 + or any( + character not in "0123456789abcdef" + for character in self.source_sha256 + ) + ): + raise ValueError("source_sha256 must be lowercase SHA-256 hex") + bounds = self.source_floor_xz_bounds + if ( + type(bounds) is not tuple + or len(bounds) != 4 + or any( + type(value) not in (int, float) or not math.isfinite(float(value)) + for value in bounds + ) + or not float(bounds[0]) < float(bounds[2]) + or not float(bounds[1]) < float(bounds[3]) + ): + raise ValueError( + "source_floor_xz_bounds must be a finite positive rectangle" + ) + object.__setattr__( + self, + "source_floor_xz_bounds", + tuple(float(value) for value in bounds), + ) + if self.native_scene_name != "Procedural": + raise ValueError( + "procedural provenance requires native Procedural scene" + ) + if self.teleport_vertical_guard_m != 1e-6: + raise ValueError("procedural provenance requires 1e-6 teleport guard") + elif self.native_scene_name == "Procedural": + raise ValueError("native Procedural scene requires source provenance") + elif self.teleport_vertical_guard_m not in { + 0.0, + _TELEPORT_VERTICAL_GUARD_M, + }: + raise ValueError("legacy scene has an unsupported teleport guard") + + +@dataclass(frozen=True) +class AI2ThorObservation: + """Immutable, same-event frames and scene state returned by AI2-THOR.""" + + scene: Scene + rgb_png: bytes + depth_npy: bytes + instance_png: bytes + pointcloud_ply: bytes + rgb_png_sha256: str + depth_npy_sha256: str + instance_png_sha256: str + pointcloud_ply_sha256: str + instance_pixel_counts: Mapping[str, int] + is_scene_at_rest: bool + + @classmethod + def create( + cls, + *, + scene: Scene, + rgb_png: bytes, + depth_npy: bytes, + instance_png: bytes, + pointcloud_ply: bytes, + instance_pixel_counts: Mapping[str, int], + is_scene_at_rest: bool, + ) -> AI2ThorObservation: + return cls( + scene=scene, + rgb_png=rgb_png, + depth_npy=depth_npy, + instance_png=instance_png, + pointcloud_ply=pointcloud_ply, + rgb_png_sha256=sha256(rgb_png).hexdigest(), + depth_npy_sha256=sha256(depth_npy).hexdigest(), + instance_png_sha256=sha256(instance_png).hexdigest(), + pointcloud_ply_sha256=sha256(pointcloud_ply).hexdigest(), + instance_pixel_counts=MappingProxyType( + dict(sorted(instance_pixel_counts.items())) + ), + is_scene_at_rest=is_scene_at_rest, + ) + + +@dataclass(frozen=True) +class AI2ThorCameraApplication: + """A requested camera pose and its immutable same-event observation.""" + + requested_pose: AI2ThorAgentPose + observed_pose: AI2ThorAgentPose + observed_camera_position: AI2ThorNativePosition + observed_scene: Scene + observation: AI2ThorObservation + position_residual_m: float + yaw_residual_degrees: float + horizon_residual_degrees: float + + +@dataclass(frozen=True) +class AI2ThorSettledCameraApplication: + """One post-unpause camera application plus its final settlement count.""" + + application: AI2ThorCameraApplication + settlement_pass_steps: int + + def __post_init__(self) -> None: + if type(self.application) is not AI2ThorCameraApplication: + raise TypeError("settled camera application must be exact") + if type(self.settlement_pass_steps) is not int: + raise TypeError("settled camera pass count must be an exact integer") + if self.settlement_pass_steps < 0: + raise ValueError("settled camera pass count must be non-negative") + + +@dataclass(frozen=True) +class AI2ThorSceneSettlement: + """A fully still source baseline captured from one final native event.""" + + observed_scene: Scene + observation: AI2ThorObservation + pass_steps: int + + +@dataclass(frozen=True) +class AI2ThorIsolatedEpisode: + """One fresh controller plus its immutable same-event source baseline.""" + + adapter: AI2ThorAdapter # noqa: F821 + baseline_settlement: AI2ThorSceneSettlement + + +@dataclass(frozen=True) +class AI2ThorPoseApplication: + """Keep the commanded canonical state separate from native observation.""" + + commanded_scene: Scene + observed_scene: Scene + commanded_position: Vec3 + observed_position: Vec3 + position_residual_m: float + observation: AI2ThorObservation + is_scene_at_rest: bool + subject_is_moving: bool + + +@dataclass(frozen=True) +class AI2ThorFloorEnvelope: + """Conservative convex floor evidence derived from one native floor AABB.""" + + scene_id: str + floor_object_id: str + floor_name: str + native_aabb: OBB + floor_top_z: float + clearance_m: float + polygon_xy: tuple[Vec2, ...] + + +@dataclass(frozen=True) +class AI2ThorNativeFeasibilityMap: + """Conservative native collision envelopes for one exact source event.""" + + scene_id: str + subject_object_id: str + clearance_m: float + obstacles: tuple[CollisionObstacle, ...] + + def __post_init__(self) -> None: + _nonempty_text(self.scene_id, "feasibility scene_id") + _nonempty_text(self.subject_object_id, "feasibility subject_object_id") + if ( + type(self.clearance_m) not in (int, float) + or not math.isfinite(float(self.clearance_m)) + or float(self.clearance_m) <= 0.0 + ): + raise ValueError("collision clearance must be finite and positive") + object.__setattr__(self, "clearance_m", float(self.clearance_m)) + if type(self.obstacles) is not tuple or any( + type(item) is not CollisionObstacle for item in self.obstacles + ): + raise ValueError("feasibility obstacles must be a CollisionObstacle tuple") + obstacle_ids = tuple(item.obstacle_id for item in self.obstacles) + source_ids = tuple(item.source_object_id for item in self.obstacles) + if len(set(obstacle_ids)) != len(obstacle_ids): + raise ValueError("feasibility obstacle IDs must be unique") + if len(set(source_ids)) != len(source_ids): + raise ValueError("feasibility source object IDs must be unique") + if any(item.clearance_m != self.clearance_m for item in self.obstacles): + raise ValueError("feasibility obstacle clearance mismatch") + + +@dataclass(frozen=True) +class AI2ThorReceptacleSurfacePatch: + """One complete native 21-by-21 receptacle trigger grid.""" + + x_min: float + x_max: float + native_y: float + z_min: float + z_max: float + + def __post_init__(self) -> None: + values = (self.x_min, self.x_max, self.native_y, self.z_min, self.z_max) + if any( + type(value) not in (int, float) or not math.isfinite(float(value)) + for value in values + ): + raise ValueError("receptacle surface patch values must be finite") + for name, value in zip( + ("x_min", "x_max", "native_y", "z_min", "z_max"), values + ): + object.__setattr__(self, name, float(value)) + if self.x_min >= self.x_max or self.z_min >= self.z_max: + raise ValueError("receptacle surface patch must have positive area") + + +@dataclass(frozen=True) +class AI2ThorReceptacleSpawnMap: + """Source-bound receptacle coordinates without a feasibility claim. + + AI2-THOR's ``GetSpawnCoordinatesAboveReceptacle`` action describes native + receptacle coordinates. It does not prove that a particular subject fits + at every returned coordinate, so this value deliberately avoids the word + ``feasible`` and remains adapter evidence rather than a solver domain. + """ + + scene_id: str + subject_object_id: str + support_object_id: str + native_subject_object_id: str + native_support_object_id: str + runtime_identity: AI2ThorRuntimeIdentity + positions: tuple[AI2ThorNativePosition, ...] + positions_sha256: str + scene_sha256: str + source_sha256: str + surface_patches: tuple[AI2ThorReceptacleSurfacePatch, ...] = () + + def __post_init__(self) -> None: + for field_name in ( + "scene_id", + "subject_object_id", + "support_object_id", + "native_subject_object_id", + "native_support_object_id", + ): + _nonempty_text(getattr(self, field_name), field_name) + if self.subject_object_id == self.support_object_id: + raise ValueError("receptacle spawn subject and support must differ") + if type(self.runtime_identity) is not AI2ThorRuntimeIdentity: + raise ValueError("receptacle spawn runtime identity has invalid type") + checked_runtime = AI2ThorRuntimeIdentity(**asdict(self.runtime_identity)) + object.__setattr__(self, "runtime_identity", checked_runtime) + if type(self.surface_patches) is not tuple or any( + type(item) is not AI2ThorReceptacleSurfacePatch + for item in self.surface_patches + ): + raise ValueError("receptacle surface patches must be an exact tuple") + checked_patches = tuple( + AI2ThorReceptacleSurfacePatch(**asdict(item)) + for item in self.surface_patches + ) + if checked_patches != tuple( + sorted( + set(checked_patches), + key=lambda item: ( + item.native_y, + item.x_min, + item.z_min, + item.x_max, + item.z_max, + ), + ) + ): + raise ValueError("receptacle surface patches must be unique and canonical") + object.__setattr__(self, "surface_patches", checked_patches) + if type(self.positions) is not tuple or not self.positions: + raise ValueError("receptacle spawn positions must be a non-empty tuple") + if any(type(item) is not AI2ThorNativePosition for item in self.positions): + raise ValueError("receptacle spawn positions must be native positions") + keys = tuple((item.x, item.z, item.y) for item in self.positions) + if keys != tuple(sorted(keys)) or len(keys) != len(set(keys)): + raise ValueError("receptacle spawn positions must be unique and sorted") + for field_name in ("positions_sha256", "scene_sha256", "source_sha256"): + digest = getattr(self, field_name) + if ( + type(digest) is not str + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"{field_name} must be lowercase SHA-256 hex") + if self.positions_sha256 != _native_positions_sha256(self.positions): + raise ValueError("receptacle spawn positions digest mismatch") + if self.surface_patches: + expected_positions = tuple( + sorted( + ( + patch.x_min + (patch.x_max - patch.x_min) * x_index / 20.0, + patch.z_min + (patch.z_max - patch.z_min) * z_index / 20.0, + patch.native_y, + ) + for patch in self.surface_patches + for x_index in range(_RECEPTACLE_TRIGGER_GRID_SIDE) + for z_index in range(_RECEPTACLE_TRIGGER_GRID_SIDE) + ) + ) + actual_positions = tuple( + (item.x, item.z, item.y) for item in self.positions + ) + tolerance = 2.0 * _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M + expected_count = len(self.surface_patches) * _RECEPTACLE_TRIGGER_GRID_SIZE + if ( + len(expected_positions) != expected_count + or len(actual_positions) != expected_count + or any( + abs(expected - actual) > tolerance + for expected_position, actual_position in zip( + expected_positions, + actual_positions, + strict=True, + ) + for expected, actual in zip( + expected_position, + actual_position, + strict=True, + ) + ) + ): + raise ValueError( + "receptacle surface patches do not close the position grid" + ) + if self.source_sha256 != _receptacle_spawn_source_sha256( + scene_id=self.scene_id, + subject_object_id=self.subject_object_id, + support_object_id=self.support_object_id, + native_subject_object_id=self.native_subject_object_id, + native_support_object_id=self.native_support_object_id, + runtime_identity=self.runtime_identity, + positions_sha256=self.positions_sha256, + scene_sha256=self.scene_sha256, + surface_patches=self.surface_patches, + ): + raise ValueError("receptacle spawn source digest mismatch") + + +@dataclass(frozen=True) +class AI2ThorNavigationFeasibilityMap: + """Source-bound conservative position region from one native nav grid.""" + + scene_id: str + subject_object_id: str + agent_radius_m: float + clearance_m: float + reachable_positions: tuple[AI2ThorNativePosition, ...] + reachable_positions_sha256: str + source_sha256: str + position_region: SubjectPositionRegion + + def __post_init__(self) -> None: + _nonempty_text(self.scene_id, "navigation scene_id") + _nonempty_text(self.subject_object_id, "navigation subject_object_id") + radius = _strict_finite_float(self.agent_radius_m, "navigation agent radius") + clearance = _strict_finite_float( + self.clearance_m, + "navigation clearance", + ) + if radius <= 0.0 or clearance < 0.0 or clearance >= radius: + raise ValueError( + "navigation agent radius must be positive and clearance smaller" + ) + object.__setattr__(self, "agent_radius_m", radius) + object.__setattr__(self, "clearance_m", clearance) + if type(self.reachable_positions) is not tuple or not self.reachable_positions: + raise ValueError("navigation reachable positions must be a non-empty tuple") + if any( + type(position) is not AI2ThorNativePosition + for position in self.reachable_positions + ): + raise ValueError("navigation positions must be native positions") + keys = tuple( + (position.x, position.z, position.y) + for position in self.reachable_positions + ) + if keys != tuple(sorted(keys)) or len(set(keys)) != len(keys): + raise ValueError("navigation positions must be unique and sorted") + for name in ("reachable_positions_sha256", "source_sha256"): + digest = getattr(self, name) + if ( + type(digest) is not str + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError(f"{name} must be lowercase SHA-256 hex") + if type(self.position_region) is not SubjectPositionRegion: + raise ValueError("navigation position region has invalid type") + if ( + self.position_region.subject_object_id != self.subject_object_id + or self.position_region.source_sha256 != self.source_sha256 + ): + raise ValueError("navigation position region identity mismatch") + + +def adapter_position_from_native(value: AI2ThorNativePosition) -> AdapterPosition: + if type(value) is not AI2ThorNativePosition: + raise TypeError("native position must be exact") + return AdapterPosition(x=value.x, y=value.y, z=value.z) + + +def native_position_from_adapter(value: AdapterPosition) -> AI2ThorNativePosition: + if type(value) is not AdapterPosition: + raise TypeError("adapter position must be exact") + return AI2ThorNativePosition(x=value.x, y=value.y, z=value.z) + + +def adapter_pose_from_native(value: AI2ThorAgentPose) -> AdapterPose: + if type(value) is not AI2ThorAgentPose: + raise TypeError("native pose must be exact") + return AdapterPose( + position=adapter_position_from_native(value.position), + yaw_degrees=value.yaw_degrees, + horizon_degrees=value.horizon_degrees, + standing=value.standing, + ) + + +def native_pose_from_adapter(value: AdapterPose) -> AI2ThorAgentPose: + if type(value) is not AdapterPose: + raise TypeError("adapter pose must be exact") + return AI2ThorAgentPose( + position=native_position_from_adapter(value.position), + yaw_degrees=value.yaw_degrees, + horizon_degrees=value.horizon_degrees, + standing=value.standing, + ) + + +def adapter_runtime_identity_from_native( + value: AI2ThorRuntimeIdentity, +) -> AdapterRuntimeIdentity: + if type(value) is not AI2ThorRuntimeIdentity: + raise TypeError("native runtime identity must be exact") + return AdapterRuntimeIdentity(**asdict(value)) + + +def adapter_observation_from_native(value: AI2ThorObservation) -> AdapterObservation: + if type(value) is not AI2ThorObservation: + raise TypeError("native observation must be exact") + return AdapterObservation.create( + scene=value.scene, + rgb_png=value.rgb_png, + depth_npy=value.depth_npy, + instance_png=value.instance_png, + pointcloud_ply=value.pointcloud_ply, + instance_pixel_counts=tuple(sorted(value.instance_pixel_counts.items())), + is_settled=value.is_scene_at_rest, + ) + + +def adapter_support_fact_from_native( + value: AI2ThorNativeSupportFact, +) -> AdapterSupportFact: + if type(value) is not AI2ThorNativeSupportFact: + raise TypeError("native support fact must be exact") + return AdapterSupportFact( + scene_id=value.scene_id, + object_id=value.object_id, + object_name=value.object_name, + native_object_id=value.native_object_id, + raw_parent_object_ids=value.raw_parent_object_ids, + structural_parent_object_ids=value.structural_parent_object_ids, + domain_parent_object_ids=value.domain_parent_object_ids, + support_kind=value.support_kind.value, + support_object_id=value.support_object_id, + floor_object_id=value.floor_object_id, + ) + + +def adapter_floor_envelope_from_native( + value: AI2ThorFloorEnvelope, +) -> AdapterFloorEnvelope: + if type(value) is not AI2ThorFloorEnvelope: + raise TypeError("native floor envelope must be exact") + return AdapterFloorEnvelope( + scene_id=value.scene_id, + floor_object_id=value.floor_object_id, + floor_name=value.floor_name, + native_aabb=value.native_aabb, + floor_top_z=value.floor_top_z, + clearance_m=value.clearance_m, + polygon_xy=value.polygon_xy, + ) + + +def adapter_spawn_map_from_native( + value: AI2ThorReceptacleSpawnMap, + *, + binding: AdapterBinding, + position_region: SubjectPositionRegion | None, +) -> AdapterSpawnMap: + if type(value) is not AI2ThorReceptacleSpawnMap: + raise TypeError("native spawn map must be exact") + if type(binding) is not AdapterBinding: + raise TypeError("spawn binding must be exact") + return AdapterSpawnMap( + binding=binding, + runtime_identity=adapter_runtime_identity_from_native(value.runtime_identity), + scene_id=value.scene_id, + subject_object_id=value.subject_object_id, + support_object_id=value.support_object_id, + native_subject_object_id=value.native_subject_object_id, + native_support_object_id=value.native_support_object_id, + positions=tuple(adapter_position_from_native(item) for item in value.positions), + positions_sha256=value.positions_sha256, + scene_sha256=value.scene_sha256, + source_sha256=value.source_sha256, + surface_patches=tuple( + AdapterSurfacePatch( + x_min=item.x_min, + x_max=item.x_max, + native_y=item.native_y, + z_min=item.z_min, + z_max=item.z_max, + ) + for item in value.surface_patches + ), + position_region=position_region, + ) + + +def ai2thor_spawn_map_from_adapter(value: AdapterSpawnMap) -> AI2ThorReceptacleSpawnMap: + """Reconstruct one concrete map only inside the AI2-THOR boundary.""" + if type(value) is not AdapterSpawnMap: + raise TypeError("adapter spawn map must be exact") + return AI2ThorReceptacleSpawnMap( + scene_id=value.scene_id, + subject_object_id=value.subject_object_id, + support_object_id=value.support_object_id, + native_subject_object_id=value.native_subject_object_id, + native_support_object_id=value.native_support_object_id, + runtime_identity=AI2ThorRuntimeIdentity(**asdict(value.runtime_identity)), + positions=tuple( + AI2ThorNativePosition(x=item.x, y=item.y, z=item.z) + for item in value.positions + ), + positions_sha256=value.positions_sha256, + scene_sha256=value.scene_sha256, + source_sha256=value.source_sha256, + surface_patches=tuple( + AI2ThorReceptacleSurfacePatch( + x_min=item.x_min, + x_max=item.x_max, + native_y=item.native_y, + z_min=item.z_min, + z_max=item.z_max, + ) + for item in value.surface_patches + ), + ) + + +def applied_certified_edit_from_native( + value: AI2ThorPoseApplication, + *, + application: CertifiedEditApplication, +) -> AppliedCertifiedEdit: + """Erase native event types from a single acknowledged placement.""" + if type(value) is not AI2ThorPoseApplication: + raise TypeError("native applied pose must be exact") + if type(application) is not CertifiedEditApplication: + raise TypeError("certified application must be exact") + return AppliedCertifiedEdit( + application=application, + edit=application.edit, + commanded_scene=value.commanded_scene, + observed_scene=value.observed_scene, + commanded_position=AdapterPosition( + x=value.commanded_position.x, + y=value.commanded_position.y, + z=value.commanded_position.z, + ), + observed_position=AdapterPosition( + x=value.observed_position.x, + y=value.observed_position.y, + z=value.observed_position.z, + ), + position_residual_m=value.position_residual_m, + observation=adapter_observation_from_native(value.observation), + is_scene_at_rest=value.is_scene_at_rest, + subject_is_moving=value.subject_is_moving, + settlement_pass_steps=0, + binding=application.source.binding, + ) + + +def settled_readback_from_native( + value: AI2ThorSceneSettlement, + *, + applied: AppliedCertifiedEdit, +) -> SettledReadback: + """Erase concrete settlement state after exactly one bounded readback.""" + if type(value) is not AI2ThorSceneSettlement: + raise TypeError("native settlement must be exact") + if type(applied) is not AppliedCertifiedEdit: + raise TypeError("applied certified edit must be exact") + observed = value.observed_scene.object_by_id(applied.edit.subject_id).position + observed_position = AdapterPosition( + x=observed.x, + y=observed.y, + z=observed.z, + ) + return SettledReadback( + applied=applied, + commanded_scene=applied.commanded_scene, + observed_scene=value.observed_scene, + commanded_position=applied.commanded_position, + observed_position=observed_position, + position_residual_m=math.dist( + ( + applied.commanded_position.x, + applied.commanded_position.y, + applied.commanded_position.z, + ), + ( + observed_position.x, + observed_position.y, + observed_position.z, + ), + ), + observation=adapter_observation_from_native(value.observation), + is_scene_at_rest=value.observation.is_scene_at_rest, + subject_is_moving=False, + settlement_pass_steps=value.pass_steps, + binding=applied.binding, + ) + + +def adapter_camera_application_from_native( + value: AI2ThorCameraApplication, + *, + source: CapturedSource, +) -> AdapterCameraApplication: + if type(value) is not AI2ThorCameraApplication: + raise TypeError("native camera application must be exact") + if type(source) is not CapturedSource: + raise TypeError("camera application source must be exact") + return AdapterCameraApplication( + source=source, + binding=source.binding, + requested_pose=adapter_pose_from_native(value.requested_pose), + observed_pose=adapter_pose_from_native(value.observed_pose), + observed_camera_position=adapter_position_from_native( + value.observed_camera_position + ), + observed_scene=value.observed_scene, + observation=adapter_observation_from_native(value.observation), + position_residual_m=value.position_residual_m, + yaw_residual_degrees=value.yaw_residual_degrees, + horizon_residual_degrees=value.horizon_residual_degrees, + ) diff --git a/src/spatialcf/adapters/ai2thor/support.py b/src/spatialcf/adapters/ai2thor/support.py new file mode 100644 index 0000000..8e9f2ae --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/support.py @@ -0,0 +1,1202 @@ +"""Native support evidence owners for the staged AI2-THOR adapter split.""" + +from __future__ import annotations + +import json +import math +from dataclasses import asdict +from hashlib import sha256 +from typing import Any + +from spatialcf.adapters.ai2thor.models import ( + _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M, + _RECEPTACLE_TRIGGER_GRID_SIDE, + _RECEPTACLE_TRIGGER_GRID_SIZE, + AI2ThorFloorEnvelope, + AI2ThorNativeFeasibilityMap, + AI2ThorNativePosition, + AI2ThorNativeReturnError, + AI2ThorNativeSupportFact, + AI2ThorNativeSupportKind, + AI2ThorNavigationFeasibilityMap, + AI2ThorReceptacleSpawnMap, + AI2ThorReceptacleSurfacePatch, + AI2ThorRuntimeIdentity, + _canonical_json_sha256, + _native_positions_sha256, + _receptacle_spawn_source_sha256, + adapter_spawn_map_from_native, +) +from spatialcf.adapters.base import ( + AdapterOperationError, + AdapterSpawnMap, + SourceCaptureFacts, +) +from spatialcf.domain.scene import ( + OBB, + CollisionObstacle, + Quaternion, + Scene, + SubjectPositionRegion, + Vec2, + Vec3, +) +from spatialcf.domain.serialization import canonical_json_bytes +from spatialcf.geometry.regions import ( + conservative_navigation_position_geometry, + conservative_receptacle_position_geometry, + planar_polygon_payloads, +) +from spatialcf.geometry.transforms import ai2thor_position_to_world + +_REACHABLE_POSITION_QUANTIZATION_M = 1e-6 +_STRUCTURAL_OBJECT_TYPES = frozenset({"Ceiling", "Floor", "Wall"}) + + +def canonicalize_ai2thor_reachable_positions( + positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorNativePosition, ...]: + """Return stable 1-micrometre identities for one native navigation grid.""" + + if ( + type(positions) is not tuple + or not positions + or any(type(item) is not AI2ThorNativePosition for item in positions) + ): + raise TypeError("reachable positions must be a non-empty exact tuple") + keyed: list[tuple[tuple[int, int, int], AI2ThorNativePosition]] = [] + seen: set[tuple[int, int, int]] = set() + for position in positions: + key = tuple( + round(value / _REACHABLE_POSITION_QUANTIZATION_M) + for value in (position.x, position.z, position.y) + ) + if key in seen: + raise ValueError("reachable positions contain a duplicate canonical point") + seen.add(key) + keyed.append( + ( + key, + AI2ThorNativePosition( + x=round(key[0] * _REACHABLE_POSITION_QUANTIZATION_M, 6), + y=round(key[2] * _REACHABLE_POSITION_QUANTIZATION_M, 6), + z=round(key[1] * _REACHABLE_POSITION_QUANTIZATION_M, 6), + ), + ) + ) + keyed.sort(key=lambda item: item[0]) + return tuple(position for _, position in keyed) + + +def bind_ai2thor_reachable_positions( + reference_positions: tuple[AI2ThorNativePosition, ...], + observed_positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorNativePosition, ...]: + """Bind one noisy replay to a frozen native navigation-grid roster.""" + + frozen = canonicalize_ai2thor_reachable_positions(reference_positions) + if ( + type(observed_positions) is not tuple + or not observed_positions + or any(type(item) is not AI2ThorNativePosition for item in observed_positions) + ): + raise TypeError("observed reachable positions must be a non-empty exact tuple") + if len(observed_positions) != len(reference_positions): + raise ValueError("reachable position roster changed") + maximum_coordinate_ulp_m = max( + math.ulp(value) + for position in (*reference_positions, *observed_positions) + for value in (position.x, position.y, position.z) + ) + maximum_tolerance_m = _REACHABLE_POSITION_QUANTIZATION_M + 4.0 * max( + maximum_coordinate_ulp_m, + math.ulp(_REACHABLE_POSITION_QUANTIZATION_M), + ) + bucket_span = math.ceil(maximum_tolerance_m / _REACHABLE_POSITION_QUANTIZATION_M) + + def bucket(position: AI2ThorNativePosition) -> tuple[int, int, int]: + return tuple( + math.floor(value / _REACHABLE_POSITION_QUANTIZATION_M) + for value in (position.x, position.y, position.z) + ) + + reference_buckets: dict[tuple[int, int, int], list[int]] = {} + for index, reference in enumerate(reference_positions): + reference_buckets.setdefault(bucket(reference), []).append(index) + + matched: set[int] = set() + for observed in observed_positions: + center = bucket(observed) + candidates: list[int] = [] + if bucket_span <= 8: + for x_offset in range(-bucket_span, bucket_span + 1): + for y_offset in range(-bucket_span, bucket_span + 1): + for z_offset in range(-bucket_span, bucket_span + 1): + candidates.extend( + reference_buckets.get( + ( + center[0] + x_offset, + center[1] + y_offset, + center[2] + z_offset, + ), + (), + ) + ) + else: + candidates.extend(range(len(reference_positions))) + within_tolerance = tuple( + index + for index in candidates + if math.dist( + ( + reference_positions[index].x, + reference_positions[index].y, + reference_positions[index].z, + ), + (observed.x, observed.y, observed.z), + ) + <= _REACHABLE_POSITION_QUANTIZATION_M + + 4.0 + * max( + *( + math.ulp(value) + for value in ( + reference_positions[index].x, + reference_positions[index].y, + reference_positions[index].z, + observed.x, + observed.y, + observed.z, + ) + ), + math.ulp(_REACHABLE_POSITION_QUANTIZATION_M), + ) + ) + if len(within_tolerance) != 1 or within_tolerance[0] in matched: + raise ValueError("reachable position roster changed") + matched.add(within_tolerance[0]) + if len(matched) != len(reference_positions): + raise ValueError("reachable position roster changed") + return frozen + + +def _grid_axis( + values: tuple[float, ...], +) -> tuple[tuple[int, ...], dict[int, tuple[float, ...]]] | None: + grouped: dict[int, list[float]] = {} + for value in values: + key = round(value / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M) + grouped.setdefault(key, []).append(value) + keys = tuple(sorted(grouped)) + if len(keys) != _RECEPTACLE_TRIGGER_GRID_SIDE: + return None + actual_min = min(values) + actual_max = max(values) + if actual_min >= actual_max: + return None + tolerance = 2.0 * _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M + for index, key in enumerate(keys): + expected = actual_min + (actual_max - actual_min) * index / 20.0 + if any(abs(value - expected) > tolerance for value in grouped[key]): + return None + return keys, {key: tuple(grouped[key]) for key in keys} + + +def build_ai2thor_receptacle_surface_patches( + raw_positions: tuple[AI2ThorNativePosition, ...], +) -> tuple[AI2ThorReceptacleSurfacePatch, ...]: + """Fail closed unless every raw contiguous block is one complete grid.""" + + if ( + type(raw_positions) is not tuple + or not raw_positions + or len(raw_positions) % _RECEPTACLE_TRIGGER_GRID_SIZE + or any(type(item) is not AI2ThorNativePosition for item in raw_positions) + ): + return () + patches: list[AI2ThorReceptacleSurfacePatch] = [] + for start in range(0, len(raw_positions), _RECEPTACLE_TRIGGER_GRID_SIZE): + block = raw_positions[start : start + _RECEPTACLE_TRIGGER_GRID_SIZE] + y_keys = { + round(item.y / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M) for item in block + } + x_axis = _grid_axis(tuple(item.x for item in block)) + z_axis = _grid_axis(tuple(item.z for item in block)) + if len(y_keys) != 1 or x_axis is None or z_axis is None: + return () + x_keys, _ = x_axis + z_keys, _ = z_axis + cells = tuple( + ( + round(item.x / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M), + round(item.z / _RECEPTACLE_TRIGGER_GRID_QUANTIZATION_M), + ) + for item in block + ) + expected_cells = {(x_key, z_key) for x_key in x_keys for z_key in z_keys} + if ( + len(set(cells)) != _RECEPTACLE_TRIGGER_GRID_SIZE + or set(cells) != expected_cells + ): + return () + patches.append( + AI2ThorReceptacleSurfacePatch( + x_min=min(item.x for item in block), + x_max=max(item.x for item in block), + native_y=(min(item.y for item in block) + max(item.y for item in block)) + / 2.0, + z_min=min(item.z for item in block), + z_max=max(item.z for item in block), + ) + ) + return tuple( + sorted( + patches, + key=lambda item: ( + item.native_y, + item.x_min, + item.z_min, + item.x_max, + item.z_max, + ), + ) + ) + + +def _canonical_scene_sha256(scene: Scene) -> str: + """Hash source facts independently of unordered roster presentation.""" + + if type(scene) is not Scene: + raise TypeError("canonical scene digest requires an exact Scene") + normalized = scene.model_copy( + update={ + "cameras": tuple(sorted(scene.cameras, key=lambda item: item.camera_id)), + "objects": tuple(sorted(scene.objects, key=lambda item: item.object_id)), + "collision_obstacles": tuple( + sorted(scene.collision_obstacles, key=lambda item: item.obstacle_id) + ), + "subject_position_regions": tuple( + sorted( + scene.subject_position_regions, + key=lambda item: item.region_id, + ) + ), + } + ) + payload = normalized.model_dump(mode="python", warnings="error") + payload["pinned_object_ids"] = tuple(sorted(scene.pinned_object_ids)) + return sha256(canonical_json_bytes(payload)).hexdigest() + + +def _receptacle_scene_sha256( + scene: Scene, + surface_patches: tuple[AI2ThorReceptacleSurfacePatch, ...], +) -> str: + """Preserve the legacy digest unless the new grid contract is active.""" + + if surface_patches: + return _canonical_scene_sha256(scene) + return _canonical_json_sha256(scene.model_dump(mode="json", warnings="error")) + + +def _strict_native_position( + value: object, + label: str, +) -> AI2ThorNativePosition: + if type(value) is not AI2ThorNativePosition: + raise TypeError(f"{label} must be an exact AI2ThorNativePosition") + return AI2ThorNativePosition(x=value.x, y=value.y, z=value.z) + + +def _strict_receptacle_spawn_map( + value: object, +) -> AI2ThorReceptacleSpawnMap: + if type(value) is not AI2ThorReceptacleSpawnMap: + raise TypeError("spawn_map must be an exact AI2ThorReceptacleSpawnMap") + if type(value.positions) is not tuple: + raise TypeError("spawn map positions must be an exact tuple") + positions = tuple( + _strict_native_position(item, "spawn map position") for item in value.positions + ) + if type(value.runtime_identity) is not AI2ThorRuntimeIdentity: + raise TypeError("spawn map runtime identity has invalid type") + return AI2ThorReceptacleSpawnMap( + scene_id=value.scene_id, + subject_object_id=value.subject_object_id, + support_object_id=value.support_object_id, + native_subject_object_id=value.native_subject_object_id, + native_support_object_id=value.native_support_object_id, + runtime_identity=AI2ThorRuntimeIdentity(**asdict(value.runtime_identity)), + positions=positions, + positions_sha256=value.positions_sha256, + scene_sha256=value.scene_sha256, + source_sha256=value.source_sha256, + surface_patches=value.surface_patches, + ) + + +def capture_bound_ai2thor_receptacle_spawn_map( + spawn_map: AI2ThorReceptacleSpawnMap, + *, + fresh_scene: Scene, + frozen_scene: Scene, +) -> AI2ThorReceptacleSpawnMap: + """Rebind one exact fresh native query to a validated frozen scene digest. + + Only the scene digest is substituted. Runtime identity, native IDs, exact + native positions and trigger-grid patches all remain those returned by the + fresh query, so drift in any native evidence still changes the source + digest and fails its upstream lineage comparison. + """ + + checked = _strict_receptacle_spawn_map(spawn_map) + if type(fresh_scene) is not Scene or type(frozen_scene) is not Scene: + raise TypeError("capture-bound spawn scenes must be exact Scene values") + expected_fresh_scene_sha256 = _receptacle_scene_sha256( + fresh_scene, + checked.surface_patches, + ) + if ( + checked.scene_id != fresh_scene.scene_id + or checked.scene_sha256 != expected_fresh_scene_sha256 + or frozen_scene.scene_id != fresh_scene.scene_id + ): + raise ValueError("capture-bound spawn map does not bind the fresh scene") + frozen_scene_sha256 = _receptacle_scene_sha256( + frozen_scene, + checked.surface_patches, + ) + source_sha256 = _receptacle_spawn_source_sha256( + scene_id=checked.scene_id, + subject_object_id=checked.subject_object_id, + support_object_id=checked.support_object_id, + native_subject_object_id=checked.native_subject_object_id, + native_support_object_id=checked.native_support_object_id, + runtime_identity=checked.runtime_identity, + positions_sha256=checked.positions_sha256, + scene_sha256=frozen_scene_sha256, + surface_patches=checked.surface_patches, + ) + return AI2ThorReceptacleSpawnMap( + scene_id=checked.scene_id, + subject_object_id=checked.subject_object_id, + support_object_id=checked.support_object_id, + native_subject_object_id=checked.native_subject_object_id, + native_support_object_id=checked.native_support_object_id, + runtime_identity=checked.runtime_identity, + positions=checked.positions, + positions_sha256=checked.positions_sha256, + scene_sha256=frozen_scene_sha256, + source_sha256=source_sha256, + surface_patches=checked.surface_patches, + ) + + +def build_receptacle_support_position_region( + scene: Scene, + spawn_map: AI2ThorReceptacleSpawnMap, +) -> SubjectPositionRegion: + """Build the fixed-pose subject-anchor locus from trigger-grid evidence.""" + + checked = _strict_receptacle_spawn_map(spawn_map) + subject = scene.object_by_id(checked.subject_object_id) + scene_sha256 = _receptacle_scene_sha256(scene, checked.surface_patches) + if ( + checked.scene_id != scene.scene_id + or checked.scene_sha256 != scene_sha256 + or subject.support_object_id != checked.support_object_id + or not checked.surface_patches + ): + raise ValueError("receptacle surface patches do not bind the scene subject") + geometry = conservative_receptacle_position_geometry( + surface_patch_bounds_xy=tuple( + (item.x_min, item.z_min, item.x_max, item.z_max) + for item in checked.surface_patches + ), + subject=subject, + ) + return SubjectPositionRegion( + region_id=( + f"native-receptacle-trigger-grid:{subject.object_id}:" + f"{checked.source_sha256[:16]}" + ), + subject_object_id=subject.object_id, + source_kind="ai2thor-receptacle-trigger-grid-v1", + source_sha256=checked.source_sha256, + components=planar_polygon_payloads(geometry), + ) + + +def build_navigation_feasibility_map( + scene: Scene, + *, + subject_object_id: str, + room_polygon_xy: tuple[Vec2, ...], + reachable_positions: tuple[AI2ThorNativePosition, ...], + agent_radius_m: float, + clearance_m: float, +) -> AI2ThorNavigationFeasibilityMap: + """Deterministically bind native navigation evidence to one Scene.""" + if ( + type(agent_radius_m) not in (int, float) + or not math.isfinite(float(agent_radius_m)) + or float(agent_radius_m) <= 0.0 + or type(clearance_m) not in (int, float) + or not math.isfinite(float(clearance_m)) + or float(clearance_m) < 0.0 + or float(clearance_m) >= float(agent_radius_m) + ): + raise ValueError( + "navigation radius must be positive and clearance non-negative " + "and smaller than the radius" + ) + subject = scene.object_by_id(subject_object_id) + positions_payload = [ + {"x": item.x, "y": item.y, "z": item.z} for item in reachable_positions + ] + positions_bytes = json.dumps( + positions_payload, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + positions_digest = sha256(positions_bytes).hexdigest() + source_payload = { + "agent_radius_m": float(agent_radius_m), + "clearance_m": float(clearance_m), + "method": "ai2thor-navigation-v1", + "reachable_positions_sha256": positions_digest, + "room_polygon_xy": [{"x": point.x, "y": point.y} for point in room_polygon_xy], + "scene_id": scene.scene_id, + "subject_object_id": subject.object_id, + "subject_obb": subject.obb.model_dump(mode="json"), + "subject_position": subject.position.model_dump(mode="json"), + } + source_digest = sha256( + json.dumps( + source_payload, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + ).hexdigest() + geometry = conservative_navigation_position_geometry( + room_polygon_xy=room_polygon_xy, + reachable_positions_xy=tuple( + Vec2(x=position.x, y=position.z) for position in reachable_positions + ), + subject=subject, + agent_radius_m=float(agent_radius_m), + clearance_m=float(clearance_m), + ) + position_region = SubjectPositionRegion( + region_id=f"native-navigation:{subject.object_id}:{source_digest[:16]}", + subject_object_id=subject.object_id, + source_kind="ai2thor-navigation-v1", + source_sha256=source_digest, + components=planar_polygon_payloads(geometry), + ) + return AI2ThorNavigationFeasibilityMap( + scene_id=scene.scene_id, + subject_object_id=subject.object_id, + agent_radius_m=float(agent_radius_m), + clearance_m=float(clearance_m), + reachable_positions=reachable_positions, + reachable_positions_sha256=positions_digest, + source_sha256=source_digest, + position_region=position_region, + ) + + +def _domain_object_metadata(raw_objects: list[Any]) -> list[Any]: + return [ + item + for item in raw_objects + if ( + not isinstance(item, dict) + or item.get("objectType") not in _STRUCTURAL_OBJECT_TYPES + ) + ] + + +def _validated_native_object_metadata( + raw_objects: list[Any], +) -> list[dict[str, Any]]: + validated_objects: list[dict[str, Any]] = [] + object_ids: set[str] = set() + object_names: set[str] = set() + for item in raw_objects: + if type(item) is not dict: + raise AI2ThorNativeReturnError( + "AI2-THOR object collection entries must be exact dictionaries" + ) + object_id = item.get("objectId") + name = item.get("name") + category = item.get("objectType") + if any( + type(value) is not str or not value.strip() + for value in (object_id, name, category) + ): + raise AI2ThorNativeReturnError( + "AI2-THOR object ID, name, and type must be non-empty text" + ) + if object_id in object_ids or name in object_names: + raise AI2ThorNativeReturnError( + "AI2-THOR object IDs and names must be unique" + ) + object_ids.add(object_id) + object_names.add(name) + if any( + field in item and type(item[field]) is not bool + for field in ("moveable", "pickupable") + ): + raise AI2ThorNativeReturnError( + "AI2-THOR object mobility fields must be exact booleans" + ) + parents = item.get("parentReceptacles") + if parents is not None and ( + type(parents) is not list + or any(type(parent) is not str or not parent.strip() for parent in parents) + ): + raise AI2ThorNativeReturnError( + "AI2-THOR parent receptacles must be null or a list of " + "non-empty text IDs" + ) + validated_objects.append(item) + + for item in validated_objects: + for parent in item.get("parentReceptacles") or []: + if parent not in object_ids: + raise AI2ThorNativeReturnError( + f"observed support {parent!r} has no stable object identity" + ) + + return [ + { + **item, + "parentReceptacles": list(item.get("parentReceptacles") or []), + } + for item in validated_objects + ] + + +def _cyclic_domain_object_ids( + graph: dict[str, tuple[str, ...]], +) -> frozenset[str]: + """Return every member of every directed cycle, including self-loops.""" + + index = 0 + indices: dict[str, int] = {} + lowlinks: dict[str, int] = {} + stack: list[str] = [] + active: set[str] = set() + cyclic: set[str] = set() + + def visit(node: str) -> None: + nonlocal index + indices[node] = index + lowlinks[node] = index + index += 1 + stack.append(node) + active.add(node) + for parent in graph.get(node, ()): + if parent not in graph: + continue + if parent not in indices: + visit(parent) + lowlinks[node] = min(lowlinks[node], lowlinks[parent]) + elif parent in active: + lowlinks[node] = min(lowlinks[node], indices[parent]) + if lowlinks[node] != indices[node]: + return + component: list[str] = [] + while True: + member = stack.pop() + active.remove(member) + component.append(member) + if member == node: + break + if len(component) > 1 or node in graph.get(node, ()): + cyclic.update(component) + + for node in sorted(graph): + if node not in indices: + visit(node) + return frozenset(cyclic) + + +def build_ai2thor_native_support_facts( + scene: Scene, + raw_objects: list[object], +) -> tuple[AI2ThorNativeSupportFact, ...]: + """Validate and normalize native parent lineage without a platform action.""" + + if type(scene) is not Scene: + raise TypeError("native support scene must be an exact Scene") + if type(raw_objects) is not list: + raise TypeError("native support raw_objects must be an exact list") + validated = _validated_native_object_metadata(raw_objects) + raw_by_id = { + item["objectId"]: (item["name"], item["objectType"]) for item in validated + } + scene_ids = tuple(item.object_id for item in scene.objects) + scene_names = tuple(item.name for item in scene.objects) + if len(scene_ids) != len(set(scene_ids)) or len(scene_names) != len( + set(scene_names) + ): + raise AI2ThorNativeReturnError( + "stable scene object IDs and names must be unique" + ) + scene_by_name = {item.name: item for item in scene.objects} + raw_domain_names = { + item["name"] + for item in validated + if item["objectType"] not in _STRUCTURAL_OBJECT_TYPES + } + if set(scene_by_name) != raw_domain_names: + raise AI2ThorNativeReturnError( + "native and stable scene object name rosters must match" + ) + native_by_name = {item["name"]: item for item in validated} + + normalized: dict[ + str, + tuple[str, str, tuple[str, ...], tuple[str, ...], tuple[str, ...]], + ] = {} + for object_name, scene_object in scene_by_name.items(): + native = native_by_name[object_name] + raw_parents = tuple(sorted(set(native["parentReceptacles"]))) + structural: list[str] = [] + domain: list[str] = [] + normalized_raw: list[str] = [] + for raw_parent in raw_parents: + parent_name, parent_type = raw_by_id[raw_parent] + if parent_type in _STRUCTURAL_OBJECT_TYPES: + structural.append(raw_parent) + normalized_raw.append(raw_parent) + else: + try: + stable_parent = scene_by_name[parent_name].object_id + except KeyError as error: + raise AI2ThorNativeReturnError( + f"observed support {raw_parent!r} has no stable object identity" + ) from error + domain.append(stable_parent) + normalized_raw.append(stable_parent) + normalized[scene_object.object_id] = ( + object_name, + native["objectId"], + tuple(sorted(set(normalized_raw))), + tuple(sorted(set(structural))), + tuple(sorted(set(domain))), + ) + + graph = {object_id: values[4] for object_id, values in normalized.items()} + cyclic = _cyclic_domain_object_ids(graph) + facts: list[AI2ThorNativeSupportFact] = [] + for object_id in sorted(normalized): + object_name, native_object_id, raw, structural, domain = normalized[object_id] + floor_parents = tuple( + parent for parent in structural if raw_by_id[parent][1] == "Floor" + ) + plausible_count = len(domain) + len(floor_parents) + if object_id in cyclic: + kind = AI2ThorNativeSupportKind.CYCLIC + support_object_id = None + floor_object_id = None + elif plausible_count > 1: + kind = AI2ThorNativeSupportKind.MULTIPLE_AMBIGUOUS + support_object_id = None + floor_object_id = None + elif len(domain) == 1 and not structural: + kind = AI2ThorNativeSupportKind.RECEPTACLE + support_object_id = domain[0] + floor_object_id = None + elif len(floor_parents) == 1 and not domain and len(structural) == 1: + kind = AI2ThorNativeSupportKind.FLOOR + support_object_id = None + floor_object_id = floor_parents[0] + else: + kind = AI2ThorNativeSupportKind.UNKNOWN + support_object_id = None + floor_object_id = None + # Structural parents other than Floor are raw provenance, not usable + # support. Keep them out of the structural support partition so the + # UNKNOWN invariant remains explicit. + structural = () + facts.append( + AI2ThorNativeSupportFact( + scene_id=scene.scene_id, + object_id=object_id, + object_name=object_name, + native_object_id=native_object_id, + raw_parent_object_ids=raw, + structural_parent_object_ids=structural, + domain_parent_object_ids=domain, + support_kind=kind, + support_object_id=support_object_id, + floor_object_id=floor_object_id, + ) + ) + return tuple(facts) + + +class AI2ThorSupportMixin: + def capture_spawn_maps( + self, + facts: SourceCaptureFacts, + *, + subject_object_ids: tuple[str, ...], + ) -> tuple[AdapterSpawnMap, ...]: + """Capture maps for only the generation-selected source subjects.""" + + if type(facts) is not SourceCaptureFacts: + raise AdapterOperationError("spawn capture facts must be exact") + if ( + type(subject_object_ids) is not tuple + or any(type(item) is not str or not item for item in subject_object_ids) + or len(set(subject_object_ids)) != len(subject_object_ids) + ): + raise AdapterOperationError( + "spawn subject IDs must be unique exact strings" + ) + try: + maps = [] + for subject_object_id in subject_object_ids: + subject = facts.scene.object_by_id(subject_object_id) + if subject.support_object_id is None: + raise ValueError("selected subject has no support") + native = self.receptacle_spawn_map( + facts.scene, + subject_object_id=subject_object_id, + ) + region = ( + build_receptacle_support_position_region(facts.scene, native) + if native.surface_patches + else None + ) + maps.append( + adapter_spawn_map_from_native( + native, + binding=facts.binding, + position_region=region, + ) + ) + return tuple(maps) + except (AI2ThorNativeReturnError, RuntimeError, ValueError, KeyError) as error: + raise AdapterOperationError(str(error)) from error + + def native_support_facts( + self, + scene: Scene, + ) -> tuple[AI2ThorNativeSupportFact, ...]: + """Read support lineage from the exact current event without an action.""" + + event = self._current_event_for_scene(scene) + raw_objects = event.metadata.get("objects") + if type(raw_objects) is not list: + raise AI2ThorNativeReturnError("invalid AI2-THOR object collection") + return build_ai2thor_native_support_facts(scene, raw_objects) + + def reachable_agent_positions( + self, + scene: Scene, + ) -> tuple[AI2ThorNativePosition, ...]: + """Return a deterministic, strictly validated native navigation grid.""" + controller = self._require_active() + current_event = self._current_event_for_scene(scene) + expected_native_scene_name = self._native_scene_name(current_event) + expected_positions = {obj.name: obj.position for obj in scene.objects} + expected_rotations = { + obj.name: self._native_rotation_for(scene.scene_id, obj) + for obj in scene.objects + } + try: + event = self._step( + controller, + "GetReachablePositions", + action="GetReachablePositions", + ) + event = self._checked_scene_event( + controller, + event, + "GetReachablePositions", + scene.scene_id, + ) + self._validate_native_scene_name_or_poison( + event, + expected_native_scene_name, + ) + self._validate_returned_state( + scene, + event, + expected_positions, + expected_rotations, + ) + action_return = event.metadata.get("actionReturn") + if type(action_return) is not list: + raise ValueError("GetReachablePositions actionReturn must be a list") + keyed_positions: list[ + tuple[tuple[int, int, int], AI2ThorNativePosition] + ] = [] + seen_keys: set[tuple[int, int, int]] = set() + for index, raw_position in enumerate(action_return): + position = self._native_position( + raw_position, + f"reachable position {index}", + ) + quantized_key = tuple( + round(value / _REACHABLE_POSITION_QUANTIZATION_M) + for value in (position.x, position.z, position.y) + ) + if quantized_key in seen_keys: + raise ValueError( + "GetReachablePositions returned a duplicate quantized position" + ) + seen_keys.add(quantized_key) + keyed_positions.append((quantized_key, position)) + except BaseException: + self._poison_scene_state() + raise + keyed_positions.sort(key=lambda item: item[0]) + self._event = event + self._current_scene = scene + return tuple(position for _, position in keyed_positions) + + def receptacle_spawn_map( + self, + scene: Scene, + *, + subject_object_id: str, + ) -> AI2ThorReceptacleSpawnMap: + """Capture deterministic native receptacle coordinates for one subject. + + Returned coordinates are source facts, not collision-free placements. + The query is executed once against the exact current source event and + cannot be used as a per-candidate platform search loop. + """ + + controller = self._require_active() + current_event = self._current_event_for_scene(scene) + subject = scene.object_by_id(subject_object_id) + support_object_id = subject.support_object_id + if support_object_id is None: + raise ValueError("receptacle spawn subject has no declared support") + support = scene.object_by_id(support_object_id) + runtime_identity = self.runtime_identity() + expected_native_scene_name = self._native_scene_name(current_event) + expected_positions = {obj.name: obj.position for obj in scene.objects} + expected_rotations = { + obj.name: self._native_rotation_for(scene.scene_id, obj) + for obj in scene.objects + } + native_subject_object_id = self._native_object_id_for_name( + current_event, + subject.name, + ) + native_support_object_id = self._native_object_id_for_name( + current_event, + support.name, + ) + try: + event = self._step( + controller, + "GetSpawnCoordinatesAboveReceptacle", + action="GetSpawnCoordinatesAboveReceptacle", + objectId=native_support_object_id, + anywhere=True, + ) + event = self._checked_scene_event( + controller, + event, + "GetSpawnCoordinatesAboveReceptacle", + scene.scene_id, + ) + self._validate_native_scene_name_or_poison( + event, + expected_native_scene_name, + ) + self._validate_returned_state( + scene, + event, + expected_positions, + expected_rotations, + ) + action_return = event.metadata.get("actionReturn") + if type(action_return) is not list or not action_return: + raise AI2ThorNativeReturnError( + "GetSpawnCoordinatesAboveReceptacle actionReturn must be " + "a non-empty list" + ) + raw_positions: list[AI2ThorNativePosition] = [] + keyed_positions: list[ + tuple[tuple[int, int, int], AI2ThorNativePosition] + ] = [] + seen_keys: set[tuple[int, int, int]] = set() + for index, raw_position in enumerate(action_return): + position = self._native_position( + raw_position, + f"receptacle spawn position {index}", + error_type=AI2ThorNativeReturnError, + ) + raw_positions.append(position) + quantized_key = tuple( + round(value / _REACHABLE_POSITION_QUANTIZATION_M) + for value in (position.x, position.z, position.y) + ) + if quantized_key in seen_keys: + raise AI2ThorNativeReturnError( + "GetSpawnCoordinatesAboveReceptacle returned a duplicate " + "quantized position" + ) + seen_keys.add(quantized_key) + keyed_positions.append((quantized_key, position)) + except BaseException: + self._poison_scene_state() + raise + positions = tuple( + sorted( + (position for _, position in keyed_positions), + key=lambda item: (item.x, item.z, item.y), + ) + ) + surface_patches = build_ai2thor_receptacle_surface_patches(tuple(raw_positions)) + positions_sha256 = _native_positions_sha256(positions) + scene_sha256 = _receptacle_scene_sha256(scene, surface_patches) + source_sha256 = _receptacle_spawn_source_sha256( + scene_id=scene.scene_id, + subject_object_id=subject.object_id, + support_object_id=support.object_id, + native_subject_object_id=native_subject_object_id, + native_support_object_id=native_support_object_id, + runtime_identity=runtime_identity, + positions_sha256=positions_sha256, + scene_sha256=scene_sha256, + surface_patches=surface_patches, + ) + spawn_map = AI2ThorReceptacleSpawnMap( + scene_id=scene.scene_id, + subject_object_id=subject.object_id, + support_object_id=support.object_id, + native_subject_object_id=native_subject_object_id, + native_support_object_id=native_support_object_id, + runtime_identity=runtime_identity, + positions=positions, + positions_sha256=positions_sha256, + scene_sha256=scene_sha256, + source_sha256=source_sha256, + surface_patches=surface_patches, + ) + self._event = event + self._current_scene = scene + return spawn_map + + def conservative_floor_envelope( + self, + scene: Scene, + clearance_m: float, + ) -> AI2ThorFloorEnvelope: + """Derive an inward-offset rectangle from the current native floor AABB.""" + event = self._current_event_for_scene(scene) + if ( + isinstance(clearance_m, bool) + or not isinstance(clearance_m, (int, float)) + or not math.isfinite(float(clearance_m)) + or clearance_m < 0.0 + ): + raise ValueError("floor clearance must be finite and non-negative") + raw_objects = event.metadata.get("objects") + raw_objects_is_list = isinstance(raw_objects, list) + if not raw_objects_is_list: + raise ValueError("current event has no structural Floor collection") + floors: list[dict[str, Any]] = [] + for item in raw_objects: + if not isinstance(item, dict) or item.get("objectType") != "Floor": + continue + bounds = item.get("axisAlignedBoundingBox") + size = bounds.get("size") if isinstance(bounds, dict) else None + size_is_dict = isinstance(size, dict) + if not size_is_dict: + raise ValueError("structural Floor must have a finite positive AABB") + try: + native_size_x = float(size["x"]) + native_size_y = float(size["y"]) + native_size_z = float(size["z"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError( + "structural Floor must have a finite positive AABB" + ) from exc + if ( + not all( + math.isfinite(value) + for value in (native_size_x, native_size_y, native_size_z) + ) + or native_size_x <= 0.0 + or native_size_z <= 0.0 + or native_size_y < 0.0 + ): + raise ValueError("structural Floor must have a finite positive AABB") + if native_size_y == 0.0: + continue + floors.append(item) + if len(floors) != 1: + raise ValueError("current event must contain exactly one structural Floor") + floor = floors[0] + bounds = floor.get("axisAlignedBoundingBox") + bounds_is_dict = isinstance(bounds, dict) + if not bounds_is_dict: + raise ValueError("structural Floor must have a finite positive AABB") + try: + center = ai2thor_position_to_world(Vec3(**bounds["center"])) + size = bounds["size"] + extent = Vec3( + x=float(size["x"]), + y=float(size["z"]), + z=float(size["y"]), + ) + floor_object_id = str(floor["objectId"]) + floor_name = str(floor["name"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError( + "structural Floor must have a finite positive AABB" + ) from exc + geometry_values = ( + center.x, + center.y, + center.z, + extent.x, + extent.y, + extent.z, + ) + if ( + not all(math.isfinite(value) for value in geometry_values) + or min(extent.x, extent.y, extent.z) <= 0.0 + or not floor_object_id + or not floor_name + ): + raise ValueError("structural Floor must have a finite positive AABB") + source = self.procedural_scenes.get(scene.scene_id) + effective_native_bounds: tuple[float, float, float, float] | None = None + if source is not None: + native_center = bounds.get("center") + if not isinstance(native_center, dict): + raise ValueError("structural Floor must have a finite positive AABB") + try: + native_center_x = float(native_center["x"]) + native_center_z = float(native_center["z"]) + except (KeyError, TypeError, ValueError) as error: + raise ValueError( + "structural Floor must have a finite positive AABB" + ) from error + floor_aabb_bounds = ( + native_center_x - extent.x / 2.0, + native_center_z - extent.y / 2.0, + native_center_x + extent.x / 2.0, + native_center_z + extent.y / 2.0, + ) + if not all( + math.isclose(expected, actual, rel_tol=0.0, abs_tol=1e-5) + for expected, actual in zip( + source.floor_xz_bounds, + floor_aabb_bounds, + strict=True, + ) + ): + raise ValueError( + "procedural floorPolygon does not match structural Floor AABB" + ) + effective_native_bounds = ( + max(source.floor_xz_bounds[0], floor_aabb_bounds[0]), + max(source.floor_xz_bounds[1], floor_aabb_bounds[1]), + min(source.floor_xz_bounds[2], floor_aabb_bounds[2]), + min(source.floor_xz_bounds[3], floor_aabb_bounds[3]), + ) + if effective_native_bounds is None: + effective_native_bounds = ( + center.x - extent.x / 2.0, + center.y - extent.y / 2.0, + center.x + extent.x / 2.0, + center.y + extent.y / 2.0, + ) + minimum_x = effective_native_bounds[0] + float(clearance_m) + minimum_y = effective_native_bounds[1] + float(clearance_m) + maximum_x = effective_native_bounds[2] - float(clearance_m) + maximum_y = effective_native_bounds[3] - float(clearance_m) + if maximum_x <= minimum_x or maximum_y <= minimum_y: + raise ValueError("floor clearance leaves no positive envelope") + native_aabb = OBB( + center=center, + extent=extent, + rotation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0), + ) + return AI2ThorFloorEnvelope( + scene_id=scene.scene_id, + floor_object_id=floor_object_id, + floor_name=floor_name, + native_aabb=native_aabb, + floor_top_z=center.z + extent.z / 2.0, + clearance_m=float(clearance_m), + polygon_xy=( + Vec2(x=minimum_x, y=minimum_y), + Vec2(x=maximum_x, y=minimum_y), + Vec2(x=maximum_x, y=maximum_y), + Vec2(x=minimum_x, y=maximum_y), + ), + ) + + def conservative_collision_map( + self, + scene: Scene, + *, + subject_object_id: str, + clearance_m: float, + ) -> AI2ThorNativeFeasibilityMap: + """Expand stationary native OBBs into view-independent obstacles.""" + self._current_event_for_scene(scene) + if ( + isinstance(clearance_m, bool) + or not isinstance(clearance_m, (int, float)) + or not math.isfinite(float(clearance_m)) + or float(clearance_m) <= 0.0 + ): + raise ValueError("collision clearance must be finite and positive") + subject = scene.object_by_id(subject_object_id) + excluded_ids = {subject.object_id, subject.support_object_id} + clearance = float(clearance_m) + obstacles = tuple( + CollisionObstacle( + obstacle_id=f"native-clearance:{obj.object_id}", + source_object_id=obj.object_id, + clearance_m=clearance, + obb=obj.obb, + ) + for obj in sorted(scene.objects, key=lambda item: item.object_id) + if obj.object_id not in excluded_ids + ) + return AI2ThorNativeFeasibilityMap( + scene_id=scene.scene_id, + subject_object_id=subject.object_id, + clearance_m=clearance, + obstacles=obstacles, + ) + + def conservative_navigation_map( + self, + scene: Scene, + *, + subject_object_id: str, + room_polygon_xy: tuple[Vec2, ...], + agent_radius_m: float, + clearance_m: float, + ) -> AI2ThorNavigationFeasibilityMap: + """Bind the current reachable grid to a conservative subject locus.""" + self._current_event_for_scene(scene) + positions = self.reachable_agent_positions(scene) + return build_navigation_feasibility_map( + scene, + subject_object_id=subject_object_id, + room_polygon_xy=room_polygon_xy, + reachable_positions=positions, + agent_radius_m=agent_radius_m, + clearance_m=clearance_m, + ) diff --git a/src/spatialcf/adapters/ai2thor/validation.py b/src/spatialcf/adapters/ai2thor/validation.py new file mode 100644 index 0000000..f7dd9a6 --- /dev/null +++ b/src/spatialcf/adapters/ai2thor/validation.py @@ -0,0 +1,141 @@ +"""Native-only validation for canonical AI2-THOR observations.""" + +from __future__ import annotations + +import math +from hashlib import sha256 +from io import BytesIO + +import numpy as np +from PIL import Image, UnidentifiedImageError + +from spatialcf.adapters.ai2thor.models import AI2ThorObservation +from spatialcf.verification.integrity import ( + competition_native_observation_payload_sha256, +) + + +def competition_native_observation_sha256(observation: AI2ThorObservation) -> str: + """Hash one exact native observation through the neutral payload owner.""" + + if type(observation) is not AI2ThorObservation: + raise TypeError("native observation digest requires an exact observation") + assets = { + "depth_npy_sha256": sha256(observation.depth_npy).hexdigest(), + "instance_png_sha256": sha256(observation.instance_png).hexdigest(), + "pointcloud_ply_sha256": sha256(observation.pointcloud_ply).hexdigest(), + "rgb_png_sha256": sha256(observation.rgb_png).hexdigest(), + } + stored = { + "depth_npy_sha256": observation.depth_npy_sha256, + "instance_png_sha256": observation.instance_png_sha256, + "pointcloud_ply_sha256": observation.pointcloud_ply_sha256, + "rgb_png_sha256": observation.rgb_png_sha256, + } + if assets != stored: + raise ValueError("native observation stored asset digests do not match bytes") + return competition_native_observation_payload_sha256( + scene=observation.scene, + rgb_png=observation.rgb_png, + depth_npy=observation.depth_npy, + instance_png=observation.instance_png, + pointcloud_ply=observation.pointcloud_ply, + instance_pixel_counts=observation.instance_pixel_counts, + is_scene_at_rest=observation.is_scene_at_rest, + ) + + +def observation_contract_errors( + observation: AI2ThorObservation, + camera_id: str, +) -> tuple[str, ...]: + errors: list[str] = [] + try: + camera = observation.scene.camera_by_id(camera_id) + except KeyError: + return ("observation_camera_missing",) + + assets = ( + ("rgb", observation.rgb_png, observation.rgb_png_sha256), + ("depth", observation.depth_npy, observation.depth_npy_sha256), + ( + "instance", + observation.instance_png, + observation.instance_png_sha256, + ), + ( + "pointcloud", + observation.pointcloud_ply, + observation.pointcloud_ply_sha256, + ), + ) + for name, payload, expected_digest in assets: + if sha256(payload).hexdigest() != expected_digest: + errors.append(f"observation_{name}_digest_mismatch") + + for name, payload in ( + ("rgb", observation.rgb_png), + ("instance", observation.instance_png), + ): + try: + with Image.open(BytesIO(payload)) as image: + image.load() + if image.size != (camera.width, camera.height): + errors.append(f"observation_{name}_dimensions_mismatch") + if image.mode != "RGB": + errors.append(f"observation_{name}_dtype_mismatch") + except (OSError, UnidentifiedImageError, ValueError): + errors.append(f"observation_{name}_invalid") + + try: + depth = np.load(BytesIO(observation.depth_npy), allow_pickle=False) + if depth.shape != (camera.height, camera.width): + errors.append("observation_depth_dimensions_mismatch") + if depth.dtype != np.float32: + errors.append("observation_depth_dtype_mismatch") + if not np.any(np.isfinite(depth) & (depth > 0.0)): + errors.append("observation_depth_has_no_positive_sample") + except (OSError, ValueError, TypeError): + errors.append("observation_depth_invalid") + + try: + pointcloud = observation.pointcloud_ply.decode("ascii") + lines = pointcloud.splitlines() + if ( + len(lines) < 4 + or lines[0] != "ply" + or lines[1] != "format ascii 1.0" + or "end_header" not in lines + ): + raise ValueError + vertex_lines = [line for line in lines if line.startswith("element vertex ")] + if len(vertex_lines) != 1: + raise ValueError + vertex_count = int(vertex_lines[0].removeprefix("element vertex ")) + header_end = lines.index("end_header") + vertices = lines[header_end + 1 :] + if vertex_count <= 0 or len(vertices) != vertex_count: + raise ValueError + for vertex in vertices: + values = vertex.split() + if len(values) != 6: + raise ValueError + coordinates = tuple(float(value) for value in values[:3]) + colors = tuple(int(value) for value in values[3:]) + if not all(math.isfinite(value) for value in coordinates) or not all( + 0 <= value <= 255 for value in colors + ): + raise ValueError + except (UnicodeDecodeError, ValueError): + errors.append("observation_pointcloud_invalid") + + expected_ids = {obj.object_id for obj in observation.scene.objects} + if set(observation.instance_pixel_counts) != expected_ids: + errors.append("observation_instance_count_ids_mismatch") + max_pixels = camera.width * camera.height + if any( + type(count) is not int or count < 0 or count > max_pixels + for count in observation.instance_pixel_counts.values() + ): + errors.append("observation_instance_count_invalid") + return tuple(sorted(set(errors))) diff --git a/src/spatialcf/adapters/ai2thor_execution.py b/src/spatialcf/adapters/ai2thor_execution.py deleted file mode 100644 index ae78d91..0000000 --- a/src/spatialcf/adapters/ai2thor_execution.py +++ /dev/null @@ -1,680 +0,0 @@ -"""AI2-THOR implementation of the platform-neutral candidate executor. - -The core solver sees only a canonical observed :class:`Scene`. Fresh isolated -episodes, physics settlement, action failures, same-event assets, and bounded -conversion roundoff remain isolated in this module. -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass - -from spatialcf.adapters import ai2thor_validation as validation -from spatialcf.adapters.ai2thor import ( - AI2ThorAdapter, - AI2ThorFloorEnvelope, - AI2ThorPoseApplication, - AI2ThorRuntimeError, - AI2ThorRuntimeIdentity, - AI2ThorSceneSettlement, -) -from spatialcf.domain.models import InterventionSpec, Scene, Vec3 -from spatialcf.geometry.obb import inside_room, obbs_intersect_3d -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.execution import CandidateExecution, ExecutionResidual - - -@dataclass(frozen=True) -class AI2ThorCandidateEvidence: - """Raw immutable evidence retained around one settled native candidate.""" - - baseline_settlement: AI2ThorSceneSettlement - immediate_application: AI2ThorPoseApplication - settlement: AI2ThorSceneSettlement - application: AI2ThorPoseApplication - floor_envelope: AI2ThorFloorEnvelope - runtime_identity: AI2ThorRuntimeIdentity - - -_PHYSICAL_NATIVE_ERROR_PREFIXES = ( - "native_stationary_geometry_residual_exceeded:", - "native_stationary_rotation_residual_exceeded:", - "native_subject_geometry_residual_exceeded", - "native_subject_rotation_residual_exceeded", -) - - -def _is_candidate_physical_error(error: str) -> bool: - return error.startswith(_PHYSICAL_NATIVE_ERROR_PREFIXES) or ( - error.startswith("native_object_field_changed:") - and error.endswith(":support_object_id") - ) - - -def _clean_message(value: object) -> str: - return " ".join(str(value).split()) or "native adapter failure" - - -def _position_distance(left: Vec3, right: Vec3) -> float: - return math.dist( - (left.x, left.y, left.z), - (right.x, right.y, right.z), - ) - - -def _residual( - name: str, - value: float, - limit: float, -) -> ExecutionResidual | None: - if not math.isfinite(value): - return None - return ExecutionResidual(name, float(value), float(limit)) - - -def _baseline_contract_errors( - nominal_before: Scene, - settlement: AI2ThorSceneSettlement, - envelope: AI2ThorFloorEnvelope, - limits: validation.AI2ThorValidationTolerances, - expected_runtime: AI2ThorRuntimeIdentity, - observed_runtime: AI2ThorRuntimeIdentity, - spec: InterventionSpec, -) -> tuple[str, ...]: - """Admit a fresh episode only when its task semantics are unchanged.""" - observed = settlement.observed_scene - errors = list( - validation.settlement_contract_errors( - nominal_before.scene_id, - settlement, - ) - ) - errors.extend( - validation.observation_contract_errors( - settlement.observation, - spec.camera_id, - ) - ) - errors.extend( - validation.floor_envelope_contract_errors( - observed, - envelope, - limits, - runtime_identity=observed_runtime, - ) - ) - if observed_runtime != expected_runtime: - errors.append("runtime_identity_changed") - - for field in ( - "scene_id", - "source", - "coordinate_system", - "generation_seed", - "pinned_object_ids", - ): - if getattr(nominal_before, field) != getattr(observed, field): - errors.append(f"baseline_{field}_changed") - errors.extend( - f"baseline_{error}" - for error in validation.camera_residual_errors( - nominal_before, - observed, - limits, - ) - ) - - expected_ids = {obj.object_id for obj in nominal_before.objects} - observed_ids = {obj.object_id for obj in observed.objects} - if expected_ids != observed_ids: - errors.append("baseline_object_set_changed") - return tuple(sorted(set(errors))) - for expected in nominal_before.objects: - current = observed.object_by_id(expected.object_id) - if any( - getattr(expected, field) != getattr(current, field) - for field in ( - "name", - "category", - "movable", - "request_eligible", - "support_object_id", - ) - ): - errors.append(f"baseline_object_structure_changed:{expected.object_id}") - - try: - relation_diff, _ = validation.relation_graph_diff( - nominal_before, - observed, - spec, - ) - source_labels = RelationEngine().pair_labels( - observed, - spec.subject_id, - spec.reference_id, - spec.camera_id, - ) - except (KeyError, ValueError): - errors.append("baseline_relation_graph_invalid") - else: - if relation_diff: - errors.append("baseline_relation_graph_changed") - if spec.relation_before not in source_labels: - errors.append("baseline_source_target_relation_changed") - return tuple(sorted(set(errors))) - - -def _native_action_rejection( - adapter: AI2ThorAdapter, - runtime_identity: AI2ThorRuntimeIdentity, - scene_id: str, - error: RuntimeError, -) -> str | None: - """Recognize an ordinary failed native command without trusting bad events.""" - if isinstance(error, AI2ThorRuntimeError): - return None - try: - event = adapter.latest_native_event(scene_id) - except (KeyError, RuntimeError, TypeError, ValueError): - return None - metadata = getattr(event, "metadata", None) - if ( - not isinstance(metadata, dict) - or metadata.get("lastActionSuccess") is not False - ): - return None - if metadata.get("sceneName") != runtime_identity.native_scene_name: - return None - message = metadata.get("errorMessage") - if type(message) is not str or not message.strip(): - return None - clean = _clean_message(message) - if clean != _clean_message(error): - return None - return clean - - -def _native_pose_rejection( - adapter: AI2ThorAdapter, - runtime_identity: AI2ThorRuntimeIdentity, - scene_id: str, - error: RuntimeError, -) -> str | None: - """Recognize the adapter's bounded candidate-pose rejection contract.""" - if isinstance(error, AI2ThorRuntimeError): - return None - message = _clean_message(error) - if not ( - message.startswith("object '") - and message.endswith("' pose changed during pose application") - ): - return None - try: - event = adapter.latest_native_event(scene_id) - except (KeyError, RuntimeError, TypeError, ValueError): - return None - metadata = getattr(event, "metadata", None) - if ( - not isinstance(metadata, dict) - or metadata.get("lastActionSuccess") is not True - or metadata.get("sceneName") != runtime_identity.native_scene_name - ): - return None - return message - - -def _did_not_settle(error: RuntimeError) -> bool: - return _clean_message(error).startswith("AI2-THOR scene did not settle within ") - - -def _final_application( - settlement: AI2ThorSceneSettlement, - commanded: Scene, - spec: InterventionSpec, -) -> AI2ThorPoseApplication: - commanded_position = commanded.object_by_id(spec.subject_id).position - observed_position = settlement.observed_scene.object_by_id(spec.subject_id).position - return AI2ThorPoseApplication( - commanded_scene=commanded, - observed_scene=settlement.observed_scene, - commanded_position=commanded_position, - observed_position=observed_position, - position_residual_m=_position_distance( - commanded_position, - observed_position, - ), - observation=settlement.observation, - is_scene_at_rest=settlement.observation.is_scene_at_rest, - subject_is_moving=False, - ) - - -def _validate_native_candidate( - before: Scene, - commanded: Scene, - spec: InterventionSpec, - envelope: AI2ThorFloorEnvelope, - limits: validation.AI2ThorValidationTolerances, - runtime_identity: AI2ThorRuntimeIdentity, - evidence: AI2ThorCandidateEvidence, -) -> tuple[ - Scene | None, - tuple[str, ...], - tuple[str, ...], - tuple[ExecutionResidual, ...], -]: - """Return normalized IR plus contract and candidate-specific failures.""" - application = evidence.application - contract_errors: list[str] = [] - physical_errors: list[str] = [] - application_errors, commanded_pose_residual = ( - validation.pose_application_contract_errors(commanded, spec, application) - ) - contract_errors.extend(application_errors) - contract_errors.extend( - validation.settlement_contract_errors(before.scene_id, evidence.settlement) - ) - contract_errors.extend( - validation.floor_envelope_contract_errors( - before, - envelope, - limits, - runtime_identity=runtime_identity, - ) - ) - if application.observation.scene != application.observed_scene: - contract_errors.append("observation_scene_mismatch") - contract_errors.extend( - validation.observation_contract_errors( - application.observation, - spec.camera_id, - ) - ) - - ( - normalized, - structure_errors, - maximum_geometry_residual, - maximum_rotation_residual, - ) = validation.normalized_native_after( - before, - application.observed_scene, - spec, - limits, - ) - for error in structure_errors: - if _is_candidate_physical_error(error): - physical_errors.append(error) - else: - contract_errors.append(error) - - if normalized is not None: - try: - raw_relation_diff, raw_leakage = validation.relation_graph_diff( - before, - application.observed_scene, - spec, - ) - normalized_relation_diff, normalized_leakage = ( - validation.relation_graph_diff(before, normalized, spec) - ) - except (KeyError, ValueError): - contract_errors.append("native_relation_graph_invalid") - else: - if raw_relation_diff != normalized_relation_diff: - physical_errors.append("native_relation_diff_mismatch") - if raw_leakage != normalized_leakage: - physical_errors.append("native_leakage_count_mismatch") - - if commanded_pose_residual > limits.commanded_pose_residual_m: - physical_errors.append("commanded_pose_residual_exceeded") - if not application.is_scene_at_rest or not application.observation.is_scene_at_rest: - physical_errors.append("scene_not_at_rest") - if application.subject_is_moving: - physical_errors.append("subject_is_moving") - - contact_residual = math.inf - observed = application.observed_scene - try: - subject = observed.object_by_id(spec.subject_id) - except KeyError: - contract_errors.append("observed_subject_missing") - else: - try: - floor = validation.floor_polygon(envelope) - except validation.AI2ThorValidationRejected as error: - contract_errors.extend(error.reasons) - else: - if not inside_room(subject.obb, floor, tolerance=0.0): - physical_errors.append("subject_outside_floor_envelope") - support_id = subject.support_object_id - for obstacle in observed.objects: - if obstacle.object_id in {subject.object_id, support_id}: - continue - if obbs_intersect_3d( - subject.obb, - obstacle.obb, - xy_area_tolerance=limits.overlap_xy_area_tolerance, - z_overlap_tolerance=limits.overlap_z_tolerance, - ): - physical_errors.append(f"native_3d_collision:{obstacle.object_id}") - try: - contact_residual, footprint_supported = validation.vertical_contact_residual( - observed, - spec, - envelope, - ) - except KeyError: - physical_errors.append("observed_support_missing") - else: - if contact_residual > limits.vertical_contact_residual_m: - physical_errors.append("vertical_contact_residual_exceeded") - if not footprint_supported: - physical_errors.append("support_footprint_invalid") - - subject_pixels = application.observation.instance_pixel_counts.get( - spec.subject_id, - 0, - ) - if type(subject_pixels) is not int or subject_pixels <= 0: - physical_errors.append("subject_instance_mask_empty") - - residuals = tuple( - item - for item in ( - _residual( - "commanded_pose_m", - commanded_pose_residual, - limits.commanded_pose_residual_m, - ), - _residual( - "stationary_geometry_m", - maximum_geometry_residual, - limits.stationary_geometry_residual_m, - ), - _residual( - "rotation_degrees", - maximum_rotation_residual, - limits.rotation_residual_degrees, - ), - _residual( - "vertical_contact_m", - contact_residual, - limits.vertical_contact_residual_m, - ), - ) - if item is not None - ) - return ( - normalized, - tuple(sorted(set(contract_errors))), - tuple(sorted(set(physical_errors))), - residuals, - ) - - -class AI2ThorCandidateExecutor: - """Execute every command from a fresh, strictly checked AI2-THOR baseline.""" - - def __init__( - self, - adapter: AI2ThorAdapter, - *, - floor_envelope: AI2ThorFloorEnvelope, - runtime_identity: AI2ThorRuntimeIdentity, - tolerances: validation.AI2ThorValidationTolerances | None = None, - max_settle_steps: int = 30, - ) -> None: - if type(floor_envelope) is not AI2ThorFloorEnvelope: - raise ValueError("floor_envelope must be AI2ThorFloorEnvelope") - if type(runtime_identity) is not AI2ThorRuntimeIdentity: - raise ValueError("runtime_identity must be AI2ThorRuntimeIdentity") - frozen = validation.AI2ThorValidationTolerances() - if tolerances is not None and tolerances != frozen: - raise ValueError("AI2-THOR executor requires frozen tolerances") - if type(max_settle_steps) is not int or max_settle_steps <= 0: - raise ValueError("max_settle_steps must be an exact positive integer") - self.adapter = adapter - self.floor_envelope = floor_envelope - self.runtime_identity = runtime_identity - self.tolerances = frozen - self.max_settle_steps = max_settle_steps - - @staticmethod - def _exception( - commanded: Scene, - stage: str, - error: BaseException, - ) -> CandidateExecution[AI2ThorCandidateEvidence]: - return CandidateExecution.error( - commanded, - (f"{stage}_exception:{type(error).__name__}:{_clean_message(error)}",), - ) - - def execute_candidate( - self, - before: Scene, - commanded: Scene, - spec: InterventionSpec, - ) -> CandidateExecution[AI2ThorCandidateEvidence]: - """Run one proposal inside its own fresh controller episode.""" - if type(before) is not Scene or type(commanded) is not Scene: - raise ValueError("before and commanded must be canonical Scene values") - if type(spec) is not InterventionSpec: - raise ValueError("spec must be InterventionSpec") - if before.scene_id != commanded.scene_id: - return CandidateExecution.error( - commanded, - ("command_scene_mismatch",), - ) - - stage = "baseline" - try: - with self.adapter.isolated_scene_observed( - before, - max_pass_steps=self.max_settle_steps, - ) as episode: - episode_adapter = episode.adapter - baseline_settlement = episode.baseline_settlement - if type(baseline_settlement) is not AI2ThorSceneSettlement: - raise TypeError("adapter settlement has an invalid type") - observed_runtime = episode_adapter.runtime_identity() - if type(observed_runtime) is not AI2ThorRuntimeIdentity: - raise TypeError("adapter runtime identity has an invalid type") - local_before = baseline_settlement.observed_scene - local_envelope = episode_adapter.conservative_floor_envelope( - local_before, - self.tolerances.floor_clearance_m, - ) - if type(local_envelope) is not AI2ThorFloorEnvelope: - raise TypeError("adapter floor envelope has an invalid type") - - baseline_errors = _baseline_contract_errors( - before, - baseline_settlement, - local_envelope, - self.tolerances, - self.runtime_identity, - observed_runtime, - spec, - ) - if baseline_errors: - result = CandidateExecution.error( - commanded, - tuple( - f"baseline_contract:{error}" - for error in baseline_errors - ), - ) - else: - stage = "candidate" - result = self._execute_in_episode( - episode_adapter, - local_before, - baseline_settlement, - commanded, - spec, - local_envelope, - observed_runtime, - ) - stage = "cleanup" - except Exception as error: # noqa: BLE001 - fail closed on child cleanup - return self._exception(commanded, stage, error) - return result - - def _execute_in_episode( - self, - adapter: AI2ThorAdapter, - local_before: Scene, - baseline_settlement: AI2ThorSceneSettlement, - commanded: Scene, - spec: InterventionSpec, - floor_envelope: AI2ThorFloorEnvelope, - runtime_identity: AI2ThorRuntimeIdentity, - ) -> CandidateExecution[AI2ThorCandidateEvidence]: - """Apply and validate one proposal against its episode-local source.""" - try: - requested_position = commanded.object_by_id(spec.subject_id).position - local_subject = local_before.object_by_id(spec.subject_id) - immediate = adapter.apply_object_xy_observed( - local_before, - spec.subject_id, - requested_position.x, - requested_position.y, - ) - if type(immediate) is not AI2ThorPoseApplication: - raise TypeError("adapter pose application has an invalid type") - except RuntimeError as error: - rejection = _native_action_rejection( - adapter, - runtime_identity, - commanded.scene_id, - error, - ) - if rejection is not None: - return CandidateExecution.rejected( - commanded, - (f"native_action_rejected:{rejection}",), - observed_before_scene=local_before, - ) - pose_rejection = _native_pose_rejection( - adapter, - runtime_identity, - commanded.scene_id, - error, - ) - if pose_rejection is not None: - return CandidateExecution.rejected( - commanded, - (f"native_pose_rejected:{pose_rejection}",), - observed_before_scene=local_before, - ) - return self._exception(commanded, "application", error) - except (KeyError, TypeError, ValueError) as error: - return self._exception(commanded, "application", error) - - expected_local_position = Vec3( - x=requested_position.x, - y=requested_position.y, - z=local_subject.position.z, - ) - if ( - immediate.commanded_position != expected_local_position - or immediate.commanded_scene.object_by_id(spec.subject_id).position - != expected_local_position - ): - return CandidateExecution.error( - commanded, - ("commanded_position_mismatch",), - ) - local_commanded = immediate.commanded_scene - - try: - settlement = adapter.settle_scene_observed( - immediate.observed_scene, - max_pass_steps=self.max_settle_steps, - ) - if type(settlement) is not AI2ThorSceneSettlement: - raise TypeError("adapter settlement has an invalid type") - observed_runtime = adapter.runtime_identity() - if type(observed_runtime) is not AI2ThorRuntimeIdentity: - raise TypeError("adapter runtime identity has an invalid type") - except RuntimeError as error: - if _did_not_settle(error): - return CandidateExecution.rejected( - commanded, - ("post_action_not_settled",), - observed_before_scene=local_before, - ) - return self._exception(commanded, "post_action", error) - except (KeyError, TypeError, ValueError) as error: - return self._exception(commanded, "post_action", error) - if observed_runtime != runtime_identity: - return CandidateExecution.error( - commanded, - ("post_action_runtime_identity_changed",), - ) - - try: - application = _final_application( - settlement, - local_commanded, - spec, - ) - except KeyError as error: - return self._exception(commanded, "post_action", error) - evidence = AI2ThorCandidateEvidence( - baseline_settlement=baseline_settlement, - immediate_application=immediate, - settlement=settlement, - application=application, - floor_envelope=floor_envelope, - runtime_identity=observed_runtime, - ) - normalized, contract_errors, physical_errors, residuals = ( - _validate_native_candidate( - local_before, - local_commanded, - spec, - floor_envelope, - self.tolerances, - observed_runtime, - evidence, - ) - ) - if contract_errors: - return CandidateExecution.error( - commanded, - contract_errors, - evidence=evidence, - ) - if physical_errors: - return CandidateExecution.rejected( - commanded, - physical_errors, - observed_before_scene=local_before, - observed_scene=normalized, - residuals=residuals, - evidence=evidence, - ) - if normalized is None: - return CandidateExecution.error( - commanded, - ("normalized_observed_scene_missing",), - evidence=evidence, - ) - return CandidateExecution.observed( - commanded, - observed_before_scene=local_before, - observed_scene=normalized, - residuals=residuals, - evidence=evidence, - ) - - -__all__ = ( - "AI2ThorCandidateEvidence", - "AI2ThorCandidateExecutor", -) diff --git a/src/spatialcf/adapters/ai2thor_validation.py b/src/spatialcf/adapters/ai2thor_validation.py deleted file mode 100644 index aa3b4ef..0000000 --- a/src/spatialcf/adapters/ai2thor_validation.py +++ /dev/null @@ -1,647 +0,0 @@ -"""Shared validation primitives for canonical AI2-THOR observations. - -This adapter-layer module owns the native tolerance and normalization contract. -Pipeline code may orchestrate and publish the resulting evidence, but both the -pipeline and grounded candidate executor use this single implementation. -""" - -from __future__ import annotations - -import math -from dataclasses import dataclass -from hashlib import sha256 -from io import BytesIO - -import numpy as np -from PIL import Image, UnidentifiedImageError -from shapely.geometry import Polygon - -from spatialcf.adapters.ai2thor import ( - AI2ThorFloorEnvelope, - AI2ThorObservation, - AI2ThorPoseApplication, - AI2ThorRuntimeIdentity, - AI2ThorSceneSettlement, -) -from spatialcf.domain.models import InterventionSpec, Quaternion, Scene, Vec2, Vec3 -from spatialcf.geometry.obb import obb_footprint -from spatialcf.relations.engine import RelationEngine - - -@dataclass(frozen=True) -class CertifiedAI2ThorPilotTolerances: - """Frozen tolerances shared by certified and grounded native execution.""" - - floor_clearance_m: float = 0.02 - native_collision_clearance_m: float = 0.02 - commanded_pose_residual_m: float = 1e-5 - stationary_geometry_residual_m: float = 1e-5 - rotation_residual_degrees: float = 1e-4 - camera_intrinsics_residual: float = 1e-8 - camera_extrinsics_residual: float = 1e-5 - vertical_contact_residual_m: float = 1e-2 - overlap_xy_area_tolerance: float = 1e-9 - overlap_z_tolerance: float = 1e-9 - - def __post_init__(self) -> None: - for name, value in vars(self).items(): - if ( - isinstance(value, bool) - or not isinstance(value, (int, float)) - or not math.isfinite(float(value)) - or value < 0.0 - ): - raise ValueError(f"{name} must be finite and non-negative") - - -AI2ThorValidationTolerances = CertifiedAI2ThorPilotTolerances - - -class CertifiedAI2ThorCaseRejected(RuntimeError): - """A native validation case failed without repairing its request.""" - - def __init__(self, stage: str, reasons: tuple[str, ...]) -> None: - if not stage or not reasons: - raise ValueError("a rejected case requires a stage and reasons") - self.stage = stage - self.reasons = reasons - super().__init__(f"{stage}: {', '.join(reasons)}") - - -AI2ThorValidationRejected = CertifiedAI2ThorCaseRejected - - -def floor_polygon(envelope: AI2ThorFloorEnvelope) -> Polygon: - polygon = Polygon([(point.x, point.y) for point in envelope.polygon_xy]) - if not polygon.is_valid or polygon.is_empty or polygon.area <= 0.0: - raise AI2ThorValidationRejected("input", ("invalid_floor_envelope",)) - return polygon - - -def is_finite_non_negative(value: object) -> bool: - return ( - not isinstance(value, bool) - and isinstance(value, (int, float)) - and math.isfinite(float(value)) - and float(value) >= 0.0 - ) - - -def position_distance(left: Vec3, right: Vec3) -> float: - return math.dist( - (left.x, left.y, left.z), - (right.x, right.y, right.z), - ) - - -def scene_without_views(scene: Scene) -> Scene: - return scene.model_copy( - update={ - "objects": tuple( - obj.model_copy(update={"views": {}}) for obj in scene.objects - ) - } - ) - - -def floor_envelope_contract_errors( - scene: Scene, - envelope: AI2ThorFloorEnvelope, - limits: AI2ThorValidationTolerances, - *, - runtime_identity: AI2ThorRuntimeIdentity | None = None, -) -> tuple[str, ...]: - errors: list[str] = [] - if envelope.scene_id != scene.scene_id: - errors.append("floor_envelope_scene_mismatch") - if not envelope.floor_object_id or not envelope.floor_name: - errors.append("floor_envelope_identity_invalid") - if not is_finite_non_negative(envelope.clearance_m): - errors.append("floor_envelope_clearance_invalid") - return tuple(errors) - clearance = float(envelope.clearance_m) - if clearance != limits.floor_clearance_m: - errors.append("floor_envelope_clearance_mismatch") - - bounds = envelope.native_aabb - identity = Quaternion(x=0.0, y=0.0, z=0.0, w=1.0) - if quaternion_residual_degrees(bounds.rotation, identity) > 1e-12: - errors.append("floor_envelope_aabb_rotation_invalid") - expected_top = bounds.center.z + bounds.extent.z / 2.0 - if ( - not math.isfinite(envelope.floor_top_z) - or abs(envelope.floor_top_z - expected_top) > 1e-12 - ): - errors.append("floor_envelope_top_mismatch") - native_bounds = ( - bounds.center.x - bounds.extent.x / 2.0, - bounds.center.y - bounds.extent.y / 2.0, - bounds.center.x + bounds.extent.x / 2.0, - bounds.center.y + bounds.extent.y / 2.0, - ) - is_procedural = ( - runtime_identity is not None - and runtime_identity.native_scene_name == "Procedural" - ) - effective_bounds = native_bounds - if is_procedural: - source_bounds = runtime_identity.source_floor_xz_bounds - if source_bounds is None or any( - not math.isclose(source, native, rel_tol=0.0, abs_tol=1e-5) - for source, native in zip(source_bounds, native_bounds, strict=True) - ): - errors.append("procedural_floor_source_mismatch") - elif runtime_identity.source_scene_alias != scene.scene_id: - errors.append("procedural_floor_source_scene_mismatch") - else: - effective_bounds = ( - max(source_bounds[0], native_bounds[0]), - max(source_bounds[1], native_bounds[1]), - min(source_bounds[2], native_bounds[2]), - min(source_bounds[3], native_bounds[3]), - ) - if is_procedural: - minimum_x = effective_bounds[0] + clearance - minimum_y = effective_bounds[1] + clearance - maximum_x = effective_bounds[2] - clearance - maximum_y = effective_bounds[3] - clearance - else: - # Preserve the frozen legacy contract's operation order. Rebuilding - # the bounds as min/max first can change a coordinate by one ULP. - half_x = bounds.extent.x / 2.0 - clearance - half_y = bounds.extent.y / 2.0 - clearance - minimum_x = bounds.center.x - half_x - minimum_y = bounds.center.y - half_y - maximum_x = bounds.center.x + half_x - maximum_y = bounds.center.y + half_y - if maximum_x <= minimum_x or maximum_y <= minimum_y: - errors.append("floor_envelope_empty") - else: - expected_polygon = ( - Vec2(x=minimum_x, y=minimum_y), - Vec2(x=maximum_x, y=minimum_y), - Vec2(x=maximum_x, y=maximum_y), - Vec2(x=minimum_x, y=maximum_y), - ) - if len(envelope.polygon_xy) != len(expected_polygon) or any( - not math.isclose(actual.x, expected.x, rel_tol=0.0, abs_tol=1e-12) - or not math.isclose(actual.y, expected.y, rel_tol=0.0, abs_tol=1e-12) - for actual, expected in zip( - envelope.polygon_xy, - expected_polygon, - strict=True, - ) - ): - errors.append("floor_envelope_polygon_mismatch") - if scene.room_polygon_xy != envelope.polygon_xy: - errors.append("floor_envelope_room_mismatch") - return tuple(sorted(set(errors))) - - -def settlement_contract_errors( - scene_id: str, - settlement: AI2ThorSceneSettlement, -) -> tuple[str, ...]: - errors: list[str] = [] - if settlement.observed_scene.scene_id != scene_id: - errors.append("settlement_scene_mismatch") - if settlement.observation.scene != settlement.observed_scene: - errors.append("settlement_observation_scene_mismatch") - if settlement.observation.is_scene_at_rest is not True: - errors.append("settlement_scene_not_at_rest") - if type(settlement.pass_steps) is not int or settlement.pass_steps < 0: - errors.append("settlement_pass_steps_invalid") - return tuple(errors) - - -def pose_application_contract_errors( - expected_after: Scene, - spec: InterventionSpec, - application: AI2ThorPoseApplication, -) -> tuple[tuple[str, ...], float]: - errors: list[str] = [] - if scene_without_views(application.commanded_scene) != scene_without_views( - expected_after - ): - errors.append("commanded_scene_mismatch") - - expected_position = expected_after.object_by_id(spec.subject_id).position - try: - observed_position = application.observed_scene.object_by_id( - spec.subject_id - ).position - except KeyError: - errors.append("observed_subject_missing") - return tuple(sorted(set(errors))), math.inf - if position_distance(application.commanded_position, expected_position) > 1e-12: - errors.append("commanded_position_mismatch") - if position_distance(application.observed_position, observed_position) > 1e-12: - errors.append("observed_position_mismatch") - - recomputed_residual = position_distance( - application.commanded_position, - application.observed_position, - ) - if not is_finite_non_negative(application.position_residual_m): - errors.append("reported_pose_residual_invalid") - elif abs(application.position_residual_m - recomputed_residual) > 1e-12: - errors.append("reported_pose_residual_mismatch") - return tuple(sorted(set(errors))), recomputed_residual - - -def observation_contract_errors( - observation: AI2ThorObservation, - camera_id: str, -) -> tuple[str, ...]: - errors: list[str] = [] - try: - camera = observation.scene.camera_by_id(camera_id) - except KeyError: - return ("observation_camera_missing",) - - assets = ( - ("rgb", observation.rgb_png, observation.rgb_png_sha256), - ("depth", observation.depth_npy, observation.depth_npy_sha256), - ( - "instance", - observation.instance_png, - observation.instance_png_sha256, - ), - ( - "pointcloud", - observation.pointcloud_ply, - observation.pointcloud_ply_sha256, - ), - ) - for name, payload, expected_digest in assets: - if sha256(payload).hexdigest() != expected_digest: - errors.append(f"observation_{name}_digest_mismatch") - - for name, payload in ( - ("rgb", observation.rgb_png), - ("instance", observation.instance_png), - ): - try: - with Image.open(BytesIO(payload)) as image: - image.load() - if image.size != (camera.width, camera.height): - errors.append(f"observation_{name}_dimensions_mismatch") - if image.mode != "RGB": - errors.append(f"observation_{name}_dtype_mismatch") - except (OSError, UnidentifiedImageError, ValueError): - errors.append(f"observation_{name}_invalid") - - try: - depth = np.load(BytesIO(observation.depth_npy), allow_pickle=False) - if depth.shape != (camera.height, camera.width): - errors.append("observation_depth_dimensions_mismatch") - if depth.dtype != np.float32: - errors.append("observation_depth_dtype_mismatch") - if not np.any(np.isfinite(depth) & (depth > 0.0)): - errors.append("observation_depth_has_no_positive_sample") - except (OSError, ValueError, TypeError): - errors.append("observation_depth_invalid") - - try: - pointcloud = observation.pointcloud_ply.decode("ascii") - lines = pointcloud.splitlines() - if ( - len(lines) < 4 - or lines[0] != "ply" - or lines[1] != "format ascii 1.0" - or "end_header" not in lines - ): - raise ValueError - vertex_lines = [line for line in lines if line.startswith("element vertex ")] - if len(vertex_lines) != 1: - raise ValueError - vertex_count = int(vertex_lines[0].removeprefix("element vertex ")) - header_end = lines.index("end_header") - vertices = lines[header_end + 1 :] - if vertex_count <= 0 or len(vertices) != vertex_count: - raise ValueError - for vertex in vertices: - values = vertex.split() - if len(values) != 6: - raise ValueError - coordinates = tuple(float(value) for value in values[:3]) - colors = tuple(int(value) for value in values[3:]) - if not all(math.isfinite(value) for value in coordinates) or not all( - 0 <= value <= 255 for value in colors - ): - raise ValueError - except (UnicodeDecodeError, ValueError): - errors.append("observation_pointcloud_invalid") - - expected_ids = {obj.object_id for obj in observation.scene.objects} - if set(observation.instance_pixel_counts) != expected_ids: - errors.append("observation_instance_count_ids_mismatch") - max_pixels = camera.width * camera.height - if any( - type(count) is not int or count < 0 or count > max_pixels - for count in observation.instance_pixel_counts.values() - ): - errors.append("observation_instance_count_invalid") - return tuple(sorted(set(errors))) - - -def vertical_contact_residual( - scene: Scene, - spec: InterventionSpec, - envelope: AI2ThorFloorEnvelope, -) -> tuple[float, bool]: - """Measure contact against AI2-THOR's declared receptacle boundary.""" - subject = scene.object_by_id(spec.subject_id) - subject_bottom = subject.obb.center.z - subject.obb.extent.z / 2.0 - if subject.support_object_id is None: - return abs(subject_bottom - envelope.floor_top_z), True - support = scene.object_by_id(subject.support_object_id) - support_bottom = support.obb.center.z - support.obb.extent.z / 2.0 - support_top = support.obb.center.z + support.obb.extent.z / 2.0 - footprint_supported = obb_footprint(support.obb).buffer(1e-9).covers( - obb_footprint(subject.obb) - ) - return min( - abs(subject_bottom - support_bottom), - abs(subject_bottom - support_top), - ), footprint_supported - - -def coordinate_residual(left: Vec3, right: Vec3) -> float: - return max( - abs(left.x - right.x), - abs(left.y - right.y), - abs(left.z - right.z), - ) - - -def quaternion_residual_degrees( - left: Quaternion, - right: Quaternion, -) -> float: - left_values = (left.x, left.y, left.z, left.w) - right_values = (right.x, right.y, right.z, right.w) - left_norm = math.sqrt(sum(value * value for value in left_values)) - right_norm = math.sqrt(sum(value * value for value in right_values)) - if ( - not math.isfinite(left_norm) - or not math.isfinite(right_norm) - or left_norm <= 0.0 - or right_norm <= 0.0 - ): - return math.inf - dot = abs( - sum( - left_value * right_value - for left_value, right_value in zip( - left_values, - right_values, - strict=True, - ) - ) - / (left_norm * right_norm) - ) - return math.degrees(2.0 * math.acos(min(1.0, max(0.0, dot)))) - - -def camera_residual_errors( - before: Scene, - after: Scene, - limits: AI2ThorValidationTolerances, -) -> tuple[str, ...]: - if len(before.cameras) != len(after.cameras): - return ("camera_set_changed",) - errors: list[str] = [] - after_by_id = {camera.camera_id: camera for camera in after.cameras} - if len(after_by_id) != len(after.cameras): - return ("camera_set_changed",) - for expected in before.cameras: - observed = after_by_id.get(expected.camera_id) - if observed is None: - errors.append("camera_set_changed") - continue - if expected.width != observed.width or expected.height != observed.height: - errors.append(f"camera_dimensions_changed:{expected.camera_id}") - if max( - abs(left - right) - for left, right in zip( - expected.intrinsics, - observed.intrinsics, - strict=True, - ) - ) > limits.camera_intrinsics_residual: - errors.append(f"camera_intrinsics_changed:{expected.camera_id}") - if max( - abs(left - right) - for left, right in zip( - expected.world_to_camera, - observed.world_to_camera, - strict=True, - ) - ) > limits.camera_extrinsics_residual: - errors.append(f"camera_extrinsics_changed:{expected.camera_id}") - return tuple(errors) - - -def relation_graph_diff( - before: Scene, - observed: Scene, - spec: InterventionSpec, -) -> tuple[tuple[str, ...], int]: - """Return the complete directed relation diff and leaked pair count.""" - engine = RelationEngine() - changed: list[str] = [] - leaked_pairs: set[tuple[str, str]] = set() - target_pair = frozenset((spec.subject_id, spec.reference_id)) - objects = sorted(before.objects, key=lambda obj: obj.object_id) - for first in objects: - for second in objects: - if first.object_id == second.object_id: - continue - old = engine.pair_labels( - before, - first.object_id, - second.object_id, - spec.camera_id, - ) - new = engine.pair_labels( - observed, - first.object_id, - second.object_id, - spec.camera_id, - ) - for relation in sorted(old - new, key=lambda item: item.value): - changed.append( - f"-{first.object_id}:{relation.value}:{second.object_id}" - ) - for relation in sorted(new - old, key=lambda item: item.value): - changed.append( - f"+{first.object_id}:{relation.value}:{second.object_id}" - ) - if ( - old != new - and frozenset((first.object_id, second.object_id)) != target_pair - ): - leaked_pairs.add(tuple(sorted((first.object_id, second.object_id)))) - return tuple(sorted(changed)), len(leaked_pairs) - - -def normalized_native_after( - before: Scene, - observed: Scene, - spec: InterventionSpec, - limits: AI2ThorValidationTolerances, -) -> tuple[Scene | None, tuple[str, ...], float, float]: - """Validate raw drift, then normalize only for the core verifier.""" - errors: list[str] = [] - maximum_geometry_residual = 0.0 - maximum_rotation_residual = 0.0 - for field in ( - "scene_id", - "source", - "coordinate_system", - "generation_seed", - "pinned_object_ids", - "room_polygon_xy", - ): - if getattr(before, field) != getattr(observed, field): - errors.append(f"native_{field}_changed") - errors.extend(camera_residual_errors(before, observed, limits)) - - before_ids = {obj.object_id for obj in before.objects} - observed_ids = {obj.object_id for obj in observed.objects} - if before_ids != observed_ids: - errors.append("native_object_set_changed") - return ( - None, - tuple(sorted(set(errors))), - maximum_geometry_residual, - maximum_rotation_residual, - ) - - normalized_objects = [] - for expected in before.objects: - current = observed.object_by_id(expected.object_id) - for field in ( - "name", - "category", - "movable", - "request_eligible", - "support_object_id", - ): - if getattr(expected, field) != getattr(current, field): - errors.append( - f"native_object_field_changed:{expected.object_id}:{field}" - ) - - geometry_residual = max( - coordinate_residual(expected.position, current.position), - coordinate_residual(expected.obb.center, current.obb.center), - coordinate_residual(expected.obb.extent, current.obb.extent), - ) - rotation_residual = max( - quaternion_residual_degrees(expected.rotation, current.rotation), - quaternion_residual_degrees(expected.obb.rotation, current.obb.rotation), - ) - maximum_rotation_residual = max( - maximum_rotation_residual, - rotation_residual, - ) - - if expected.object_id == spec.subject_id: - subject_geometry_residual = max( - abs(expected.position.z - current.position.z), - abs(expected.obb.center.z - current.obb.center.z), - coordinate_residual(expected.obb.extent, current.obb.extent), - abs( - (current.position.x - expected.position.x) - - (current.obb.center.x - expected.obb.center.x) - ), - abs( - (current.position.y - expected.position.y) - - (current.obb.center.y - expected.obb.center.y) - ), - ) - maximum_geometry_residual = max( - maximum_geometry_residual, - subject_geometry_residual, - ) - if subject_geometry_residual > limits.stationary_geometry_residual_m: - errors.append("native_subject_geometry_residual_exceeded") - if rotation_residual > limits.rotation_residual_degrees: - errors.append("native_subject_rotation_residual_exceeded") - delta_x = current.position.x - expected.position.x - delta_y = current.position.y - expected.position.y - normalized_objects.append( - expected.model_copy( - update={ - "position": Vec3( - x=current.position.x, - y=current.position.y, - z=expected.position.z, - ), - "obb": expected.obb.model_copy( - update={ - "center": Vec3( - x=expected.obb.center.x + delta_x, - y=expected.obb.center.y + delta_y, - z=expected.obb.center.z, - ) - } - ), - "views": current.views, - } - ) - ) - else: - maximum_geometry_residual = max( - maximum_geometry_residual, - geometry_residual, - ) - if geometry_residual > limits.stationary_geometry_residual_m: - errors.append( - f"native_stationary_geometry_residual_exceeded:{expected.object_id}" - ) - if rotation_residual > limits.rotation_residual_degrees: - errors.append( - f"native_stationary_rotation_residual_exceeded:{expected.object_id}" - ) - normalized_objects.append( - expected.model_copy(update={"views": current.views}) - ) - - if errors: - return ( - None, - tuple(sorted(set(errors))), - maximum_geometry_residual, - maximum_rotation_residual, - ) - return ( - before.model_copy(update={"objects": tuple(normalized_objects)}), - (), - maximum_geometry_residual, - maximum_rotation_residual, - ) - - -__all__ = ( - "AI2ThorValidationRejected", - "AI2ThorValidationTolerances", - "CertifiedAI2ThorCaseRejected", - "CertifiedAI2ThorPilotTolerances", - "camera_residual_errors", - "coordinate_residual", - "floor_envelope_contract_errors", - "floor_polygon", - "is_finite_non_negative", - "normalized_native_after", - "observation_contract_errors", - "pose_application_contract_errors", - "position_distance", - "quaternion_residual_degrees", - "relation_graph_diff", - "scene_without_views", - "settlement_contract_errors", - "vertical_contact_residual", -) diff --git a/src/spatialcf/adapters/base.py b/src/spatialcf/adapters/base.py index 513b92a..1c80633 100644 --- a/src/spatialcf/adapters/base.py +++ b/src/spatialcf/adapters/base.py @@ -1,8 +1,15 @@ -from dataclasses import dataclass +import json +import math +from dataclasses import asdict, dataclass +from hashlib import sha256 from pathlib import Path -from typing import Protocol, runtime_checkable +from types import TracebackType +from typing import Protocol, Self, runtime_checkable -from spatialcf.domain.models import Scene +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.request import InterventionSpec +from spatialcf.domain.scene import OBB, Scene, SubjectPositionRegion, Vec2, Vec3 +from spatialcf.domain.serialization import canonical_json_bytes @dataclass(frozen=True) @@ -13,23 +20,1086 @@ class RenderedAssets: pointcloud_path: Path +@dataclass(frozen=True) +class AdapterBinding: + """Opaque, Canonical-only binding between one capture and its readbacks.""" + + scene_id: str + token: str + + def __post_init__(self) -> None: + if type(self.scene_id) is not str or not self.scene_id: + raise TypeError("binding scene_id must be a non-empty exact string") + if type(self.token) is not str or not self.token: + raise TypeError("binding token must be a non-empty exact string") + + +@dataclass(frozen=True) +class CaptureRequest: + scene_id: str + camera_id: str + + def __post_init__(self) -> None: + if type(self.scene_id) is not str or not self.scene_id: + raise TypeError("capture scene_id must be a non-empty exact string") + if type(self.camera_id) is not str or not self.camera_id: + raise TypeError("capture camera_id must be a non-empty exact string") + + +@dataclass(frozen=True) +class CapturedSource: + request: CaptureRequest + scene: Scene + binding: AdapterBinding + + def __post_init__(self) -> None: + if type(self.request) is not CaptureRequest or type(self.scene) is not Scene: + raise TypeError("captured source must contain exact Canonical values") + if type(self.binding) is not AdapterBinding: + raise TypeError("captured source binding must be exact") + if ( + self.scene.scene_id != self.request.scene_id + or self.binding.scene_id != self.scene.scene_id + ): + raise ValueError("captured source binding does not match its scene") + self.scene.camera_by_id(self.request.camera_id) + + +@dataclass(frozen=True) +class CapturedSupport: + source: CapturedSource + scene: Scene + binding: AdapterBinding + + def __post_init__(self) -> None: + if type(self.source) is not CapturedSource or type(self.scene) is not Scene: + raise TypeError("captured support must contain exact Canonical values") + if type(self.binding) is not AdapterBinding: + raise TypeError("captured support binding must be exact") + if self.scene != self.source.scene or self.binding != self.source.binding: + raise ValueError("captured support must retain its source binding") + + +@dataclass(frozen=True) +class CertifiedEditApplication: + source: CapturedSource + intervention: InterventionSpec + edit: CanonicalEdit + spawn_map: "AdapterSpawnMap" + max_settlement_steps: int + + def __post_init__(self) -> None: + if type(self.source) is not CapturedSource: + raise TypeError("certified application source must be exact") + if type(self.intervention) is not InterventionSpec: + raise TypeError("certified application intervention must be exact") + if type(self.edit) is not CanonicalEdit: + raise TypeError("certified application edit must be exact") + if type(self.spawn_map) is not AdapterSpawnMap: + raise TypeError("certified application spawn map must be exact") + if type(self.max_settlement_steps) is not int or self.max_settlement_steps <= 0: + raise ValueError("certified application settlement steps must be positive") + if ( + self.edit.subject_id != self.intervention.subject_id + or self.source.request.camera_id != self.intervention.camera_id + ): + raise ValueError("certified application does not bind its edit") + subject = self.source.scene.object_by_id(self.intervention.subject_id) + self.source.scene.object_by_id(self.intervention.reference_id) + if ( + self.spawn_map.binding != self.source.binding + or self.spawn_map.scene_id != self.source.scene.scene_id + or self.spawn_map.subject_object_id != subject.object_id + or self.spawn_map.support_object_id != subject.support_object_id + ): + raise ValueError("certified application spawn map does not bind its source") + + +@dataclass(frozen=True) +class AppliedCertifiedEdit: + application: CertifiedEditApplication + edit: CanonicalEdit + commanded_scene: Scene + observed_scene: Scene + commanded_position: "AdapterPosition" + observed_position: "AdapterPosition" + position_residual_m: float + observation: "AdapterObservation" + is_scene_at_rest: bool + subject_is_moving: bool + settlement_pass_steps: int + binding: AdapterBinding + + def __post_init__(self) -> None: + if type(self.application) is not CertifiedEditApplication: + raise TypeError("applied application must be exact") + if ( + type(self.edit) is not CanonicalEdit + or type(self.commanded_scene) is not Scene + or type(self.observed_scene) is not Scene + ): + raise TypeError("applied edit must contain exact Canonical values") + if ( + type(self.commanded_position) is not AdapterPosition + or type(self.observed_position) is not AdapterPosition + or type(self.observation) is not AdapterObservation + ): + raise TypeError("applied edit must carry exact observed facts") + if type(self.binding) is not AdapterBinding: + raise TypeError("applied edit binding must be exact") + if ( + type(self.is_scene_at_rest) is not bool + or type(self.subject_is_moving) is not bool + ): + raise TypeError("applied edit rest and motion flags must be exact") + if ( + type(self.settlement_pass_steps) is not int + or self.settlement_pass_steps < 0 + ): + raise ValueError("applied edit settlement count must be non-negative") + if self.edit != self.application.edit: + raise ValueError("applied edit must equal the certified edit") + if ( + self.commanded_scene.scene_id != self.application.source.scene.scene_id + or self.observed_scene.scene_id != self.application.source.scene.scene_id + or self.binding != self.application.source.binding + or self.binding != self.application.spawn_map.binding + ): + raise ValueError("applied edit does not retain its source binding") + commanded = self.commanded_scene.object_by_id(self.edit.subject_id).position + observed = self.observed_scene.object_by_id(self.edit.subject_id).position + expected_commanded = self.application.source.scene.object_by_id( + self.edit.subject_id + ).position + if ( + self.commanded_position + != AdapterPosition( + x=commanded.x, + y=commanded.y, + z=commanded.z, + ) + or self.observed_position + != AdapterPosition( + x=observed.x, + y=observed.y, + z=observed.z, + ) + or self.commanded_position + != AdapterPosition( + x=expected_commanded.x + self.edit.translation_xy_m.x, + y=expected_commanded.y + self.edit.translation_xy_m.y, + z=expected_commanded.z, + ) + ): + raise ValueError("applied edit positions do not bind its Canonical scenes") + residual = math.dist( + ( + self.commanded_position.x, + self.commanded_position.y, + self.commanded_position.z, + ), + ( + self.observed_position.x, + self.observed_position.y, + self.observed_position.z, + ), + ) + value = _strict_float(self.position_residual_m, "applied edit residual") + if value < 0.0 or not math.isclose(value, residual, rel_tol=0.0, abs_tol=1e-12): + raise ValueError("applied edit residual does not match observed position") + object.__setattr__(self, "position_residual_m", value) + if ( + self.observation.scene != self.observed_scene + or self.observation.is_settled != self.is_scene_at_rest + or (self.subject_is_moving and self.is_scene_at_rest) + ): + raise ValueError("applied edit observation does not match motion state") + + @property + def scene(self) -> Scene: + """Compatibility view of the concrete post-action Canonical scene.""" + return self.observed_scene + + +@dataclass(frozen=True) +class SettledReadback: + applied: AppliedCertifiedEdit + commanded_scene: Scene + observed_scene: Scene + commanded_position: "AdapterPosition" + observed_position: "AdapterPosition" + position_residual_m: float + observation: "AdapterObservation" + is_scene_at_rest: bool + subject_is_moving: bool + settlement_pass_steps: int + binding: AdapterBinding + + def __post_init__(self) -> None: + if ( + type(self.applied) is not AppliedCertifiedEdit + or type(self.commanded_scene) is not Scene + or type(self.observed_scene) is not Scene + ): + raise TypeError("settled readback must contain exact Canonical values") + if ( + type(self.commanded_position) is not AdapterPosition + or type(self.observed_position) is not AdapterPosition + or type(self.observation) is not AdapterObservation + ): + raise TypeError("settled readback must carry exact observed facts") + if type(self.binding) is not AdapterBinding: + raise TypeError("settled readback binding must be exact") + if ( + type(self.is_scene_at_rest) is not bool + or type(self.subject_is_moving) is not bool + ): + raise TypeError("settled readback rest and motion flags must be exact") + if ( + type(self.settlement_pass_steps) is not int + or self.settlement_pass_steps < 0 + ): + raise ValueError("settled readback pass count must be non-negative") + if ( + self.commanded_scene != self.applied.commanded_scene + or self.commanded_position != self.applied.commanded_position + or self.observed_scene.scene_id != self.applied.observed_scene.scene_id + or self.binding != self.applied.binding + ): + raise ValueError("settled readback does not retain its application binding") + observed = self.observed_scene.object_by_id( + self.applied.edit.subject_id + ).position + if self.observed_position != AdapterPosition( + x=observed.x, + y=observed.y, + z=observed.z, + ): + raise ValueError("settled readback position does not bind observed scene") + residual = math.dist( + ( + self.commanded_position.x, + self.commanded_position.y, + self.commanded_position.z, + ), + ( + self.observed_position.x, + self.observed_position.y, + self.observed_position.z, + ), + ) + value = _strict_float(self.position_residual_m, "settled readback residual") + if value < 0.0 or not math.isclose(value, residual, rel_tol=0.0, abs_tol=1e-12): + raise ValueError( + "settled readback residual does not match observed position" + ) + object.__setattr__(self, "position_residual_m", value) + if ( + self.observation.scene != self.observed_scene + or self.observation.is_settled != self.is_scene_at_rest + or not self.is_scene_at_rest + or self.subject_is_moving + ): + raise ValueError("settled readback does not carry a settled observation") + + @property + def application(self) -> CertifiedEditApplication: + return self.applied.application + + @property + def scene(self) -> Scene: + """Compatibility view of the settled Canonical scene.""" + return self.observed_scene + + +class AdapterOperationError(RuntimeError): + """A normalized adapter operation could not produce its bound result.""" + + +class AdapterActionRejected(AdapterOperationError): + """The adapter received one explicit rejected platform action.""" + + def __init__(self, reason: str) -> None: + _strict_text(reason, "adapter action rejection") + self.reason = " ".join(reason.split()) + super().__init__(self.reason) + + +class AdapterReturnRejected(AdapterOperationError): + """A successful platform action returned invalid adapter evidence.""" + + def __init__(self, reason: str) -> None: + _strict_text(reason, "adapter return rejection") + self.reason = " ".join(reason.split()) + super().__init__(self.reason) + + +class AdapterSettlementTimeout(AdapterOperationError): + """An explicitly bounded adapter settlement did not complete.""" + + +def _strict_text(value: str, label: str) -> None: + if type(value) is not str or not value: + raise TypeError(f"{label} must be a non-empty exact string") + + +def _strict_digest(value: str, label: str) -> None: + _strict_text(value, label) + if len(value) != 64 or any( + character not in "0123456789abcdef" for character in value + ): + raise ValueError(f"{label} must be lowercase SHA-256 hex") + + +def _strict_float(value: float, label: str) -> float: + if type(value) not in (int, float) or isinstance(value, bool): + raise TypeError(f"{label} must be an exact finite scalar") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{label} must be finite") + return result + + +@dataclass(frozen=True) +class SourceCaptureOptions: + """Bounded source-observation settings selected by generation.""" + + max_settlement_steps: int + floor_clearance_m: float + navigation_agent_radius_m: float + navigation_clearance_m: float + + def __post_init__(self) -> None: + if type(self.max_settlement_steps) is not int or self.max_settlement_steps <= 0: + raise ValueError("source capture max_settlement_steps must be positive") + for name, raw_value in ( + ("floor_clearance_m", self.floor_clearance_m), + ("navigation_agent_radius_m", self.navigation_agent_radius_m), + ("navigation_clearance_m", self.navigation_clearance_m), + ): + value = _strict_float(raw_value, f"source capture {name}") + if value < 0.0: + raise ValueError(f"source capture {name} must be non-negative") + object.__setattr__(self, name, value) + if self.navigation_agent_radius_m <= 0.0: + raise ValueError("source capture navigation agent radius must be positive") + + +@dataclass(frozen=True) +class AdapterProceduralScene: + """Immutable source bytes supplied to an adapter without a native model.""" + + dataset_id: str + revision: str + split: str + index: int + source_loader_id: str + source_loader_version: str + canonical_house_json: bytes + house_sha256: str + room_id: str + floor_xz_bounds: tuple[float, float, float, float] + + def __post_init__(self) -> None: + for name, value in ( + ("dataset_id", self.dataset_id), + ("revision", self.revision), + ("split", self.split), + ("source_loader_id", self.source_loader_id), + ("source_loader_version", self.source_loader_version), + ("room_id", self.room_id), + ): + _strict_text(value, f"procedural source {name}") + if type(self.index) is not int or self.index < 0: + raise ValueError("procedural source index must be non-negative") + if type(self.canonical_house_json) is not bytes: + raise TypeError("procedural source bytes must be exact") + _strict_digest(self.house_sha256, "procedural source SHA-256") + if sha256(self.canonical_house_json).hexdigest() != self.house_sha256: + raise ValueError("procedural source digest does not match bytes") + if type(self.floor_xz_bounds) is not tuple or len(self.floor_xz_bounds) != 4: + raise TypeError("procedural source bounds must be an exact tuple") + bounds = tuple( + _strict_float(value, "procedural source floor bound") + for value in self.floor_xz_bounds + ) + if not bounds[0] < bounds[2] or not bounds[1] < bounds[3]: + raise ValueError("procedural source bounds must have positive area") + object.__setattr__(self, "floor_xz_bounds", bounds) + self.decode_house() + + @classmethod + def create( + cls, + *, + dataset_id: str, + revision: str, + split: str, + index: int, + source_loader_id: str, + source_loader_version: str, + house: dict, + ) -> Self: + if type(house) is not dict: + raise TypeError("procedural source house must be an exact dictionary") + encoded = ( + json.dumps( + house, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + "\n" + ).encode("utf-8") + rooms = house.get("rooms") + if type(rooms) is not list or len(rooms) != 1 or type(rooms[0]) is not dict: + raise ValueError("procedural source must contain one room") + room = rooms[0] + room_id = room.get("id") + polygon = room.get("floorPolygon") + if type(room_id) is not str or type(polygon) is not list or len(polygon) < 3: + raise ValueError("procedural source room is invalid") + try: + xs = tuple(float(point["x"]) for point in polygon) + zs = tuple(float(point["z"]) for point in polygon) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("procedural source room polygon is invalid") from error + return cls( + dataset_id=dataset_id, + revision=revision, + split=split, + index=index, + source_loader_id=source_loader_id, + source_loader_version=source_loader_version, + canonical_house_json=encoded, + house_sha256=sha256(encoded).hexdigest(), + room_id=room_id, + floor_xz_bounds=(min(xs), min(zs), max(xs), max(zs)), + ) + + def decode_house(self) -> dict: + try: + decoded = json.loads(self.canonical_house_json) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("procedural source bytes are not UTF-8 JSON") from error + if type(decoded) is not dict: + raise ValueError("procedural source JSON root must be a dictionary") + return decoded + + +@dataclass(frozen=True) +class AdapterPosition: + """One immutable adapter-coordinate position carried without native objects.""" + + x: float + y: float + z: float + + def __post_init__(self) -> None: + object.__setattr__(self, "x", _strict_float(self.x, "adapter position x")) + object.__setattr__(self, "y", _strict_float(self.y, "adapter position y")) + object.__setattr__(self, "z", _strict_float(self.z, "adapter position z")) + + +@dataclass(frozen=True) +class AdapterPose: + position: AdapterPosition + yaw_degrees: float + horizon_degrees: float + standing: bool + + def __post_init__(self) -> None: + if type(self.position) is not AdapterPosition: + raise TypeError("adapter pose position must be exact") + object.__setattr__( + self, "yaw_degrees", _strict_float(self.yaw_degrees, "adapter yaw") + ) + object.__setattr__( + self, + "horizon_degrees", + _strict_float(self.horizon_degrees, "adapter camera horizon"), + ) + if type(self.standing) is not bool: + raise TypeError("adapter pose standing must be an exact boolean") + + +@dataclass(frozen=True) +class AdapterRuntimeProvenance: + dataset_id: str + revision: str + split: str + index: int + source_sha256: str + scene_alias: str + loader_id: str + loader_version: str + room_id: str + floor_xz_bounds: tuple[float, float, float, float] + + def __post_init__(self) -> None: + for name, value in ( + ("dataset_id", self.dataset_id), + ("revision", self.revision), + ("split", self.split), + ("source_sha256", self.source_sha256), + ("scene_alias", self.scene_alias), + ("loader_id", self.loader_id), + ("loader_version", self.loader_version), + ("room_id", self.room_id), + ): + _strict_text(value, f"runtime provenance {name}") + _strict_digest(self.source_sha256, "runtime provenance source_sha256") + if type(self.index) is not int or self.index < 0: + raise ValueError("runtime provenance index must be non-negative") + if type(self.floor_xz_bounds) is not tuple or len(self.floor_xz_bounds) != 4: + raise TypeError("runtime provenance floor bounds must be an exact tuple") + bounds = tuple( + _strict_float(value, "runtime provenance floor bound") + for value in self.floor_xz_bounds + ) + if not bounds[0] < bounds[2] or not bounds[1] < bounds[3]: + raise ValueError("runtime provenance floor bounds must be positive") + object.__setattr__(self, "floor_xz_bounds", bounds) + + +@dataclass(frozen=True) +class AdapterRuntimeIdentity: + ai2thor_version: str + unity_commit_id: str + native_scene_name: str + width: int + height: int + seed: int + render_depth_image: bool = True + render_instance_segmentation: bool = True + grid_size_m: float = 0.05 + snap_to_grid: bool = True + rotate_step_degrees: int = 90 + coordinate_transform_version: str = "ai2thor-native-xzy-to-rh-z-up-v1" + source_dataset_id: str | None = None + source_revision: str | None = None + source_split: str | None = None + source_index: int | None = None + source_sha256: str | None = None + source_scene_alias: str | None = None + source_loader_id: str | None = None + source_loader_version: str | None = None + source_room_id: str | None = None + source_floor_xz_bounds: tuple[float, float, float, float] | None = None + teleport_vertical_guard_m: float = 0.0 + + def __post_init__(self) -> None: + for name, value in ( + ("ai2thor_version", self.ai2thor_version), + ("unity_commit_id", self.unity_commit_id), + ("native_scene_name", self.native_scene_name), + ("coordinate_transform_version", self.coordinate_transform_version), + ): + _strict_text(value, f"runtime identity {name}") + for name, value in ( + ("width", self.width), + ("height", self.height), + ("seed", self.seed), + ("rotate_step_degrees", self.rotate_step_degrees), + ): + if type(value) is not int: + raise TypeError(f"runtime identity {name} must be an exact integer") + if self.width <= 0 or self.height <= 0 or self.rotate_step_degrees <= 0: + raise ValueError("runtime identity dimensions must be positive") + for name, value in ( + ("render_depth_image", self.render_depth_image), + ("render_instance_segmentation", self.render_instance_segmentation), + ("snap_to_grid", self.snap_to_grid), + ): + if type(value) is not bool: + raise TypeError(f"runtime identity {name} must be an exact boolean") + object.__setattr__( + self, "grid_size_m", _strict_float(self.grid_size_m, "runtime grid size") + ) + object.__setattr__( + self, + "teleport_vertical_guard_m", + _strict_float(self.teleport_vertical_guard_m, "runtime teleport guard"), + ) + provenance_values = ( + self.source_dataset_id, + self.source_revision, + self.source_split, + self.source_index, + self.source_sha256, + self.source_scene_alias, + self.source_loader_id, + self.source_loader_version, + self.source_room_id, + self.source_floor_xz_bounds, + ) + if any(value is not None for value in provenance_values): + if any(value is None for value in provenance_values): + raise ValueError("runtime identity provenance must be complete") + AdapterRuntimeProvenance( + dataset_id=self.source_dataset_id, + revision=self.source_revision, + split=self.source_split, + index=self.source_index, + source_sha256=self.source_sha256, + scene_alias=self.source_scene_alias, + loader_id=self.source_loader_id, + loader_version=self.source_loader_version, + room_id=self.source_room_id, + floor_xz_bounds=self.source_floor_xz_bounds, + ) + + +@dataclass(frozen=True) +class AdapterObservation: + scene: Scene + rgb_png: bytes + depth_npy: bytes + instance_png: bytes + pointcloud_ply: bytes + rgb_png_sha256: str + depth_npy_sha256: str + instance_png_sha256: str + pointcloud_ply_sha256: str + instance_pixel_counts: tuple[tuple[str, int], ...] + is_settled: bool + + @classmethod + def create( + cls, + *, + scene: Scene, + rgb_png: bytes, + depth_npy: bytes, + instance_png: bytes, + pointcloud_ply: bytes, + instance_pixel_counts: tuple[tuple[str, int], ...], + is_settled: bool, + ) -> Self: + return cls( + scene=scene, + rgb_png=rgb_png, + depth_npy=depth_npy, + instance_png=instance_png, + pointcloud_ply=pointcloud_ply, + rgb_png_sha256=sha256(rgb_png).hexdigest(), + depth_npy_sha256=sha256(depth_npy).hexdigest(), + instance_png_sha256=sha256(instance_png).hexdigest(), + pointcloud_ply_sha256=sha256(pointcloud_ply).hexdigest(), + instance_pixel_counts=instance_pixel_counts, + is_settled=is_settled, + ) + + def __post_init__(self) -> None: + if type(self.scene) is not Scene: + raise TypeError("adapter observation scene must be exact") + for name, value in ( + ("rgb_png", self.rgb_png), + ("depth_npy", self.depth_npy), + ("instance_png", self.instance_png), + ("pointcloud_ply", self.pointcloud_ply), + ): + if type(value) is not bytes: + raise TypeError(f"adapter observation {name} must be exact bytes") + for name, value in ( + ("rgb_png_sha256", self.rgb_png_sha256), + ("depth_npy_sha256", self.depth_npy_sha256), + ("instance_png_sha256", self.instance_png_sha256), + ("pointcloud_ply_sha256", self.pointcloud_ply_sha256), + ): + _strict_digest(value, f"adapter observation {name}") + expected = ( + sha256(self.rgb_png).hexdigest(), + sha256(self.depth_npy).hexdigest(), + sha256(self.instance_png).hexdigest(), + sha256(self.pointcloud_ply).hexdigest(), + ) + if expected != ( + self.rgb_png_sha256, + self.depth_npy_sha256, + self.instance_png_sha256, + self.pointcloud_ply_sha256, + ): + raise ValueError("adapter observation asset digests do not match bytes") + if ( + type(self.instance_pixel_counts) is not tuple + or any( + type(item) is not tuple + or len(item) != 2 + or type(item[0]) is not str + or type(item[1]) is not int + or item[1] < 0 + for item in self.instance_pixel_counts + ) + or self.instance_pixel_counts != tuple(sorted(self.instance_pixel_counts)) + or len({item[0] for item in self.instance_pixel_counts}) + != len(self.instance_pixel_counts) + ): + raise TypeError( + "adapter observation pixel counts must be sorted exact pairs" + ) + if type(self.is_settled) is not bool: + raise TypeError("adapter observation settled flag must be exact") + + +@dataclass(frozen=True) +class AdapterSupportFact: + scene_id: str + object_id: str + object_name: str + native_object_id: str + raw_parent_object_ids: tuple[str, ...] + structural_parent_object_ids: tuple[str, ...] + domain_parent_object_ids: tuple[str, ...] + support_kind: str + support_object_id: str | None + floor_object_id: str | None + + def __post_init__(self) -> None: + for name, value in ( + ("scene_id", self.scene_id), + ("object_id", self.object_id), + ("object_name", self.object_name), + ("native_object_id", self.native_object_id), + ("support_kind", self.support_kind), + ): + _strict_text(value, f"adapter support {name}") + for name, values in ( + ("raw_parent_object_ids", self.raw_parent_object_ids), + ("structural_parent_object_ids", self.structural_parent_object_ids), + ("domain_parent_object_ids", self.domain_parent_object_ids), + ): + if ( + type(values) is not tuple + or any(type(value) is not str or not value for value in values) + or values != tuple(sorted(set(values))) + ): + raise TypeError(f"adapter support {name} must be sorted exact strings") + for name, value in ( + ("support_object_id", self.support_object_id), + ("floor_object_id", self.floor_object_id), + ): + if value is not None: + _strict_text(value, f"adapter support {name}") + + +@dataclass(frozen=True) +class AdapterFloorEnvelope: + scene_id: str + floor_object_id: str + floor_name: str + native_aabb: OBB + floor_top_z: float + clearance_m: float + polygon_xy: tuple[Vec2, ...] + + def __post_init__(self) -> None: + for name, value in ( + ("scene_id", self.scene_id), + ("floor_object_id", self.floor_object_id), + ("floor_name", self.floor_name), + ): + _strict_text(value, f"adapter floor {name}") + if type(self.native_aabb) is not OBB: + raise TypeError("adapter floor native_aabb must be exact") + object.__setattr__( + self, "floor_top_z", _strict_float(self.floor_top_z, "adapter floor top") + ) + object.__setattr__( + self, + "clearance_m", + _strict_float(self.clearance_m, "adapter floor clearance"), + ) + if ( + type(self.polygon_xy) is not tuple + or len(self.polygon_xy) < 3 + or any(type(item) is not Vec2 for item in self.polygon_xy) + ): + raise TypeError("adapter floor polygon must be exact canonical points") + + +@dataclass(frozen=True) +class AdapterSurfacePatch: + x_min: float + x_max: float + native_y: float + z_min: float + z_max: float + + def __post_init__(self) -> None: + for name, value in ( + ("x_min", self.x_min), + ("x_max", self.x_max), + ("native_y", self.native_y), + ("z_min", self.z_min), + ("z_max", self.z_max), + ): + object.__setattr__( + self, name, _strict_float(value, f"adapter surface {name}") + ) + if self.x_min >= self.x_max or self.z_min >= self.z_max: + raise ValueError("adapter surface patch must have positive area") + + +@dataclass(frozen=True) +class AdapterSpawnMap: + binding: AdapterBinding + runtime_identity: AdapterRuntimeIdentity + scene_id: str + subject_object_id: str + support_object_id: str + native_subject_object_id: str + native_support_object_id: str + positions: tuple[AdapterPosition, ...] + positions_sha256: str + scene_sha256: str + source_sha256: str + surface_patches: tuple[AdapterSurfacePatch, ...] + position_region: SubjectPositionRegion | None = None + + def __post_init__(self) -> None: + if type(self.binding) is not AdapterBinding: + raise TypeError("adapter spawn map binding must be exact") + if type(self.runtime_identity) is not AdapterRuntimeIdentity: + raise TypeError("adapter spawn map runtime identity must be exact") + for name, value in ( + ("scene_id", self.scene_id), + ("subject_object_id", self.subject_object_id), + ("support_object_id", self.support_object_id), + ("native_subject_object_id", self.native_subject_object_id), + ("native_support_object_id", self.native_support_object_id), + ): + _strict_text(value, f"adapter spawn map {name}") + if self.scene_id != self.binding.scene_id: + raise ValueError("adapter spawn map does not retain its source binding") + if ( + type(self.positions) is not tuple + or any(type(item) is not AdapterPosition for item in self.positions) + or self.positions + != tuple(sorted(self.positions, key=lambda item: (item.x, item.z, item.y))) + ): + raise TypeError("adapter spawn map positions must be sorted exact values") + for name, value in ( + ("positions_sha256", self.positions_sha256), + ("scene_sha256", self.scene_sha256), + ("source_sha256", self.source_sha256), + ): + _strict_digest(value, f"adapter spawn map {name}") + if type(self.surface_patches) is not tuple or any( + type(item) is not AdapterSurfacePatch for item in self.surface_patches + ): + raise TypeError("adapter spawn map patches must be exact") + if ( + self.position_region is not None + and type(self.position_region) is not SubjectPositionRegion + ): + raise TypeError("adapter spawn map position region must be canonical") + + +@dataclass(frozen=True) +class SourceCaptureFacts: + source: CapturedSource + binding: AdapterBinding + scene: Scene + runtime_identity: AdapterRuntimeIdentity + observation: AdapterObservation + support_facts: tuple[AdapterSupportFact, ...] + floor_envelope: AdapterFloorEnvelope | None + floor_position_regions: tuple[tuple[str, SubjectPositionRegion], ...] + reachable_positions: tuple[AdapterPosition, ...] + current_pose: AdapterPose + settlement_pass_steps: int + + def __post_init__(self) -> None: + if ( + type(self.source) is not CapturedSource + or type(self.binding) is not AdapterBinding + ): + raise TypeError("source capture facts must retain exact source and binding") + if self.binding != self.source.binding: + raise ValueError("source capture facts binding does not match source") + if ( + type(self.scene) is not Scene + or type(self.runtime_identity) is not AdapterRuntimeIdentity + ): + raise TypeError("source capture facts require exact canonical values") + if ( + type(self.observation) is not AdapterObservation + or self.observation.scene != self.scene + ): + raise ValueError("source capture facts observation does not bind scene") + if not self.observation.is_settled: + raise ValueError("source capture facts require a settled observation") + if ( + type(self.support_facts) is not tuple + or any(type(item) is not AdapterSupportFact for item in self.support_facts) + or tuple(item.object_id for item in self.support_facts) + != tuple(item.object_id for item in self.scene.objects) + ): + raise ValueError("source capture facts support roster does not bind scene") + if ( + self.floor_envelope is not None + and type(self.floor_envelope) is not AdapterFloorEnvelope + ): + raise TypeError("source capture facts floor must be exact when present") + if self.scene.scene_id != self.binding.scene_id: + raise ValueError( + "source capture facts scene does not retain source binding" + ) + if ( + self.floor_envelope is not None + and self.floor_envelope.scene_id != self.scene.scene_id + ): + raise ValueError("source capture facts floor does not bind observed scene") + if ( + type(self.floor_position_regions) is not tuple + or any( + type(item) is not tuple + or len(item) != 2 + or type(item[0]) is not str + or type(item[1]) is not SubjectPositionRegion + for item in self.floor_position_regions + ) + or self.floor_position_regions + != tuple(sorted(self.floor_position_regions, key=lambda item: item[0])) + ): + raise TypeError( + "source capture facts floor regions must be sorted exact pairs" + ) + if any(item.scene_id != self.scene.scene_id for item in self.support_facts): + raise ValueError( + "source capture facts support values do not bind observed scene" + ) + if ( + type(self.reachable_positions) is not tuple + or any( + type(item) is not AdapterPosition for item in self.reachable_positions + ) + or self.reachable_positions + != tuple( + sorted( + self.reachable_positions, key=lambda item: (item.x, item.z, item.y) + ) + ) + ): + raise TypeError("source capture facts reachable positions must be sorted") + if type(self.current_pose) is not AdapterPose: + raise TypeError("source capture facts current pose must be exact") + if ( + type(self.settlement_pass_steps) is not int + or self.settlement_pass_steps < 0 + ): + raise ValueError( + "source capture facts settlement pass count must be non-negative" + ) + + +@dataclass(frozen=True) +class CameraObservationHandle: + source: CapturedSource + binding: AdapterBinding + scene: Scene + token: str + settle_after_resume: bool + + def __post_init__(self) -> None: + if ( + type(self.source) is not CapturedSource + or type(self.binding) is not AdapterBinding + ): + raise TypeError("camera handle source and binding must be exact") + if type(self.scene) is not Scene: + raise TypeError("camera handle scene must be exact") + if ( + self.binding != self.source.binding + or self.scene.scene_id != self.binding.scene_id + ): + raise ValueError("camera handle does not bind its source") + _strict_text(self.token, "camera handle token") + if type(self.settle_after_resume) is not bool: + raise TypeError("camera handle settle_after_resume must be exact") + + +@dataclass(frozen=True) +class AdapterCameraApplication: + source: CapturedSource + binding: AdapterBinding + requested_pose: AdapterPose + observed_pose: AdapterPose + observed_camera_position: AdapterPosition + observed_scene: Scene + observation: AdapterObservation + position_residual_m: float + yaw_residual_degrees: float + horizon_residual_degrees: float + + def __post_init__(self) -> None: + if ( + type(self.source) is not CapturedSource + or type(self.binding) is not AdapterBinding + ): + raise TypeError("camera application source and binding must be exact") + if self.binding != self.source.binding: + raise ValueError("camera application does not retain source binding") + for name, value in ( + ("requested_pose", self.requested_pose), + ("observed_pose", self.observed_pose), + ): + if type(value) is not AdapterPose: + raise TypeError(f"camera application {name} must be exact") + if type(self.observed_camera_position) is not AdapterPosition: + raise TypeError("camera application observed position must be exact") + if ( + type(self.observed_scene) is not Scene + or type(self.observation) is not AdapterObservation + ): + raise TypeError("camera application observed facts must be exact") + if self.observation.scene != self.observed_scene: + raise ValueError( + "camera application observation does not bind observed scene" + ) + self.observed_scene.camera_by_id(self.source.request.camera_id) + for name, raw_value in ( + ("position_residual_m", self.position_residual_m), + ("yaw_residual_degrees", self.yaw_residual_degrees), + ("horizon_residual_degrees", self.horizon_residual_degrees), + ): + value = _strict_float(raw_value, f"camera application {name}") + if value < 0.0: + raise ValueError(f"camera application {name} must be non-negative") + object.__setattr__(self, name, value) + + +@dataclass(frozen=True) +class AdapterSettledCameraApplication: + application: AdapterCameraApplication + settlement_pass_steps: int + + def __post_init__(self) -> None: + if type(self.application) is not AdapterCameraApplication: + raise TypeError("settled camera application must be exact") + if ( + type(self.settlement_pass_steps) is not int + or self.settlement_pass_steps < 0 + ): + raise ValueError("settled camera pass count must be non-negative") + + @runtime_checkable class XYSceneTransformer(Protocol): """Create a canonical scene with one object's XY position changed.""" def with_object_xy( self, scene: Scene, object_id: str, x: float, y: float - ) -> Scene: - pass + ) -> Scene: ... @runtime_checkable class SceneAdapter(XYSceneTransformer, Protocol): - def list_scene_ids(self) -> list[str]: - pass + def list_scene_ids(self) -> list[str]: ... - def load_scene(self, scene_id: str) -> Scene: - pass + def load_scene(self, scene_id: str) -> Scene: ... def render_assets( self, @@ -37,5 +1107,360 @@ def render_assets( camera_id: str, destination_root: Path, stem: str, - ) -> RenderedAssets: - pass + ) -> RenderedAssets: ... + + +@runtime_checkable +class EnvironmentAdapter(SceneAdapter, Protocol): + """Lifecycle and Canonical readback contract for platform boundaries.""" + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + def capture_source(self, request: CaptureRequest) -> CapturedSource: ... + + def capture_support(self, source: CapturedSource) -> CapturedSupport: ... + + def apply_certified_edit( + self, + application: CertifiedEditApplication, + ) -> AppliedCertifiedEdit: ... + + def settle_readback(self, applied: AppliedCertifiedEdit) -> SettledReadback: ... + + def observe_source( + self, + source: CapturedSource, + *, + options: SourceCaptureOptions, + settle: bool, + ) -> SourceCaptureFacts: ... + + def capture_spawn_maps( + self, + facts: SourceCaptureFacts, + *, + subject_object_ids: tuple[str, ...], + ) -> tuple[AdapterSpawnMap, ...]: ... + + def pause_camera_observations( + self, + facts: SourceCaptureFacts, + *, + settle_after_resume: bool, + ) -> CameraObservationHandle: ... + + def resume_camera_observations(self, handle: CameraObservationHandle) -> None: ... + + def apply_camera_pose( + self, + facts: SourceCaptureFacts, + pose: AdapterPose, + *, + handle: CameraObservationHandle | None, + source_scene: Scene, + reset_from_source: bool, + max_settlement_steps: int, + ) -> AdapterCameraApplication: ... + + def settle_camera_pose( + self, + facts: SourceCaptureFacts, + pose: AdapterPose, + *, + source_scene: Scene, + max_settlement_steps: int, + ) -> AdapterSettledCameraApplication: ... + + +def capture_source_with_scene_adapter( + adapter: SceneAdapter, + request: CaptureRequest, +) -> CapturedSource: + scene = adapter.load_scene(request.scene_id) + return CapturedSource( + request=request, + scene=scene, + binding=AdapterBinding( + scene_id=scene.scene_id, token=f"scene:{scene.scene_id}" + ), + ) + + +def capture_support_with_environment_adapter( + adapter: EnvironmentAdapter, + source: CapturedSource, +) -> CapturedSupport: + del adapter + return CapturedSupport(source=source, scene=source.scene, binding=source.binding) + + +def apply_certified_edit_with_scene_adapter( + adapter: XYSceneTransformer, + application: CertifiedEditApplication, +) -> AppliedCertifiedEdit: + subject = application.source.scene.object_by_id(application.edit.subject_id) + edit = application.edit + scene = adapter.with_object_xy( + application.source.scene, + edit.subject_id, + subject.position.x + edit.translation_xy_m.x, + subject.position.y + edit.translation_xy_m.y, + ) + commanded_position = AdapterPosition( + x=scene.object_by_id(edit.subject_id).position.x, + y=scene.object_by_id(edit.subject_id).position.y, + z=scene.object_by_id(edit.subject_id).position.z, + ) + return AppliedCertifiedEdit( + application=application, + edit=edit, + commanded_scene=scene, + observed_scene=scene, + commanded_position=commanded_position, + observed_position=commanded_position, + position_residual_m=0.0, + observation=_protocol_observation(scene, settled=False), + is_scene_at_rest=False, + subject_is_moving=True, + settlement_pass_steps=0, + binding=application.source.binding, + ) + + +def settle_readback_with_environment_adapter( + adapter: EnvironmentAdapter, + applied: AppliedCertifiedEdit, +) -> SettledReadback: + del adapter + return SettledReadback( + applied=applied, + commanded_scene=applied.commanded_scene, + observed_scene=applied.observed_scene, + commanded_position=applied.commanded_position, + observed_position=applied.observed_position, + position_residual_m=applied.position_residual_m, + observation=_protocol_observation(applied.observed_scene, settled=True), + is_scene_at_rest=True, + subject_is_moving=False, + settlement_pass_steps=0, + binding=applied.binding, + ) + + +def _protocol_observation(scene: Scene, *, settled: bool) -> AdapterObservation: + """Use deterministic opaque bytes for the Canonical-only fallback adapter.""" + prefix = scene.scene_id.encode("utf-8") + return AdapterObservation.create( + scene=scene, + rgb_png=b"protocol-rgb:" + prefix, + depth_npy=b"protocol-depth:" + prefix, + instance_png=b"protocol-instance:" + prefix, + pointcloud_ply=b"protocol-ply:" + prefix, + instance_pixel_counts=(), + is_settled=settled, + ) + + +def capture_bound_adapter_spawn_map( + spawn_map: AdapterSpawnMap, + *, + fresh_scene: Scene, + frozen_scene: Scene, +) -> AdapterSpawnMap: + """Rebind source facts without exposing a concrete adapter representation.""" + if type(spawn_map) is not AdapterSpawnMap: + raise TypeError("capture-bound spawn map must be exact") + if type(fresh_scene) is not Scene or type(frozen_scene) is not Scene: + raise TypeError("capture-bound spawn scenes must be exact Scene values") + expected_fresh_scene_sha256 = _adapter_spawn_map_scene_sha256( + fresh_scene, + spawn_map.surface_patches, + ) + if ( + spawn_map.scene_id != fresh_scene.scene_id + or spawn_map.scene_sha256 != expected_fresh_scene_sha256 + or frozen_scene.scene_id != fresh_scene.scene_id + ): + raise ValueError("capture-bound spawn map does not bind the fresh scene") + frozen_scene_sha256 = _adapter_spawn_map_scene_sha256( + frozen_scene, + spawn_map.surface_patches, + ) + source_sha256 = _adapter_spawn_map_source_sha256( + scene_id=spawn_map.scene_id, + subject_object_id=spawn_map.subject_object_id, + support_object_id=spawn_map.support_object_id, + native_subject_object_id=spawn_map.native_subject_object_id, + native_support_object_id=spawn_map.native_support_object_id, + runtime_identity=spawn_map.runtime_identity, + positions_sha256=spawn_map.positions_sha256, + scene_sha256=frozen_scene_sha256, + surface_patches=spawn_map.surface_patches, + ) + return AdapterSpawnMap( + binding=spawn_map.binding, + runtime_identity=spawn_map.runtime_identity, + scene_id=spawn_map.scene_id, + subject_object_id=spawn_map.subject_object_id, + support_object_id=spawn_map.support_object_id, + native_subject_object_id=spawn_map.native_subject_object_id, + native_support_object_id=spawn_map.native_support_object_id, + positions=spawn_map.positions, + positions_sha256=spawn_map.positions_sha256, + scene_sha256=frozen_scene_sha256, + source_sha256=source_sha256, + surface_patches=spawn_map.surface_patches, + position_region=spawn_map.position_region, + ) + + +def _adapter_spawn_map_scene_sha256( + scene: Scene, + surface_patches: tuple[AdapterSurfacePatch, ...], +) -> str: + if surface_patches: + normalized = scene.model_copy( + update={ + "cameras": tuple( + sorted(scene.cameras, key=lambda item: item.camera_id) + ), + "objects": tuple( + sorted(scene.objects, key=lambda item: item.object_id) + ), + "collision_obstacles": tuple( + sorted( + scene.collision_obstacles, + key=lambda item: item.obstacle_id, + ) + ), + "subject_position_regions": tuple( + sorted( + scene.subject_position_regions, + key=lambda item: item.region_id, + ) + ), + } + ) + payload = normalized.model_dump(mode="python", warnings="error") + payload["pinned_object_ids"] = tuple(sorted(scene.pinned_object_ids)) + return sha256(canonical_json_bytes(payload)).hexdigest() + payload = json.dumps( + scene.model_dump(mode="json", warnings="error"), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return sha256(payload).hexdigest() + + +def _adapter_spawn_map_source_sha256( + *, + scene_id: str, + subject_object_id: str, + support_object_id: str, + native_subject_object_id: str, + native_support_object_id: str, + runtime_identity: AdapterRuntimeIdentity, + positions_sha256: str, + scene_sha256: str, + surface_patches: tuple[AdapterSurfacePatch, ...], +) -> str: + payload: dict[str, object] = { + "action": "GetSpawnCoordinatesAboveReceptacle", + "anywhere": True, + "method": ( + "ai2thor-receptacle-trigger-grid-v1" + if surface_patches + else "ai2thor-receptacle-spawn-map-v1" + ), + "native_subject_object_id": native_subject_object_id, + "native_support_object_id": native_support_object_id, + "positions_sha256": positions_sha256, + "runtime_identity": asdict(runtime_identity), + "scene_id": scene_id, + "scene_sha256": scene_sha256, + "subject_object_id": subject_object_id, + "support_object_id": support_object_id, + } + if surface_patches: + payload["surface_patches"] = tuple(asdict(item) for item in surface_patches) + encoded = json.dumps( + payload, + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return sha256(encoded).hexdigest() + + +def move_object_xy(scene: Scene, object_id: str, x: float, y: float) -> Scene: + """Apply the protocol's sole local operation without a platform runtime.""" + + target = scene.object_by_id(object_id) + if not target.movable: + raise ValueError("protocol edits require a movable subject") + delta_x = x - target.position.x + delta_y = y - target.position.y + objects = tuple( + object_ + if object_.object_id != object_id + else object_.model_copy( + update={ + "position": Vec3(x=x, y=y, z=object_.position.z), + "obb": OBB( + center=Vec3( + x=object_.obb.center.x + delta_x, + y=object_.obb.center.y + delta_y, + z=object_.obb.center.z, + ), + extent=object_.obb.extent, + rotation=object_.obb.rotation, + ), + } + ) + for object_ in scene.objects + ) + return scene.model_copy(update={"objects": objects}) + + +__all__ = ( + "AdapterActionRejected", + "AdapterBinding", + "AdapterCameraApplication", + "AdapterFloorEnvelope", + "AdapterObservation", + "AdapterOperationError", + "AdapterPose", + "AdapterPosition", + "AdapterProceduralScene", + "AdapterReturnRejected", + "AdapterRuntimeIdentity", + "AdapterRuntimeProvenance", + "AdapterSettledCameraApplication", + "AdapterSettlementTimeout", + "AdapterSpawnMap", + "AdapterSupportFact", + "AppliedCertifiedEdit", + "CameraObservationHandle", + "CaptureRequest", + "CapturedSource", + "CapturedSupport", + "CertifiedEditApplication", + "EnvironmentAdapter", + "RenderedAssets", + "SceneAdapter", + "SettledReadback", + "SourceCaptureFacts", + "SourceCaptureOptions", + "XYSceneTransformer", + "capture_bound_adapter_spawn_map", +) diff --git a/src/spatialcf/adapters/canonical_v2_1.py b/src/spatialcf/adapters/canonical_v2_1.py deleted file mode 100644 index 6dee947..0000000 --- a/src/spatialcf/adapters/canonical_v2_1.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Cold, platform-neutral protocol for Canonical 2.1 scene-fact adapters.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable - -if TYPE_CHECKING: - from spatialcf.adapters.canonical_v2_1_models import CanonicalSceneAdaptationV2_1 - -__all__ = ["CanonicalSceneAdaptationV2_1", "CanonicalSceneFactsAdapterV2_1"] - -NativeT_contra = TypeVar("NativeT_contra", contravariant=True) - - -@runtime_checkable -class CanonicalSceneFactsAdapterV2_1(Protocol[NativeT_contra]): - """Translate native facts into the exact-cardinal 2.1 scene contract.""" - - def adapt_scene_facts( - self, - native: NativeT_contra, - /, - ) -> CanonicalSceneAdaptationV2_1: - """Return one closed Canonical scene and independent provenance.""" - ... - - -def __getattr__(name: str) -> Any: - if name == "CanonicalSceneAdaptationV2_1": - from spatialcf.adapters.canonical_v2_1_models import ( - CanonicalSceneAdaptationV2_1, - ) - - globals()[name] = CanonicalSceneAdaptationV2_1 - return CanonicalSceneAdaptationV2_1 - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -def __dir__() -> list[str]: - return sorted((*globals(), *__all__)) diff --git a/src/spatialcf/adapters/canonical_v2_1_binding.py b/src/spatialcf/adapters/canonical_v2_1_binding.py deleted file mode 100644 index 0975bd8..0000000 --- a/src/spatialcf/adapters/canonical_v2_1_binding.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Bind Canonical 2.1 scene provenance to an exact matching problem.""" - -from __future__ import annotations - -import warnings - -from spatialcf.adapters.canonical_v2_1 import CanonicalSceneAdaptationV2_1 -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.evidence import EvidenceEnvelopeV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - - -def bind_canonical_scene_evidence_v2_1( - adaptation: CanonicalSceneAdaptationV2_1, - problem: SemanticProblemV2_1, - /, -) -> EvidenceEnvelopeV2: - """Bind provenance iff the 2.1 problem embeds the adapted scene exactly.""" - - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - trusted_adaptation = CanonicalSceneAdaptationV2_1.model_validate( - adaptation, - strict=True, - ) - trusted_problem = SemanticProblemV2_1.model_validate(problem, strict=True) - if canonical_json_bytes_v2(trusted_adaptation.scene) != canonical_json_bytes_v2( - trusted_problem.scene - ): - raise ValueError( - "semantic problem scene does not match adapted Canonical scene" - ) - - pre = trusted_adaptation.pre_semantic_evidence - return EvidenceEnvelopeV2( - semantic_problem_sha256=trusted_problem.semantic_problem_sha256, - adapter=pre.adapter, - source=pre.source, - native_object_bindings=trusted_adaptation.bindings, - raw_evidence_refs=pre.raw_evidence_refs, - mapping_proofs=pre.mapping_proofs, - runtime_identities=(), - final_audits=(), - ) diff --git a/src/spatialcf/adapters/canonical_v2_1_models.py b/src/spatialcf/adapters/canonical_v2_1_models.py deleted file mode 100644 index d5136f9..0000000 --- a/src/spatialcf/adapters/canonical_v2_1_models.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Strict value model returned by Canonical 2.1 scene-fact adapters.""" - -from __future__ import annotations - -from spatialcf.adapters.canonical_v2_models import CanonicalSceneAdaptationV2 -from spatialcf.domain.v2.cardinal import CanonicalSceneV2_1 - - -class CanonicalSceneAdaptationV2_1(CanonicalSceneAdaptationV2): - """Closed Canonical 2.1 scene, provenance, and native object bindings.""" - - scene: CanonicalSceneV2_1 - - @classmethod - def _scene_model_type(cls) -> type[CanonicalSceneV2_1]: - return CanonicalSceneV2_1 diff --git a/src/spatialcf/adapters/canonical_v2_binding.py b/src/spatialcf/adapters/canonical_v2_binding.py index 2aa3935..9cec605 100644 --- a/src/spatialcf/adapters/canonical_v2_binding.py +++ b/src/spatialcf/adapters/canonical_v2_binding.py @@ -5,16 +5,16 @@ import warnings from spatialcf.adapters.canonical_v2 import CanonicalSceneAdaptationV2 -from spatialcf.domain.v2.evidence import EvidenceEnvelopeV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 +from spatialcf.domain.evidence import EvidenceEnvelope +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.serialization import canonical_json_bytes def bind_canonical_scene_evidence_v2( adaptation: CanonicalSceneAdaptationV2, problem: SemanticProblemV2, /, -) -> EvidenceEnvelopeV2: +) -> EvidenceEnvelope: """Bind provenance only when the problem contains the adapted scene exactly.""" with warnings.catch_warnings(): @@ -24,15 +24,15 @@ def bind_canonical_scene_evidence_v2( strict=True, ) trusted_problem = SemanticProblemV2.model_validate(problem, strict=True) - adapted_scene_bytes = canonical_json_bytes_v2(trusted_adaptation.scene) - problem_scene_bytes = canonical_json_bytes_v2(trusted_problem.scene) + adapted_scene_bytes = canonical_json_bytes(trusted_adaptation.scene) + problem_scene_bytes = canonical_json_bytes(trusted_problem.scene) if adapted_scene_bytes != problem_scene_bytes: raise ValueError( "semantic problem scene does not match adapted Canonical scene" ) pre = trusted_adaptation.pre_semantic_evidence - return EvidenceEnvelopeV2( + return EvidenceEnvelope( semantic_problem_sha256=trusted_problem.semantic_problem_sha256, adapter=pre.adapter, source=pre.source, diff --git a/src/spatialcf/adapters/canonical_v2_models.py b/src/spatialcf/adapters/canonical_v2_models.py index 3e8c3fc..eb1230b 100644 --- a/src/spatialcf/adapters/canonical_v2_models.py +++ b/src/spatialcf/adapters/canonical_v2_models.py @@ -9,17 +9,17 @@ from pydantic import model_validator -from spatialcf.domain.v2.base import FactAvailabilityV2, FactSetV2, V2Model -from spatialcf.domain.v2.evidence import ( - MappingProofKindV2, - MappingProofStatusV2, - NativeObjectBindingV2, - PreSemanticEvidenceEnvelopeV2, +from spatialcf.domain.base import CanonicalModel, FactAvailabilityV2, FactSetV2 +from spatialcf.domain.evidence import ( + MappingProofKind, + MappingProofStatus, + NativeObjectBinding, + PreSemanticEvidenceEnvelope, ) -from spatialcf.domain.v2.scene import CanonicalSceneV2 +from spatialcf.domain.scene import CanonicalScene -class CanonicalSceneAdaptationV2(V2Model): +class CanonicalSceneAdaptationV2(CanonicalModel): """Strict, frozen output of one native-to-Canonical fact translation. Every Canonical object has exactly one native binding. Each binding is @@ -29,15 +29,15 @@ class CanonicalSceneAdaptationV2(V2Model): coverage in the matching facts family. """ - scene: CanonicalSceneV2 - pre_semantic_evidence: PreSemanticEvidenceEnvelopeV2 - bindings: tuple[NativeObjectBindingV2, ...] + scene: CanonicalScene + pre_semantic_evidence: PreSemanticEvidenceEnvelope + bindings: tuple[NativeObjectBinding, ...] @classmethod - def _scene_model_type(cls) -> type[CanonicalSceneV2]: + def _scene_model_type(cls) -> type[CanonicalScene]: """Return the root scene type owned by this versioned seam.""" - return CanonicalSceneV2 + return CanonicalScene @model_validator(mode="after") def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: @@ -45,12 +45,12 @@ def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: # explicitly here also documents and preserves this public trust # boundary if their construction path changes later. scene = type(self)._scene_model_type().model_validate(self.scene, strict=True) - evidence = PreSemanticEvidenceEnvelopeV2.model_validate( + evidence = PreSemanticEvidenceEnvelope.model_validate( self.pre_semantic_evidence, strict=True, ) bindings = tuple( - NativeObjectBindingV2.model_validate(binding, strict=True) + NativeObjectBinding.model_validate(binding, strict=True) for binding in self.bindings ) @@ -83,8 +83,8 @@ def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: expected_ids_by_kind = _expected_fact_ids_by_proof_kind(scene) verified_ids_by_kind = {kind: set() for kind in expected_ids_by_kind} unsupported_kinds = { - MappingProofKindV2.RELATION_NORMALIZATION, - MappingProofKindV2.WRITE_BACK, + MappingProofKind.RELATION_NORMALIZATION, + MappingProofKind.WRITE_BACK, } for proof in evidence.mapping_proofs: if proof.kind in unsupported_kinds: @@ -98,7 +98,7 @@ def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: f"{proof.kind.value} proof references wrong-family or unknown " "Canonical facts: " + ", ".join(sorted(dangling_ids)) ) - if proof.status is MappingProofStatusV2.VERIFIED: + if proof.status is MappingProofStatus.VERIFIED: verified_ids_by_kind[proof.kind].update(proof.canonical_ids) for kind, expected_ids in expected_ids_by_kind.items(): @@ -112,7 +112,7 @@ def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: identity_proof_ids = { proof.proof_id for proof in evidence.mapping_proofs - if proof.kind is MappingProofKindV2.ENTITY_IDENTITY + if proof.kind is MappingProofKind.ENTITY_IDENTITY } consumed_identity_proof_ids: set[str] = set() for binding in bindings: @@ -122,8 +122,8 @@ def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: "native object binding references unknown mapping proof" ) if ( - proof.kind is not MappingProofKindV2.ENTITY_IDENTITY - or proof.status is not MappingProofStatusV2.VERIFIED + proof.kind is not MappingProofKind.ENTITY_IDENTITY + or proof.status is not MappingProofStatus.VERIFIED ): raise ValueError( "native object binding requires a VERIFIED ENTITY_IDENTITY proof" @@ -157,7 +157,7 @@ def strictly_revalidate_and_close_bindings(self) -> CanonicalSceneAdaptationV2: return self -def _known_fact_ids(facts: FactSetV2[V2Model], id_field: str) -> set[str]: +def _known_fact_ids(facts: FactSetV2[CanonicalModel], id_field: str) -> set[str]: if facts.availability is not FactAvailabilityV2.KNOWN: return set() values = ( @@ -169,25 +169,25 @@ def _known_fact_ids(facts: FactSetV2[V2Model], id_field: str) -> set[str]: def _expected_fact_ids_by_proof_kind( - scene: CanonicalSceneV2, -) -> dict[MappingProofKindV2, set[str]]: + scene: CanonicalScene, +) -> dict[MappingProofKind, set[str]]: return { - MappingProofKindV2.ENTITY_IDENTITY: _known_fact_ids( + MappingProofKind.ENTITY_IDENTITY: _known_fact_ids( scene.objects, "object_id", ), - MappingProofKindV2.GEOMETRY: set().union( + MappingProofKind.GEOMETRY: set().union( _known_fact_ids(scene.geometry_instances, "geometry_id"), _known_fact_ids(scene.collision_bodies, "body_id"), _known_fact_ids(scene.workspace_boundaries, "fact_id"), _known_fact_ids(scene.known_free_spaces, "fact_id"), ), - MappingProofKindV2.SUPPORT: _known_fact_ids( + MappingProofKind.SUPPORT: _known_fact_ids( scene.support_surfaces, "surface_id", ), - MappingProofKindV2.CAMERA: _known_fact_ids(scene.cameras, "camera_id"), - MappingProofKindV2.OBSERVATION_NORMALIZATION: _known_fact_ids( + MappingProofKind.CAMERA: _known_fact_ids(scene.cameras, "camera_id"), + MappingProofKind.OBSERVATION_NORMALIZATION: _known_fact_ids( scene.baseline_observations, "observation_id", ), diff --git a/src/spatialcf/solver/execution.py b/src/spatialcf/adapters/execution.py similarity index 90% rename from src/spatialcf/solver/execution.py rename to src/spatialcf/adapters/execution.py index 196ece9..23f3884 100644 --- a/src/spatialcf/solver/execution.py +++ b/src/spatialcf/adapters/execution.py @@ -1,8 +1,8 @@ -"""Platform-neutral execution oracle for grounded counterfactual search. +"""Adapter-owned execution values for grounded counterfactual observation. -The core solver only speaks canonical :class:`~spatialcf.domain.models.Scene` -objects. Simulator actions, physics engines, renderer events, and platform -specific tolerances belong in implementations of :class:`CandidateExecutor`. +The pure core never imports these records. Simulator actions, physics engines, +renderer events, and platform-specific tolerances belong in implementations of +:class:`CandidateExecutor`. """ from __future__ import annotations @@ -12,7 +12,8 @@ from enum import StrEnum from typing import Generic, Protocol, TypeVar, runtime_checkable -from spatialcf.domain.models import InterventionSpec, Scene +from spatialcf.domain.request import InterventionSpec +from spatialcf.domain.scene import Scene EvidenceT_co = TypeVar("EvidenceT_co", covariant=True) @@ -87,7 +88,10 @@ def __post_init__(self) -> None: type(error) is not str or not error for error in self.errors ): raise ValueError("errors must be a tuple of non-empty exact strings") - if len(set(self.errors)) != len(self.errors) or tuple(sorted(self.errors)) != self.errors: + if ( + len(set(self.errors)) != len(self.errors) + or tuple(sorted(self.errors)) != self.errors + ): raise ValueError("execution errors must be sorted and unique") if self.status is CandidateExecutionStatus.OBSERVED: if self.observed_before_scene is None: @@ -104,7 +108,10 @@ def __post_init__(self) -> None: if not self.errors: raise ValueError("REJECTED execution requires errors") else: - if self.observed_before_scene is not None or self.observed_scene is not None: + if ( + self.observed_before_scene is not None + or self.observed_scene is not None + ): raise ValueError("ERROR execution cannot contain observed scenes") if not self.errors: raise ValueError("ERROR execution requires errors") @@ -186,5 +193,4 @@ def execute_candidate( before: Scene, commanded: Scene, spec: InterventionSpec, - ) -> CandidateExecution[EvidenceT_co]: - ... + ) -> CandidateExecution[EvidenceT_co]: ... diff --git a/src/spatialcf/adapters/in_memory.py b/src/spatialcf/adapters/in_memory.py new file mode 100644 index 0000000..036e4bb --- /dev/null +++ b/src/spatialcf/adapters/in_memory.py @@ -0,0 +1,483 @@ +"""Deterministic Canonical-only implementation of :class:`EnvironmentAdapter`.""" + +from __future__ import annotations + +from hashlib import sha256 +from pathlib import Path +from types import TracebackType +from typing import Self + +import numpy as np +from PIL import Image, ImageDraw + +from spatialcf.adapters.base import ( + AdapterCameraApplication, + AdapterFloorEnvelope, + AdapterObservation, + AdapterOperationError, + AdapterPose, + AdapterPosition, + AdapterRuntimeIdentity, + AdapterSettledCameraApplication, + AdapterSpawnMap, + AdapterSupportFact, + AppliedCertifiedEdit, + CameraObservationHandle, + CapturedSource, + CapturedSupport, + CaptureRequest, + CertifiedEditApplication, + RenderedAssets, + SettledReadback, + SourceCaptureFacts, + SourceCaptureOptions, + capture_source_with_scene_adapter, + capture_support_with_environment_adapter, + move_object_xy, +) +from spatialcf.domain.scene import OBB, Quaternion, Scene, Vec2, Vec3 + + +def _validate_stem(stem: str) -> None: + if ( + type(stem) is not str + or not stem + or stem.strip() != stem + or stem in {".", ".."} + or Path(stem).name != stem + or "/" in stem + or "\\" in stem + ): + raise ValueError("artifact stem must be a safe filename component") + + +class InMemoryEnvironmentAdapter: + """A no-runtime adapter that retains exactly one Canonical scene.""" + + def __init__(self, scene: Scene) -> None: + if type(scene) is not Scene: + raise TypeError("in-memory adapter requires an exact Canonical Scene") + self._scene = scene + self._paused_camera_handles: dict[str, CameraObservationHandle] = {} + self._resumed_tokens: set[str] = set() + self._camera_pause_token_sequence = 0 + self._execution_actions: list[str] = [] + self._pending_protocol_applied: AppliedCertifiedEdit | None = None + + @classmethod + def from_scene(cls, scene: Scene) -> Self: + return cls(scene) + + def __enter__(self) -> Self: + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type, exc, traceback + + def list_scene_ids(self) -> list[str]: + return [self._scene.scene_id] + + def load_scene(self, scene_id: str) -> Scene: + if scene_id != self._scene.scene_id: + raise KeyError(scene_id) + self._execution_actions.append("load_scene") + return self._scene + + @property + def execution_actions(self) -> tuple[str, ...]: + return tuple(self._execution_actions) + + def with_object_xy(self, scene: Scene, object_id: str, x: float, y: float) -> Scene: + return move_object_xy(scene, object_id, x, y) + + def capture_source(self, request: CaptureRequest) -> CapturedSource: + return capture_source_with_scene_adapter(self, request) + + def capture_support(self, source: CapturedSource) -> CapturedSupport: + return capture_support_with_environment_adapter(self, source) + + def apply_certified_edit( + self, + application: CertifiedEditApplication, + ) -> AppliedCertifiedEdit: + if type(application) is not CertifiedEditApplication: + raise AdapterOperationError("certified application must be exact") + if application.source.scene != self._scene: + raise AdapterOperationError("certified application source is stale") + if self._pending_protocol_applied is not None: + raise AdapterOperationError("pending certified application is not settled") + subject = application.source.scene.object_by_id(application.edit.subject_id) + observed_scene = move_object_xy( + application.source.scene, + application.edit.subject_id, + subject.position.x + application.edit.translation_xy_m.x, + subject.position.y + application.edit.translation_xy_m.y, + ) + observed = observed_scene.object_by_id(application.edit.subject_id).position + position = AdapterPosition(x=observed.x, y=observed.y, z=observed.z) + applied = AppliedCertifiedEdit( + application=application, + edit=application.edit, + commanded_scene=observed_scene, + observed_scene=observed_scene, + commanded_position=position, + observed_position=position, + position_residual_m=0.0, + observation=self._observation(observed_scene, settled=False), + is_scene_at_rest=False, + subject_is_moving=True, + settlement_pass_steps=0, + binding=application.source.binding, + ) + self._pending_protocol_applied = applied + self._execution_actions.append( + f"apply_certified_edit:{application.edit.subject_id}" + ) + return applied + + def settle_readback(self, applied: AppliedCertifiedEdit) -> SettledReadback: + if self._pending_protocol_applied is not applied: + raise AdapterOperationError( + "pending certified application is stale or consumed" + ) + settled = SettledReadback( + applied=applied, + commanded_scene=applied.commanded_scene, + observed_scene=applied.observed_scene, + commanded_position=applied.commanded_position, + observed_position=applied.observed_position, + position_residual_m=applied.position_residual_m, + observation=self._observation(applied.observed_scene, settled=True), + is_scene_at_rest=True, + subject_is_moving=False, + settlement_pass_steps=0, + binding=applied.binding, + ) + self._pending_protocol_applied = None + self._execution_actions.append(f"settle_readback:{applied.edit.subject_id}") + return settled + + def _runtime_identity(self, source: CapturedSource) -> AdapterRuntimeIdentity: + """Bind dimensions to the caller-selected capture camera exactly.""" + camera = source.scene.camera_by_id(source.request.camera_id) + return AdapterRuntimeIdentity( + ai2thor_version="in-memory", + unity_commit_id="in-memory", + native_scene_name=source.scene.scene_id, + width=camera.width, + height=camera.height, + seed=source.scene.generation_seed, + ) + + @staticmethod + def _observation(scene: Scene, *, settled: bool) -> AdapterObservation: + source = scene.scene_id.encode("utf-8") + return AdapterObservation.create( + scene=scene, + rgb_png=b"in-memory-rgb:" + source, + depth_npy=b"in-memory-depth:" + source, + instance_png=b"in-memory-instance:" + source, + pointcloud_ply=b"in-memory-ply:" + source, + instance_pixel_counts=(), + is_settled=settled, + ) + + @staticmethod + def _support_facts(scene: Scene) -> tuple[AdapterSupportFact, ...]: + return tuple( + AdapterSupportFact( + scene_id=scene.scene_id, + object_id=item.object_id, + object_name=item.name, + native_object_id=f"in-memory:{item.object_id}", + raw_parent_object_ids=( + () if item.support_object_id is None else (item.support_object_id,) + ), + structural_parent_object_ids=( + ("in-memory-floor",) if item.support_object_id is None else () + ), + domain_parent_object_ids=( + () if item.support_object_id is None else (item.support_object_id,) + ), + support_kind=( + "FLOOR" if item.support_object_id is None else "RECEPTACLE" + ), + support_object_id=item.support_object_id, + floor_object_id=( + "in-memory-floor" if item.support_object_id is None else None + ), + ) + for item in scene.objects + ) + + def observe_source( + self, + source: CapturedSource, + *, + options: SourceCaptureOptions, + settle: bool, + ) -> SourceCaptureFacts: + if type(source) is not CapturedSource or source.scene != self._scene: + raise AdapterOperationError("missing source fixture") + if type(options) is not SourceCaptureOptions or type(settle) is not bool: + raise AdapterOperationError("invalid source capture options") + if not settle: + raise AdapterOperationError("source observation must settle") + support_facts = self._support_facts(source.scene) + polygon = source.scene.room_polygon_xy + floor = None + if len(polygon) >= 3: + xs = tuple(point.x for point in polygon) + ys = tuple(point.y for point in polygon) + floor = AdapterFloorEnvelope( + scene_id=source.scene.scene_id, + floor_object_id="in-memory-floor", + floor_name="in-memory-floor", + native_aabb=OBB( + center=Vec3( + x=(min(xs) + max(xs)) / 2.0, + y=(min(ys) + max(ys)) / 2.0, + z=0.0, + ), + extent=Vec3( + x=max(xs) - min(xs), + y=max(ys) - min(ys), + z=0.01, + ), + rotation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0), + ), + floor_top_z=0.0, + clearance_m=options.floor_clearance_m, + polygon_xy=tuple(Vec2(x=point.x, y=point.y) for point in polygon), + ) + position = AdapterPosition(x=0.0, y=0.0, z=0.0) + facts = SourceCaptureFacts( + source=source, + binding=source.binding, + scene=source.scene, + runtime_identity=self._runtime_identity(source), + observation=self._observation(source.scene, settled=True), + support_facts=support_facts, + floor_envelope=floor, + floor_position_regions=(), + reachable_positions=(position,), + current_pose=AdapterPose( + position=position, + yaw_degrees=0.0, + horizon_degrees=0.0, + standing=True, + ), + settlement_pass_steps=0, + ) + self._execution_actions.append("settle_source") + return facts + + def capture_spawn_maps( + self, + facts: SourceCaptureFacts, + *, + subject_object_ids: tuple[str, ...], + ) -> tuple[AdapterSpawnMap, ...]: + if ( + type(facts) is not SourceCaptureFacts + or facts.source.scene != self._scene + or facts.scene != self._scene + ): + raise AdapterOperationError("missing source fixture") + if facts.support_facts != self._support_facts(self._scene): + raise AdapterOperationError("source support facts changed") + if type(subject_object_ids) is not tuple or any( + type(item) is not str or not item for item in subject_object_ids + ): + raise AdapterOperationError("subject IDs must be exact") + if len(set(subject_object_ids)) != len(subject_object_ids): + raise AdapterOperationError("subject IDs must be unique") + maps = [] + for subject_id in subject_object_ids: + try: + subject = facts.scene.object_by_id(subject_id) + except (KeyError, ValueError) as error: + raise AdapterOperationError("missing subject fixture") from error + if subject.support_object_id is None: + raise AdapterOperationError("missing subject support fixture") + digest = sha256(subject_id.encode("utf-8")).hexdigest() + maps.append( + AdapterSpawnMap( + binding=facts.binding, + runtime_identity=facts.runtime_identity, + scene_id=facts.scene.scene_id, + subject_object_id=subject_id, + support_object_id=subject.support_object_id, + native_subject_object_id=f"in-memory:{subject_id}", + native_support_object_id=(f"in-memory:{subject.support_object_id}"), + positions=(), + positions_sha256=digest, + scene_sha256=digest, + source_sha256=digest, + surface_patches=(), + ) + ) + captured = tuple(maps) + self._execution_actions.extend( + f"capture_spawn_maps:{item.subject_object_id}" for item in captured + ) + return captured + + def pause_camera_observations( + self, + facts: SourceCaptureFacts, + *, + settle_after_resume: bool, + ) -> CameraObservationHandle: + if ( + type(facts) is not SourceCaptureFacts + or facts.source.scene != self._scene + or facts.binding != facts.source.binding + or type(settle_after_resume) is not bool + ): + raise AdapterOperationError("invalid camera pause fixture") + token = f"in-memory:{facts.binding.token}:{self._camera_pause_token_sequence}" + self._camera_pause_token_sequence += 1 + if token in self._paused_camera_handles or token in self._resumed_tokens: + raise AdapterOperationError("camera pause token collision") + handle = CameraObservationHandle( + source=facts.source, + binding=facts.binding, + scene=facts.scene, + token=token, + settle_after_resume=settle_after_resume, + ) + self._paused_camera_handles[token] = handle + return handle + + def resume_camera_observations(self, handle: CameraObservationHandle) -> None: + if type(handle) is not CameraObservationHandle: + raise AdapterOperationError("missing camera pause fixture") + if handle.token in self._resumed_tokens: + raise AdapterOperationError("camera observations already resumed") + try: + issued_handle = self._paused_camera_handles[handle.token] + except KeyError as error: + raise AdapterOperationError("missing camera pause fixture") from error + if handle is not issued_handle or handle != issued_handle: + raise AdapterOperationError("camera pause handle is not the issued handle") + del self._paused_camera_handles[handle.token] + self._resumed_tokens.add(handle.token) + + def apply_camera_pose( + self, + facts: SourceCaptureFacts, + pose: AdapterPose, + *, + handle: CameraObservationHandle | None, + source_scene: Scene, + reset_from_source: bool, + max_settlement_steps: int, + ) -> AdapterCameraApplication: + if ( + type(facts) is not SourceCaptureFacts + or facts.source.scene != self._scene + or facts.binding != facts.source.binding + or type(pose) is not AdapterPose + or type(source_scene) is not Scene + or type(reset_from_source) is not bool + or type(max_settlement_steps) is not int + or max_settlement_steps <= 0 + ): + raise AdapterOperationError("invalid camera application fixture") + if handle is not None and ( + type(handle) is not CameraObservationHandle + or handle.binding != facts.binding + or handle.token not in self._paused_camera_handles + or handle.token in self._resumed_tokens + ): + raise AdapterOperationError("invalid camera pause handle") + observation = self._observation(source_scene, settled=True) + return AdapterCameraApplication( + source=facts.source, + binding=facts.binding, + requested_pose=pose, + observed_pose=pose, + observed_camera_position=pose.position, + observed_scene=source_scene, + observation=observation, + position_residual_m=0.0, + yaw_residual_degrees=0.0, + horizon_residual_degrees=0.0, + ) + + def settle_camera_pose( + self, + facts: SourceCaptureFacts, + pose: AdapterPose, + *, + source_scene: Scene, + max_settlement_steps: int, + ) -> AdapterSettledCameraApplication: + application = self.apply_camera_pose( + facts, + pose, + handle=None, + source_scene=source_scene, + reset_from_source=False, + max_settlement_steps=max_settlement_steps, + ) + return AdapterSettledCameraApplication( + application=application, + settlement_pass_steps=0, + ) + + def render_assets( + self, + scene: Scene, + camera_id: str, + destination_root: Path, + stem: str, + ) -> RenderedAssets: + camera = scene.camera_by_id(camera_id) + _validate_stem(stem) + destination_root.mkdir(parents=True, exist_ok=True) + rgb_path = destination_root / f"{stem}-rgb.png" + depth_path = destination_root / f"{stem}-depth.npy" + instance_path = destination_root / f"{stem}-instance.png" + pointcloud_path = destination_root / f"{stem}-pointcloud.ply" + rgb = Image.new("RGB", (camera.width, camera.height), "white") + instance = Image.new("RGB", (camera.width, camera.height), "black") + rgb_draw = ImageDraw.Draw(rgb) + instance_draw = ImageDraw.Draw(instance) + for index, object_ in enumerate(scene.objects, start=1): + view = object_.views.get(camera_id) + if view is None: + continue + box = (view.bbox.xmin, view.bbox.ymin, view.bbox.xmax, view.bbox.ymax) + rgb_draw.rectangle(box, outline=(40, 90, 180), width=3) + instance_draw.rectangle( + box, + fill=(index % 255, (index * 17) % 255, (index * 31) % 255), + ) + rgb.save(rgb_path) + instance.save(instance_path) + np.save( + depth_path, + np.full((camera.height, camera.width), 2.0, dtype=np.float32), + allow_pickle=False, + ) + points = tuple(object_.obb.center for object_ in scene.objects) + pointcloud_path.write_text( + "ply\nformat ascii 1.0\n" + f"element vertex {len(points)}\n" + "property float x\nproperty float y\nproperty float z\nend_header\n" + + "".join(f"{point.x} {point.y} {point.z}\n" for point in points), + encoding="ascii", + ) + return RenderedAssets( + rgb_path=rgb_path, + depth_path=depth_path, + instance_path=instance_path, + pointcloud_path=pointcloud_path, + ) diff --git a/src/spatialcf/adapters/json_scene.py b/src/spatialcf/adapters/json_scene.py index 890fd5c..ae5313a 100644 --- a/src/spatialcf/adapters/json_scene.py +++ b/src/spatialcf/adapters/json_scene.py @@ -4,7 +4,7 @@ from PIL import Image, ImageDraw from spatialcf.adapters.base import RenderedAssets -from spatialcf.domain.models import OBB, Scene, Vec3 +from spatialcf.domain.scene import OBB, Scene, Vec3 class JsonSceneAdapter: @@ -39,15 +39,21 @@ def with_object_xy(self, scene: Scene, object_id: str, x: float, y: float) -> Sc ) views = {} for camera_id, view in obj.views.items(): - views[camera_id] = view.model_copy(update={ - "bbox": view.bbox.model_copy(update={ - "xmin": view.bbox.xmin + 100 * dx, - "xmax": view.bbox.xmax + 100 * dx, - }), - "camera_depth": view.camera_depth + dy, - }) + views[camera_id] = view.model_copy( + update={ + "bbox": view.bbox.model_copy( + update={ + "xmin": view.bbox.xmin + 100 * dx, + "xmax": view.bbox.xmax + 100 * dx, + } + ), + "camera_depth": view.camera_depth + dy, + } + ) objects.append( - obj.model_copy(update={"position": position, "obb": obb, "views": views}) + obj.model_copy( + update={"position": position, "obb": obb, "views": views} + ) ) return scene.model_copy(update={"objects": tuple(objects)}) @@ -79,7 +85,9 @@ def render_assets( ) rgb.save(rgb_path) instance.save(instance_path) - np.save(depth_path, np.full((camera.height, camera.width), 2.0, dtype=np.float32)) + np.save( + depth_path, np.full((camera.height, camera.width), 2.0, dtype=np.float32) + ) points = [obj.obb.center for obj in scene.objects] header = ( "ply\nformat ascii 1.0\n" diff --git a/src/spatialcf/composition.py b/src/spatialcf/composition.py new file mode 100644 index 0000000..e78bfb6 --- /dev/null +++ b/src/spatialcf/composition.py @@ -0,0 +1,7 @@ +"""The sole concrete default-composition leaf for environment adapters.""" + +from spatialcf.adapters.ai2thor import AI2ThorAdapter + +DEFAULT_ENVIRONMENT_ADAPTER_FACTORY = AI2ThorAdapter + +__all__ = ("DEFAULT_ENVIRONMENT_ADAPTER_FACTORY",) diff --git a/src/spatialcf/core/__init__.py b/src/spatialcf/core/__init__.py index 10284fd..3aec9a5 100644 --- a/src/spatialcf/core/__init__.py +++ b/src/spatialcf/core/__init__.py @@ -1 +1,15 @@ """Platform-neutral SpatialCF core algorithms.""" + +__all__ = ("solve_minimum_cost", "verify_solve_result") + + +def __getattr__(name: str): + if name == "solve_minimum_cost": + from spatialcf.core.solver import solve_minimum_cost + + return solve_minimum_cost + if name == "verify_solve_result": + from spatialcf.core.verification import verify_solve_result + + return verify_solve_result + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/spatialcf/core/_internal/compilation/__init__.py b/src/spatialcf/core/_internal/compilation/__init__.py new file mode 100644 index 0000000..544804a --- /dev/null +++ b/src/spatialcf/core/_internal/compilation/__init__.py @@ -0,0 +1 @@ +"""Private candidate-family compilation leaves.""" diff --git a/src/spatialcf/core/v2/candidate_domain.py b/src/spatialcf/core/_internal/compilation/candidate_cells.py similarity index 97% rename from src/spatialcf/core/v2/candidate_domain.py rename to src/spatialcf/core/_internal/compilation/candidate_cells.py index ae2792c..67b91ea 100644 --- a/src/spatialcf/core/v2/candidate_domain.py +++ b/src/spatialcf/core/_internal/compilation/candidate_cells.py @@ -1,3 +1,7 @@ +"""Shared current candidate cells; exact migrated body.""" + +from __future__ import annotations + """Deterministic Canonical v2 candidate-domain compilation. The certified subset is intentionally narrow: exact, closed, axis-aligned @@ -6,7 +10,6 @@ preserves the complete outer bound while clearing the inner bound. """ -from __future__ import annotations import warnings from dataclasses import dataclass @@ -15,14 +18,23 @@ from pydantic import ValidationError -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.collision_domain import ( +from spatialcf.core._internal.compilation.collision import ( CollisionDomainKindV2, compile_collision_domain_v2, ) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.compilation.support import ( + SupportDomainKindV2, + compile_support_domain_v2, +) +from spatialcf.core._internal.compilation.target import ( + TargetRelationDomainKindV2, + compile_target_relation_domain_v2, +) +from spatialcf.core._internal.compilation.visibility import ( + VisibilityDomainKindV2, + compile_visibility_domain_v2, +) +from spatialcf.core._internal.kernels.rect import ( AxisMarginXYV2, DirectedRectRoundingV2, ExactAxisAlignedRectV2, @@ -32,7 +44,7 @@ UnsupportedRectRegionErrorV2, WorldPointXYV2, ) -from spatialcf.core.v2.rectilinear_kernel import ( +from spatialcf.core._internal.kernels.rectilinear import ( RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M, RECTILINEAR_KERNEL_ID_V2, RECTILINEAR_KERNEL_VERSION_V2, @@ -45,19 +57,10 @@ normalize_rectilinear_region_v2, project_rectilinear_region_v2, ) -from spatialcf.core.v2.support_domain import ( - SupportDomainKindV2, - compile_support_domain_v2, -) -from spatialcf.core.v2.target_relation_domain import ( - TargetRelationDomainKindV2, - compile_target_relation_domain_v2, -) -from spatialcf.core.v2.visibility_domain import ( - VisibilityDomainKindV2, - compile_visibility_domain_v2, +from spatialcf.core._internal.resources import ( + DomainOperationBudgetV2, ) -from spatialcf.domain.v2.artifacts import ( +from spatialcf.domain.artifacts import ( ArtifactCoverageV2, CandidateCompilationCoverageV2, CandidateConstraintKindV2, @@ -71,34 +74,34 @@ PlanarRegionBoundV2, RegionBoundStatusV2, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, FactSetV2, NumericPolicyV2, UncertaintyBudgetV2, - Vec2V2, + Vec2, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - PositionRegionInterpretationV2, - RegionAggregationV2, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + PositionRegionInterpretation, + RegionAggregation, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.geometry import ( ExtrudedPlanarPolygonV2, GeometryApproximationV2, PlanarRegionV2, UprightBox3DV2, ) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.result import ( CoreSolverConfigV2, DirectedOutwardGeometryKernelSpecV2, UncertifiedReasonV2, ) -from spatialcf.domain.v2.scene import ( - CanonicalObjectV2, - RegionBoundaryPolicyV2, +from spatialcf.domain.scene import ( + CanonicalObject, + RegionBoundaryPolicy, ) CANDIDATE_DOMAIN_ALGORITHM_ID_V2 = "solver:canonical-branch-and-bound-v2" @@ -1251,16 +1254,16 @@ def _position_support_findings(problem: SemanticProblemV2) -> tuple[str, ...]: findings: list[str] = [] if len(position.workspace_fact_ids) != 1: findings.append("UNSUPPORTED_POSITION_DOMAIN:MULTIPLE_WORKSPACES") - if position.workspace_aggregation is not RegionAggregationV2.INTERSECTION: + if position.workspace_aggregation is not RegionAggregation.INTERSECTION: findings.append("UNSUPPORTED_POSITION_DOMAIN:WORKSPACE_AGGREGATION") if ( position.region_interpretation - is not PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS + is not PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS ): findings.append( f"UNSUPPORTED_POSITION_DOMAIN:{position.region_interpretation.value}" ) - if position.boundary_policy is not BoundaryPolicyV2.CLOSED: + if position.boundary_policy is not BoundaryPolicy.CLOSED: findings.append("UNSUPPORTED_POSITION_DOMAIN:BOUNDARY") if FactCompletenessV2.EXACT not in position.required_completeness: findings.append("UNSUPPORTED_POSITION_DOMAIN:COMPLETENESS_POLICY") @@ -1308,7 +1311,7 @@ def _fact_family_support_findings( continue if item.region_approximation is not GeometryApproximationV2.EXACT: findings.append(f"UNSUPPORTED_POSITION_DOMAIN:{label}_APPROXIMATION") - if item.boundary_policy is not RegionBoundaryPolicyV2.CLOSED: + if item.boundary_policy is not RegionBoundaryPolicy.CLOSED: findings.append(f"UNSUPPORTED_POSITION_DOMAIN:{label}_BOUNDARY") if not _uncertainty_is_zero(item.geometry_uncertainty): findings.append(f"UNSUPPORTED_POSITION_DOMAIN:{label}_UNCERTAINTY") @@ -1366,7 +1369,7 @@ def _search_anchor_margin( position = problem.constraints.position_domain if ( position.region_interpretation - is PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS + is PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS ): return margin occupancy_radius = _occupancy_outer_radius(problem) @@ -1643,7 +1646,7 @@ def _artifact( ) -def _baseline_anchor(problem: SemanticProblemV2) -> Vec2V2: +def _baseline_anchor(problem: SemanticProblemV2) -> Vec2: subject_id = problem.constraints.allowed_edit.subject_id subject = next( item @@ -1653,9 +1656,9 @@ def _baseline_anchor(problem: SemanticProblemV2) -> Vec2V2: return _object_anchor_world_xy(subject) -def _object_anchor_world_xy(subject: CanonicalObjectV2) -> Vec2V2: +def _object_anchor_world_xy(subject: CanonicalObject) -> Vec2: translation = subject.pose.world_from_object.translation - return Vec2V2(x=translation.x, y=translation.y) + return Vec2(x=translation.x, y=translation.y) def _domain_from_exact_rect( diff --git a/src/spatialcf/core/_internal/compilation/collision.py b/src/spatialcf/core/_internal/compilation/collision.py new file mode 100644 index 0000000..f8efaf5 --- /dev/null +++ b/src/spatialcf/core/_internal/compilation/collision.py @@ -0,0 +1,2505 @@ +# ruff: noqa: F811, I001 +"""Private current candidate-family compilers; exact migrated bodies.""" + +from __future__ import annotations + +# Migrated from collision_domain.py. +"""Sound collision-domain compilation for the exact Canonical v2 box subset. + +The only variable is the subject's world-XY translation delta. Exact rational +configuration obstacles are subtracted from a caller-supplied finite search +universe while retaining their contact boundary. Positive Euclidean +clearance is published as a conservative inner/outer rectilinear bracket. +""" + + +import itertools +from dataclasses import dataclass +from enum import StrEnum +from fractions import Fraction + +from spatialcf.core._internal.kernels.rect import ( + AxisMarginXYV2, + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, + RectTopologyV2, +) +from spatialcf.core._internal.kernels.rectilinear import ( + ExactRectilinearRegionV2, + RectilinearAtomicBudgetExhaustedV2, + RectilinearAtomicBudgetV2, + RectilinearOutcomeKindV2, + RectilinearTopologyV2, + difference_rectilinear_region_v2, + intersect_rectilinear_regions_v2, + normalize_rectilinear_region_v2, +) +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + FactSetV2, + NumericPolicyV2, + Quaternion, + RigidTransformV2, + UncertaintyBudgetV2, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + CollisionClearanceMetric, + CollisionConstraint, +) +from spatialcf.domain.geometry import ( + CollisionBodyFactV2, + GeometryApproximationV2, + GeometryInstanceV2, + GeometryRoleV2, + UprightBox3DV2, +) +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.scene import CanonicalObject + + +class CollisionDomainKindV2(StrEnum): + """Mathematical effect of one collision predicate on finite edit deltas.""" + + REGION_BRACKET = "REGION_BRACKET" + IDENTITY = "IDENTITY" + EMPTY = "EMPTY" + UNKNOWN = "UNKNOWN" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + + +@dataclass(frozen=True, slots=True) +class CollisionDomainCompilationOutcomeV2: + """Closed result of compiling one collision predicate. + + ``IDENTITY`` means every point in the supplied search universe is allowed. + A ``REGION_BRACKET`` always satisfies ``inner_allowed_delta`` subset + ``outer_allowed_delta``; both are already clipped to that universe. + """ + + kind: CollisionDomainKindV2 + inner_allowed_delta: ExactRectilinearRegionV2 | None = None + outer_allowed_delta: ExactRectilinearRegionV2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.kind, CollisionDomainKindV2): + raise TypeError("kind must be a CollisionDomainKindV2") + if type(self.finding_codes) is not tuple or any( + type(item) is not str for item in self.finding_codes + ): + raise TypeError("finding_codes must be an exact tuple of strings") + object.__setattr__( + self, + "finding_codes", + tuple(sorted(set(self.finding_codes))), + ) + + if self.kind is CollisionDomainKindV2.REGION_BRACKET: + if self.inner_allowed_delta is None or self.outer_allowed_delta is None: + raise ValueError("REGION_BRACKET requires both inner and outer regions") + _validate_delta_region_shell(self.inner_allowed_delta) + _validate_delta_region_shell(self.outer_allowed_delta) + if self.outer_allowed_delta.topology is RectilinearTopologyV2.EMPTY: + raise ValueError("an empty outer region must use EMPTY") + if not _rectilinear_subset( + self.inner_allowed_delta, + self.outer_allowed_delta, + ): + raise ValueError("inner allowed delta must be a subset of outer") + if self.finding_codes: + raise ValueError("REGION_BRACKET cannot carry findings") + return + + if self.inner_allowed_delta is not None or self.outer_allowed_delta is not None: + raise ValueError(f"{self.kind.value} must not carry allowed regions") + if self.kind is CollisionDomainKindV2.IDENTITY: + if self.finding_codes: + raise ValueError("IDENTITY cannot carry findings") + return + if self.kind is CollisionDomainKindV2.EMPTY: + if not self.finding_codes or any( + not item.startswith("EXACT_EMPTY:") for item in self.finding_codes + ): + raise ValueError("EMPTY requires an exact cause finding") + return + if not self.finding_codes: + raise ValueError(f"{self.kind.value} requires a finding") + + +CollisionDomainOutcomeV2 = CollisionDomainCompilationOutcomeV2 + + +@dataclass(frozen=True, slots=True) +class _WorldBoxV2: + min_x_m: Fraction + min_y_m: Fraction + min_z_m: Fraction + max_x_m: Fraction + max_y_m: Fraction + max_z_m: Fraction + + +def compile_collision_domain_v2( + problem: SemanticProblemV2, + constraint: CollisionConstraint | str, + search_universe: ExactRectilinearRegionV2, + *, + max_atomic_cells: int | None = None, + atomic_budget: RectilinearAtomicBudgetV2 | None = None, +) -> CollisionDomainCompilationOutcomeV2: + """Compile one exact box-union collision predicate over XY translations. + + The semantic root, an optional caller-supplied constraint object, and the + exact search universe are independently reconstructed or normalized before + they can influence a certified result. + """ + + budget = _resolve_atomic_budget(max_atomic_cells, atomic_budget) + if not isinstance(problem, SemanticProblemV2): + raise TypeError("problem must be a SemanticProblemV2") + checked_problem = SemanticProblemV2.model_validate( + problem.model_dump(mode="python"), + strict=True, + ) + selected = _resolve_constraint(checked_problem, constraint) + checked_universe = _revalidate_search_universe( + search_universe, + atomic_budget=budget, + ) + if isinstance(checked_universe, CollisionDomainCompilationOutcomeV2): + return checked_universe + if checked_universe.topology is RectilinearTopologyV2.EMPTY: + constraint_id = ( + _requested_constraint_id(constraint) + if isinstance(selected, CollisionDomainCompilationOutcomeV2) + else selected.constraint_id + ) + return _empty(f"EXACT_EMPTY:SEARCH_UNIVERSE:{constraint_id}") + if isinstance(selected, CollisionDomainCompilationOutcomeV2): + return selected + + findings = _supported_subset_findings(checked_problem, selected) + if findings: + return _unknown(*findings) + + bodies = { + item.body_id: item + for item in checked_problem.scene.collision_bodies.values or () + } + geometries = { + item.geometry_id: item + for item in checked_problem.scene.geometry_instances.values or () + } + objects = { + item.object_id: item for item in checked_problem.scene.objects.values or () + } + subject_boxes = _boxes_for_bodies( + tuple(bodies[body_id] for body_id in selected.subject_body_ids), + geometries, + objects, + ) + obstacle_boxes = _boxes_for_bodies( + tuple(bodies[body_id] for body_id in selected.obstacle_body_ids), + geometries, + objects, + ) + pair_count = len(subject_boxes) * len(obstacle_boxes) + try: + budget.consume(pair_count) + except RectilinearAtomicBudgetExhaustedV2: + return _resource_limit() + + clearance_m = Fraction.from_float(selected.minimum_clearance_m) + outer_forbidden: list[ExactAxisAlignedRectV2] = [] + inner_forbidden: list[ExactAxisAlignedRectV2] = [] + for subject_box in subject_boxes: + for obstacle_box in obstacle_boxes: + q0 = _pair_configuration_obstacle( + subject_box, + obstacle_box, + clearance_m=clearance_m, + ) + if q0 is None: + continue + outer_forbidden.append(q0) + inner_forbidden.append( + q0 + if clearance_m == 0 + else q0.dilate_axis(AxisMarginXYV2(x_m=clearance_m, y_m=clearance_m)) + ) + + if not outer_forbidden: + return CollisionDomainCompilationOutcomeV2(kind=CollisionDomainKindV2.IDENTITY) + + outer_forbidden_region = normalize_rectilinear_region_v2( + tuple(outer_forbidden), + atomic_budget=budget, + ) + if outer_forbidden_region.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource_limit() + assert outer_forbidden_region.region is not None + + if clearance_m == 0: + inner_forbidden_region = outer_forbidden_region + else: + inner_forbidden_region = normalize_rectilinear_region_v2( + tuple(inner_forbidden), + atomic_budget=budget, + ) + if inner_forbidden_region.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource_limit() + assert inner_forbidden_region.region is not None + + outer_allowed = difference_rectilinear_region_v2( + checked_universe, + outer_forbidden_region.region, + atomic_budget=budget, + ) + if outer_allowed.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource_limit() + if clearance_m == 0: + inner_allowed = outer_allowed + else: + inner_allowed = difference_rectilinear_region_v2( + checked_universe, + inner_forbidden_region.region, + atomic_budget=budget, + ) + if inner_allowed.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource_limit() + assert outer_allowed.region is not None and inner_allowed.region is not None + + if outer_allowed.region.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:COLLISION_DOMAIN:{selected.constraint_id}") + return CollisionDomainCompilationOutcomeV2( + kind=CollisionDomainKindV2.REGION_BRACKET, + inner_allowed_delta=inner_allowed.region, + outer_allowed_delta=outer_allowed.region, + ) + + +def _resolve_atomic_budget( + max_atomic_cells: int | None, + atomic_budget: RectilinearAtomicBudgetV2 | None, +) -> RectilinearAtomicBudgetV2: + if (max_atomic_cells is None) == (atomic_budget is None): + raise ValueError("provide exactly one of max_atomic_cells or atomic_budget") + if atomic_budget is not None: + if type(atomic_budget) is not RectilinearAtomicBudgetV2: + raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") + atomic_budget.validate() + return atomic_budget + if type(max_atomic_cells) is not int: + raise TypeError("max_atomic_cells must be an exact int") + return RectilinearAtomicBudgetV2(limit=max_atomic_cells) + + +def _resolve_constraint( + problem: SemanticProblemV2, + requested: CollisionConstraint | str, +) -> CollisionConstraint | CollisionDomainCompilationOutcomeV2: + if isinstance(requested, CollisionConstraint): + checked = CollisionConstraint.model_validate( + requested.model_dump(mode="python"), + strict=True, + ) + constraint_id = checked.constraint_id + elif type(requested) is str: + checked = None + constraint_id = requested + else: + raise TypeError("constraint must be a CollisionConstraint or exact str ID") + + registered = next( + ( + item + for item in problem.constraints.collision_constraints + if item.constraint_id == constraint_id + ), + None, + ) + if registered is None: + return _unknown(f"UNKNOWN_COLLISION_CONSTRAINT:{constraint_id}") + if checked is not None and checked != registered: + return _unknown(f"COLLISION_CONSTRAINT_MISMATCH:{constraint_id}") + return registered + + +def _requested_constraint_id(requested: CollisionConstraint | str) -> str: + if isinstance(requested, CollisionConstraint): + return requested.constraint_id + if type(requested) is str: + return requested + raise TypeError("constraint must be a CollisionConstraint or exact str ID") + + +def _revalidate_search_universe( + search_universe: ExactRectilinearRegionV2, + *, + atomic_budget: RectilinearAtomicBudgetV2, +) -> ExactRectilinearRegionV2 | CollisionDomainCompilationOutcomeV2: + if not isinstance(search_universe, ExactRectilinearRegionV2): + raise TypeError("search_universe must be an ExactRectilinearRegionV2") + checked = intersect_rectilinear_regions_v2( + search_universe, + search_universe, + atomic_budget=atomic_budget, + ) + if checked.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource_limit() + assert checked.region is not None + return checked.region + + +def _supported_subset_findings( + problem: SemanticProblemV2, + constraint: CollisionConstraint, +) -> tuple[str, ...]: + findings: list[str] = [] + if ( + constraint.clearance_metric + is not CollisionClearanceMetric.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE + ): + findings.append( + f"UNSUPPORTED_COLLISION_DOMAIN:CLEARANCE_METRIC:{constraint.constraint_id}" + ) + if constraint.boundary_policy is not BoundaryPolicy.CLOSED: + findings.append( + f"UNSUPPORTED_COLLISION_DOMAIN:BOUNDARY_POLICY:{constraint.constraint_id}" + ) + if constraint.support_contact_exceptions: + findings.append( + "UNSUPPORTED_COLLISION_DOMAIN:SUPPORT_CONTACT_EXCEPTIONS:" + f"{constraint.constraint_id}" + ) + if not _numeric_policy_is_zero(problem.numeric_policy): + findings.append("UNSUPPORTED_COLLISION_DOMAIN:NUMERIC_POLICY") + + for label, facts in ( + ("OBJECTS", problem.scene.objects), + ("COLLISION_BODIES", problem.scene.collision_bodies), + ("GEOMETRY_INSTANCES", problem.scene.geometry_instances), + ): + findings.extend(_fact_family_findings(label, facts)) + + if not _all_exact_families(problem): + return tuple(sorted(set(findings))) + + objects = {item.object_id: item for item in problem.scene.objects.values or ()} + bodies = { + item.body_id: item for item in problem.scene.collision_bodies.values or () + } + geometries = { + item.geometry_id: item for item in problem.scene.geometry_instances.values or () + } + relevant_body_ids = tuple( + sorted((*constraint.subject_body_ids, *constraint.obstacle_body_ids)) + ) + relevant_geometry_ids: set[str] = set() + relevant_owner_ids: set[str] = set() + for body_id in relevant_body_ids: + body = bodies.get(body_id) + if body is None: + findings.append(f"MISSING_FACT:COLLISION_BODY:{body_id}") + continue + if body.composition != "CLOSED_SOLID_UNION": + findings.append(f"UNSUPPORTED_COLLISION_DOMAIN:BODY_COMPOSITION:{body_id}") + relevant_geometry_ids.update(body.geometry_instance_ids) + if body.owner_object_id is not None: + relevant_owner_ids.add(body.owner_object_id) + owner = objects.get(body.owner_object_id) + if owner is None: + findings.append( + f"MISSING_FACT:COLLISION_BODY_OWNER:{body.owner_object_id}" + ) + + for owner_id in sorted(relevant_owner_ids): + owner = objects.get(owner_id) + if owner is not None and not _has_exact_identity_rotation( + owner.pose.world_from_object + ): + findings.append( + "UNSUPPORTED_COLLISION_DOMAIN:NON_IDENTITY_ROTATION:" + f"OBJECT_POSE:{owner_id}" + ) + + for geometry_id in sorted(relevant_geometry_ids): + geometry = geometries.get(geometry_id) + if geometry is None: + findings.append(f"MISSING_FACT:COLLISION_GEOMETRY:{geometry_id}") + continue + if geometry.role is not GeometryRoleV2.COLLISION: + findings.append(f"UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_ROLE:{geometry_id}") + if geometry.approximation is not GeometryApproximationV2.EXACT: + findings.append( + "UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_APPROXIMATION:" + f"{geometry_id}:{geometry.approximation.value}" + ) + if not isinstance(geometry.shape, UprightBox3DV2): + findings.append( + f"UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_SHAPE:{geometry_id}" + ) + if not _uncertainty_is_zero(geometry.uncertainty): + findings.append( + f"UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_ITEM_UNCERTAINTY:{geometry_id}" + ) + if not _has_exact_identity_rotation(geometry.anchor_from_geometry): + findings.append( + "UNSUPPORTED_COLLISION_DOMAIN:NON_IDENTITY_ROTATION:" + f"GEOMETRY_ANCHOR:{geometry_id}" + ) + return tuple(sorted(set(findings))) + + +def _all_exact_families(problem: SemanticProblemV2) -> bool: + return all( + facts.availability is FactAvailabilityV2.KNOWN + and facts.completeness is FactCompletenessV2.EXACT + and facts.uncertainty is not None + and _uncertainty_is_zero(facts.uncertainty) + for facts in ( + problem.scene.objects, + problem.scene.collision_bodies, + problem.scene.geometry_instances, + ) + ) + + +def _fact_family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: + if facts.availability is FactAvailabilityV2.MISSING: + return (f"MISSING_FACT:{label}",) + if facts.availability is not FactAvailabilityV2.KNOWN: + return (f"UNSUPPORTED_COLLISION_DOMAIN:{label}_AVAILABILITY",) + findings: list[str] = [] + if facts.completeness is not FactCompletenessV2.EXACT: + completeness = ( + facts.completeness.value if facts.completeness is not None else "NONE" + ) + findings.append( + f"UNSUPPORTED_COLLISION_DOMAIN:{label}_COMPLETENESS:{completeness}" + ) + if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): + findings.append(f"UNSUPPORTED_COLLISION_DOMAIN:{label}_FACT_UNCERTAINTY") + return tuple(findings) + + +def _has_exact_identity_rotation(transform: RigidTransformV2) -> bool: + rotation: Quaternion = transform.rotation + return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) + + +def _boxes_for_bodies( + bodies: tuple[CollisionBodyFactV2, ...], + geometries: dict[str, GeometryInstanceV2], + objects: dict[str, CanonicalObject], +) -> tuple[_WorldBoxV2, ...]: + boxes: list[_WorldBoxV2] = [] + for body in sorted(bodies, key=lambda item: item.body_id): + owner_translation = (Fraction(), Fraction(), Fraction()) + if body.owner_object_id is not None: + translation = objects[ + body.owner_object_id + ].pose.world_from_object.translation + owner_translation = tuple( + Fraction.from_float(value) + for value in (translation.x, translation.y, translation.z) + ) + for geometry_id in sorted(body.geometry_instance_ids): + geometry = geometries[geometry_id] + assert isinstance(geometry.shape, UprightBox3DV2) + geometry_translation = geometry.anchor_from_geometry.translation + center = tuple( + owner_translation[index] + Fraction.from_float(value) + for index, value in enumerate( + ( + geometry_translation.x, + geometry_translation.y, + geometry_translation.z, + ) + ) + ) + half_size = tuple( + Fraction.from_float(value) / 2 + for value in ( + geometry.shape.size_m.x, + geometry.shape.size_m.y, + geometry.shape.size_m.z, + ) + ) + boxes.append( + _WorldBoxV2( + min_x_m=center[0] - half_size[0], + min_y_m=center[1] - half_size[1], + min_z_m=center[2] - half_size[2], + max_x_m=center[0] + half_size[0], + max_y_m=center[1] + half_size[1], + max_z_m=center[2] + half_size[2], + ) + ) + return tuple(boxes) + + +def _pair_configuration_obstacle( + subject: _WorldBoxV2, + obstacle: _WorldBoxV2, + *, + clearance_m: Fraction, +) -> ExactAxisAlignedRectV2 | None: + if clearance_m == 0: + z_interiors_overlap = ( + subject.min_z_m < obstacle.max_z_m and obstacle.min_z_m < subject.max_z_m + ) + if not z_interiors_overlap: + return None + else: + vertical_gap_m = max( + obstacle.min_z_m - subject.max_z_m, + subject.min_z_m - obstacle.max_z_m, + Fraction(), + ) + if vertical_gap_m >= clearance_m: + return None + + return ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=obstacle.min_x_m - subject.max_x_m, + min_y_m=obstacle.min_y_m - subject.max_y_m, + max_x_m=obstacle.max_x_m - subject.min_x_m, + max_y_m=obstacle.max_y_m - subject.min_y_m, + coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, + ) + + +def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: + return all( + value == 0.0 + for value in ( + policy.linear_tolerance_m, + policy.area_tolerance_m2, + policy.angular_tolerance_rad, + policy.pixel_tolerance_px, + policy.fraction_tolerance, + ) + ) + + +def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: + return _numeric_policy_is_zero( + uncertainty.source_error + ) and _numeric_policy_is_zero(uncertainty.shape_approximation) + + +def _validate_delta_region_shell(region: ExactRectilinearRegionV2) -> None: + if not isinstance(region, ExactRectilinearRegionV2): + raise TypeError("allowed delta must be an ExactRectilinearRegionV2") + if not isinstance(region.topology, RectilinearTopologyV2): + raise TypeError("allowed delta topology is invalid") + if type(region.rectangles) is not tuple: + raise TypeError("allowed delta rectangles must be an exact tuple") + expected_topology = RectilinearTopologyV2.EMPTY + for rectangle in region.rectangles: + if type(rectangle) is not ExactAxisAlignedRectV2: + raise TypeError("allowed delta rectangles have an invalid value") + checked = ExactAxisAlignedRectV2( + coordinate_space=rectangle.coordinate_space, + topology=rectangle.topology, + min_x_m=rectangle.min_x_m, + min_y_m=rectangle.min_y_m, + max_x_m=rectangle.max_x_m, + max_y_m=rectangle.max_y_m, + ) + if checked.coordinate_space is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M: + raise ValueError("allowed delta must use translation-delta coordinates") + if checked.topology is RectTopologyV2.AREA: + expected_topology = RectilinearTopologyV2.AREA + elif ( + checked.topology is RectTopologyV2.DEGENERATE + and expected_topology is RectilinearTopologyV2.EMPTY + ): + expected_topology = RectilinearTopologyV2.DEGENERATE + if region.topology is not expected_topology: + raise ValueError("allowed delta topology does not match its rectangles") + + +def _rectilinear_subset( + inner: ExactRectilinearRegionV2, + outer: ExactRectilinearRegionV2, +) -> bool: + """Decide inclusion on the exact axis-aligned arrangement atoms.""" + + if inner.topology is RectilinearTopologyV2.EMPTY: + return True + coordinates = inner.rectangles + outer.rectangles + xs = tuple( + sorted( + { + value + for rectangle in coordinates + for value in (rectangle.min_x_m, rectangle.max_x_m) + if value is not None + } + ) + ) + ys = tuple( + sorted( + { + value + for rectangle in coordinates + for value in (rectangle.min_y_m, rectangle.max_y_m) + if value is not None + } + ) + ) + x_samples = tuple( + sorted(set(xs) | {(left + right) / 2 for left, right in itertools.pairwise(xs)}) + ) + y_samples = tuple( + sorted( + set(ys) | {(lower + upper) / 2 for lower, upper in itertools.pairwise(ys)} + ) + ) + return all( + not inner.contains_point(x_m, y_m) or outer.contains_point(x_m, y_m) + for x_m in x_samples + for y_m in y_samples + ) + + +def _unknown(*findings: str) -> CollisionDomainCompilationOutcomeV2: + return CollisionDomainCompilationOutcomeV2( + kind=CollisionDomainKindV2.UNKNOWN, + finding_codes=tuple(findings), + ) + + +def _resource_limit() -> CollisionDomainCompilationOutcomeV2: + return CollisionDomainCompilationOutcomeV2( + kind=CollisionDomainKindV2.RESOURCE_LIMIT, + finding_codes=("RESOURCE_LIMIT:COLLISION_DOMAIN_ATOMIC_CELLS",), + ) + + +def _empty(finding: str) -> CollisionDomainCompilationOutcomeV2: + return CollisionDomainCompilationOutcomeV2( + kind=CollisionDomainKindV2.EMPTY, + finding_codes=(finding,), + ) + + +# Migrated from strict_convex_candidate_domain.py. +"""Frozen candidate-stage values for strict convex continuous-yaw domains. + +These values are immutable proposals, not proof capabilities. A semantic +consumer must fresh replay the raw problem and compiler config. +""" + + +import hashlib +import json +import re +import warnings +from dataclasses import dataclass, fields, is_dataclass +from enum import Enum, StrEnum +from fractions import Fraction +from typing import Any, ClassVar + +from pydantic import ValidationError +from pydantic_core import PydanticSerializationError + +from spatialcf.core._internal.kernels.convex_partition import ( + ConvexAllowedTranslationBracketV2, + ConvexAllowedTranslationKindV2, + compile_convex_allowed_translation_v2, +) +from spatialcf.core._internal.kernels.rect import ( + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, + RectTopologyV2, +) +from spatialcf.core._internal.kernels.so2 import SO2AtomicBudgetV2 +from spatialcf.core._internal.resources import ( + DomainOperationBudgetV2, +) +from spatialcf.domain.artifacts import ( + GeometryInstanceV2_2, + SemanticProblemV2_2, + StrictConvexCandidateCompilerConfigV2_5, +) +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + NumericPolicyV2, + UncertaintyBudgetV2, + Vec3, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + CollisionClearanceMetric, + PositionRegionInterpretation, + RegionAggregation, +) +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, + GeometryApproximationV2, + GeometryRoleV2, + UprightBox3DV2, +) + +_strict_artifact_hash_domain_v2_2 = b"spatialcf.strict-convex-candidate-artifact.v2.2\0" +_strict_digest_pattern = re.compile(r"[0-9a-f]{64}") + + +class StrictConvexCandidateCompilationKindV2(StrEnum): + ARTIFACT = "ARTIFACT" + UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" + NUMERIC_GAP = "NUMERIC_GAP" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + INVALID_INPUT = "INVALID_INPUT" + + +class StrictConvexCandidateVerificationKindV2(StrEnum): + VERIFIED = "VERIFIED" + MISMATCH = "MISMATCH" + UNCERTIFIED = "UNCERTIFIED" + + +@dataclass(frozen=True, slots=True) +class StrictConvexCandidateResourceUsageV2: + domain_operations: int + so2_atomic_steps: int + + def __post_init__(self) -> None: + if type(self.domain_operations) is not int or self.domain_operations < 0: + raise ValueError("domain_operations must be a non-negative exact int") + if type(self.so2_atomic_steps) is not int or self.so2_atomic_steps <= 0: + raise ValueError("so2_atomic_steps must be a positive exact int") + + +@dataclass(frozen=True, slots=True) +class StrictConvexCandidateDomainArtifactV2_2: + semantic_problem_sha256: str + compiler_config_sha256: str + subject_id: str + search_universe: ExactAxisAlignedRectV2 + ordered_constraint_ids: tuple[str, ...] + allowed_domain_bracket: ConvexAllowedTranslationBracketV2 + resource_usage: StrictConvexCandidateResourceUsageV2 + remaining_constraint_ids: tuple[str, ...] + + def __post_init__(self) -> None: + for name, digest in ( + ("semantic_problem_sha256", self.semantic_problem_sha256), + ("compiler_config_sha256", self.compiler_config_sha256), + ): + if ( + type(digest) is not str + or _strict_digest_pattern.fullmatch(digest) is None + ): + raise ValueError(f"{name} must be a lowercase SHA-256 digest") + if type(self.subject_id) is not str or not self.subject_id.strip(): + raise ValueError("subject_id must be a non-blank exact string") + checked_universe = _strict_copy_universe(self.search_universe) + if ( + checked_universe.topology is not RectTopologyV2.AREA + or checked_universe.coordinate_space + is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M + ): + raise ValueError("search universe must be an AREA translation-delta rect") + if type(self.ordered_constraint_ids) is not tuple or not ( + self.ordered_constraint_ids + ): + raise ValueError("ordered_constraint_ids must be a non-empty exact tuple") + if any( + type(item) is not str or not item.strip() + for item in self.ordered_constraint_ids + ): + raise ValueError("constraint IDs must be non-blank exact strings") + if len(set(self.ordered_constraint_ids)) != len(self.ordered_constraint_ids): + raise ValueError("constraint IDs must be unique") + if type(self.remaining_constraint_ids) is not tuple or any( + type(item) is not str or not item.strip() + for item in self.remaining_constraint_ids + ): + raise ValueError( + "remaining_constraint_ids must be an exact tuple of non-blank strings" + ) + if len(set(self.remaining_constraint_ids)) != len( + self.remaining_constraint_ids + ): + raise ValueError("remaining constraint IDs must be unique") + if ( + tuple(sorted(self.remaining_constraint_ids)) + != self.remaining_constraint_ids + ): + raise ValueError("remaining constraint IDs must be canonically ordered") + if set(self.ordered_constraint_ids) & set(self.remaining_constraint_ids): + raise ValueError("compiled and remaining constraint IDs must be disjoint") + checked_bracket = _strict_copy_bracket(self.allowed_domain_bracket) + if ( + checked_bracket.inner_allowed.universe != checked_universe + or checked_bracket.outer_allowed.universe != checked_universe + ): + raise ValueError("allowed bracket must use the exact search universe") + if type(self.resource_usage) is not StrictConvexCandidateResourceUsageV2: + raise TypeError("resource_usage has the wrong exact type") + checked_usage = StrictConvexCandidateResourceUsageV2( + self.resource_usage.domain_operations, + self.resource_usage.so2_atomic_steps, + ) + if checked_usage.so2_atomic_steps != checked_bracket.atomic_steps_used: + raise ValueError("SO(2) resource usage must equal the bracket replay usage") + object.__setattr__(self, "search_universe", checked_universe) + object.__setattr__(self, "allowed_domain_bracket", checked_bracket) + object.__setattr__(self, "resource_usage", checked_usage) + + @property + def artifact_sha256(self) -> str: + return hashlib.sha256( + _strict_artifact_hash_domain_v2_2 + _strict_artifact_bytes(self) + ).hexdigest() + + +@dataclass(frozen=True, slots=True) +class StrictConvexCandidateCompilationOutcomeV2: + kind: StrictConvexCandidateCompilationKindV2 + artifact: StrictConvexCandidateDomainArtifactV2_2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not StrictConvexCandidateCompilationKindV2: + raise TypeError("kind has the wrong exact type") + if type(self.finding_codes) is not tuple or any( + type(item) is not str or not item.strip() for item in self.finding_codes + ): + raise ValueError("finding_codes must be exact non-blank strings") + findings = tuple(sorted(set(self.finding_codes))) + object.__setattr__(self, "finding_codes", findings) + if self.kind is StrictConvexCandidateCompilationKindV2.ARTIFACT: + if type(self.artifact) is not StrictConvexCandidateDomainArtifactV2_2: + raise ValueError("ARTIFACT outcome requires an exact artifact") + if findings: + raise ValueError("ARTIFACT outcome cannot carry findings") + object.__setattr__(self, "artifact", _strict_copy_artifact(self.artifact)) + return + if self.artifact is not None: + raise ValueError("failure outcome cannot carry an artifact") + if not findings: + raise ValueError("failure outcome requires at least one finding") + + +@dataclass(frozen=True, slots=True) +class StrictConvexCandidateVerificationOutcomeV2: + kind: StrictConvexCandidateVerificationKindV2 + semantic_problem_sha256: str | None = None + compiler_config_sha256: str | None = None + artifact_sha256: str | None = None + verification_resource_usage: StrictConvexCandidateResourceUsageV2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not StrictConvexCandidateVerificationKindV2: + raise TypeError("verification kind has the wrong exact type") + if type(self.finding_codes) is not tuple or any( + type(item) is not str or not item.strip() for item in self.finding_codes + ): + raise ValueError("finding_codes must be exact non-blank strings") + findings = tuple(sorted(set(self.finding_codes))) + object.__setattr__(self, "finding_codes", findings) + refs = ( + self.semantic_problem_sha256, + self.compiler_config_sha256, + self.artifact_sha256, + ) + if self.kind is StrictConvexCandidateVerificationKindV2.VERIFIED: + if any( + type(value) is not str + or _strict_digest_pattern.fullmatch(value) is None + for value in refs + ): + raise ValueError("VERIFIED requires three exact SHA-256 references") + if ( + type(self.verification_resource_usage) + is not StrictConvexCandidateResourceUsageV2 + ): + raise ValueError("VERIFIED requires exact replay resource usage") + object.__setattr__( + self, + "verification_resource_usage", + StrictConvexCandidateResourceUsageV2( + self.verification_resource_usage.domain_operations, + self.verification_resource_usage.so2_atomic_steps, + ), + ) + if findings: + raise ValueError("VERIFIED cannot carry findings") + return + if any(value is not None for value in refs): + raise ValueError("non-VERIFIED outcomes cannot carry verified references") + if not findings: + raise ValueError("non-VERIFIED outcomes require findings") + if self.verification_resource_usage is not None: + if ( + type(self.verification_resource_usage) + is not StrictConvexCandidateResourceUsageV2 + ): + raise TypeError("verification_resource_usage has the wrong exact type") + object.__setattr__( + self, + "verification_resource_usage", + StrictConvexCandidateResourceUsageV2( + self.verification_resource_usage.domain_operations, + self.verification_resource_usage.so2_atomic_steps, + ), + ) + + +class StrictConvexCandidateDomainCompilerV2_5: + """Compile the bounded one-pair continuous-yaw collision prefix.""" + + def compile( + self, + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_5, + ) -> StrictConvexCandidateCompilationOutcomeV2: + return compile_strict_convex_candidate_domain_v2_5(problem, config) + + +class _StrictInvalidInputV2(ValueError): + pass + + +class _StrictUnsupportedModelV2(ValueError): + def __init__(self, finding_code: str) -> None: + self.finding_code = finding_code + super().__init__(finding_code) + + +class _ResourceLimitErrorV2(RuntimeError): + pass + + +@dataclass(slots=True) +class _DomainOperationBudgetV2(DomainOperationBudgetV2): + _exhaustion_error_type: ClassVar[type[RuntimeError]] = _ResourceLimitErrorV2 + + +def compile_strict_convex_candidate_domain_v2_5( + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_5, +) -> StrictConvexCandidateCompilationOutcomeV2: + """Fresh-compile a collision prefix; remaining hard constraints stay explicit.""" + + try: + checked_config = _strict_legacy_config(config) + except _StrictInvalidInputV2: + return _strict_failure( + StrictConvexCandidateCompilationKindV2.INVALID_INPUT, + "INVALID_INPUT:STRICT_CONVEX_CANDIDATE_INPUT", + ) + except (ArithmeticError, RuntimeWarning): + return _strict_failure( + StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:STRICT_CONVEX_CANDIDATE_REVALIDATION", + ) + + budget = _DomainOperationBudgetV2(checked_config.max_domain_operations) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + _strict_precharge_problem_structure(problem, budget) + checked_problem = _strict_legacy_problem(problem) + except _ResourceLimitErrorV2: + return _strict_failure( + StrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:max_domain_operations", + ) + except _StrictInvalidInputV2: + return _strict_failure( + StrictConvexCandidateCompilationKindV2.INVALID_INPUT, + "INVALID_INPUT:STRICT_CONVEX_CANDIDATE_INPUT", + ) + except (ArithmeticError, RuntimeWarning): + return _strict_failure( + StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:STRICT_CONVEX_CANDIDATE_REVALIDATION", + ) + atomic_budget = SO2AtomicBudgetV2(limit=checked_config.max_so2_atomic_steps) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + from spatialcf.core._internal.compilation.candidate_cells import ( + _compile_search_universe, + _SearchUniverseFailureV2, + ) + + search = _compile_search_universe(checked_problem, budget) + if isinstance(search, _SearchUniverseFailureV2): + kind = ( + StrictConvexCandidateCompilationKindV2.NUMERIC_GAP + if search.uncertified_reason.value == "NUMERIC_GAP" + else StrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL + ) + return StrictConvexCandidateCompilationOutcomeV2( + kind=kind, + finding_codes=search.finding_codes, + ) + subject_transform, subject_shape, obstacle_transform, obstacle_shape = ( + _extract_supported_pair(checked_problem, budget) + ) + allowed = compile_convex_allowed_translation_v2( + subject_transform, + subject_shape, + obstacle_transform, + obstacle_shape, + search.delta_rect, + atomic_budget=atomic_budget, + ) + if allowed.kind is ConvexAllowedTranslationKindV2.RESOURCE_LIMIT: + return _strict_failure( + StrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:max_so2_atomic_steps", + ) + if allowed.kind is ConvexAllowedTranslationKindV2.NUMERIC_GAP: + return StrictConvexCandidateCompilationOutcomeV2( + kind=StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + finding_codes=allowed.finding_codes, + ) + if allowed.kind is ConvexAllowedTranslationKindV2.INVALID_INPUT: + raise RuntimeError("strict supported pair produced invalid T12 input") + if ( + allowed.kind is not ConvexAllowedTranslationKindV2.BRACKET + or type(allowed.bracket) is not ConvexAllowedTranslationBracketV2 + ): + raise RuntimeError("malformed T12 allowed-domain outcome") + constraints = checked_problem.constraints + budget.consume( + len(constraints.support_constraints) + + len(constraints.visibility_constraints) + + 1 + ) + remaining_ids = tuple( + sorted( + ( + *( + item.constraint_id + for item in constraints.support_constraints + ), + *( + item.constraint_id + for item in constraints.visibility_constraints + ), + constraints.target_relation.constraint_id, + ) + ) + ) + artifact = StrictConvexCandidateDomainArtifactV2_2( + semantic_problem_sha256=checked_problem.semantic_problem_sha256, + compiler_config_sha256=checked_config.config_sha256, + subject_id=constraints.allowed_edit.subject_id, + search_universe=search.delta_rect, + ordered_constraint_ids=( + constraints.position_domain.constraint_id, + constraints.collision_constraints[0].constraint_id, + ), + allowed_domain_bracket=allowed.bracket, + resource_usage=StrictConvexCandidateResourceUsageV2( + domain_operations=budget.used, + so2_atomic_steps=atomic_budget.used, + ), + remaining_constraint_ids=remaining_ids, + ) + return StrictConvexCandidateCompilationOutcomeV2( + kind=StrictConvexCandidateCompilationKindV2.ARTIFACT, + artifact=artifact, + ) + except _StrictUnsupportedModelV2 as error: + return _strict_failure( + StrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL, + error.finding_code, + ) + except _ResourceLimitErrorV2: + return _strict_failure( + StrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:max_domain_operations", + ) + except (ArithmeticError, RuntimeWarning): + return _strict_failure( + StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:STRICT_CONVEX_CANDIDATE_COMPILATION", + ) + + +def verify_strict_convex_candidate_domain_v2_5( + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_5, + submitted_artifact: StrictConvexCandidateDomainArtifactV2_2, +) -> StrictConvexCandidateVerificationOutcomeV2: + """Fresh replay raw inputs and compare the entire submitted prefix artifact.""" + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + checked_submitted = _strict_copy_artifact(submitted_artifact) + except (ArithmeticError, RuntimeWarning): + return StrictConvexCandidateVerificationOutcomeV2( + kind=StrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("NUMERIC_GAP:SUBMITTED_CANDIDATE_ARTIFACT",), + ) + except (AttributeError, TypeError, ValueError, Warning): + return StrictConvexCandidateVerificationOutcomeV2( + kind=StrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("INVALID_INPUT:SUBMITTED_CANDIDATE_ARTIFACT",), + ) + + replay = compile_strict_convex_candidate_domain_v2_5(problem, config) + if ( + replay.kind is not StrictConvexCandidateCompilationKindV2.ARTIFACT + or type(replay.artifact) is not StrictConvexCandidateDomainArtifactV2_2 + ): + return StrictConvexCandidateVerificationOutcomeV2( + kind=StrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=replay.finding_codes, + ) + fresh = replay.artifact + usage = fresh.resource_usage + if ( + checked_submitted != fresh + or _strict_artifact_bytes(checked_submitted) != _strict_artifact_bytes(fresh) + or checked_submitted.artifact_sha256 != fresh.artifact_sha256 + ): + return StrictConvexCandidateVerificationOutcomeV2( + kind=StrictConvexCandidateVerificationKindV2.MISMATCH, + verification_resource_usage=usage, + finding_codes=("MISMATCH:STRICT_CONVEX_CANDIDATE_ARTIFACT",), + ) + return StrictConvexCandidateVerificationOutcomeV2( + kind=StrictConvexCandidateVerificationKindV2.VERIFIED, + semantic_problem_sha256=fresh.semantic_problem_sha256, + compiler_config_sha256=fresh.compiler_config_sha256, + artifact_sha256=fresh.artifact_sha256, + verification_resource_usage=usage, + ) + + +def _strict_legacy_problem(value: object) -> SemanticProblemV2_2: + if type(value) is not SemanticProblemV2_2: + raise _StrictInvalidInputV2 + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + payload = value.model_dump(mode="python", warnings="error") + return SemanticProblemV2_2.model_validate(payload, strict=True) + except (ArithmeticError, RuntimeWarning): + raise + except ( + AttributeError, + PydanticSerializationError, + TypeError, + ValidationError, + ValueError, + Warning, + ) as error: + raise _StrictInvalidInputV2 from error + + +def _strict_precharge_problem_structure( + value: object, + budget: _DomainOperationBudgetV2, +) -> None: + if type(value) is not SemanticProblemV2_2: + raise _StrictInvalidInputV2 + try: + scene = value.scene + constraints = value.constraints + objective = value.objective + relation_semantics = value.relation_semantics + visibility_semantics = value.visibility_semantics + families = ( + scene.objects, + scene.geometry_instances, + scene.collision_bodies, + scene.workspace_boundaries, + scene.known_free_spaces, + scene.support_surfaces, + scene.cameras, + scene.baseline_observations, + ) + family_values = tuple( + values + for facts in families + for values in (facts.values, facts.inner_values, facts.outer_values) + if values is not None + ) + if any(type(values) is not tuple for values in family_values): + raise _StrictInvalidInputV2 + budget.consume( + 1 + + sum(len(values) for values in family_values) + + len(constraints.collision_constraints) + + len(constraints.support_constraints) + + len(constraints.visibility_constraints) + + len(relation_semantics.definitions) + + len(visibility_semantics.definitions) + + len(objective.relation_damage.pair_axis_weights) + + len(objective.visibility_change.object_camera_weights) + + len(objective.safety_margin.aggregation.targets) + ) + for facts in (scene.workspace_boundaries, scene.known_free_spaces): + for item in facts.values or (): + _precharge_region(item.region_world_xy, budget) + for item in scene.support_surfaces.values or (): + _precharge_region(item.region_uv, budget) + for item in scene.geometry_instances.values or (): + shape = item.shape + if hasattr(shape, "footprint"): + budget.consume() + _precharge_component(shape.footprint, budget) + except _ResourceLimitErrorV2: + raise + except (AttributeError, TypeError, ValueError) as error: + raise _StrictInvalidInputV2 from error + + +def _precharge_region(value: Any, budget: _DomainOperationBudgetV2) -> None: + components = value.components + if type(components) is not tuple: + raise _StrictInvalidInputV2 + budget.consume(len(components) + 1) + for component in components: + _precharge_component(component, budget) + + +def _precharge_component(value: Any, budget: _DomainOperationBudgetV2) -> None: + holes = value.holes + if type(holes) is not tuple: + raise _StrictInvalidInputV2 + budget.consume(len(value.exterior.vertices) + len(holes) + 1) + for hole in holes: + budget.consume(len(hole.vertices) + 1) + + +def _strict_legacy_config(value: object) -> StrictConvexCandidateCompilerConfigV2_5: + if type(value) is not StrictConvexCandidateCompilerConfigV2_5: + raise _StrictInvalidInputV2 + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + payload = value.model_dump(mode="python", warnings="error") + return StrictConvexCandidateCompilerConfigV2_5.model_validate( + payload, strict=True + ) + except (ArithmeticError, RuntimeWarning): + raise + except ( + AttributeError, + PydanticSerializationError, + TypeError, + ValidationError, + ValueError, + Warning, + ) as error: + raise _StrictInvalidInputV2 from error + + +def _extract_supported_pair( + problem: SemanticProblemV2_2, + budget: _DomainOperationBudgetV2, +) -> tuple[ + DirectedYawIntervalTransformV2_2, + UprightBox3DV2, + DirectedYawIntervalTransformV2_2, + UprightBox3DV2, +]: + constraints = problem.constraints + position = constraints.position_domain + if ( + position.region_interpretation + is not PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS + or position.workspace_aggregation is not RegionAggregation.INTERSECTION + or position.boundary_policy is not BoundaryPolicy.CLOSED + or position.known_free_space_fact_ids + or len(position.workspace_fact_ids) != 1 + or position.minimum_boundary_clearance_m != 0.0 + ): + raise _StrictUnsupportedModelV2("UNSUPPORTED:POSITION_DOMAIN_SUBSET") + workspace_values = _strict_exact_fact_values( + problem.scene.workspace_boundaries, "WORKSPACE_BOUNDARIES", budget + ) + if ( + len(workspace_values) != 1 + or workspace_values[0].fact_id != position.workspace_fact_ids[0] + or workspace_values[0].region_approximation is not GeometryApproximationV2.EXACT + or workspace_values[0].geometry_uncertainty != UncertaintyBudgetV2() + ): + raise _StrictUnsupportedModelV2("UNSUPPORTED:POSITION_WORKSPACE_SUBSET") + if len(constraints.collision_constraints) != 1: + raise _StrictUnsupportedModelV2("UNSUPPORTED:COLLISION_CONSTRAINT_CARDINALITY") + collision = constraints.collision_constraints[0] + if len(collision.subject_body_ids) != 1 or len(collision.obstacle_body_ids) != 1: + raise _StrictUnsupportedModelV2("UNSUPPORTED:COLLISION_PAIR_CARDINALITY") + if ( + collision.clearance_metric + is not CollisionClearanceMetric.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE + or collision.boundary_policy is not BoundaryPolicy.CLOSED + or collision.minimum_clearance_m != 0.0 + or collision.support_contact_exceptions + ): + raise _StrictUnsupportedModelV2("UNSUPPORTED:COLLISION_POLICY") + if problem.numeric_policy != NumericPolicyV2(): + raise _StrictUnsupportedModelV2("UNSUPPORTED:NUMERIC_POLICY") + + bodies = _strict_exact_fact_values( + problem.scene.collision_bodies, "COLLISION_BODIES", budget + ) + geometries = _strict_exact_fact_values( + problem.scene.geometry_instances, "GEOMETRY_INSTANCES", budget + ) + objects = _strict_exact_fact_values(problem.scene.objects, "OBJECTS", budget) + body_by_id = {item.body_id: item for item in bodies} + geometry_by_id = {item.geometry_id: item for item in geometries} + object_by_id = {item.object_id: item for item in objects} + budget.consume(len(bodies) + len(geometries) + len(objects)) + + subject_id = constraints.allowed_edit.subject_id + subject_body = body_by_id[collision.subject_body_ids[0]] + obstacle_body = body_by_id[collision.obstacle_body_ids[0]] + if ( + subject_body.owner_object_id != subject_id + or obstacle_body.owner_object_id is None + or obstacle_body.owner_object_id == subject_id + or len(subject_body.geometry_instance_ids) != 1 + or len(obstacle_body.geometry_instance_ids) != 1 + ): + raise _StrictUnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") + try: + subject_object = object_by_id[subject_id] + obstacle_object = object_by_id[obstacle_body.owner_object_id] + subject_geometry = geometry_by_id[subject_body.geometry_instance_ids[0]] + obstacle_geometry = geometry_by_id[obstacle_body.geometry_instance_ids[0]] + except KeyError as error: + raise RuntimeError( + "strict semantic graph lost a collision reference" + ) from error + budget.consume(4) + if not subject_object.movable or obstacle_object.movable: + raise _StrictUnsupportedModelV2("UNSUPPORTED:FIXED_OBSTACLE_SUBSET") + for geometry, owner_id in ( + (subject_geometry, subject_id), + (obstacle_geometry, obstacle_body.owner_object_id), + ): + if ( + type(geometry) is not GeometryInstanceV2_2 + or geometry.owner_object_id != owner_id + or geometry.role is not GeometryRoleV2.COLLISION + or geometry.approximation is not GeometryApproximationV2.EXACT + or geometry.uncertainty != UncertaintyBudgetV2() + or type(geometry.shape) is not UprightBox3DV2 + or not _strict_is_identity_anchor(geometry.anchor_from_geometry) + ): + raise _StrictUnsupportedModelV2("UNSUPPORTED:COLLISION_GEOMETRY_SUBSET") + subject_transform = subject_object.pose.world_from_object + obstacle_transform = obstacle_object.pose.world_from_object + if ( + type(subject_transform) is not DirectedYawIntervalTransformV2_2 + or type(obstacle_transform) is not DirectedYawIntervalTransformV2_2 + ): + raise RuntimeError("v2.2 object pose lost its directed-yaw transform") + subject_shape = subject_geometry.shape + obstacle_shape = obstacle_geometry.shape + assert type(subject_shape) is UprightBox3DV2 + assert type(obstacle_shape) is UprightBox3DV2 + if not _z_interiors_overlap( + subject_transform, subject_shape, obstacle_transform, obstacle_shape + ): + raise _StrictUnsupportedModelV2("UNSUPPORTED:Z_SEPARATED_COLLISION_PAIR") + return subject_transform, subject_shape, obstacle_transform, obstacle_shape + + +def _strict_exact_fact_values( + facts: Any, + label: str, + budget: _DomainOperationBudgetV2, +) -> tuple[Any, ...]: + if ( + facts.availability is not FactAvailabilityV2.KNOWN + or facts.completeness is not FactCompletenessV2.EXACT + or facts.uncertainty != UncertaintyBudgetV2() + or type(facts.values) is not tuple + ): + raise _StrictUnsupportedModelV2(f"UNSUPPORTED:{label}_FACT_SET") + budget.consume(len(facts.values) + 1) + return facts.values + + +def _strict_is_identity_anchor(transform: DirectedYawIntervalTransformV2_2) -> bool: + return ( + type(transform) is DirectedYawIntervalTransformV2_2 + and transform.translation == Vec3(x=0.0, y=0.0, z=0.0) + and transform.yaw_radians == 0.0 + ) + + +def _z_interiors_overlap( + subject_transform: DirectedYawIntervalTransformV2_2, + subject_shape: UprightBox3DV2, + obstacle_transform: DirectedYawIntervalTransformV2_2, + obstacle_shape: UprightBox3DV2, +) -> bool: + subject_z = Fraction.from_float(subject_transform.translation.z) + obstacle_z = Fraction.from_float(obstacle_transform.translation.z) + subject_half = Fraction.from_float(subject_shape.size_m.z) / 2 + obstacle_half = Fraction.from_float(obstacle_shape.size_m.z) / 2 + return max(subject_z - subject_half, obstacle_z - obstacle_half) < min( + subject_z + subject_half, obstacle_z + obstacle_half + ) + + +def _strict_failure( + kind: StrictConvexCandidateCompilationKindV2, + finding_code: str, +) -> StrictConvexCandidateCompilationOutcomeV2: + return StrictConvexCandidateCompilationOutcomeV2( + kind=kind, + finding_codes=(finding_code,), + ) + + +def _strict_copy_universe(value: ExactAxisAlignedRectV2) -> ExactAxisAlignedRectV2: + if type(value) is not ExactAxisAlignedRectV2: + raise TypeError("search_universe has the wrong exact type") + bounds = value.bounds + if bounds is None: + raise ValueError("search universe cannot be empty") + return ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=bounds[0], + min_y_m=bounds[1], + max_x_m=bounds[2], + max_y_m=bounds[3], + coordinate_space=value.coordinate_space, + ) + + +def _strict_copy_bracket( + value: ConvexAllowedTranslationBracketV2, +) -> ConvexAllowedTranslationBracketV2: + if type(value) is not ConvexAllowedTranslationBracketV2: + raise TypeError("allowed_domain_bracket has the wrong exact type") + return ConvexAllowedTranslationBracketV2( + inner_allowed=value.inner_allowed, + outer_allowed=value.outer_allowed, + obstacle_kernel_id=value.obstacle_kernel_id, + obstacle_kernel_version=value.obstacle_kernel_version, + partition_kernel_id=value.partition_kernel_id, + partition_kernel_version=value.partition_kernel_version, + atomic_steps_used=value.atomic_steps_used, + ) + + +def _strict_copy_artifact( + value: StrictConvexCandidateDomainArtifactV2_2, +) -> StrictConvexCandidateDomainArtifactV2_2: + return StrictConvexCandidateDomainArtifactV2_2( + semantic_problem_sha256=value.semantic_problem_sha256, + compiler_config_sha256=value.compiler_config_sha256, + subject_id=value.subject_id, + search_universe=value.search_universe, + ordered_constraint_ids=value.ordered_constraint_ids, + allowed_domain_bracket=value.allowed_domain_bracket, + resource_usage=value.resource_usage, + remaining_constraint_ids=value.remaining_constraint_ids, + ) + + +def _strict_artifact_bytes(value: StrictConvexCandidateDomainArtifactV2_2) -> bytes: + return json.dumps( + _strict_canonical_value(value), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _strict_canonical_value(value: Any) -> Any: + if isinstance(value, Fraction): + return {"denominator": value.denominator, "numerator": value.numerator} + if isinstance(value, Enum): + return value.value + if is_dataclass(value) and not isinstance(value, type): + return { + field.name: _strict_canonical_value(getattr(value, field.name)) + for field in fields(value) + } + if isinstance(value, tuple): + return [_strict_canonical_value(item) for item in value] + if value is None or type(value) in {str, int, float, bool}: + return value + raise TypeError(f"unsupported artifact hash value: {type(value).__name__}") + + +_strict_exports = ( + "StrictConvexCandidateCompilationKindV2", + "StrictConvexCandidateCompilationOutcomeV2", + "StrictConvexCandidateDomainArtifactV2_2", + "StrictConvexCandidateDomainCompilerV2_5", + "StrictConvexCandidateResourceUsageV2", + "StrictConvexCandidateVerificationKindV2", + "StrictConvexCandidateVerificationOutcomeV2", + "compile_strict_convex_candidate_domain_v2_5", + "verify_strict_convex_candidate_domain_v2_5", +) + +# Migrated from multi_obstacle_strict_convex_candidate_domain.py. +"""Raw multi-obstacle strict-convex candidate compilation for Canonical v2.2.""" + + +import hashlib +import json +import re +import warnings +from dataclasses import dataclass, fields, is_dataclass +from enum import Enum, StrEnum +from fractions import Fraction +from typing import Any + +from pydantic import ValidationError +from pydantic_core import PydanticSerializationError + +from spatialcf.core._internal.kernels.convex_partition import ( + ConvexAllowedTranslationBracketV2, + ConvexAllowedTranslationKindV2, + compile_convex_allowed_translation_v2, +) +from spatialcf.core._internal.kernels.rect import ( + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, + RectTopologyV2, +) +from spatialcf.core._internal.kernels.so2 import SO2AtomicBudgetV2 +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionComplexV2, + StrictConvexIntersectionKindV2, + intersect_strict_convex_allowed_complexes_v2, +) +from spatialcf.core._internal.compilation.collision import ( + _StrictInvalidInputV2 as _LegacyInvalidInputV2, +) +from spatialcf.core._internal.compilation.collision import ( + _strict_precharge_problem_structure as _precharge_problem_structure, +) +from spatialcf.domain.artifacts import ( + GeometryInstanceV2_2, + SemanticProblemV2_2, + StrictConvexCandidateCompilerConfigV2_6, +) +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + NumericPolicyV2, + UncertaintyBudgetV2, + Vec3, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + CollisionClearanceMetric, + PositionRegionInterpretation, + RegionAggregation, +) +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, + GeometryApproximationV2, + GeometryRoleV2, + UprightBox3DV2, +) + +_ARTIFACT_HASH_DOMAIN_V2_2 = ( + b"spatialcf.multi-obstacle-strict-convex-candidate-artifact.v2.2\0" +) +_DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}") +_INTERSECTION_KERNEL_ID = "geometry-kernel:rational-strict-convex-intersection-v2" +_INTERSECTION_KERNEL_VERSION = "kernel:2.5-strict-convex-intersection" + + +class MultiObstacleStrictConvexCandidateCompilationKindV2(StrEnum): + ARTIFACT = "ARTIFACT" + UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" + NUMERIC_GAP = "NUMERIC_GAP" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + INVALID_INPUT = "INVALID_INPUT" + + +class MultiObstacleStrictConvexCandidateVerificationKindV2(StrEnum): + VERIFIED = "VERIFIED" + MISMATCH = "MISMATCH" + UNCERTIFIED = "UNCERTIFIED" + + +@dataclass(frozen=True, slots=True) +class MultiObstacleStrictConvexCandidateResourceUsageV2: + domain_operations: int + so2_atomic_steps: int + candidate_cells: int + + def __post_init__(self) -> None: + if type(self.domain_operations) is not int or self.domain_operations < 0: + raise ValueError("domain_operations must be a non-negative exact int") + if type(self.so2_atomic_steps) is not int or self.so2_atomic_steps <= 0: + raise ValueError("so2_atomic_steps must be a positive exact int") + if type(self.candidate_cells) is not int or self.candidate_cells < 0: + raise ValueError("candidate_cells must be a non-negative exact int") + + +@dataclass(frozen=True, slots=True) +class MultiObstacleStrictConvexAllowedBracketV2: + inner_allowed: StrictConvexIntersectionComplexV2 + outer_allowed: StrictConvexIntersectionComplexV2 + intersection_kernel_id: str + intersection_kernel_version: str + so2_atomic_steps_used: int + + def __post_init__(self) -> None: + checked_inner = _copy_intersection_complex(self.inner_allowed) + checked_outer = _copy_intersection_complex(self.outer_allowed) + if checked_inner.universe != checked_outer.universe: + raise ValueError("inner and outer intersections require one universe") + if self.intersection_kernel_id != _INTERSECTION_KERNEL_ID: + raise ValueError("unexpected intersection kernel ID") + if self.intersection_kernel_version != _INTERSECTION_KERNEL_VERSION: + raise ValueError("unexpected intersection kernel version") + if ( + type(self.so2_atomic_steps_used) is not int + or self.so2_atomic_steps_used <= 0 + ): + raise ValueError("so2_atomic_steps_used must be a positive exact int") + if not all( + checked_outer.contains_point(cell.strict_witness) + for cell in checked_inner.cells + ): + raise ValueError("inner intersection witness escaped outer intersection") + object.__setattr__(self, "inner_allowed", checked_inner) + object.__setattr__(self, "outer_allowed", checked_outer) + + +@dataclass(frozen=True, slots=True) +class MultiObstacleStrictConvexCandidateDomainArtifactV2_2: + semantic_problem_sha256: str + compiler_config_sha256: str + subject_id: str + search_universe: ExactAxisAlignedRectV2 + ordered_constraint_ids: tuple[str, ...] + ordered_obstacle_body_ids: tuple[str, ...] + allowed_domain_bracket: MultiObstacleStrictConvexAllowedBracketV2 + resource_usage: MultiObstacleStrictConvexCandidateResourceUsageV2 + remaining_constraint_ids: tuple[str, ...] + + def __post_init__(self) -> None: + for label, digest in ( + ("semantic_problem_sha256", self.semantic_problem_sha256), + ("compiler_config_sha256", self.compiler_config_sha256), + ): + if type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None: + raise ValueError(f"{label} must be a lowercase SHA-256 digest") + if type(self.subject_id) is not str or not self.subject_id.strip(): + raise ValueError("subject_id must be a non-blank exact string") + checked_universe = _copy_universe(self.search_universe) + if ( + checked_universe.topology is not RectTopologyV2.AREA + or checked_universe.coordinate_space + is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M + ): + raise ValueError("search universe must be an AREA translation-delta rect") + compiled_ids = _require_id_tuple( + self.ordered_constraint_ids, + label="ordered_constraint_ids", + nonempty=True, + sorted_required=False, + ) + obstacle_ids = _require_id_tuple( + self.ordered_obstacle_body_ids, + label="ordered_obstacle_body_ids", + nonempty=True, + sorted_required=True, + ) + remaining_ids = _require_id_tuple( + self.remaining_constraint_ids, + label="remaining_constraint_ids", + nonempty=False, + sorted_required=True, + ) + if set(compiled_ids) & set(remaining_ids): + raise ValueError("compiled and remaining constraint IDs must be disjoint") + if ( + type(self.allowed_domain_bracket) + is not MultiObstacleStrictConvexAllowedBracketV2 + ): + raise TypeError("allowed_domain_bracket has the wrong exact type") + checked_bracket = _copy_bracket(self.allowed_domain_bracket) + if ( + checked_bracket.inner_allowed.universe != checked_universe + or checked_bracket.outer_allowed.universe != checked_universe + ): + raise ValueError("allowed bracket must use the exact search universe") + if ( + type(self.resource_usage) + is not MultiObstacleStrictConvexCandidateResourceUsageV2 + ): + raise TypeError("resource_usage has the wrong exact type") + checked_usage = MultiObstacleStrictConvexCandidateResourceUsageV2( + domain_operations=self.resource_usage.domain_operations, + so2_atomic_steps=self.resource_usage.so2_atomic_steps, + candidate_cells=self.resource_usage.candidate_cells, + ) + if checked_usage.so2_atomic_steps != checked_bracket.so2_atomic_steps_used: + raise ValueError("SO(2) usage must equal the aggregate bracket usage") + if checked_usage.candidate_cells != ( + len(checked_bracket.inner_allowed.cells) + + len(checked_bracket.outer_allowed.cells) + ): + raise ValueError("candidate-cell usage must equal all published cells") + object.__setattr__(self, "search_universe", checked_universe) + object.__setattr__(self, "ordered_constraint_ids", compiled_ids) + object.__setattr__(self, "ordered_obstacle_body_ids", obstacle_ids) + object.__setattr__(self, "remaining_constraint_ids", remaining_ids) + object.__setattr__(self, "allowed_domain_bracket", checked_bracket) + object.__setattr__(self, "resource_usage", checked_usage) + + @property + def artifact_sha256(self) -> str: + return hashlib.sha256( + _ARTIFACT_HASH_DOMAIN_V2_2 + _artifact_bytes(self) + ).hexdigest() + + +@dataclass(frozen=True, slots=True) +class MultiObstacleStrictConvexCandidateCompilationOutcomeV2: + kind: MultiObstacleStrictConvexCandidateCompilationKindV2 + artifact: MultiObstacleStrictConvexCandidateDomainArtifactV2_2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not MultiObstacleStrictConvexCandidateCompilationKindV2: + raise TypeError("kind has the wrong exact type") + findings = _require_finding_codes(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + if self.kind is MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT: + if ( + type(self.artifact) + is not MultiObstacleStrictConvexCandidateDomainArtifactV2_2 + ): + raise ValueError("ARTIFACT outcome requires an exact artifact") + if findings: + raise ValueError("ARTIFACT outcome cannot carry findings") + object.__setattr__(self, "artifact", _copy_artifact(self.artifact)) + return + if self.artifact is not None: + raise ValueError("failure outcome cannot carry an artifact") + if not findings: + raise ValueError("failure outcome requires at least one finding") + + +@dataclass(frozen=True, slots=True) +class MultiObstacleStrictConvexCandidateVerificationOutcomeV2: + kind: MultiObstacleStrictConvexCandidateVerificationKindV2 + semantic_problem_sha256: str | None = None + compiler_config_sha256: str | None = None + artifact_sha256: str | None = None + verification_resource_usage: ( + MultiObstacleStrictConvexCandidateResourceUsageV2 | None + ) = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not MultiObstacleStrictConvexCandidateVerificationKindV2: + raise TypeError("verification kind has the wrong exact type") + findings = _require_finding_codes(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + refs = ( + self.semantic_problem_sha256, + self.compiler_config_sha256, + self.artifact_sha256, + ) + if self.kind is MultiObstacleStrictConvexCandidateVerificationKindV2.VERIFIED: + if any( + type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None + for digest in refs + ): + raise ValueError("VERIFIED outcome requires three SHA-256 references") + if findings: + raise ValueError("VERIFIED outcome cannot carry findings") + if ( + type(self.verification_resource_usage) + is not MultiObstacleStrictConvexCandidateResourceUsageV2 + ): + raise ValueError("VERIFIED outcome requires replay resource usage") + else: + if any(digest is not None for digest in refs): + raise ValueError("failure verification outcome cannot carry references") + if not findings: + raise ValueError("failure verification outcome requires findings") + if self.verification_resource_usage is not None: + if ( + type(self.verification_resource_usage) + is not MultiObstacleStrictConvexCandidateResourceUsageV2 + ): + raise TypeError("verification resource usage has the wrong exact type") + object.__setattr__( + self, + "verification_resource_usage", + MultiObstacleStrictConvexCandidateResourceUsageV2( + domain_operations=( + self.verification_resource_usage.domain_operations + ), + so2_atomic_steps=self.verification_resource_usage.so2_atomic_steps, + candidate_cells=self.verification_resource_usage.candidate_cells, + ), + ) + + +class MultiObstacleStrictConvexCandidateDomainCompilerV2_6: + def compile( + self, + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_6, + ) -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: + return compile_multi_obstacle_strict_convex_candidate_domain_v2_6( + problem, config + ) + + +class _InvalidInputV2(ValueError): + pass + + +class _UnsupportedModelV2(ValueError): + def __init__(self, finding_code: str) -> None: + super().__init__(finding_code) + self.finding_code = finding_code + + +def compile_multi_obstacle_strict_convex_candidate_domain_v2_6( + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_6, +) -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: + """Fresh-compile the bounded multi-obstacle collision prefix.""" + + try: + checked_config = _strict_config(config) + except _InvalidInputV2: + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.INVALID_INPUT, + "INVALID_INPUT:MULTI_OBSTACLE_STRICT_CONVEX_INPUT", + ) + except (ArithmeticError, RuntimeWarning): + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:MULTI_OBSTACLE_REVALIDATION", + ) + + budget = StrictConvexIntersectionBudgetV2( + max_domain_operations=checked_config.max_domain_operations, + max_candidate_cells=checked_config.max_candidate_cells, + ) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + _precharge_problem_structure(problem, budget) # type: ignore[arg-type] + checked_problem = _strict_problem(problem) + except StrictConvexIntersectionBudgetExhaustedV2: + return _resource_failure() + except (_InvalidInputV2, _LegacyInvalidInputV2): + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.INVALID_INPUT, + "INVALID_INPUT:MULTI_OBSTACLE_STRICT_CONVEX_INPUT", + ) + except (ArithmeticError, RuntimeWarning): + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:MULTI_OBSTACLE_REVALIDATION", + ) + + atomic_budget = SO2AtomicBudgetV2(limit=checked_config.max_so2_atomic_steps) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + from spatialcf.core._internal.compilation.candidate_cells import ( + _compile_search_universe, + _SearchUniverseFailureV2, + ) + + search = _compile_search_universe(checked_problem, budget) # type: ignore[arg-type] + if isinstance(search, _SearchUniverseFailureV2): + kind = ( + MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP + if search.uncertified_reason.value == "NUMERIC_GAP" + else MultiObstacleStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL + ) + return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( + kind=kind, + finding_codes=search.finding_codes, + ) + subject, obstacles = _extract_supported_pairs(checked_problem, budget) + inner_complexes = [] + outer_complexes = [] + for _, obstacle_transform, obstacle_shape in obstacles: + allowed = compile_convex_allowed_translation_v2( + subject[0], + subject[1], + obstacle_transform, + obstacle_shape, + search.delta_rect, + atomic_budget=atomic_budget, + ) + if allowed.kind is ConvexAllowedTranslationKindV2.RESOURCE_LIMIT: + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:max_so2_atomic_steps", + ) + if allowed.kind is ConvexAllowedTranslationKindV2.NUMERIC_GAP: + return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + finding_codes=allowed.finding_codes, + ) + if allowed.kind is ConvexAllowedTranslationKindV2.INVALID_INPUT: + raise RuntimeError("supported obstacle produced invalid T12 input") + if ( + allowed.kind is not ConvexAllowedTranslationKindV2.BRACKET + or type(allowed.bracket) is not ConvexAllowedTranslationBracketV2 + ): + raise RuntimeError("malformed T12 allowed-domain outcome") + inner_complexes.append(allowed.bracket.inner_allowed) + outer_complexes.append(allowed.bracket.outer_allowed) + + inner = intersect_strict_convex_allowed_complexes_v2( + tuple(inner_complexes), budget=budget + ) + outer = intersect_strict_convex_allowed_complexes_v2( + tuple(outer_complexes), budget=budget + ) + checked_inner = _require_intersection_success(inner) + checked_outer = _require_intersection_success(outer) + + constraints = checked_problem.constraints + remaining_ids = tuple( + sorted( + ( + *( + item.constraint_id + for item in constraints.support_constraints + ), + *( + item.constraint_id + for item in constraints.visibility_constraints + ), + constraints.target_relation.constraint_id, + ) + ) + ) + budget.consume_domain( + 12 + + len(remaining_ids) + + len(obstacles) + + sum( + len(cell.half_planes) + len(cell.closure_polygon.vertices_ccw) + for complex_ in (checked_inner, checked_outer) + for cell in complex_.cells + ) + ) + bracket = MultiObstacleStrictConvexAllowedBracketV2( + inner_allowed=checked_inner, + outer_allowed=checked_outer, + intersection_kernel_id=checked_config.intersection_kernel_id, + intersection_kernel_version=checked_config.intersection_kernel_version, + so2_atomic_steps_used=atomic_budget.used, + ) + artifact = MultiObstacleStrictConvexCandidateDomainArtifactV2_2( + semantic_problem_sha256=checked_problem.semantic_problem_sha256, + compiler_config_sha256=checked_config.config_sha256, + subject_id=constraints.allowed_edit.subject_id, + search_universe=search.delta_rect, + ordered_constraint_ids=( + constraints.position_domain.constraint_id, + constraints.collision_constraints[0].constraint_id, + ), + ordered_obstacle_body_ids=tuple(item[0] for item in obstacles), + allowed_domain_bracket=bracket, + resource_usage=MultiObstacleStrictConvexCandidateResourceUsageV2( + domain_operations=budget.domain_operations_used, + so2_atomic_steps=atomic_budget.used, + candidate_cells=budget.candidate_cells_used, + ), + remaining_constraint_ids=remaining_ids, + ) + return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT, + artifact=artifact, + ) + except _UnsupportedModelV2 as error: + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL, + error.finding_code, + ) + except StrictConvexIntersectionBudgetExhaustedV2: + return _resource_failure() + except (ArithmeticError, RuntimeWarning): + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:MULTI_OBSTACLE_COMPILATION", + ) + + +def verify_multi_obstacle_strict_convex_candidate_domain_v2_6( + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_6, + submitted_artifact: MultiObstacleStrictConvexCandidateDomainArtifactV2_2, +) -> MultiObstacleStrictConvexCandidateVerificationOutcomeV2: + """Fresh replay raw inputs and compare the entire submitted T14 artifact.""" + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + checked_submitted = _copy_artifact(submitted_artifact) + except (ArithmeticError, RuntimeWarning): + return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("NUMERIC_GAP:SUBMITTED_MULTI_OBSTACLE_ARTIFACT",), + ) + except (AttributeError, TypeError, ValueError, Warning): + return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("INVALID_INPUT:SUBMITTED_MULTI_OBSTACLE_ARTIFACT",), + ) + + replay = compile_multi_obstacle_strict_convex_candidate_domain_v2_6(problem, config) + if ( + replay.kind is not MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT + or type(replay.artifact) + is not MultiObstacleStrictConvexCandidateDomainArtifactV2_2 + ): + return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=replay.finding_codes, + ) + fresh = replay.artifact + usage = fresh.resource_usage + if ( + checked_submitted != fresh + or _artifact_bytes(checked_submitted) != _artifact_bytes(fresh) + or checked_submitted.artifact_sha256 != fresh.artifact_sha256 + ): + return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateVerificationKindV2.MISMATCH, + verification_resource_usage=usage, + finding_codes=("MISMATCH:MULTI_OBSTACLE_STRICT_CONVEX_ARTIFACT",), + ) + return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( + kind=MultiObstacleStrictConvexCandidateVerificationKindV2.VERIFIED, + semantic_problem_sha256=fresh.semantic_problem_sha256, + compiler_config_sha256=fresh.compiler_config_sha256, + artifact_sha256=fresh.artifact_sha256, + verification_resource_usage=usage, + ) + + +def _extract_supported_pairs( + problem: SemanticProblemV2_2, + budget: StrictConvexIntersectionBudgetV2, +) -> tuple[ + tuple[DirectedYawIntervalTransformV2_2, UprightBox3DV2], + tuple[tuple[str, DirectedYawIntervalTransformV2_2, UprightBox3DV2], ...], +]: + constraints = problem.constraints + position = constraints.position_domain + if ( + position.region_interpretation + is not PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS + or position.workspace_aggregation is not RegionAggregation.INTERSECTION + or position.boundary_policy is not BoundaryPolicy.CLOSED + or position.known_free_space_fact_ids + or len(position.workspace_fact_ids) != 1 + or position.minimum_boundary_clearance_m != 0.0 + ): + raise _UnsupportedModelV2("UNSUPPORTED:POSITION_DOMAIN_SUBSET") + workspace_values = _exact_fact_values( + problem.scene.workspace_boundaries, "WORKSPACE_BOUNDARIES", budget + ) + if ( + len(workspace_values) != 1 + or workspace_values[0].fact_id != position.workspace_fact_ids[0] + or workspace_values[0].region_approximation is not GeometryApproximationV2.EXACT + or workspace_values[0].geometry_uncertainty != UncertaintyBudgetV2() + ): + raise _UnsupportedModelV2("UNSUPPORTED:POSITION_WORKSPACE_SUBSET") + if len(constraints.collision_constraints) != 1: + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_CONSTRAINT_CARDINALITY") + collision = constraints.collision_constraints[0] + if len(collision.subject_body_ids) != 1 or not collision.obstacle_body_ids: + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_PAIR_CARDINALITY") + if tuple(sorted(set(collision.obstacle_body_ids))) != collision.obstacle_body_ids: + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_OBSTACLE_IDENTITIES") + if ( + collision.clearance_metric + is not CollisionClearanceMetric.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE + or collision.boundary_policy is not BoundaryPolicy.CLOSED + or collision.minimum_clearance_m != 0.0 + or collision.support_contact_exceptions + ): + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_POLICY") + if problem.numeric_policy != NumericPolicyV2(): + raise _UnsupportedModelV2("UNSUPPORTED:NUMERIC_POLICY") + + bodies = _exact_fact_values( + problem.scene.collision_bodies, "COLLISION_BODIES", budget + ) + geometries = _exact_fact_values( + problem.scene.geometry_instances, "GEOMETRY_INSTANCES", budget + ) + objects = _exact_fact_values(problem.scene.objects, "OBJECTS", budget) + body_by_id = {item.body_id: item for item in bodies} + geometry_by_id = {item.geometry_id: item for item in geometries} + object_by_id = {item.object_id: item for item in objects} + budget.consume_domain(len(bodies) + len(geometries) + len(objects)) + + subject_id = constraints.allowed_edit.subject_id + try: + subject_body = body_by_id[collision.subject_body_ids[0]] + subject_object = object_by_id[subject_id] + except KeyError as error: + raise RuntimeError("semantic graph lost the collision subject") from error + if ( + subject_body.owner_object_id != subject_id + or len(subject_body.geometry_instance_ids) != 1 + or not subject_object.movable + ): + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") + try: + subject_geometry = geometry_by_id[subject_body.geometry_instance_ids[0]] + except KeyError as error: + raise RuntimeError("semantic graph lost subject collision geometry") from error + subject_shape = _require_collision_geometry(subject_geometry, subject_id) + subject_transform = subject_object.pose.world_from_object + if type(subject_transform) is not DirectedYawIntervalTransformV2_2: + raise RuntimeError("v2.2 subject pose lost its directed-yaw transform") + + obstacles = [] + for body_id in collision.obstacle_body_ids: + try: + body = body_by_id[body_id] + owner_id = body.owner_object_id + except KeyError as error: + raise RuntimeError("semantic graph lost an obstacle reference") from error + if owner_id == subject_id or len(body.geometry_instance_ids) != 1: + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") + try: + obstacle_geometry = geometry_by_id[body.geometry_instance_ids[0]] + except KeyError as error: + raise RuntimeError( + "semantic graph lost obstacle collision geometry" + ) from error + if owner_id is None: + obstacle_shape, obstacle_transform = ( + _require_environment_collision_geometry(obstacle_geometry) + ) + else: + try: + obstacle_object = object_by_id[owner_id] + except KeyError as error: + raise RuntimeError("semantic graph lost an obstacle owner") from error + if obstacle_object.movable: + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") + obstacle_shape = _require_collision_geometry(obstacle_geometry, owner_id) + obstacle_transform = obstacle_object.pose.world_from_object + if type(obstacle_transform) is not DirectedYawIntervalTransformV2_2: + raise RuntimeError("v2.2 obstacle pose lost its directed-yaw transform") + # T12 publishes the exact full universe when immutable subject and + # obstacle Z interiors are disjoint (including closed-face contact). + # Keep the pair in the replay ledger rather than rejecting it or + # silently dropping its semantic identity. + budget.consume_domain(4) + obstacles.append((body_id, obstacle_transform, obstacle_shape)) + return (subject_transform, subject_shape), tuple(obstacles) + + +def _require_collision_geometry(geometry: Any, owner_id: str) -> UprightBox3DV2: + if ( + type(geometry) is not GeometryInstanceV2_2 + or geometry.owner_object_id != owner_id + or geometry.role is not GeometryRoleV2.COLLISION + or geometry.approximation is not GeometryApproximationV2.EXACT + or geometry.uncertainty != UncertaintyBudgetV2() + or type(geometry.shape) is not UprightBox3DV2 + or not _is_identity_anchor(geometry.anchor_from_geometry) + ): + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_GEOMETRY_SUBSET") + return geometry.shape + + +def _require_environment_collision_geometry( + geometry: Any, +) -> tuple[UprightBox3DV2, DirectedYawIntervalTransformV2_2]: + """Close one ownerless world-frame collision box for the T12 kernel.""" + + if ( + type(geometry) is not GeometryInstanceV2_2 + or geometry.owner_object_id is not None + or geometry.role is not GeometryRoleV2.COLLISION + or geometry.approximation is not GeometryApproximationV2.EXACT + or geometry.uncertainty != UncertaintyBudgetV2() + or type(geometry.shape) is not UprightBox3DV2 + or type(geometry.anchor_from_geometry) is not DirectedYawIntervalTransformV2_2 + ): + raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_GEOMETRY_SUBSET") + return geometry.shape, geometry.anchor_from_geometry + + +def _exact_fact_values( + facts: Any, + label: str, + budget: StrictConvexIntersectionBudgetV2, +) -> tuple[Any, ...]: + if ( + facts.availability is not FactAvailabilityV2.KNOWN + or facts.completeness is not FactCompletenessV2.EXACT + or facts.uncertainty != UncertaintyBudgetV2() + or type(facts.values) is not tuple + ): + raise _UnsupportedModelV2(f"UNSUPPORTED:{label}_FACT_SET") + budget.consume_domain(len(facts.values) + 1) + return facts.values + + +def _is_identity_anchor(transform: DirectedYawIntervalTransformV2_2) -> bool: + return ( + type(transform) is DirectedYawIntervalTransformV2_2 + and transform.translation == Vec3(x=0.0, y=0.0, z=0.0) + and transform.yaw_radians == 0.0 + ) + + +def _require_intersection_success( + outcome: Any, +) -> StrictConvexIntersectionComplexV2: + if outcome.kind is StrictConvexIntersectionKindV2.RESOURCE_LIMIT: + raise StrictConvexIntersectionBudgetExhaustedV2 + if outcome.kind is StrictConvexIntersectionKindV2.NUMERIC_GAP: + raise ArithmeticError("strict-convex intersection numeric gap") + if outcome.kind is StrictConvexIntersectionKindV2.INVALID_INPUT: + raise RuntimeError("compiler produced invalid strict-convex operands") + if ( + outcome.kind is not StrictConvexIntersectionKindV2.COMPLEX + or type(outcome.complex) is not StrictConvexIntersectionComplexV2 + ): + raise RuntimeError("malformed strict-convex intersection outcome") + return outcome.complex + + +def _strict_config(value: object) -> StrictConvexCandidateCompilerConfigV2_6: + if type(value) is not StrictConvexCandidateCompilerConfigV2_6: + raise _InvalidInputV2 + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + payload = value.model_dump(mode="python", warnings="error") + return StrictConvexCandidateCompilerConfigV2_6.model_validate( + payload, strict=True + ) + except (ArithmeticError, RuntimeWarning): + raise + except ( + AttributeError, + PydanticSerializationError, + TypeError, + ValidationError, + ValueError, + Warning, + ) as error: + raise _InvalidInputV2 from error + + +def _strict_problem(value: object) -> SemanticProblemV2_2: + if type(value) is not SemanticProblemV2_2: + raise _InvalidInputV2 + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + payload = value.model_dump(mode="python", warnings="error") + return SemanticProblemV2_2.model_validate(payload, strict=True) + except (ArithmeticError, RuntimeWarning): + raise + except ( + AttributeError, + PydanticSerializationError, + TypeError, + ValidationError, + ValueError, + Warning, + ) as error: + raise _InvalidInputV2 from error + + +def _require_id_tuple( + value: object, + *, + label: str, + nonempty: bool, + sorted_required: bool, +) -> tuple[str, ...]: + if type(value) is not tuple or (nonempty and not value): + raise ValueError(f"{label} must be an exact tuple with valid cardinality") + if any(type(item) is not str or not item.strip() for item in value): + raise ValueError(f"{label} must contain non-blank exact strings") + if len(set(value)) != len(value): + raise ValueError(f"{label} must contain unique IDs") + if sorted_required and tuple(sorted(value)) != value: + raise ValueError(f"{label} must be canonically sorted") + return value + + +def _require_finding_codes(value: object) -> tuple[str, ...]: + if type(value) is not tuple or any( + type(item) is not str or not item.strip() for item in value + ): + raise ValueError("finding_codes must be exact non-blank strings") + return tuple(sorted(set(value))) + + +def _copy_universe(value: ExactAxisAlignedRectV2) -> ExactAxisAlignedRectV2: + if type(value) is not ExactAxisAlignedRectV2: + raise TypeError("search_universe has the wrong exact type") + bounds = value.bounds + if bounds is None: + raise ValueError("search universe cannot be empty") + return ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=bounds[0], + min_y_m=bounds[1], + max_x_m=bounds[2], + max_y_m=bounds[3], + coordinate_space=value.coordinate_space, + ) + + +def _copy_intersection_complex( + value: StrictConvexIntersectionComplexV2, +) -> StrictConvexIntersectionComplexV2: + if type(value) is not StrictConvexIntersectionComplexV2: + raise TypeError("intersection complex has the wrong exact type") + return StrictConvexIntersectionComplexV2( + cells=value.cells, + universe=value.universe, + topology=value.topology, + ) + + +def _copy_bracket( + value: MultiObstacleStrictConvexAllowedBracketV2, +) -> MultiObstacleStrictConvexAllowedBracketV2: + return MultiObstacleStrictConvexAllowedBracketV2( + inner_allowed=value.inner_allowed, + outer_allowed=value.outer_allowed, + intersection_kernel_id=value.intersection_kernel_id, + intersection_kernel_version=value.intersection_kernel_version, + so2_atomic_steps_used=value.so2_atomic_steps_used, + ) + + +def _copy_artifact( + value: MultiObstacleStrictConvexCandidateDomainArtifactV2_2, +) -> MultiObstacleStrictConvexCandidateDomainArtifactV2_2: + return MultiObstacleStrictConvexCandidateDomainArtifactV2_2( + semantic_problem_sha256=value.semantic_problem_sha256, + compiler_config_sha256=value.compiler_config_sha256, + subject_id=value.subject_id, + search_universe=value.search_universe, + ordered_constraint_ids=value.ordered_constraint_ids, + ordered_obstacle_body_ids=value.ordered_obstacle_body_ids, + allowed_domain_bracket=value.allowed_domain_bracket, + resource_usage=value.resource_usage, + remaining_constraint_ids=value.remaining_constraint_ids, + ) + + +def _artifact_bytes( + value: MultiObstacleStrictConvexCandidateDomainArtifactV2_2, +) -> bytes: + return json.dumps( + _canonical_value(value), + ensure_ascii=False, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _canonical_value(value: Any) -> Any: + if isinstance(value, Fraction): + return { + "denominator": _canonical_integer(value.denominator), + "numerator": _canonical_integer(value.numerator), + } + if isinstance(value, Enum): + return value.value + if is_dataclass(value) and not isinstance(value, type): + return { + field.name: _canonical_value(getattr(value, field.name)) + for field in fields(value) + } + if isinstance(value, tuple): + return [_canonical_value(item) for item in value] + if value is None or type(value) in {str, int, float, bool}: + return value + raise TypeError(f"unsupported artifact hash value: {type(value).__name__}") + + +def _canonical_integer(value: int) -> int | dict[str, str]: + """Serialize huge exact integers without Python's decimal-digit limit. + + Ordinary values deliberately retain the historical JSON number encoding, + preserving every existing artifact byte and hash. Very large directed + rational coefficients use a frozen signed hexadecimal representation; + hexadecimal conversion is linear and is not governed by + ``sys.int_max_str_digits``. + """ + + if type(value) is not int: + raise TypeError("canonical artifact integer must have exact int type") + # Frozen v2.6/v2.7 artifacts reach 8,583 bits and must retain their exact + # historical decimal JSON bytes. Twelve thousand bits remain safely + # below CPython's default 4,300-decimal-digit conversion boundary, while + # the larger v2.9 projection coefficients use the limit-independent form. + if value.bit_length() <= 12_000: + return value + sign = "-" if value < 0 else "+" + return {"encoding": "signed-hex-v1", "value": sign + format(abs(value), "x")} + + +def _failure( + kind: MultiObstacleStrictConvexCandidateCompilationKindV2, + finding_code: str, +) -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: + return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( + kind=kind, + finding_codes=(finding_code,), + ) + + +def _resource_failure() -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: + return _failure( + MultiObstacleStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:MULTI_OBSTACLE_STRICT_CONVEX_CANDIDATE", + ) + + +__all__ = ( + "MultiObstacleStrictConvexAllowedBracketV2", + "MultiObstacleStrictConvexCandidateCompilationKindV2", + "MultiObstacleStrictConvexCandidateCompilationOutcomeV2", + "MultiObstacleStrictConvexCandidateDomainArtifactV2_2", + "MultiObstacleStrictConvexCandidateDomainCompilerV2_6", + "MultiObstacleStrictConvexCandidateResourceUsageV2", + "MultiObstacleStrictConvexCandidateVerificationKindV2", + "MultiObstacleStrictConvexCandidateVerificationOutcomeV2", + "compile_multi_obstacle_strict_convex_candidate_domain_v2_6", + "verify_multi_obstacle_strict_convex_candidate_domain_v2_6", +) diff --git a/src/spatialcf/core/_internal/compilation/support.py b/src/spatialcf/core/_internal/compilation/support.py new file mode 100644 index 0000000..8ea1a0f --- /dev/null +++ b/src/spatialcf/core/_internal/compilation/support.py @@ -0,0 +1,1973 @@ +# ruff: noqa: F811, I001 +"""Private current candidate-family compilers; exact migrated bodies.""" + +from __future__ import annotations + +# Migrated from support_domain.py. +"""Sound SUPPORT-domain compilation for the exact rectangular Canonical v2 subset. + +The compiler works entirely in Canonical world coordinates. It derives a +domain over the sole allowed edit variable, world-XY translation delta, from +the relative motion of the supported object and the support-surface owner. +Anything outside the deliberately small exact subset is ``UNKNOWN`` rather +than being approximated into a false ``EMPTY`` or certified restriction. +""" + + +from dataclasses import dataclass +from enum import StrEnum +from fractions import Fraction + +from spatialcf.core._internal.kernels.rect import ( + AxisMarginXYV2, + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, + RectTopologyV2, + TranslationDeltaXYV2, + UnsupportedRectRegionErrorV2, +) +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + FactSetV2, + NumericPolicyV2, + Quaternion, + RigidTransformV2, + UncertaintyBudgetV2, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + SupportConstraint, +) +from spatialcf.domain.geometry import ( + ExtrudedPlanarPolygonV2, + GeometryApproximationV2, + GeometryInstanceV2, + PlanarRegionV2, + UprightBox3DV2, +) +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.scene import ( + CanonicalObject, + RegionBoundaryPolicy, + SupportSurfaceFact, +) + + +class SupportDomainKindV2(StrEnum): + """Mathematical effect of one SUPPORT predicate on edit deltas.""" + + RECT_DELTA_LOCUS = "RECT_DELTA_LOCUS" + IDENTITY = "IDENTITY" + EMPTY = "EMPTY" + UNKNOWN = "UNKNOWN" + + +@dataclass(frozen=True, slots=True) +class SupportDomainCompilationOutcomeV2: + """Closed, deterministic result of compiling one support constraint. + + ``IDENTITY`` means the exact predicate is constant true over every XY edit + delta; it does not mean that only the zero delta is allowed. + """ + + kind: SupportDomainKindV2 + delta_locus: ExactAxisAlignedRectV2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.kind, SupportDomainKindV2): + raise TypeError("kind must be a SupportDomainKindV2") + object.__setattr__( + self, + "finding_codes", + tuple(sorted(set(self.finding_codes))), + ) + if self.kind is SupportDomainKindV2.RECT_DELTA_LOCUS: + if self.delta_locus is None: + raise ValueError("RECT_DELTA_LOCUS requires a rectangle") + if ( + self.delta_locus.coordinate_space + is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M + or self.delta_locus.topology is not RectTopologyV2.AREA + ): + raise ValueError( + "support delta locus must be a positive-area translation rectangle" + ) + if self.finding_codes: + raise ValueError("a compiled rectangular locus cannot carry findings") + return + if self.delta_locus is not None: + raise ValueError(f"{self.kind.value} must not carry a delta rectangle") + if self.kind is SupportDomainKindV2.UNKNOWN and not self.finding_codes: + raise ValueError("UNKNOWN support outcome requires a finding") + + +SupportDomainOutcomeV2 = SupportDomainCompilationOutcomeV2 + + +@dataclass(frozen=True, slots=True) +class _WorldContactV2: + rectangle: ExactAxisAlignedRectV2 + plane_z_m: Fraction + + +def compile_support_domain_v2( + problem: SemanticProblemV2, + constraint: SupportConstraint | str, +) -> SupportDomainCompilationOutcomeV2: + """Compile one exact rectangular SUPPORT predicate into delta coordinates. + + The root and optional constraint instance are reconstructed with strict + validation before any certified geometric conclusion. + """ + + if not isinstance(problem, SemanticProblemV2): + raise TypeError("problem must be a SemanticProblemV2") + + checked_problem = SemanticProblemV2.model_validate( + problem.model_dump(mode="python"), + strict=True, + ) + selected = _resolve_constraint(checked_problem, constraint) + if isinstance(selected, SupportDomainCompilationOutcomeV2): + return selected + + findings = list(_supported_subset_findings(checked_problem, selected)) + if findings: + return _unknown(*findings) + + objects = { + item.object_id: item for item in checked_problem.scene.objects.values or () + } + geometries = { + item.geometry_id: item + for item in checked_problem.scene.geometry_instances.values or () + } + surfaces = { + item.surface_id: item + for item in checked_problem.scene.support_surfaces.values or () + } + supported_object = objects[selected.supported_object_id] + geometry = geometries[selected.subject_contact_geometry_ids[0]] + surface = surfaces[selected.surface_id] + + transform_findings = _transform_findings( + supported_object, + geometry, + surface, + objects, + ) + if transform_findings: + return _unknown(*transform_findings) + + try: + contact = _contact_in_world(supported_object, geometry) + support = _surface_in_world(surface, objects) + except UnsupportedRectRegionErrorV2: + return _unknown( + f"UNSUPPORTED_SUPPORT_DOMAIN:NON_RECTANGULAR_GEOMETRY:" + f"{selected.constraint_id}" + ) + + gap_m = contact.plane_z_m - support.plane_z_m + gap_min_m = Fraction.from_float(selected.contact_gap_min_m) + gap_max_m = Fraction.from_float(selected.contact_gap_max_m) + if gap_m < gap_min_m or gap_m > gap_max_m: + return _empty(f"EXACT_EMPTY:CONTACT_GAP:{selected.constraint_id}") + + contact_bounds = contact.rectangle.bounds + assert contact_bounds is not None + contact_area_m2 = (contact_bounds[2] - contact_bounds[0]) * ( + contact_bounds[3] - contact_bounds[1] + ) + if contact_area_m2 < Fraction.from_float(selected.minimum_overlap_area_m2): + return _empty(f"EXACT_EMPTY:OVERLAP_AREA:{selected.constraint_id}") + + inset = support.rectangle.erode_axis( + AxisMarginXYV2.isotropic_from_binary64(selected.stability_margin_m) + ) + if inset.topology is RectTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:STABILITY_INSET:{selected.constraint_id}") + if inset.topology is RectTopologyV2.DEGENERATE: + return _unknown( + f"UNSUPPORTED_SUPPORT_DOMAIN:DEGENERATE_SURFACE_INSET:" + f"{selected.constraint_id}" + ) + + relative_edit_coefficient = _relative_edit_coefficient( + checked_problem, + supported_object, + surface, + ) + if relative_edit_coefficient == 0: + if inset.contains(contact.rectangle): + return SupportDomainCompilationOutcomeV2(kind=SupportDomainKindV2.IDENTITY) + return _empty(f"EXACT_EMPTY:STABILITY_CONTAINMENT:{selected.constraint_id}") + + relative_locus = _containment_translation_locus(contact.rectangle, inset) + if relative_locus.topology is RectTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:STABILITY_CONTAINMENT:{selected.constraint_id}") + if relative_locus.topology is RectTopologyV2.DEGENERATE: + return _unknown( + f"UNSUPPORTED_SUPPORT_DOMAIN:DEGENERATE_DELTA_LOCUS:" + f"{selected.constraint_id}" + ) + + delta_locus = ( + relative_locus + if relative_edit_coefficient == 1 + else _negate_delta_rectangle(relative_locus) + ) + return SupportDomainCompilationOutcomeV2( + kind=SupportDomainKindV2.RECT_DELTA_LOCUS, + delta_locus=delta_locus, + ) + + +def _resolve_constraint( + problem: SemanticProblemV2, + requested: SupportConstraint | str, +) -> SupportConstraint | SupportDomainCompilationOutcomeV2: + if isinstance(requested, SupportConstraint): + checked = SupportConstraint.model_validate( + requested.model_dump(mode="python"), + strict=True, + ) + constraint_id = checked.constraint_id + elif type(requested) is str: + checked = None + constraint_id = requested + else: + raise TypeError("constraint must be a SupportConstraint or exact str ID") + + registered = next( + ( + item + for item in problem.constraints.support_constraints + if item.constraint_id == constraint_id + ), + None, + ) + if registered is None: + return _unknown(f"UNKNOWN_SUPPORT_CONSTRAINT:{constraint_id}") + if checked is not None and checked != registered: + return _unknown(f"SUPPORT_CONSTRAINT_MISMATCH:{constraint_id}") + return registered + + +def _supported_subset_findings( + problem: SemanticProblemV2, + constraint: SupportConstraint, +) -> tuple[str, ...]: + findings: list[str] = [] + if constraint.boundary_policy is not BoundaryPolicy.CLOSED: + findings.append( + f"UNSUPPORTED_SUPPORT_DOMAIN:BOUNDARY_POLICY:{constraint.constraint_id}" + ) + if not _numeric_policy_is_zero(problem.numeric_policy): + findings.append("UNSUPPORTED_SUPPORT_DOMAIN:NUMERIC_POLICY") + + for label, facts in ( + ("OBJECTS", problem.scene.objects), + ("GEOMETRY_INSTANCES", problem.scene.geometry_instances), + ("SUPPORT_SURFACES", problem.scene.support_surfaces), + ): + findings.extend(_fact_family_findings(label, facts)) + + if len(constraint.subject_contact_geometry_ids) != 1: + findings.append( + f"UNSUPPORTED_SUPPORT_DOMAIN:CONTACT_UNION_CARDINALITY:" + f"{constraint.constraint_id}:{len(constraint.subject_contact_geometry_ids)}" + ) + + geometry_facts = problem.scene.geometry_instances + if ( + geometry_facts.availability is FactAvailabilityV2.KNOWN + and geometry_facts.completeness is FactCompletenessV2.EXACT + ): + geometries = {item.geometry_id: item for item in geometry_facts.values or ()} + for geometry_id in constraint.subject_contact_geometry_ids: + geometry = geometries.get(geometry_id) + if geometry is None: + findings.append(f"MISSING_FACT:SUPPORT_GEOMETRY:{geometry_id}") + continue + if geometry.approximation is not GeometryApproximationV2.EXACT: + findings.append( + "UNSUPPORTED_SUPPORT_DOMAIN:CONTACT_APPROXIMATION:" + f"{geometry_id}:{geometry.approximation.value}" + ) + if not _uncertainty_is_zero(geometry.uncertainty): + findings.append( + f"UNSUPPORTED_SUPPORT_DOMAIN:CONTACT_ITEM_UNCERTAINTY:{geometry_id}" + ) + + surface_facts = problem.scene.support_surfaces + if ( + surface_facts.availability is FactAvailabilityV2.KNOWN + and surface_facts.completeness is FactCompletenessV2.EXACT + ): + surfaces = {item.surface_id: item for item in surface_facts.values or ()} + surface = surfaces.get(constraint.surface_id) + if surface is None: + findings.append(f"MISSING_FACT:SUPPORT_SURFACE:{constraint.surface_id}") + return tuple(sorted(set(findings))) + if surface.region_approximation is not GeometryApproximationV2.EXACT: + findings.append( + "UNSUPPORTED_SUPPORT_DOMAIN:SURFACE_APPROXIMATION:" + f"{surface.surface_id}:{surface.region_approximation.value}" + ) + if surface.boundary_policy is not RegionBoundaryPolicy.CLOSED: + findings.append( + f"UNSUPPORTED_SUPPORT_DOMAIN:SURFACE_BOUNDARY:{surface.surface_id}" + ) + if not _uncertainty_is_zero(surface.geometry_uncertainty): + findings.append( + f"UNSUPPORTED_SUPPORT_DOMAIN:SURFACE_ITEM_UNCERTAINTY:" + f"{surface.surface_id}" + ) + if ( + surface.normal_in_anchor.x, + surface.normal_in_anchor.y, + surface.normal_in_anchor.z, + ) != ( + 0.0, + 0.0, + 1.0, + ): + findings.append( + f"UNSUPPORTED_SUPPORT_DOMAIN:NON_HORIZONTAL_SURFACE:" + f"{surface.surface_id}" + ) + return tuple(sorted(set(findings))) + + +def _fact_family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: + if facts.availability is FactAvailabilityV2.MISSING: + return (f"MISSING_FACT:{label}",) + if facts.availability is not FactAvailabilityV2.KNOWN: + return (f"UNSUPPORTED_SUPPORT_DOMAIN:{label}_AVAILABILITY",) + findings: list[str] = [] + if facts.completeness is not FactCompletenessV2.EXACT: + value = facts.completeness.value if facts.completeness is not None else "NONE" + findings.append(f"UNSUPPORTED_SUPPORT_DOMAIN:{label}_COMPLETENESS:{value}") + if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): + findings.append(f"UNSUPPORTED_SUPPORT_DOMAIN:{label}_FACT_UNCERTAINTY") + return tuple(findings) + + +def _transform_findings( + supported_object: CanonicalObject, + geometry: GeometryInstanceV2, + surface: SupportSurfaceFact, + objects: dict[str, CanonicalObject], +) -> tuple[str, ...]: + findings: list[str] = [] + transforms = ( + ( + f"OBJECT_POSE:{supported_object.object_id}", + supported_object.pose.world_from_object, + ), + (geometry.geometry_id, geometry.anchor_from_geometry), + (f"SURFACE_FRAME:{surface.surface_id}", surface.anchor_from_surface), + ) + for label, transform in transforms: + if not _has_exact_identity_rotation(transform): + findings.append(f"UNSUPPORTED_SUPPORT_DOMAIN:NON_IDENTITY_ROTATION:{label}") + if surface.owner_object_id is not None: + owner = objects[surface.owner_object_id] + if not _has_exact_identity_rotation(owner.pose.world_from_object): + findings.append( + "UNSUPPORTED_SUPPORT_DOMAIN:NON_IDENTITY_ROTATION:" + f"SURFACE_OWNER_POSE:{owner.object_id}" + ) + return tuple(sorted(set(findings))) + + +def _has_exact_identity_rotation(transform: RigidTransformV2) -> bool: + rotation: Quaternion = transform.rotation + return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) + + +def _contact_in_world( + supported_object: CanonicalObject, + geometry: GeometryInstanceV2, +) -> _WorldContactV2: + if isinstance(geometry.shape, UprightBox3DV2): + half_x = Fraction.from_float(geometry.shape.size_m.x) / 2 + half_y = Fraction.from_float(geometry.shape.size_m.y) / 2 + local = ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=-half_x, + min_y_m=-half_y, + max_x_m=half_x, + max_y_m=half_y, + coordinate_space=RectCoordinateSpaceV2.WORLD_XY_M, + ) + local_z = -Fraction.from_float(geometry.shape.size_m.z) / 2 + elif isinstance(geometry.shape, ExtrudedPlanarPolygonV2): + local = ExactAxisAlignedRectV2.from_planar_region( + # A component is wrapped back into the canonical region expected by + # the exact parser; no floating-point geometric operation is used. + PlanarRegionV2(components=(geometry.shape.footprint,)) + ) + local_z = Fraction.from_float(geometry.shape.lower_z_m) + else: # pragma: no cover - closed discriminated union, retained defensively + raise UnsupportedRectRegionErrorV2("unsupported support geometry shape") + + object_transform = supported_object.pose.world_from_object + geometry_transform = geometry.anchor_from_geometry + dx = Fraction.from_float(object_transform.translation.x) + Fraction.from_float( + geometry_transform.translation.x + ) + dy = Fraction.from_float(object_transform.translation.y) + Fraction.from_float( + geometry_transform.translation.y + ) + plane_z = ( + Fraction.from_float(object_transform.translation.z) + + Fraction.from_float(geometry_transform.translation.z) + + local_z + ) + return _WorldContactV2( + rectangle=local.translate(TranslationDeltaXYV2(dx_m=dx, dy_m=dy)), + plane_z_m=plane_z, + ) + + +def _surface_in_world( + surface: SupportSurfaceFact, + objects: dict[str, CanonicalObject], +) -> _WorldContactV2: + local = ExactAxisAlignedRectV2.from_planar_region(surface.region_uv) + owner_translation = (Fraction(), Fraction(), Fraction()) + if surface.owner_object_id is not None: + translation = objects[ + surface.owner_object_id + ].pose.world_from_object.translation + owner_translation = tuple( + Fraction.from_float(value) + for value in (translation.x, translation.y, translation.z) + ) + surface_translation = surface.anchor_from_surface.translation + dx = owner_translation[0] + Fraction.from_float(surface_translation.x) + dy = owner_translation[1] + Fraction.from_float(surface_translation.y) + plane_z = owner_translation[2] + Fraction.from_float(surface_translation.z) + return _WorldContactV2( + rectangle=local.translate(TranslationDeltaXYV2(dx_m=dx, dy_m=dy)), + plane_z_m=plane_z, + ) + + +def _relative_edit_coefficient( + problem: SemanticProblemV2, + supported_object: CanonicalObject, + surface: SupportSurfaceFact, +) -> int: + subject_id = problem.constraints.allowed_edit.subject_id + object_coefficient = int(supported_object.object_id == subject_id) + surface_coefficient = int(surface.owner_object_id == subject_id) + return object_coefficient - surface_coefficient + + +def _containment_translation_locus( + contact: ExactAxisAlignedRectV2, + surface_inset: ExactAxisAlignedRectV2, +) -> ExactAxisAlignedRectV2: + contact_bounds = contact.bounds + surface_bounds = surface_inset.bounds + assert contact_bounds is not None and surface_bounds is not None + return ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=surface_bounds[0] - contact_bounds[0], + min_y_m=surface_bounds[1] - contact_bounds[1], + max_x_m=surface_bounds[2] - contact_bounds[2], + max_y_m=surface_bounds[3] - contact_bounds[3], + coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, + ) + + +def _negate_delta_rectangle( + rectangle: ExactAxisAlignedRectV2, +) -> ExactAxisAlignedRectV2: + bounds = rectangle.bounds + assert bounds is not None + return ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=-bounds[2], + min_y_m=-bounds[3], + max_x_m=-bounds[0], + max_y_m=-bounds[1], + coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, + ) + + +def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: + return all( + value == 0.0 + for value in ( + policy.linear_tolerance_m, + policy.area_tolerance_m2, + policy.angular_tolerance_rad, + policy.pixel_tolerance_px, + policy.fraction_tolerance, + ) + ) + + +def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: + return _numeric_policy_is_zero( + uncertainty.source_error + ) and _numeric_policy_is_zero(uncertainty.shape_approximation) + + +def _unknown(*findings: str) -> SupportDomainCompilationOutcomeV2: + return SupportDomainCompilationOutcomeV2( + kind=SupportDomainKindV2.UNKNOWN, + finding_codes=tuple(findings), + ) + + +def _empty(finding: str) -> SupportDomainCompilationOutcomeV2: + return SupportDomainCompilationOutcomeV2( + kind=SupportDomainKindV2.EMPTY, + finding_codes=(finding,), + ) + + +# Migrated from continuous_yaw_support_projection.py. +"""Directed strict-convex SUPPORT projection for one continuously yawed box.""" + + +import warnings +from dataclasses import dataclass +from enum import StrEnum +from fractions import Fraction +from math import gcd, lcm + +from pydantic import ValidationError +from pydantic_core import PydanticSerializationError + +from spatialcf.core._internal.kernels import so2 as so2_interval +from spatialcf.core._internal.kernels.convex_partition import ( + RationalHalfPlane2V2, + RationalHalfPlaneRelationV2, +) +from spatialcf.core._internal.kernels.convex_translation import ( + RationalConvexPolygonV2, + RationalPoint2V2, +) +from spatialcf.core._internal.kernels.rect import ( + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, + RectTopologyV2, + UnsupportedRectRegionErrorV2, +) +from spatialcf.core._internal.kernels.so2 import ( + SO2AtomicBudgetExhaustedV2, + SO2AtomicBudgetV2, + SO2IntervalKindV2, +) +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionCellV2, + StrictConvexIntersectionComplexV2, + StrictConvexIntersectionTopologyV2, +) +from spatialcf.core._internal.kernels.upright_box import ( + OrientedUprightBoxBoundsV2, + compile_oriented_upright_box_bounds_v2, +) +from spatialcf.domain.artifacts import ( + GeometryInstanceV2_2, + SemanticProblemV2_2, + SupportSurfaceFactV2_2, +) +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + NumericPolicyV2, + UncertaintyBudgetV2, + Vec3, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + SupportAssignmentPolicy, + SupportContactAggregation, + SupportContactFeature, + SupportOverlapMetric, + SupportStabilityMetric, +) +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, + GeometryApproximationV2, + GeometryRoleV2, + UprightBox3DV2, +) +from spatialcf.domain.scene import RegionBoundaryPolicy + +CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 = ( + "geometry-kernel:rational-continuous-yaw-support-projection-v2" +) +CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 = ( + "kernel:2.6-exact-horizontal-support-projection" +) + + +class ContinuousYawSupportProjectionKindV2(StrEnum): + BRACKET = "BRACKET" + UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" + NUMERIC_GAP = "NUMERIC_GAP" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + INVALID_INPUT = "INVALID_INPUT" + + +class _UnsupportedSupportProjectionV2(ValueError): + def __init__(self, finding_code: str) -> None: + super().__init__(finding_code) + self.finding_code = finding_code + + +class _InvalidSupportProjectionInputV2(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class ContinuousYawSupportProjectionBracketV2: + support_constraint_id: str + surface_id: str + contact_geometry_id: str + support_projection_kernel_id: str + support_projection_kernel_version: str + inner_allowed: StrictConvexIntersectionComplexV2 + outer_allowed: StrictConvexIntersectionComplexV2 + inner_bounds: tuple[Fraction, Fraction, Fraction, Fraction] + outer_bounds: tuple[Fraction, Fraction, Fraction, Fraction] + so2_atomic_steps_used: int + domain_operations_used: int + candidate_cells_used: int + + def __post_init__(self) -> None: + for field_name in ( + "support_constraint_id", + "surface_id", + "contact_geometry_id", + ): + value = getattr(self, field_name) + if type(value) is not str or not value.strip(): + raise ValueError(f"{field_name} must be an exact non-blank string") + if self.support_projection_kernel_id != ( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 + ): + raise ValueError("unexpected support projection kernel ID") + if self.support_projection_kernel_version != ( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 + ): + raise ValueError("unexpected support projection kernel version") + for field_name in ("inner_allowed", "outer_allowed"): + value = getattr(self, field_name) + if type(value) is not StrictConvexIntersectionComplexV2: + raise TypeError(f"{field_name} must be a strict complex") + object.__setattr__( + self, + field_name, + StrictConvexIntersectionComplexV2( + cells=value.cells, + universe=value.universe, + topology=value.topology, + ), + ) + if self.inner_allowed.universe != self.outer_allowed.universe: + raise ValueError("support bracket requires one exact universe") + inner = _require_bounds_tuple(self.inner_bounds, label="inner_bounds") + outer = _require_bounds_tuple(self.outer_bounds, label="outer_bounds") + if not ( + outer[0] <= inner[0] <= inner[2] <= outer[2] + and outer[1] <= inner[1] <= inner[3] <= outer[3] + ): + raise ValueError("support inner bounds must be contained in outer bounds") + object.__setattr__(self, "inner_bounds", inner) + object.__setattr__(self, "outer_bounds", outer) + for field_name in ( + "so2_atomic_steps_used", + "domain_operations_used", + "candidate_cells_used", + ): + value = getattr(self, field_name) + if type(value) is not int or value <= 0: + raise ValueError(f"{field_name} must be a positive exact int") + + +@dataclass(frozen=True, slots=True) +class ContinuousYawSupportProjectionOutcomeV2: + kind: ContinuousYawSupportProjectionKindV2 + bracket: ContinuousYawSupportProjectionBracketV2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not ContinuousYawSupportProjectionKindV2: + raise TypeError("kind must be ContinuousYawSupportProjectionKindV2") + if type(self.finding_codes) is not tuple or any( + type(code) is not str or not code.strip() for code in self.finding_codes + ): + raise ValueError("finding_codes must be exact non-blank strings") + findings = tuple(sorted(set(self.finding_codes))) + object.__setattr__(self, "finding_codes", findings) + if self.kind is ContinuousYawSupportProjectionKindV2.BRACKET: + if type(self.bracket) is not ContinuousYawSupportProjectionBracketV2: + raise ValueError("BRACKET requires a support projection bracket") + if findings: + raise ValueError("BRACKET cannot carry findings") + object.__setattr__(self, "bracket", _projection_copy_bracket(self.bracket)) + return + if self.bracket is not None or not findings: + raise ValueError("failure requires findings and no bracket") + + +def compile_exact_horizontal_support_projection_v2( + problem: SemanticProblemV2_2, + support_constraint_id: str, + universe: ExactAxisAlignedRectV2, + *, + atomic_budget: SO2AtomicBudgetV2, + intersection_budget: StrictConvexIntersectionBudgetV2, +) -> ContinuousYawSupportProjectionOutcomeV2: + """Compile one fixed-owner horizontal SUPPORT predicate into a bracket.""" + + try: + _require_budgets(atomic_budget, intersection_budget) + start_so2 = atomic_budget.used + start_domain = intersection_budget.domain_operations_used + start_cells = intersection_budget.candidate_cells_used + checked_problem, checked_id, checked_universe = _strict_inputs( + problem, + support_constraint_id, + universe, + intersection_budget, + ) + except StrictConvexIntersectionBudgetExhaustedV2: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:SUPPORT_PROJECTION", + ) + except (ArithmeticError, RuntimeWarning): + return _projection_failure( + ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, + "NUMERIC_GAP:SUPPORT_PROJECTION_REVALIDATION", + ) + except _InvalidSupportProjectionInputV2: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.INVALID_INPUT, + "INVALID_INPUT:SUPPORT_PROJECTION", + ) + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + constraint, subject, geometry, surface, owner_transform = ( + _extract_supported_subset( + checked_problem, + checked_id, + intersection_budget, + ) + ) + box_outcome = compile_oriented_upright_box_bounds_v2( + subject.pose.world_from_object, + geometry.shape, + atomic_budget=atomic_budget, + ) + if box_outcome.kind is SO2IntervalKindV2.RESOURCE_LIMIT: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:SUPPORT_PROJECTION_SO2", + ) + if box_outcome.kind is SO2IntervalKindV2.NUMERIC_GAP: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, + *box_outcome.finding_codes, + ) + if box_outcome.kind is not SO2IntervalKindV2.EXACT: + raise RuntimeError("supported support box failed strict compilation") + if type(box_outcome.bounds) is not OrientedUprightBoxBoundsV2: + raise RuntimeError("EXACT oriented support box is missing bounds") + inner_bounds, outer_bounds = _support_bounds( + constraint, + subject.pose.world_from_object, + geometry, + surface, + owner_transform, + box_outcome.bounds, + intersection_budget, + ) + inner = _complex_from_bounds( + inner_bounds, + checked_universe, + cell_id="cell:support-projection:inner", + budget=intersection_budget, + ) + outer = _complex_from_bounds( + outer_bounds, + checked_universe, + cell_id="cell:support-projection:outer", + budget=intersection_budget, + ) + if not inner.cells or not outer.cells: + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_PROJECTION_NON_AREA_LOCUS" + ) + intersection_budget.consume_domain( + 8 + len(inner.cells[0].half_planes) + len(outer.cells[0].half_planes) + ) + bracket = ContinuousYawSupportProjectionBracketV2( + support_constraint_id=constraint.constraint_id, + surface_id=surface.surface_id, + contact_geometry_id=geometry.geometry_id, + support_projection_kernel_id=( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 + ), + support_projection_kernel_version=( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 + ), + inner_allowed=inner, + outer_allowed=outer, + inner_bounds=inner_bounds, + outer_bounds=outer_bounds, + so2_atomic_steps_used=atomic_budget.used - start_so2, + domain_operations_used=( + intersection_budget.domain_operations_used - start_domain + ), + candidate_cells_used=( + intersection_budget.candidate_cells_used - start_cells + ), + ) + return ContinuousYawSupportProjectionOutcomeV2( + kind=ContinuousYawSupportProjectionKindV2.BRACKET, + bracket=bracket, + ) + except _UnsupportedSupportProjectionV2 as error: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.UNSUPPORTED_MODEL, + error.finding_code, + ) + except (SO2AtomicBudgetExhaustedV2, StrictConvexIntersectionBudgetExhaustedV2): + return _projection_failure( + ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:SUPPORT_PROJECTION", + ) + except ArithmeticError: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, + "NUMERIC_GAP:SUPPORT_PROJECTION_ARITHMETIC", + ) + except RuntimeWarning: + return _projection_failure( + ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, + "NUMERIC_GAP:SUPPORT_PROJECTION_RUNTIME_WARNING", + ) + + +def _require_budgets( + atomic_budget: SO2AtomicBudgetV2, + intersection_budget: StrictConvexIntersectionBudgetV2, +) -> None: + if type(atomic_budget) is not SO2AtomicBudgetV2: + raise TypeError("atomic_budget must be SO2AtomicBudgetV2") + atomic_budget.validate() + if type(intersection_budget) is not StrictConvexIntersectionBudgetV2: + raise TypeError("intersection_budget must be StrictConvexIntersectionBudgetV2") + intersection_budget.consume_domain(0) + intersection_budget.consume_candidate_cells(0) + + +def _strict_inputs( + problem: object, + support_constraint_id: object, + universe: object, + budget: StrictConvexIntersectionBudgetV2, +) -> tuple[SemanticProblemV2_2, str, ExactAxisAlignedRectV2]: + if type(problem) is not SemanticProblemV2_2: + raise _InvalidSupportProjectionInputV2 + if type(support_constraint_id) is not str or not support_constraint_id.strip(): + raise _InvalidSupportProjectionInputV2 + if type(universe) is not ExactAxisAlignedRectV2: + raise _InvalidSupportProjectionInputV2 + budget.consume_domain(3) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + checked_problem = SemanticProblemV2_2.model_validate( + problem.model_dump(mode="python", warnings="error"), strict=True + ) + except (ArithmeticError, RuntimeWarning): + raise + except (ValidationError, PydanticSerializationError, Warning) as error: + raise _InvalidSupportProjectionInputV2 from error + checked_universe = ExactAxisAlignedRectV2( + coordinate_space=universe.coordinate_space, + topology=universe.topology, + min_x_m=universe.min_x_m, + min_y_m=universe.min_y_m, + max_x_m=universe.max_x_m, + max_y_m=universe.max_y_m, + ) + if ( + checked_universe.coordinate_space + is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M + or checked_universe.topology is not RectTopologyV2.AREA + ): + raise _InvalidSupportProjectionInputV2 + return checked_problem, support_constraint_id, checked_universe + + +def _extract_supported_subset( + problem: SemanticProblemV2_2, + constraint_id: str, + budget: StrictConvexIntersectionBudgetV2, +): + constraints = problem.constraints.support_constraints + if len(constraints) != 1 or constraints[0].constraint_id != constraint_id: + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_CONSTRAINT_CARDINALITY" + ) + constraint = constraints[0] + if ( + constraint.supported_object_id != problem.constraints.allowed_edit.subject_id + or len(constraint.subject_contact_geometry_ids) != 1 + or constraint.contact_feature + is not SupportContactFeature.LOWEST_FACE_ALONG_SURFACE_NORMAL + or constraint.contact_aggregation + is not SupportContactAggregation.UNION_ALL_SELECTED_FEATURES + or constraint.overlap_metric + is not SupportOverlapMetric.PROJECTED_CONTACT_UNION_INTERSECTION_AREA + or constraint.stability_metric + is not SupportStabilityMetric.FULL_CONTACT_UNION_CONTAINED_IN_SURFACE_INSET + or constraint.boundary_policy is not BoundaryPolicy.CLOSED + or constraint.assignment_policy is not SupportAssignmentPolicy.EXACT_SURFACE + or problem.numeric_policy != NumericPolicyV2() + ): + raise _UnsupportedSupportProjectionV2("UNSUPPORTED_MODEL:SUPPORT_POLICY") + for label, facts in ( + ("OBJECTS", problem.scene.objects), + ("GEOMETRIES", problem.scene.geometry_instances), + ("BODIES", problem.scene.collision_bodies), + ("SURFACES", problem.scene.support_surfaces), + ): + if ( + facts.availability is not FactAvailabilityV2.KNOWN + or facts.completeness is not FactCompletenessV2.EXACT + or facts.uncertainty != UncertaintyBudgetV2() + ): + raise _UnsupportedSupportProjectionV2( + f"UNSUPPORTED_MODEL:SUPPORT_{label}_FACTS" + ) + objects = {item.object_id: item for item in problem.scene.objects.values or ()} + geometries = { + item.geometry_id: item for item in problem.scene.geometry_instances.values or () + } + surfaces = { + item.surface_id: item for item in problem.scene.support_surfaces.values or () + } + bodies = { + item.body_id: item for item in problem.scene.collision_bodies.values or () + } + budget.consume_domain( + len(objects) + len(geometries) + len(surfaces) + len(bodies) + 4 + ) + try: + subject = objects[constraint.supported_object_id] + geometry = geometries[constraint.subject_contact_geometry_ids[0]] + surface = surfaces[constraint.surface_id] + body = bodies[surface.supporting_body_id] + except KeyError as error: + raise RuntimeError( + "support semantic graph lost a canonical reference" + ) from error + collision = problem.constraints.collision_constraints[0] + if not subject.movable or body.body_id not in collision.obstacle_body_ids: + raise _UnsupportedSupportProjectionV2("UNSUPPORTED_MODEL:SUPPORT_OWNER_SUBSET") + if surface.owner_object_id is None: + if body.owner_object_id is not None: + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_OWNER_SUBSET" + ) + owner_transform = DirectedYawIntervalTransformV2_2( + translation=Vec3(x=0.0, y=0.0, z=0.0), + yaw_radians=0.0, + ) + else: + try: + owner = objects[surface.owner_object_id] + except KeyError as error: + raise RuntimeError( + "support semantic graph lost its owner object" + ) from error + if owner.movable or body.owner_object_id != owner.object_id: + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_OWNER_SUBSET" + ) + owner_transform = owner.pose.world_from_object + if ( + type(geometry) is not GeometryInstanceV2_2 + or geometry.owner_object_id != subject.object_id + or geometry.role is not GeometryRoleV2.SUPPORT + or geometry.approximation is not GeometryApproximationV2.EXACT + or geometry.uncertainty != UncertaintyBudgetV2() + or type(geometry.shape) is not UprightBox3DV2 + or not _identity_transform( + geometry.anchor_from_geometry, require_zero_translation=True + ) + ): + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_CONTACT_GEOMETRY" + ) + if ( + type(surface) is not SupportSurfaceFactV2_2 + or surface.region_approximation is not GeometryApproximationV2.EXACT + or surface.boundary_policy is not RegionBoundaryPolicy.CLOSED + or surface.geometry_uncertainty != UncertaintyBudgetV2() + or ( + surface.normal_in_anchor.x, + surface.normal_in_anchor.y, + surface.normal_in_anchor.z, + ) + != (0.0, 0.0, 1.0) + or not _identity_transform(surface.anchor_from_surface) + or not _identity_transform(owner_transform) + ): + raise _UnsupportedSupportProjectionV2("UNSUPPORTED_MODEL:SUPPORT_SURFACE") + try: + ExactAxisAlignedRectV2.from_planar_region(surface.region_uv) + except UnsupportedRectRegionErrorV2 as error: + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_SURFACE_RECTANGLE" + ) from error + return constraint, subject, geometry, surface, owner_transform + + +def _identity_transform( + transform: DirectedYawIntervalTransformV2_2, + *, + require_zero_translation: bool = False, +) -> bool: + if type(transform) is not DirectedYawIntervalTransformV2_2: + return False + if transform.yaw_radians != 0.0: + return False + return not require_zero_translation or ( + transform.translation.x, + transform.translation.y, + transform.translation.z, + ) == (0.0, 0.0, 0.0) + + +def _support_bounds( + constraint, + subject_transform: DirectedYawIntervalTransformV2_2, + geometry: GeometryInstanceV2_2, + surface: SupportSurfaceFactV2_2, + owner_transform: DirectedYawIntervalTransformV2_2, + box: OrientedUprightBoxBoundsV2, + budget: StrictConvexIntersectionBudgetV2, +) -> tuple[ + tuple[Fraction, Fraction, Fraction, Fraction], + tuple[Fraction, Fraction, Fraction, Fraction], +]: + budget.consume_domain(24) + surface_rect = ExactAxisAlignedRectV2.from_planar_region(surface.region_uv) + surface_bounds = surface_rect.bounds + assert surface_bounds is not None + owner_x = Fraction.from_float(owner_transform.translation.x) + owner_y = Fraction.from_float(owner_transform.translation.y) + owner_z = Fraction.from_float(owner_transform.translation.z) + surface_x = Fraction.from_float(surface.anchor_from_surface.translation.x) + surface_y = Fraction.from_float(surface.anchor_from_surface.translation.y) + surface_z = Fraction.from_float(surface.anchor_from_surface.translation.z) + center_x = Fraction.from_float(subject_transform.translation.x) + center_y = Fraction.from_float(subject_transform.translation.y) + center_z = Fraction.from_float(subject_transform.translation.z) + contact_z = center_z - box.half_extent_z + gap = contact_z - (owner_z + surface_z) + if not ( + Fraction.from_float(constraint.contact_gap_min_m) + <= gap + <= Fraction.from_float(constraint.contact_gap_max_m) + ): + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_CONTACT_GAP_EMPTY" + ) + contact_area = Fraction.from_float(geometry.shape.size_m.x) * Fraction.from_float( + geometry.shape.size_m.y + ) + if contact_area < Fraction.from_float(constraint.minimum_overlap_area_m2): + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_CONTACT_AREA_EMPTY" + ) + margin = Fraction.from_float(constraint.stability_margin_m) + inset = ( + owner_x + surface_x + surface_bounds[0] + margin, + owner_y + surface_y + surface_bounds[1] + margin, + owner_x + surface_x + surface_bounds[2] - margin, + owner_y + surface_y + surface_bounds[3] - margin, + ) + inner = ( + inset[0] - center_x + box.x_radius.rational_upper, + inset[1] - center_y + box.y_radius.rational_upper, + inset[2] - center_x - box.x_radius.rational_upper, + inset[3] - center_y - box.y_radius.rational_upper, + ) + outer = ( + inset[0] - center_x + box.x_radius.rational_lower, + inset[1] - center_y + box.y_radius.rational_lower, + inset[2] - center_x - box.x_radius.rational_lower, + inset[3] - center_y - box.y_radius.rational_lower, + ) + for value in (*inner, *outer): + so2_interval._require_numeric_fraction_cap( + value, "NUMERIC_GAP:SUPPORT_PROJECTION_FRACTION_BIT_CAP" + ) + return inner, outer + + +def _complex_from_bounds( + bounds: tuple[Fraction, Fraction, Fraction, Fraction], + universe: ExactAxisAlignedRectV2, + *, + cell_id: str, + budget: StrictConvexIntersectionBudgetV2, +) -> StrictConvexIntersectionComplexV2: + budget.consume_domain(16) + rectangle = ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=bounds[0], + min_y_m=bounds[1], + max_x_m=bounds[2], + max_y_m=bounds[3], + coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, + ).intersect(universe) + if rectangle.topology is RectTopologyV2.EMPTY: + return _strict_complex((), universe) + if rectangle.topology is RectTopologyV2.DEGENERATE: + raise _UnsupportedSupportProjectionV2( + "UNSUPPORTED_MODEL:SUPPORT_PROJECTION_DEGENERATE_LOCUS" + ) + clipped = rectangle.bounds + universe_bounds = universe.bounds + assert clipped is not None and universe_bounds is not None + semantic_planes = tuple( + sorted( + ( + _canonical_plane(Fraction(-1), Fraction(), -bounds[0]), + _canonical_plane(Fraction(1), Fraction(), bounds[2]), + _canonical_plane(Fraction(), Fraction(-1), -bounds[1]), + _canonical_plane(Fraction(), Fraction(1), bounds[3]), + ), + key=lambda plane: ( + plane.normal_x, + plane.normal_y, + plane.offset, + 1, + ), + ) + ) + universe_planes = ( + _canonical_plane(Fraction(-1), Fraction(), -universe_bounds[0]), + _canonical_plane(Fraction(1), Fraction(), universe_bounds[2]), + _canonical_plane(Fraction(), Fraction(-1), -universe_bounds[1]), + _canonical_plane(Fraction(), Fraction(1), universe_bounds[3]), + ) + closure = RationalConvexPolygonV2( + vertices_ccw=( + RationalPoint2V2(x=clipped[0], y=clipped[1]), + RationalPoint2V2(x=clipped[2], y=clipped[1]), + RationalPoint2V2(x=clipped[2], y=clipped[3]), + RationalPoint2V2(x=clipped[0], y=clipped[3]), + ) + ) + witness = RationalPoint2V2( + x=(clipped[0] + clipped[2]) / 2, + y=(clipped[1] + clipped[3]) / 2, + ) + budget.consume_candidate_cells() + return _strict_complex( + ( + StrictConvexIntersectionCellV2( + cell_id=cell_id, + half_planes=universe_planes + semantic_planes, + closure_polygon=closure, + strict_witness=witness, + ), + ), + universe, + ) + + +def _strict_complex( + cells: tuple[StrictConvexIntersectionCellV2, ...], + universe: ExactAxisAlignedRectV2, +) -> StrictConvexIntersectionComplexV2: + return StrictConvexIntersectionComplexV2( + cells=cells, + universe=universe, + topology=( + StrictConvexIntersectionTopologyV2.DISTRIBUTIVE_STRICT_CELL_INTERSECTION + ), + ) + + +def _canonical_plane( + normal_x: Fraction, + normal_y: Fraction, + offset: Fraction, +) -> RationalHalfPlane2V2: + for value in (normal_x, normal_y, offset): + so2_interval._require_numeric_fraction_cap( + value, "NUMERIC_GAP:SUPPORT_PROJECTION_HALF_PLANE_BIT_CAP" + ) + denominator = lcm(normal_x.denominator, normal_y.denominator, offset.denominator) + values = ( + normal_x.numerator * (denominator // normal_x.denominator), + normal_y.numerator * (denominator // normal_y.denominator), + offset.numerator * (denominator // offset.denominator), + ) + divisor = gcd(gcd(abs(values[0]), abs(values[1])), abs(values[2])) or 1 + return RationalHalfPlane2V2( + normal_x=Fraction(values[0] // divisor), + normal_y=Fraction(values[1] // divisor), + offset=Fraction(values[2] // divisor), + relation=RationalHalfPlaneRelationV2.LE, + ) + + +def _require_bounds_tuple( + value: object, *, label: str +) -> tuple[Fraction, Fraction, Fraction, Fraction]: + if type(value) is not tuple or len(value) != 4: + raise TypeError(f"{label} must be an exact four-Fraction tuple") + if any(type(item) is not Fraction for item in value): + raise TypeError(f"{label} must contain exact Fractions") + checked = value + if checked[0] > checked[2] or checked[1] > checked[3]: + raise ValueError(f"{label} must be ordered") + return checked + + +def _projection_copy_bracket( + value: ContinuousYawSupportProjectionBracketV2, +) -> ContinuousYawSupportProjectionBracketV2: + return ContinuousYawSupportProjectionBracketV2( + support_constraint_id=value.support_constraint_id, + surface_id=value.surface_id, + contact_geometry_id=value.contact_geometry_id, + support_projection_kernel_id=value.support_projection_kernel_id, + support_projection_kernel_version=value.support_projection_kernel_version, + inner_allowed=value.inner_allowed, + outer_allowed=value.outer_allowed, + inner_bounds=value.inner_bounds, + outer_bounds=value.outer_bounds, + so2_atomic_steps_used=value.so2_atomic_steps_used, + domain_operations_used=value.domain_operations_used, + candidate_cells_used=value.candidate_cells_used, + ) + + +def _projection_failure( + kind: ContinuousYawSupportProjectionKindV2, + *finding_codes: str, +) -> ContinuousYawSupportProjectionOutcomeV2: + return ContinuousYawSupportProjectionOutcomeV2( + kind=kind, + finding_codes=tuple(finding_codes), + ) + + +_projection_exports = ( + "CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2", + "CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2", + "ContinuousYawSupportProjectionBracketV2", + "ContinuousYawSupportProjectionKindV2", + "ContinuousYawSupportProjectionOutcomeV2", + "compile_exact_horizontal_support_projection_v2", +) + +# Migrated from support_strict_convex_candidate_domain.py. +"""Raw support-aware strict-convex candidate compilation for Canonical v2.2.""" + + +import hashlib +import re +import warnings +from dataclasses import dataclass +from enum import StrEnum + +from pydantic import ValidationError +from pydantic_core import PydanticSerializationError + +from spatialcf.core._internal.kernels.rect import ( + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, + RectTopologyV2, +) +from spatialcf.core._internal.kernels.so2 import SO2AtomicBudgetV2 +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionComplexV2, + StrictConvexIntersectionKindV2, + StrictConvexIntersectionOutcomeV2, + intersect_strict_convex_allowed_complexes_v2, +) +from spatialcf.core._internal.compilation.support import ( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2, + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2, + ContinuousYawSupportProjectionBracketV2, + ContinuousYawSupportProjectionKindV2, + compile_exact_horizontal_support_projection_v2, +) +from spatialcf.core._internal.compilation.collision import ( + MultiObstacleStrictConvexCandidateCompilationKindV2, + MultiObstacleStrictConvexCandidateDomainArtifactV2_2, + MultiObstacleStrictConvexCandidateResourceUsageV2, + _artifact_bytes, + _copy_intersection_complex, + _copy_universe, + _precharge_problem_structure, + _require_finding_codes, + _require_id_tuple, + _strict_problem, + compile_multi_obstacle_strict_convex_candidate_domain_v2_6, +) +from spatialcf.core._internal.compilation.collision import ( + _StrictInvalidInputV2 as _LegacyInvalidInputV2, +) +from spatialcf.domain.artifacts import ( + SemanticProblemV2_2, + StrictConvexCandidateCompilerConfigV2_6, + StrictConvexCandidateCompilerConfigV2_7, +) + +_ARTIFACT_HASH_DOMAIN_V2_2 = ( + b"spatialcf.support-strict-convex-candidate-artifact.v2.2\0" +) +_DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}") +_INTERSECTION_KERNEL_ID = "geometry-kernel:rational-strict-convex-intersection-v2" +_INTERSECTION_KERNEL_VERSION = "kernel:2.5-strict-convex-intersection" + + +class SupportStrictConvexCandidateCompilationKindV2(StrEnum): + ARTIFACT = "ARTIFACT" + UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" + NUMERIC_GAP = "NUMERIC_GAP" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + INVALID_INPUT = "INVALID_INPUT" + + +class SupportStrictConvexCandidateVerificationKindV2(StrEnum): + VERIFIED = "VERIFIED" + MISMATCH = "MISMATCH" + UNCERTIFIED = "UNCERTIFIED" + + +@dataclass(frozen=True, slots=True) +class SupportStrictConvexAllowedBracketV2: + inner_allowed: StrictConvexIntersectionComplexV2 + outer_allowed: StrictConvexIntersectionComplexV2 + intersection_kernel_id: str + intersection_kernel_version: str + support_projection_kernel_id: str + support_projection_kernel_version: str + so2_atomic_steps_used: int + + def __post_init__(self) -> None: + checked_inner = _copy_intersection_complex(self.inner_allowed) + checked_outer = _copy_intersection_complex(self.outer_allowed) + if checked_inner.universe != checked_outer.universe: + raise ValueError("support-aware bracket requires one exact universe") + if self.intersection_kernel_id != _INTERSECTION_KERNEL_ID: + raise ValueError("unexpected intersection kernel ID") + if self.intersection_kernel_version != _INTERSECTION_KERNEL_VERSION: + raise ValueError("unexpected intersection kernel version") + if self.support_projection_kernel_id != ( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 + ): + raise ValueError("unexpected support projection kernel ID") + if self.support_projection_kernel_version != ( + CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 + ): + raise ValueError("unexpected support projection kernel version") + if ( + type(self.so2_atomic_steps_used) is not int + or self.so2_atomic_steps_used <= 0 + ): + raise ValueError("so2_atomic_steps_used must be a positive exact int") + if not all( + checked_outer.contains_point(cell.strict_witness) + for cell in checked_inner.cells + ): + raise ValueError("support-aware inner witness escaped the outer domain") + object.__setattr__(self, "inner_allowed", checked_inner) + object.__setattr__(self, "outer_allowed", checked_outer) + + +@dataclass(frozen=True, slots=True) +class SupportStrictConvexCandidateDomainArtifactV2_2: + semantic_problem_sha256: str + compiler_config_sha256: str + upstream_t14_artifact_sha256: str + subject_id: str + search_universe: ExactAxisAlignedRectV2 + ordered_constraint_ids: tuple[str, ...] + ordered_obstacle_body_ids: tuple[str, ...] + support_constraint_id: str + surface_id: str + contact_geometry_id: str + allowed_domain_bracket: SupportStrictConvexAllowedBracketV2 + resource_usage: MultiObstacleStrictConvexCandidateResourceUsageV2 + remaining_constraint_ids: tuple[str, ...] + + def __post_init__(self) -> None: + for label, digest in ( + ("semantic_problem_sha256", self.semantic_problem_sha256), + ("compiler_config_sha256", self.compiler_config_sha256), + ("upstream_t14_artifact_sha256", self.upstream_t14_artifact_sha256), + ): + if type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None: + raise ValueError(f"{label} must be a lowercase SHA-256 digest") + for label, value in ( + ("subject_id", self.subject_id), + ("support_constraint_id", self.support_constraint_id), + ("surface_id", self.surface_id), + ("contact_geometry_id", self.contact_geometry_id), + ): + if type(value) is not str or not value.strip(): + raise ValueError(f"{label} must be a non-blank exact string") + checked_universe = _copy_universe(self.search_universe) + if ( + checked_universe.topology is not RectTopologyV2.AREA + or checked_universe.coordinate_space + is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M + ): + raise ValueError("search universe must be an AREA translation-delta rect") + compiled_ids = _require_id_tuple( + self.ordered_constraint_ids, + label="ordered_constraint_ids", + nonempty=True, + sorted_required=False, + ) + obstacle_ids = _require_id_tuple( + self.ordered_obstacle_body_ids, + label="ordered_obstacle_body_ids", + nonempty=True, + sorted_required=True, + ) + remaining_ids = _require_id_tuple( + self.remaining_constraint_ids, + label="remaining_constraint_ids", + nonempty=False, + sorted_required=True, + ) + if self.support_constraint_id not in compiled_ids: + raise ValueError("support constraint must be included in compiled IDs") + if set(compiled_ids) & set(remaining_ids): + raise ValueError("compiled and remaining constraint IDs must be disjoint") + if type(self.allowed_domain_bracket) is not SupportStrictConvexAllowedBracketV2: + raise TypeError("allowed_domain_bracket has the wrong exact type") + checked_bracket = _copy_bracket(self.allowed_domain_bracket) + if ( + checked_bracket.inner_allowed.universe != checked_universe + or checked_bracket.outer_allowed.universe != checked_universe + ): + raise ValueError("allowed bracket must use the search universe") + if ( + type(self.resource_usage) + is not MultiObstacleStrictConvexCandidateResourceUsageV2 + ): + raise TypeError("resource_usage has the wrong exact type") + usage = MultiObstacleStrictConvexCandidateResourceUsageV2( + domain_operations=self.resource_usage.domain_operations, + so2_atomic_steps=self.resource_usage.so2_atomic_steps, + candidate_cells=self.resource_usage.candidate_cells, + ) + if usage.so2_atomic_steps != checked_bracket.so2_atomic_steps_used: + raise ValueError("SO(2) usage must equal bracket cumulative usage") + published_cells = len(checked_bracket.inner_allowed.cells) + len( + checked_bracket.outer_allowed.cells + ) + if usage.candidate_cells < published_cells: + raise ValueError("cumulative candidate usage cannot undercount final cells") + object.__setattr__(self, "search_universe", checked_universe) + object.__setattr__(self, "ordered_constraint_ids", compiled_ids) + object.__setattr__(self, "ordered_obstacle_body_ids", obstacle_ids) + object.__setattr__(self, "remaining_constraint_ids", remaining_ids) + object.__setattr__(self, "allowed_domain_bracket", checked_bracket) + object.__setattr__(self, "resource_usage", usage) + + @property + def artifact_sha256(self) -> str: + return hashlib.sha256( + _ARTIFACT_HASH_DOMAIN_V2_2 + _artifact_bytes(self) # type: ignore[arg-type] + ).hexdigest() + + +@dataclass(frozen=True, slots=True) +class SupportStrictConvexCandidateCompilationOutcomeV2: + kind: SupportStrictConvexCandidateCompilationKindV2 + artifact: SupportStrictConvexCandidateDomainArtifactV2_2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not SupportStrictConvexCandidateCompilationKindV2: + raise TypeError("kind has the wrong exact type") + findings = _require_finding_codes(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + if self.kind is SupportStrictConvexCandidateCompilationKindV2.ARTIFACT: + if ( + type(self.artifact) + is not SupportStrictConvexCandidateDomainArtifactV2_2 + ): + raise ValueError("ARTIFACT outcome requires an exact artifact") + if findings: + raise ValueError("ARTIFACT outcome cannot carry findings") + object.__setattr__(self, "artifact", _copy_artifact(self.artifact)) + return + if self.artifact is not None or not findings: + raise ValueError("failure requires findings and no artifact") + + +@dataclass(frozen=True, slots=True) +class SupportStrictConvexCandidateVerificationOutcomeV2: + kind: SupportStrictConvexCandidateVerificationKindV2 + semantic_problem_sha256: str | None = None + compiler_config_sha256: str | None = None + artifact_sha256: str | None = None + verification_resource_usage: ( + MultiObstacleStrictConvexCandidateResourceUsageV2 | None + ) = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if type(self.kind) is not SupportStrictConvexCandidateVerificationKindV2: + raise TypeError("verification kind has the wrong exact type") + findings = _require_finding_codes(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + refs = ( + self.semantic_problem_sha256, + self.compiler_config_sha256, + self.artifact_sha256, + ) + if self.kind is SupportStrictConvexCandidateVerificationKindV2.VERIFIED: + if any( + type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None + for digest in refs + ): + raise ValueError("VERIFIED outcome requires three SHA-256 references") + if findings: + raise ValueError("VERIFIED outcome cannot carry findings") + if ( + type(self.verification_resource_usage) + is not MultiObstacleStrictConvexCandidateResourceUsageV2 + ): + raise ValueError("VERIFIED outcome requires replay resource usage") + else: + if any(digest is not None for digest in refs): + raise ValueError("failure verification outcome cannot carry references") + if not findings: + raise ValueError("failure verification outcome requires findings") + if self.verification_resource_usage is not None: + if ( + type(self.verification_resource_usage) + is not MultiObstacleStrictConvexCandidateResourceUsageV2 + ): + raise TypeError("verification resource usage has the wrong exact type") + usage = self.verification_resource_usage + object.__setattr__( + self, + "verification_resource_usage", + MultiObstacleStrictConvexCandidateResourceUsageV2( + domain_operations=usage.domain_operations, + so2_atomic_steps=usage.so2_atomic_steps, + candidate_cells=usage.candidate_cells, + ), + ) + + +class SupportStrictConvexCandidateDomainCompilerV2_7: + def compile( + self, + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_7, + ) -> SupportStrictConvexCandidateCompilationOutcomeV2: + return compile_support_strict_convex_candidate_domain_v2_7(problem, config) + + +class _InvalidInputV2(ValueError): + pass + + +def compile_support_strict_convex_candidate_domain_v2_7( + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_7, +) -> SupportStrictConvexCandidateCompilationOutcomeV2: + """Fresh-compile T14 plus one exact horizontal SUPPORT predicate.""" + + try: + checked_config = _strict_config(config) + except _InvalidInputV2: + return _failure( + SupportStrictConvexCandidateCompilationKindV2.INVALID_INPUT, + "INVALID_INPUT:SUPPORT_STRICT_CONVEX_INPUT", + ) + except (ArithmeticError, RuntimeWarning): + return _failure( + SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:SUPPORT_CONFIG_REVALIDATION", + ) + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + upstream = compile_multi_obstacle_strict_convex_candidate_domain_v2_6( + problem, _t14_config(checked_config) + ) + except (ArithmeticError, RuntimeWarning): + return _failure( + SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:UPSTREAM_MULTI_OBSTACLE_REPLAY", + ) + if ( + upstream.kind + is not MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT + or type(upstream.artifact) + is not MultiObstacleStrictConvexCandidateDomainArtifactV2_2 + ): + return _from_upstream_failure(upstream.kind, upstream.finding_codes) + upstream_artifact = upstream.artifact + budget = StrictConvexIntersectionBudgetV2( + max_domain_operations=checked_config.max_domain_operations, + max_candidate_cells=checked_config.max_candidate_cells, + domain_operations_used=upstream_artifact.resource_usage.domain_operations, + candidate_cells_used=upstream_artifact.resource_usage.candidate_cells, + ) + atomic_budget = SO2AtomicBudgetV2( + limit=checked_config.max_so2_atomic_steps, + used=upstream_artifact.resource_usage.so2_atomic_steps, + ) + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + _precharge_problem_structure(problem, budget) # type: ignore[arg-type] + checked_problem = _strict_problem(problem) + if ( + checked_problem.semantic_problem_sha256 + != upstream_artifact.semantic_problem_sha256 + ): + raise _InvalidInputV2 + support = compile_exact_horizontal_support_projection_v2( + checked_problem, + "constraint:support", + upstream_artifact.search_universe, + atomic_budget=atomic_budget, + intersection_budget=budget, + ) + if support.kind is ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT: + return _resource_failure() + if support.kind is ContinuousYawSupportProjectionKindV2.NUMERIC_GAP: + return SupportStrictConvexCandidateCompilationOutcomeV2( + kind=SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + finding_codes=support.finding_codes, + ) + if support.kind is ContinuousYawSupportProjectionKindV2.INVALID_INPUT: + raise RuntimeError("strictly checked support input became invalid") + if support.kind is ContinuousYawSupportProjectionKindV2.UNSUPPORTED_MODEL: + return SupportStrictConvexCandidateCompilationOutcomeV2( + kind=SupportStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL, + finding_codes=support.finding_codes, + ) + if ( + support.kind is not ContinuousYawSupportProjectionKindV2.BRACKET + or type(support.bracket) is not ContinuousYawSupportProjectionBracketV2 + ): + raise RuntimeError("malformed support projection outcome") + support_bracket = support.bracket + inner = _require_intersection( + intersect_strict_convex_allowed_complexes_v2( + ( + upstream_artifact.allowed_domain_bracket.inner_allowed, + support_bracket.inner_allowed, + ), + budget=budget, + ) + ) + outer = _require_intersection( + intersect_strict_convex_allowed_complexes_v2( + ( + upstream_artifact.allowed_domain_bracket.outer_allowed, + support_bracket.outer_allowed, + ), + budget=budget, + ) + ) + remaining = tuple( + item + for item in upstream_artifact.remaining_constraint_ids + if item != support_bracket.support_constraint_id + ) + if len(remaining) + 1 != len(upstream_artifact.remaining_constraint_ids): + raise RuntimeError("T14 remaining IDs lost the support constraint") + budget.consume_domain( + 24 + + len(remaining) + + len(upstream_artifact.ordered_obstacle_body_ids) + + sum( + len(cell.half_planes) + len(cell.closure_polygon.vertices_ccw) + for complex_ in (inner, outer) + for cell in complex_.cells + ) + ) + bracket = SupportStrictConvexAllowedBracketV2( + inner_allowed=inner, + outer_allowed=outer, + intersection_kernel_id=checked_config.intersection_kernel_id, + intersection_kernel_version=checked_config.intersection_kernel_version, + support_projection_kernel_id=( + checked_config.support_projection_kernel_id + ), + support_projection_kernel_version=( + checked_config.support_projection_kernel_version + ), + so2_atomic_steps_used=atomic_budget.used, + ) + artifact = SupportStrictConvexCandidateDomainArtifactV2_2( + semantic_problem_sha256=checked_problem.semantic_problem_sha256, + compiler_config_sha256=checked_config.config_sha256, + upstream_t14_artifact_sha256=upstream_artifact.artifact_sha256, + subject_id=upstream_artifact.subject_id, + search_universe=upstream_artifact.search_universe, + ordered_constraint_ids=( + *upstream_artifact.ordered_constraint_ids, + support_bracket.support_constraint_id, + ), + ordered_obstacle_body_ids=(upstream_artifact.ordered_obstacle_body_ids), + support_constraint_id=support_bracket.support_constraint_id, + surface_id=support_bracket.surface_id, + contact_geometry_id=support_bracket.contact_geometry_id, + allowed_domain_bracket=bracket, + resource_usage=MultiObstacleStrictConvexCandidateResourceUsageV2( + domain_operations=budget.domain_operations_used, + so2_atomic_steps=atomic_budget.used, + candidate_cells=budget.candidate_cells_used, + ), + remaining_constraint_ids=remaining, + ) + return SupportStrictConvexCandidateCompilationOutcomeV2( + kind=SupportStrictConvexCandidateCompilationKindV2.ARTIFACT, + artifact=artifact, + ) + except StrictConvexIntersectionBudgetExhaustedV2: + return _resource_failure() + except (_InvalidInputV2, _LegacyInvalidInputV2): + return _failure( + SupportStrictConvexCandidateCompilationKindV2.INVALID_INPUT, + "INVALID_INPUT:SUPPORT_STRICT_CONVEX_INPUT", + ) + except (ArithmeticError, RuntimeWarning): + return _failure( + SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, + "NUMERIC_GAP:SUPPORT_STRICT_CONVEX_COMPILATION", + ) + + +def verify_support_strict_convex_candidate_domain_v2_7( + problem: SemanticProblemV2_2, + config: StrictConvexCandidateCompilerConfigV2_7, + submitted_artifact: SupportStrictConvexCandidateDomainArtifactV2_2, +) -> SupportStrictConvexCandidateVerificationOutcomeV2: + """Fresh replay raw inputs and compare the entire submitted T15 artifact.""" + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + checked_submitted = _copy_artifact(submitted_artifact) + except (ArithmeticError, RuntimeWarning): + return SupportStrictConvexCandidateVerificationOutcomeV2( + kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("NUMERIC_GAP:SUBMITTED_SUPPORT_STRICT_CONVEX_ARTIFACT",), + ) + except (AttributeError, TypeError, ValueError, Warning): + return SupportStrictConvexCandidateVerificationOutcomeV2( + kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("INVALID_INPUT:SUBMITTED_SUPPORT_STRICT_CONVEX_ARTIFACT",), + ) + + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + replay = compile_support_strict_convex_candidate_domain_v2_7( + problem, config + ) + except (ArithmeticError, RuntimeWarning): + return SupportStrictConvexCandidateVerificationOutcomeV2( + kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=("NUMERIC_GAP:SUPPORT_STRICT_CONVEX_REPLAY",), + ) + if ( + replay.kind is not SupportStrictConvexCandidateCompilationKindV2.ARTIFACT + or type(replay.artifact) is not SupportStrictConvexCandidateDomainArtifactV2_2 + ): + return SupportStrictConvexCandidateVerificationOutcomeV2( + kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, + finding_codes=replay.finding_codes, + ) + fresh = replay.artifact + usage = fresh.resource_usage + if ( + checked_submitted != fresh + or _artifact_bytes(checked_submitted) != _artifact_bytes(fresh) # type: ignore[arg-type] + or checked_submitted.artifact_sha256 != fresh.artifact_sha256 + ): + return SupportStrictConvexCandidateVerificationOutcomeV2( + kind=SupportStrictConvexCandidateVerificationKindV2.MISMATCH, + verification_resource_usage=usage, + finding_codes=("MISMATCH:SUPPORT_STRICT_CONVEX_ARTIFACT",), + ) + return SupportStrictConvexCandidateVerificationOutcomeV2( + kind=SupportStrictConvexCandidateVerificationKindV2.VERIFIED, + semantic_problem_sha256=fresh.semantic_problem_sha256, + compiler_config_sha256=fresh.compiler_config_sha256, + artifact_sha256=fresh.artifact_sha256, + verification_resource_usage=usage, + ) + + +def _t14_config( + config: StrictConvexCandidateCompilerConfigV2_7, +) -> StrictConvexCandidateCompilerConfigV2_6: + return StrictConvexCandidateCompilerConfigV2_6( + max_domain_operations=config.max_domain_operations, + max_so2_atomic_steps=config.max_so2_atomic_steps, + max_candidate_cells=config.max_candidate_cells, + ) + + +def _require_intersection( + outcome: StrictConvexIntersectionOutcomeV2, +) -> StrictConvexIntersectionComplexV2: + if outcome.kind is StrictConvexIntersectionKindV2.RESOURCE_LIMIT: + raise StrictConvexIntersectionBudgetExhaustedV2 + if outcome.kind is StrictConvexIntersectionKindV2.NUMERIC_GAP: + raise ArithmeticError("strict-convex support intersection numeric gap") + if outcome.kind is StrictConvexIntersectionKindV2.INVALID_INPUT: + raise RuntimeError("compiler produced invalid support intersection operands") + if ( + outcome.kind is not StrictConvexIntersectionKindV2.COMPLEX + or type(outcome.complex) is not StrictConvexIntersectionComplexV2 + ): + raise RuntimeError("malformed support intersection outcome") + return outcome.complex + + +def _strict_config(value: object) -> StrictConvexCandidateCompilerConfigV2_7: + if type(value) is not StrictConvexCandidateCompilerConfigV2_7: + raise _InvalidInputV2 + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + return StrictConvexCandidateCompilerConfigV2_7.model_validate( + value.model_dump(mode="python", warnings="error"), strict=True + ) + except (ArithmeticError, RuntimeWarning): + raise + except ( + AttributeError, + PydanticSerializationError, + TypeError, + ValidationError, + ValueError, + Warning, + ) as error: + raise _InvalidInputV2 from error + + +def _from_upstream_failure( + kind: MultiObstacleStrictConvexCandidateCompilationKindV2, + finding_codes: tuple[str, ...], +) -> SupportStrictConvexCandidateCompilationOutcomeV2: + mapped = { + MultiObstacleStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL: ( + SupportStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL + ), + MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP: ( + SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP + ), + MultiObstacleStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT: ( + SupportStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT + ), + MultiObstacleStrictConvexCandidateCompilationKindV2.INVALID_INPUT: ( + SupportStrictConvexCandidateCompilationKindV2.INVALID_INPUT + ), + }.get(kind) + if mapped is None: + raise RuntimeError("malformed T14 compiler outcome") + return SupportStrictConvexCandidateCompilationOutcomeV2( + kind=mapped, + finding_codes=finding_codes, + ) + + +def _copy_bracket( + value: SupportStrictConvexAllowedBracketV2, +) -> SupportStrictConvexAllowedBracketV2: + return SupportStrictConvexAllowedBracketV2( + inner_allowed=value.inner_allowed, + outer_allowed=value.outer_allowed, + intersection_kernel_id=value.intersection_kernel_id, + intersection_kernel_version=value.intersection_kernel_version, + support_projection_kernel_id=value.support_projection_kernel_id, + support_projection_kernel_version=value.support_projection_kernel_version, + so2_atomic_steps_used=value.so2_atomic_steps_used, + ) + + +def _copy_artifact( + value: SupportStrictConvexCandidateDomainArtifactV2_2, +) -> SupportStrictConvexCandidateDomainArtifactV2_2: + return SupportStrictConvexCandidateDomainArtifactV2_2( + semantic_problem_sha256=value.semantic_problem_sha256, + compiler_config_sha256=value.compiler_config_sha256, + upstream_t14_artifact_sha256=value.upstream_t14_artifact_sha256, + subject_id=value.subject_id, + search_universe=value.search_universe, + ordered_constraint_ids=value.ordered_constraint_ids, + ordered_obstacle_body_ids=value.ordered_obstacle_body_ids, + support_constraint_id=value.support_constraint_id, + surface_id=value.surface_id, + contact_geometry_id=value.contact_geometry_id, + allowed_domain_bracket=value.allowed_domain_bracket, + resource_usage=value.resource_usage, + remaining_constraint_ids=value.remaining_constraint_ids, + ) + + +def _failure( + kind: SupportStrictConvexCandidateCompilationKindV2, + finding_code: str, +) -> SupportStrictConvexCandidateCompilationOutcomeV2: + return SupportStrictConvexCandidateCompilationOutcomeV2( + kind=kind, + finding_codes=(finding_code,), + ) + + +def _resource_failure() -> SupportStrictConvexCandidateCompilationOutcomeV2: + return _failure( + SupportStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, + "RESOURCE_LIMIT:SUPPORT_STRICT_CONVEX_CANDIDATE", + ) + + +__all__ = ( + "SupportStrictConvexAllowedBracketV2", + "SupportStrictConvexCandidateCompilationKindV2", + "SupportStrictConvexCandidateCompilationOutcomeV2", + "SupportStrictConvexCandidateDomainArtifactV2_2", + "SupportStrictConvexCandidateDomainCompilerV2_7", + "SupportStrictConvexCandidateVerificationKindV2", + "SupportStrictConvexCandidateVerificationOutcomeV2", + "compile_support_strict_convex_candidate_domain_v2_7", + "verify_support_strict_convex_candidate_domain_v2_7", +) diff --git a/src/spatialcf/core/v2/continuous_yaw_target_relation.py b/src/spatialcf/core/_internal/compilation/target.py similarity index 61% rename from src/spatialcf/core/v2/continuous_yaw_target_relation.py rename to src/spatialcf/core/_internal/compilation/target.py index 2d3cee2..0f3c6a5 100644 --- a/src/spatialcf/core/v2/continuous_yaw_target_relation.py +++ b/src/spatialcf/core/_internal/compilation/target.py @@ -1,44 +1,574 @@ -"""Private T16 target-relation projection over the T15 strict-convex prefix.""" +# ruff: noqa: F811, I001 +"""Private current candidate-family compilers; exact migrated bodies.""" from __future__ import annotations +# Migrated from target_relation_domain.py. +"""Sound target-relation domain compilation for exact rectangular NEAR/FAR. + +The exact Euclidean rounded-rectangle locus is bracketed by rational +axis-aligned regions. No camera callback or source-specific predicate is +consulted: unsupported semantics remain explicit ``UNKNOWN`` outcomes. +""" + + +from dataclasses import dataclass +from enum import StrEnum +from fractions import Fraction + +from spatialcf.core._internal.kernels.rect import ( + AxisMarginXYV2, + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, +) +from spatialcf.core._internal.kernels.rectilinear import ( + ExactRectilinearRegionV2, + RectilinearAtomicBudgetV2, + RectilinearOutcomeKindV2, + RectilinearRegionOutcomeV2, + RectilinearTopologyV2, + difference_rectilinear_region_v2, + intersect_rectilinear_regions_v2, + normalize_rectilinear_region_v2, + union_rectilinear_regions_v2, +) +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + FactSetV2, + NumericPolicyV2, + Quaternion, + UncertaintyBudgetV2, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + Relation, + RelationMeasurement, +) +from spatialcf.domain.geometry import ( + GeometryApproximationV2, + GeometryInstanceV2, + GeometryRoleV2, + UprightBox3DV2, +) +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.scene import CanonicalObject + + +class TargetRelationDomainKindV2(StrEnum): + BRACKET = "BRACKET" + IDENTITY = "IDENTITY" + EMPTY = "EMPTY" + UNKNOWN = "UNKNOWN" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + + +@dataclass(frozen=True, slots=True) +class TargetRelationDomainOutcomeV2: + """Inner/outer allowed-delta bracket or one closed terminal disposition.""" + + kind: TargetRelationDomainKindV2 + inner_allowed_delta: ExactRectilinearRegionV2 | None = None + outer_allowed_delta: ExactRectilinearRegionV2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.kind, TargetRelationDomainKindV2): + raise TypeError("kind must be a TargetRelationDomainKindV2") + object.__setattr__( + self, + "finding_codes", + tuple(sorted(set(self.finding_codes))), + ) + if self.kind is TargetRelationDomainKindV2.BRACKET: + if self.inner_allowed_delta is None or self.outer_allowed_delta is None: + raise ValueError( + "BRACKET requires both inner and outer allowed domains" + ) + if self.finding_codes: + raise ValueError("BRACKET cannot carry findings") + return + if self.inner_allowed_delta is not None or self.outer_allowed_delta is not None: + raise ValueError(f"{self.kind.value} must not carry partial domains") + if ( + self.kind + in { + TargetRelationDomainKindV2.UNKNOWN, + TargetRelationDomainKindV2.RESOURCE_LIMIT, + } + and not self.finding_codes + ): + raise ValueError(f"{self.kind.value} requires a finding") + + +TargetRelationDomainCompilationOutcomeV2 = TargetRelationDomainOutcomeV2 + + +def compile_target_relation_domain_v2( + problem: SemanticProblemV2, + universe: ExactRectilinearRegionV2, + *, + max_atomic_cells: int | None = None, + atomic_budget: RectilinearAtomicBudgetV2 | None = None, +) -> TargetRelationDomainOutcomeV2: + """Compile the target's after-relation over one finite delta universe.""" + + budget = _resolve_atomic_budget(max_atomic_cells, atomic_budget) + if not isinstance(problem, SemanticProblemV2): + raise TypeError("problem must be a SemanticProblemV2") + if not isinstance(universe, ExactRectilinearRegionV2): + raise TypeError("universe must be an ExactRectilinearRegionV2") + + checked_problem = SemanticProblemV2.model_validate( + problem.model_dump(mode="python"), + strict=True, + ) + + validation = union_rectilinear_regions_v2( + universe, + universe, + atomic_budget=budget, + ) + failure = _maybe_nested_failure(validation) + if failure is not None: + return failure + checked_universe = _exact_region(validation) + + findings = _supported_subset_findings(checked_problem) + if findings: + return _unknown(*findings) + + target = checked_problem.constraints.target_relation + definition = next( + item + for item in checked_problem.relation_semantics.definitions + if item.relation is target.relation_after + ) + threshold = Fraction.from_float(definition.threshold) + + if target.relation_after is Relation.NEAR and threshold < 0: + return _empty(f"EXACT_EMPTY:NEGATIVE_NEAR_THRESHOLD:{target.constraint_id}") + if target.relation_after is Relation.FAR and threshold <= 0: + return TargetRelationDomainOutcomeV2(kind=TargetRelationDomainKindV2.IDENTITY) + if checked_universe.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{target.constraint_id}") + + q0_rectangle = _overlap_delta_rectangle(checked_problem) + q0_outcome = normalize_rectilinear_region_v2( + (q0_rectangle,), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(q0_outcome) + if failure is not None: + return failure + q0 = _exact_region(q0_outcome) + + if target.relation_after is Relation.NEAR: + return _compile_near( + target.constraint_id, + checked_universe, + q0_rectangle, + q0, + threshold, + budget, + ) + return _compile_far( + target.constraint_id, + checked_universe, + q0_rectangle, + q0, + threshold, + budget, + ) + + +def _compile_near( + constraint_id: str, + universe: ExactRectilinearRegionV2, + q0_rectangle: ExactAxisAlignedRectV2, + q0: ExactRectilinearRegionV2, + threshold: Fraction, + budget: RectilinearAtomicBudgetV2, +) -> TargetRelationDomainOutcomeV2: + if threshold == 0: + clipped = intersect_rectilinear_regions_v2( + universe, + q0, + atomic_budget=budget, + ) + failure = _maybe_nested_failure(clipped) + if failure is not None: + return failure + exact = _exact_region(clipped) + if exact.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{constraint_id}") + return _bracket(exact, exact) + + inner_rectangle = q0_rectangle.dilate_axis( + AxisMarginXYV2(x_m=threshold / 2, y_m=threshold / 2) + ) + outer_rectangle = q0_rectangle.dilate_axis( + AxisMarginXYV2(x_m=threshold, y_m=threshold) + ) + inner_shape_outcome = normalize_rectilinear_region_v2( + (inner_rectangle,), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(inner_shape_outcome) + if failure is not None: + return failure + outer_shape_outcome = normalize_rectilinear_region_v2( + (outer_rectangle,), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(outer_shape_outcome) + if failure is not None: + return failure + + inner_outcome = intersect_rectilinear_regions_v2( + universe, + _exact_region(inner_shape_outcome), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(inner_outcome) + if failure is not None: + return failure + outer_outcome = intersect_rectilinear_regions_v2( + universe, + _exact_region(outer_shape_outcome), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(outer_outcome) + if failure is not None: + return failure + inner = _exact_region(inner_outcome) + outer = _exact_region(outer_outcome) + if outer.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{constraint_id}") + return _bracket(inner, outer) + + +def _compile_far( + constraint_id: str, + universe: ExactRectilinearRegionV2, + q0_rectangle: ExactAxisAlignedRectV2, + q0: ExactRectilinearRegionV2, + threshold: Fraction, + budget: RectilinearAtomicBudgetV2, +) -> TargetRelationDomainOutcomeV2: + dilated_rectangle = q0_rectangle.dilate_axis( + AxisMarginXYV2(x_m=threshold, y_m=threshold) + ) + dilated_outcome = normalize_rectilinear_region_v2( + (dilated_rectangle,), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(dilated_outcome) + if failure is not None: + return failure + inner_outcome = difference_rectilinear_region_v2( + universe, + _exact_region(dilated_outcome), + atomic_budget=budget, + ) + failure = _maybe_nested_failure(inner_outcome) + if failure is not None: + return failure + outer_outcome = difference_rectilinear_region_v2( + universe, + q0, + atomic_budget=budget, + ) + failure = _maybe_nested_failure(outer_outcome) + if failure is not None: + return failure + inner = _exact_region(inner_outcome) + outer = _exact_region(outer_outcome) + if outer.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{constraint_id}") + return _bracket(inner, outer) + + +def _supported_subset_findings(problem: SemanticProblemV2) -> tuple[str, ...]: + target = problem.constraints.target_relation + definition = next( + item + for item in problem.relation_semantics.definitions + if item.relation is target.relation_after + ) + findings: list[str] = [] + if target.relation_after not in {Relation.NEAR, Relation.FAR}: + findings.append( + f"UNSUPPORTED_TARGET_RELATION:AFTER_RELATION:{target.relation_after.value}" + ) + if definition.measurement is not RelationMeasurement.SHAPE_GAP_XY: + findings.append( + f"UNSUPPORTED_TARGET_RELATION:MEASUREMENT:{definition.measurement.value}" + ) + if definition.tolerance != 0.0: + findings.append("UNSUPPORTED_TARGET_RELATION:TOLERANCE") + if definition.boundary_policy is not BoundaryPolicy.CLOSED: + findings.append( + "UNSUPPORTED_TARGET_RELATION:BOUNDARY_POLICY:" + f"{definition.boundary_policy.value}" + ) + if not _numeric_policy_is_zero(problem.numeric_policy): + findings.append("UNSUPPORTED_TARGET_RELATION:NUMERIC_POLICY") + + object_facts = problem.scene.objects + geometry_facts = problem.scene.geometry_instances + findings.extend(_family_findings("OBJECT", object_facts)) + findings.extend(_family_findings("GEOMETRY", geometry_facts)) + + objects = {item.object_id: item for item in object_facts.values or ()} + for object_id in (target.subject_id, target.reference_id): + object_ = objects.get(object_id) + if object_ is not None and not _is_identity_rotation( + object_.pose.world_from_object.rotation + ): + findings.append( + f"UNSUPPORTED_TARGET_RELATION:NON_IDENTITY_ROTATION:" + f"OBJECT_POSE:{object_id}" + ) + + if ( + geometry_facts.availability is FactAvailabilityV2.KNOWN + and geometry_facts.completeness is FactCompletenessV2.EXACT + ): + geometries = geometry_facts.values or () + for object_id in (target.subject_id, target.reference_id): + selected = tuple( + item + for item in geometries + if item.owner_object_id == object_id + and item.role is GeometryRoleV2.RELATION + ) + if len(selected) != 1: + findings.append( + f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_CARDINALITY:" + f"{object_id}:{len(selected)}" + ) + continue + geometry = selected[0] + if geometry.approximation is not GeometryApproximationV2.EXACT: + findings.append( + f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_APPROXIMATION:" + f"{geometry.geometry_id}:{geometry.approximation.value}" + ) + if not _uncertainty_is_zero(geometry.uncertainty): + findings.append( + f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_ITEM_UNCERTAINTY:" + f"{geometry.geometry_id}" + ) + if not isinstance(geometry.shape, UprightBox3DV2): + findings.append( + f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_SHAPE:" + f"{geometry.geometry_id}:{geometry.shape.shape_type}" + ) + if not _is_identity_rotation(geometry.anchor_from_geometry.rotation): + findings.append( + f"UNSUPPORTED_TARGET_RELATION:NON_IDENTITY_ROTATION:" + f"{geometry.geometry_id}" + ) + return tuple(sorted(set(findings))) + + +def _family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: + if facts.availability is FactAvailabilityV2.MISSING: + return (f"MISSING_FACT:{label}",) + if facts.availability is not FactAvailabilityV2.KNOWN: + return ( + f"UNSUPPORTED_TARGET_RELATION:{label}_AVAILABILITY:{facts.availability.value}", + ) + findings: list[str] = [] + if facts.completeness is not FactCompletenessV2.EXACT: + value = facts.completeness.value if facts.completeness is not None else "NONE" + findings.append(f"UNSUPPORTED_TARGET_RELATION:{label}_COMPLETENESS:{value}") + if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): + findings.append(f"UNSUPPORTED_TARGET_RELATION:{label}_FACT_UNCERTAINTY") + return tuple(findings) + + +def _overlap_delta_rectangle( + problem: SemanticProblemV2, +) -> ExactAxisAlignedRectV2: + target = problem.constraints.target_relation + objects = {item.object_id: item for item in problem.scene.objects.values or ()} + geometries = problem.scene.geometry_instances.values or () + subject_geometry = next( + item + for item in geometries + if item.owner_object_id == target.subject_id + and item.role is GeometryRoleV2.RELATION + ) + reference_geometry = next( + item + for item in geometries + if item.owner_object_id == target.reference_id + and item.role is GeometryRoleV2.RELATION + ) + subject_bounds = _world_box_bounds(objects[target.subject_id], subject_geometry) + reference_bounds = _world_box_bounds( + objects[target.reference_id], reference_geometry + ) + return ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=reference_bounds[0] - subject_bounds[2], + min_y_m=reference_bounds[1] - subject_bounds[3], + max_x_m=reference_bounds[2] - subject_bounds[0], + max_y_m=reference_bounds[3] - subject_bounds[1], + coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, + ) + + +def _world_box_bounds( + object_: CanonicalObject, + geometry: GeometryInstanceV2, +) -> tuple[Fraction, Fraction, Fraction, Fraction]: + if not isinstance(geometry.shape, UprightBox3DV2): + raise TypeError("certified relation geometry must be an UprightBox3DV2") + center_x = Fraction.from_float( + object_.pose.world_from_object.translation.x + ) + Fraction.from_float(geometry.anchor_from_geometry.translation.x) + center_y = Fraction.from_float( + object_.pose.world_from_object.translation.y + ) + Fraction.from_float(geometry.anchor_from_geometry.translation.y) + half_x = Fraction.from_float(geometry.shape.size_m.x) / 2 + half_y = Fraction.from_float(geometry.shape.size_m.y) / 2 + return ( + center_x - half_x, + center_y - half_y, + center_x + half_x, + center_y + half_y, + ) + + +def _is_identity_rotation(rotation: Quaternion) -> bool: + return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) + + +def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: + return all( + value == 0.0 + for value in ( + policy.linear_tolerance_m, + policy.area_tolerance_m2, + policy.angular_tolerance_rad, + policy.pixel_tolerance_px, + policy.fraction_tolerance, + ) + ) + + +def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: + return _numeric_policy_is_zero( + uncertainty.source_error + ) and _numeric_policy_is_zero(uncertainty.shape_approximation) + + +def _resolve_atomic_budget( + max_atomic_cells: int | None, + atomic_budget: RectilinearAtomicBudgetV2 | None, +) -> RectilinearAtomicBudgetV2: + if (max_atomic_cells is None) == (atomic_budget is None): + raise ValueError("provide exactly one of max_atomic_cells or atomic_budget") + if atomic_budget is not None: + if type(atomic_budget) is not RectilinearAtomicBudgetV2: + raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") + atomic_budget.validate() + return atomic_budget + if type(max_atomic_cells) is not int: + raise TypeError("max_atomic_cells must be an exact int") + return RectilinearAtomicBudgetV2(limit=max_atomic_cells) + + +def _exact_region(outcome: RectilinearRegionOutcomeV2) -> ExactRectilinearRegionV2: + if outcome.kind is not RectilinearOutcomeKindV2.EXACT or outcome.region is None: + raise RuntimeError("nested rectilinear outcome is not exact") + return outcome.region + + +def _maybe_nested_failure( + outcome: RectilinearRegionOutcomeV2, +) -> TargetRelationDomainOutcomeV2 | None: + if outcome.kind is RectilinearOutcomeKindV2.EXACT: + return None + return _nested_failure(outcome) + + +def _nested_failure( + outcome: RectilinearRegionOutcomeV2, +) -> TargetRelationDomainOutcomeV2: + if outcome.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource() + return _unknown(*outcome.finding_codes) + + +def _bracket( + inner: ExactRectilinearRegionV2, + outer: ExactRectilinearRegionV2, +) -> TargetRelationDomainOutcomeV2: + return TargetRelationDomainOutcomeV2( + kind=TargetRelationDomainKindV2.BRACKET, + inner_allowed_delta=inner, + outer_allowed_delta=outer, + ) + + +def _empty(finding: str) -> TargetRelationDomainOutcomeV2: + return TargetRelationDomainOutcomeV2( + kind=TargetRelationDomainKindV2.EMPTY, + finding_codes=(finding,), + ) + + +def _unknown(*findings: str) -> TargetRelationDomainOutcomeV2: + return TargetRelationDomainOutcomeV2( + kind=TargetRelationDomainKindV2.UNKNOWN, + finding_codes=tuple(findings), + ) + + +def _resource() -> TargetRelationDomainOutcomeV2: + return TargetRelationDomainOutcomeV2( + kind=TargetRelationDomainKindV2.RESOURCE_LIMIT, + finding_codes=("RESOURCE_LIMIT:ATOMIC_CELLS",), + ) + + +# Migrated from continuous_yaw_target_relation.py. +"""Private T16 target-relation projection over the T15 strict-convex prefix.""" + + import hashlib import warnings from dataclasses import dataclass from enum import StrEnum from fractions import Fraction -from spatialcf.core.v2.convex_translation_domain import ( - ConvexTranslationDomainKindV2, - RationalConvexPolygonV2, - RationalPoint2V2, - _canonical_convex_hull_v2, - _clip_polygon_to_universe_v2, - compile_convex_translation_obstacle_v2, -) -from spatialcf.core.v2.convex_translation_partition import ( +from spatialcf.core._internal.kernels.convex_partition import ( ConvexAllowedCellComplexV2, RationalHalfPlane2V2, RationalHalfPlaneRelationV2, _canonical_half_plane_v2, _compile_polygon_complement_v2, ) -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - MultiObstacleStrictConvexCandidateResourceUsageV2, - _artifact_bytes, - _copy_intersection_complex, - _require_finding_codes, - _strict_problem, +from spatialcf.core._internal.kernels.convex_translation import ( + ConvexTranslationDomainKindV2, + RationalConvexPolygonV2, + RationalPoint2V2, + _canonical_convex_hull_v2, + _clip_polygon_to_universe_v2, + compile_convex_translation_obstacle_v2, ) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels.rect import ( ExactAxisAlignedRectV2, RectCoordinateSpaceV2, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetExhaustedV2, StrictConvexIntersectionBudgetV2, StrictConvexIntersectionCellV2, @@ -50,31 +580,38 @@ _universe_planes, intersect_strict_convex_allowed_complexes_v2, ) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( +from spatialcf.core._internal.compilation.collision import ( + MultiObstacleStrictConvexCandidateResourceUsageV2, + _artifact_bytes, + _copy_intersection_complex, + _require_finding_codes, + _strict_problem, +) +from spatialcf.core._internal.compilation.support import ( SupportStrictConvexCandidateDomainArtifactV2_2, ) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( +from spatialcf.core._internal.compilation.support import ( _copy_artifact as _copy_t15_artifact, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.artifacts import ( + GeometryInstanceV2_2, + SemanticProblemV2_2, +) +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, NumericPolicyV2, UncertaintyBudgetV2, - Vec3V2, + Vec3, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - MeasurementComparatorV2, - RelationMeasurementV2, - RelationV2, -) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - GeometryInstanceV2_2, - SemanticProblemV2_2, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + MeasurementComparator, + Relation, + RelationMeasurement, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, GeometryApproximationV2, GeometryRoleV2, UprightBox3DV2, @@ -101,7 +638,7 @@ class _TargetAwareCandidateStageV2: upstream_t15_artifact_sha256: str subject_id: str target_constraint_id: str - target_relation: RelationV2 + target_relation: Relation target_threshold_m: Fraction inner_allowed: StrictConvexIntersectionComplexV2 outer_allowed: StrictConvexIntersectionComplexV2 @@ -126,7 +663,7 @@ def __post_init__(self) -> None: ): if type(value) is not str or not value.strip(): raise ValueError(f"{label} must be a non-blank exact string") - if self.target_relation not in (RelationV2.NEAR, RelationV2.FAR): + if self.target_relation not in (Relation.NEAR, Relation.FAR): raise ValueError("target stage accepts only NEAR or FAR") if type(self.target_threshold_m) is not Fraction or self.target_threshold_m < 0: raise ValueError("target threshold must be a non-negative Fraction") @@ -376,14 +913,14 @@ def _extract_target( if item.relation is target.relation_after ) expected_comparator = { - RelationV2.NEAR: MeasurementComparatorV2.LESS_THAN, - RelationV2.FAR: MeasurementComparatorV2.GREATER_THAN, + Relation.NEAR: MeasurementComparator.LESS_THAN, + Relation.FAR: MeasurementComparator.GREATER_THAN, }.get(target.relation_after) if ( expected_comparator is None - or definition.measurement is not RelationMeasurementV2.SHAPE_GAP_XY + or definition.measurement is not RelationMeasurement.SHAPE_GAP_XY or definition.comparator is not expected_comparator - or definition.boundary_policy is not BoundaryPolicyV2.CLOSED + or definition.boundary_policy is not BoundaryPolicy.CLOSED or definition.tolerance != 0.0 or problem.numeric_policy != NumericPolicyV2() ): @@ -469,7 +1006,7 @@ def _xy_relation_transform( if type(transform) is not DirectedYawIntervalTransformV2_2: raise RuntimeError("v2.2 object lost its directed-yaw transform") return DirectedYawIntervalTransformV2_2( - translation=Vec3V2( + translation=Vec3( x=transform.translation.x, y=transform.translation.y, z=0.0, @@ -534,7 +1071,7 @@ def _offset_and_clip_polygon( def _target_complexes( - relation: RelationV2, + relation: Relation, inner_near: RationalConvexPolygonV2 | None, outer_near: RationalConvexPolygonV2 | None, universe: ExactAxisAlignedRectV2, @@ -544,12 +1081,12 @@ def _target_complexes( ConvexAllowedCellComplexV2 | StrictConvexIntersectionComplexV2, ConvexAllowedCellComplexV2 | StrictConvexIntersectionComplexV2, ]: - if relation is RelationV2.NEAR: + if relation is Relation.NEAR: return ( _inside_complex(inner_near, universe, atomic_budget, intersection_budget), _inside_complex(outer_near, universe, atomic_budget, intersection_budget), ) - if relation is not RelationV2.FAR: + if relation is not Relation.FAR: raise RuntimeError("unsupported target relation escaped extraction") if outer_near is None: raw_target_inner = _identity_complex(universe, intersection_budget) diff --git a/src/spatialcf/core/v2/continuous_yaw_visibility.py b/src/spatialcf/core/_internal/compilation/visibility.py similarity index 50% rename from src/spatialcf/core/v2/continuous_yaw_visibility.py rename to src/spatialcf/core/_internal/compilation/visibility.py index e9e63ec..6be87ef 100644 --- a/src/spatialcf/core/v2/continuous_yaw_visibility.py +++ b/src/spatialcf/core/_internal/compilation/visibility.py @@ -1,78 +1,901 @@ -"""Directed fixed-camera visibility projection for continuous-yaw candidates.""" +# ruff: noqa: F811, I001 +"""Private current candidate-family compilers; exact migrated bodies.""" from __future__ import annotations -import hashlib -import warnings +# Migrated from visibility_domain.py. +"""Sound visibility-domain compilation for a small analytic Canonical v2 subset.""" + + from dataclasses import dataclass from enum import StrEnum from fractions import Fraction -from spatialcf.core.v2 import so2_interval -from spatialcf.core.v2.continuous_yaw_target_relation import ( - _copy_stage as _copy_target_stage, +from spatialcf.core._internal.kernels.rect import ( + ExactAxisAlignedRectV2, + RectCoordinateSpaceV2, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( - _TargetAwareCandidateStageV2, +from spatialcf.core._internal.kernels.rectilinear import ( + ExactRectilinearRegionV2, + RectilinearAtomicBudgetV2, + RectilinearOutcomeKindV2, + RectilinearRegionOutcomeV2, + RectilinearTopologyV2, + intersect_rectilinear_regions_v2, + normalize_rectilinear_region_v2, + union_rectilinear_regions_v2, ) -from spatialcf.core.v2.convex_translation_domain import ( - _directed_world_corner_boxes, +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + FactSetV2, + NumericPolicyV2, + Quaternion, + RigidTransformV2, + UncertaintyBudgetV2, ) -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - MultiObstacleStrictConvexCandidateResourceUsageV2, - _artifact_bytes, - _copy_intersection_complex, - _InvalidInputV2, - _require_finding_codes, - _strict_problem, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + OccluderSoundnessPolicy, + VisibilityAreaMeasure, + VisibilityConstraint, + VisibilityDepthPolicy, + VisibilityMaskPolicy, + VisibilityMetricFormula, + VisibilityMetricKind, ) -from spatialcf.core.v2.oriented_upright_box import ( - OrientedUprightBoxBoundsV2, - compile_oriented_upright_box_bounds_v2, +from spatialcf.domain.geometry import ( + GeometryApproximationV2, + GeometryInstanceV2, + GeometryRoleV2, + UprightBox3DV2, +) +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.scene import ( + CameraAxes, + CameraDepthConvention, + CameraDistortionModel, + CameraMatrixLayout, + CameraPixelConvention, + CanonicalObject, + PinholeCamera, +) + + +class VisibilityDomainKindV2(StrEnum): + BRACKET = "BRACKET" + IDENTITY = "IDENTITY" + EMPTY = "EMPTY" + UNKNOWN = "UNKNOWN" + RESOURCE_LIMIT = "RESOURCE_LIMIT" + + +@dataclass(frozen=True, slots=True) +class VisibilityDomainOutcomeV2: + """One exact constant result or an inner/outer allowed-delta bracket.""" + + kind: VisibilityDomainKindV2 + inner_allowed_delta: ExactRectilinearRegionV2 | None = None + outer_allowed_delta: ExactRectilinearRegionV2 | None = None + finding_codes: tuple[str, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.kind, VisibilityDomainKindV2): + raise TypeError("kind must be a VisibilityDomainKindV2") + object.__setattr__( + self, + "finding_codes", + tuple(sorted(set(self.finding_codes))), + ) + if self.kind is VisibilityDomainKindV2.BRACKET: + if not isinstance( + self.inner_allowed_delta, ExactRectilinearRegionV2 + ) or not isinstance(self.outer_allowed_delta, ExactRectilinearRegionV2): + raise ValueError("BRACKET requires exact inner and outer regions") + if self.finding_codes: + raise ValueError("BRACKET cannot carry findings") + return + if self.inner_allowed_delta is not None or self.outer_allowed_delta is not None: + raise ValueError(f"{self.kind.value} must not carry partial regions") + if self.kind is VisibilityDomainKindV2.IDENTITY: + if self.finding_codes: + raise ValueError("IDENTITY cannot carry findings") + return + if not self.finding_codes: + raise ValueError(f"{self.kind.value} requires a finding") + + +VisibilityDomainCompilationOutcomeV2 = VisibilityDomainOutcomeV2 + + +class _BaselineStateV2(StrEnum): + PASS = "PASS" + FAIL = "FAIL" + AMBIGUOUS = "AMBIGUOUS" + + +@dataclass(frozen=True, slots=True) +class _BaselineClassificationV2: + state: _BaselineStateV2 + failure_codes: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class _ProjectionCertificateV2: + min_dx_m: Fraction + min_dy_m: Fraction + max_dx_m: Fraction + max_dy_m: Fraction + near_face_image_area_fraction: Fraction + + @property + def has_full_containment_delta(self) -> bool: + return self.min_dx_m <= self.max_dx_m and self.min_dy_m <= self.max_dy_m + + +def compile_visibility_domain_v2( + problem: SemanticProblemV2, + constraint: VisibilityConstraint | str, + universe: ExactRectilinearRegionV2, + *, + max_atomic_cells: int | None = None, + atomic_budget: RectilinearAtomicBudgetV2 | None = None, +) -> VisibilityDomainOutcomeV2: + """Compile one hard visibility predicate over a finite XY-delta universe.""" + + budget = _resolve_atomic_budget(max_atomic_cells, atomic_budget) + if not isinstance(problem, SemanticProblemV2): + raise TypeError("problem must be a SemanticProblemV2") + if not isinstance(universe, ExactRectilinearRegionV2): + raise TypeError("universe must be an ExactRectilinearRegionV2") + + checked_problem = SemanticProblemV2.model_validate( + problem.model_dump(mode="python"), + strict=True, + ) + selected = _resolve_constraint(checked_problem, constraint) + checked_universe = _revalidate_universe(universe, budget) + if isinstance(checked_universe, VisibilityDomainOutcomeV2): + return checked_universe + if isinstance(selected, VisibilityDomainOutcomeV2): + return selected + + common_findings = _common_findings(checked_problem, selected) + subject_id = checked_problem.constraints.allowed_edit.subject_id + moving_subject = subject_id in selected.query_object_ids + fixed_query_ids = tuple( + object_id for object_id in selected.query_object_ids if object_id != subject_id + ) + + if moving_subject: + findings = [*common_findings] + findings.extend(_moving_subset_findings(checked_problem, selected)) + if fixed_query_ids: + findings.extend(_baseline_family_findings(checked_problem, selected)) + if findings: + return _unknown(*findings) + return _compile_moving_subject( + checked_problem, + selected, + fixed_query_ids, + checked_universe, + atomic_budget=budget, + ) + + findings = [*common_findings] + findings.extend(_fixed_subset_findings(checked_problem, selected)) + findings.extend(_baseline_family_findings(checked_problem, selected)) + if findings: + return _unknown(*findings) + return _compile_fixed_queries( + checked_problem, + selected, + checked_universe, + atomic_budget=budget, + ) + + +def _compile_fixed_queries( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, + universe: ExactRectilinearRegionV2, + *, + atomic_budget: RectilinearAtomicBudgetV2, +) -> VisibilityDomainOutcomeV2: + classification = _classify_baselines( + problem, + constraint, + constraint.query_object_ids, + ) + if classification.state is _BaselineStateV2.FAIL: + return _empty(*classification.failure_codes) + if classification.state is _BaselineStateV2.PASS: + return VisibilityDomainOutcomeV2(kind=VisibilityDomainKindV2.IDENTITY) + if universe.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:VISIBILITY_DOMAIN:{constraint.constraint_id}") + empty = _make_empty_region(atomic_budget) + if isinstance(empty, VisibilityDomainOutcomeV2): + return empty + return _bracket(empty, universe) + + +def _compile_moving_subject( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, + fixed_query_ids: tuple[str, ...], + universe: ExactRectilinearRegionV2, + *, + atomic_budget: RectilinearAtomicBudgetV2, +) -> VisibilityDomainOutcomeV2: + fixed_classification = _classify_baselines( + problem, + constraint, + fixed_query_ids, + ) + if fixed_classification.state is _BaselineStateV2.FAIL: + return _empty(*fixed_classification.failure_codes) + + certificate = _projection_certificate(problem, constraint) + area_threshold = Fraction.from_float(constraint.minimum_image_area_fraction) + subject_is_guaranteed = certificate.near_face_image_area_fraction >= area_threshold + clear_inner = ( + fixed_classification.state is _BaselineStateV2.AMBIGUOUS + or not subject_is_guaranteed + or not certificate.has_full_containment_delta + ) + if universe.topology is RectilinearTopologyV2.EMPTY: + return _empty(f"EXACT_EMPTY:VISIBILITY_DOMAIN:{constraint.constraint_id}") + if clear_inner: + empty = _make_empty_region(atomic_budget) + if isinstance(empty, VisibilityDomainOutcomeV2): + return empty + return _bracket(empty, universe) + + containment_rectangle = ExactAxisAlignedRectV2.from_fraction_bounds( + min_x_m=certificate.min_dx_m, + min_y_m=certificate.min_dy_m, + max_x_m=certificate.max_dx_m, + max_y_m=certificate.max_dy_m, + coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, + ) + containment_outcome = normalize_rectilinear_region_v2( + (containment_rectangle,), + atomic_budget=atomic_budget, + ) + failure = _maybe_nested_failure(containment_outcome) + if failure is not None: + return failure + containment = _exact_region(containment_outcome) + inner_outcome = intersect_rectilinear_regions_v2( + universe, + containment, + atomic_budget=atomic_budget, + ) + failure = _maybe_nested_failure(inner_outcome) + if failure is not None: + return failure + return _bracket(_exact_region(inner_outcome), universe) + + +def _resolve_constraint( + problem: SemanticProblemV2, + requested: VisibilityConstraint | str, +) -> VisibilityConstraint | VisibilityDomainOutcomeV2: + if isinstance(requested, VisibilityConstraint): + checked = VisibilityConstraint.model_validate( + requested.model_dump(mode="python"), + strict=True, + ) + constraint_id = checked.constraint_id + elif type(requested) is str: + checked = None + constraint_id = requested + else: + raise TypeError("constraint must be a VisibilityConstraint or exact str ID") + registered = next( + ( + item + for item in problem.constraints.visibility_constraints + if item.constraint_id == constraint_id + ), + None, + ) + if registered is None: + return _unknown(f"UNKNOWN_VISIBILITY_CONSTRAINT:{constraint_id}") + if checked is not None and checked != registered: + return _unknown(f"VISIBILITY_CONSTRAINT_MISMATCH:{constraint_id}") + return registered + + +def _common_findings( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, +) -> tuple[str, ...]: + findings: list[str] = [] + if constraint.threshold_boundary_policy is not BoundaryPolicy.CLOSED: + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:BOUNDARY_POLICY:" + f"{constraint.threshold_boundary_policy.value}" + ) + if constraint.mask_policy is not VisibilityMaskPolicy.FULL_OBJECT: + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:MASK_POLICY:{constraint.mask_policy.value}" + ) + if ( + constraint.occluder_soundness_policy + is not OccluderSoundnessPolicy.EXACT_OR_OUTER_SHAPE_BOUND + ): + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:OCCLUDER_SOUNDNESS_POLICY:" + f"{constraint.occluder_soundness_policy.value}" + ) + if not _numeric_policy_is_zero(problem.numeric_policy): + findings.append("UNSUPPORTED_VISIBILITY_DOMAIN:NUMERIC_POLICY") + findings.extend(_fact_family_findings("OBJECTS", problem.scene.objects)) + findings.extend( + _fact_family_findings( + "GEOMETRY_INSTANCES", + problem.scene.geometry_instances, + ) + ) + findings.extend(_visibility_semantics_findings(problem, constraint)) + return tuple(sorted(set(findings))) + + +def _visibility_semantics_findings( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, + *, + supported_image_area_formulas: tuple[VisibilityMetricFormula, ...] = ( + VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA, + ), +) -> tuple[str, ...]: + expected = { + VisibilityMetricKind.VISIBLE_FRACTION: ( + constraint.visible_fraction_metric_definition_id, + constraint.visible_fraction_metric_definition_version, + ( + VisibilityMetricFormula.VISIBLE_CLIPPED_OVER_UNOCCLUDED_CLIPPED_PROJECTED_AREA, + ), + ), + VisibilityMetricKind.IMAGE_AREA_FRACTION: ( + constraint.image_area_metric_definition_id, + constraint.image_area_metric_definition_version, + supported_image_area_formulas, + ), + VisibilityMetricKind.TRUNCATED_FRACTION: ( + constraint.truncated_fraction_metric_definition_id, + constraint.truncated_fraction_metric_definition_version, + (VisibilityMetricFormula.ONE_MINUS_CLIPPED_OVER_UNCLIPPED_PROJECTED_AREA,), + ), + } + findings: list[str] = [] + for definition in problem.visibility_semantics.definitions: + definition_id, version, formulas = expected[definition.kind] + if ( + definition.reference != (definition_id, version) + or definition.formula not in formulas + or definition.area_measure + is not VisibilityAreaMeasure.CONTINUOUS_PIXEL_PLANE_AREA + or definition.depth_policy + is not VisibilityDepthPolicy.NEAREST_POSITIVE_CAMERA_DEPTH_OCCLUDES + ): + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:METRIC_SEMANTICS:{definition.kind.value}" + ) + return tuple(findings) + + +def _fixed_subset_findings( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, +) -> tuple[str, ...]: + subject_id = problem.constraints.allowed_edit.subject_id + subject_occluders = tuple( + item.geometry_id + for item in problem.scene.geometry_instances.values or () + if item.owner_object_id == subject_id and item.role is GeometryRoleV2.OCCLUDER + ) + if subject_occluders: + return ( + ( + "UNSUPPORTED_VISIBILITY_DOMAIN:SUBJECT_OCCLUDER:" + f"{constraint.constraint_id}:{','.join(sorted(subject_occluders))}" + ), + ) + return () + + +def _moving_subset_findings( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, +) -> tuple[str, ...]: + findings: list[str] = [] + geometries = problem.scene.geometry_instances.values or () + occluders = tuple( + item.geometry_id for item in geometries if item.role is GeometryRoleV2.OCCLUDER + ) + if occluders: + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:OCCLUDER_GEOMETRY:" + + ",".join(sorted(occluders)) + ) + + subject_id = problem.constraints.allowed_edit.subject_id + objects = {item.object_id: item for item in problem.scene.objects.values or ()} + subject = objects[subject_id] + if not _is_identity_rotation(subject.pose.world_from_object.rotation): + findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:OBJECT_ROTATION:{subject_id}") + visual_geometries = tuple( + item + for item in geometries + if item.owner_object_id == subject_id and item.role is GeometryRoleV2.VISUAL + ) + if len(visual_geometries) != 1: + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_GEOMETRY_CARDINALITY:" + f"{subject_id}:{len(visual_geometries)}" + ) + else: + visual = visual_geometries[0] + if visual.approximation is not GeometryApproximationV2.EXACT: + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_APPROXIMATION:" + f"{visual.geometry_id}:{visual.approximation.value}" + ) + if not _uncertainty_is_zero(visual.uncertainty): + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_UNCERTAINTY:{visual.geometry_id}" + ) + if not isinstance(visual.shape, UprightBox3DV2): + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_SHAPE:" + f"{visual.geometry_id}:{visual.shape.shape_type}" + ) + if not _is_identity_rotation(visual.anchor_from_geometry.rotation): + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_ROTATION:{visual.geometry_id}" + ) + + camera_facts = problem.scene.cameras + findings.extend(_fact_family_findings("CAMERAS", camera_facts)) + cameras = {item.camera_id: item for item in camera_facts.values or ()} + camera = cameras.get(constraint.camera_id) + if camera is not None: + findings.extend(_camera_findings(camera)) + + if not findings and camera is not None: + visual = visual_geometries[0] + assert isinstance(visual.shape, UprightBox3DV2) + z_min, z_max = _box_depth_interval(subject, visual) + near = Fraction.from_float(camera.near_clip_m) + far = Fraction.from_float(camera.far_clip_m) + if z_min <= 0 or z_min < near or z_max > far: + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:DEPTH_CLIP:{visual.geometry_id}" + ) + return tuple(sorted(set(findings))) + + +def _camera_findings(camera: PinholeCamera) -> tuple[str, ...]: + findings: list[str] = [] + if not _is_identity_transform(camera.world_to_camera): + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:CAMERA_TRANSFORM:{camera.camera_id}" + ) + if camera.distortion_model is not CameraDistortionModel.NONE: + findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:DISTORTION:{camera.camera_id}") + if not _uncertainty_is_zero(camera.calibration_uncertainty): + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:CAMERA_UNCERTAINTY:{camera.camera_id}" + ) + intrinsics = camera.intrinsics_row_major + if ( + intrinsics[1] != 0.0 + or intrinsics[3] != 0.0 + or intrinsics[6:] != (0.0, 0.0, 1.0) + ): + findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:INTRINSICS:{camera.camera_id}") + if ( + camera.matrix_layout is not CameraMatrixLayout.ROW_MAJOR + or camera.camera_axes is not CameraAxes.X_RIGHT_Y_DOWN_Z_FORWARD + or camera.pixel_convention is not CameraPixelConvention.CENTER_AT_HALF + or camera.depth_convention is not CameraDepthConvention.POSITIVE_Z_FORWARD + ): + findings.append( + f"UNSUPPORTED_VISIBILITY_DOMAIN:CAMERA_CONVENTION:{camera.camera_id}" + ) + return tuple(findings) + + +def _baseline_family_findings( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, +) -> tuple[str, ...]: + findings = list( + _fact_family_findings( + "BASELINE_OBSERVATIONS", + problem.scene.baseline_observations, + ) + ) + if FactCompletenessV2.EXACT not in constraint.accepted_baseline_completeness: + findings.append( + "UNSUPPORTED_VISIBILITY_DOMAIN:BASELINE_COMPLETENESS_POLICY:" + f"{constraint.constraint_id}" + ) + return tuple(findings) + + +def _fact_family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: + if facts.availability is FactAvailabilityV2.MISSING: + return (f"MISSING_FACT:{label}",) + if facts.availability is not FactAvailabilityV2.KNOWN: + return ( + ( + "UNSUPPORTED_VISIBILITY_DOMAIN:" + f"{label}_AVAILABILITY:{facts.availability.value}" + ), + ) + findings: list[str] = [] + if facts.completeness is not FactCompletenessV2.EXACT: + value = facts.completeness.value if facts.completeness is not None else "NONE" + findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:{label}_COMPLETENESS:{value}") + if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): + findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:{label}_UNCERTAINTY") + return tuple(findings) + + +def _classify_baselines( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, + object_ids: tuple[str, ...], +) -> _BaselineClassificationV2: + if not object_ids: + return _BaselineClassificationV2(state=_BaselineStateV2.PASS) + observations = { + ( + item.object_id, + item.camera_id, + item.metric_definition_id, + item.metric_definition_version, + ): item + for item in problem.scene.baseline_observations.values or () + } + specifications = ( + ( + "VISIBLE_FRACTION", + constraint.visible_fraction_metric_definition_id, + constraint.visible_fraction_metric_definition_version, + Fraction.from_float(constraint.minimum_visible_fraction), + True, + ), + ( + "IMAGE_AREA_FRACTION", + constraint.image_area_metric_definition_id, + constraint.image_area_metric_definition_version, + Fraction.from_float(constraint.minimum_image_area_fraction), + True, + ), + ( + "TRUNCATED_FRACTION", + constraint.truncated_fraction_metric_definition_id, + constraint.truncated_fraction_metric_definition_version, + Fraction.from_float(constraint.maximum_truncated_fraction), + False, + ), + ) + failures: list[str] = [] + ambiguous = False + for object_id in object_ids: + for label, definition_id, version, threshold, is_minimum in specifications: + observation = observations[ + (object_id, constraint.camera_id, definition_id, version) + ] + lower = Fraction.from_float(observation.normalized_lower_bound) + upper = Fraction.from_float(observation.normalized_upper_bound) + if is_minimum: + worst_passes = lower >= threshold + best_fails = upper < threshold + else: + worst_passes = upper <= threshold + best_fails = lower > threshold + if best_fails: + failures.append( + "EXACT_EMPTY:VISIBILITY_BASELINE:" + f"{constraint.constraint_id}:{object_id}:{label}" + ) + elif not worst_passes: + ambiguous = True + if failures: + return _BaselineClassificationV2( + state=_BaselineStateV2.FAIL, + failure_codes=tuple(sorted(set(failures))), + ) + if ambiguous: + return _BaselineClassificationV2(state=_BaselineStateV2.AMBIGUOUS) + return _BaselineClassificationV2(state=_BaselineStateV2.PASS) + + +def _projection_certificate( + problem: SemanticProblemV2, + constraint: VisibilityConstraint, +) -> _ProjectionCertificateV2: + subject_id = problem.constraints.allowed_edit.subject_id + subject = next( + item + for item in problem.scene.objects.values or () + if item.object_id == subject_id + ) + visual = next( + item + for item in problem.scene.geometry_instances.values or () + if item.owner_object_id == subject_id and item.role is GeometryRoleV2.VISUAL + ) + camera = next( + item + for item in problem.scene.cameras.values or () + if item.camera_id == constraint.camera_id + ) + assert isinstance(visual.shape, UprightBox3DV2) + + center_x = Fraction.from_float( + subject.pose.world_from_object.translation.x + ) + Fraction.from_float(visual.anchor_from_geometry.translation.x) + center_y = Fraction.from_float( + subject.pose.world_from_object.translation.y + ) + Fraction.from_float(visual.anchor_from_geometry.translation.y) + center_z = Fraction.from_float( + subject.pose.world_from_object.translation.z + ) + Fraction.from_float(visual.anchor_from_geometry.translation.z) + half_x = Fraction.from_float(visual.shape.size_m.x) / 2 + half_y = Fraction.from_float(visual.shape.size_m.y) / 2 + half_z = Fraction.from_float(visual.shape.size_m.z) / 2 + corners = tuple( + (center_x + sx * half_x, center_y + sy * half_y, center_z + sz * half_z) + for sx in (-1, 1) + for sy in (-1, 1) + for sz in (-1, 1) + ) + + intrinsics = tuple( + Fraction.from_float(item) for item in camera.intrinsics_row_major + ) + fx, cx = intrinsics[0], intrinsics[2] + fy, cy = intrinsics[4], intrinsics[5] + left = Fraction(1, 2) + right = Fraction(camera.width_px) - left + top = Fraction(1, 2) + bottom = Fraction(camera.height_px) - top + min_dx = max((left - cx) * z / fx - x for x, _, z in corners) + max_dx = min((right - cx) * z / fx - x for x, _, z in corners) + min_dy = max((top - cy) * z / fy - y for _, y, z in corners) + max_dy = min((bottom - cy) * z / fy - y for _, y, z in corners) + + near_z = center_z - half_z + projected_width = fx * Fraction.from_float(visual.shape.size_m.x) / near_z + projected_height = fy * Fraction.from_float(visual.shape.size_m.y) / near_z + image_area_fraction = (projected_width * projected_height) / ( + Fraction(camera.width_px) * Fraction(camera.height_px) + ) + return _ProjectionCertificateV2( + min_dx_m=min_dx, + min_dy_m=min_dy, + max_dx_m=max_dx, + max_dy_m=max_dy, + near_face_image_area_fraction=image_area_fraction, + ) + + +def _box_depth_interval( + subject: CanonicalObject, + visual: GeometryInstanceV2, +) -> tuple[Fraction, Fraction]: + assert isinstance(visual.shape, UprightBox3DV2) + center = Fraction.from_float( + subject.pose.world_from_object.translation.z + ) + Fraction.from_float(visual.anchor_from_geometry.translation.z) + half = Fraction.from_float(visual.shape.size_m.z) / 2 + return center - half, center + half + + +def _revalidate_universe( + universe: ExactRectilinearRegionV2, + atomic_budget: RectilinearAtomicBudgetV2, +) -> ExactRectilinearRegionV2 | VisibilityDomainOutcomeV2: + validation = union_rectilinear_regions_v2( + universe, + universe, + atomic_budget=atomic_budget, + ) + failure = _maybe_nested_failure(validation) + if failure is not None: + return failure + return _exact_region(validation) + + +def _make_empty_region( + atomic_budget: RectilinearAtomicBudgetV2, +) -> ExactRectilinearRegionV2 | VisibilityDomainOutcomeV2: + outcome = normalize_rectilinear_region_v2((), atomic_budget=atomic_budget) + failure = _maybe_nested_failure(outcome) + if failure is not None: + return failure + return _exact_region(outcome) + + +def _is_identity_rotation(rotation: Quaternion) -> bool: + return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) + + +def _is_identity_transform(transform: RigidTransformV2) -> bool: + translation = transform.translation + return (translation.x, translation.y, translation.z) == ( + 0.0, + 0.0, + 0.0, + ) and _is_identity_rotation(transform.rotation) + + +def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: + return all( + value == 0.0 + for value in ( + policy.linear_tolerance_m, + policy.area_tolerance_m2, + policy.angular_tolerance_rad, + policy.pixel_tolerance_px, + policy.fraction_tolerance, + ) + ) + + +def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: + return _numeric_policy_is_zero( + uncertainty.source_error + ) and _numeric_policy_is_zero(uncertainty.shape_approximation) + + +def _resolve_atomic_budget( + max_atomic_cells: int | None, + atomic_budget: RectilinearAtomicBudgetV2 | None, +) -> RectilinearAtomicBudgetV2: + if (max_atomic_cells is None) == (atomic_budget is None): + raise ValueError("provide exactly one of max_atomic_cells or atomic_budget") + if atomic_budget is not None: + if type(atomic_budget) is not RectilinearAtomicBudgetV2: + raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") + atomic_budget.validate() + return atomic_budget + if type(max_atomic_cells) is not int: + raise TypeError("max_atomic_cells must be an exact int") + return RectilinearAtomicBudgetV2(limit=max_atomic_cells) + + +def _exact_region(outcome: RectilinearRegionOutcomeV2) -> ExactRectilinearRegionV2: + if outcome.kind is not RectilinearOutcomeKindV2.EXACT or outcome.region is None: + raise RuntimeError("nested rectilinear outcome is not exact") + return outcome.region + + +def _maybe_nested_failure( + outcome: RectilinearRegionOutcomeV2, +) -> VisibilityDomainOutcomeV2 | None: + if outcome.kind is RectilinearOutcomeKindV2.EXACT: + return None + if outcome.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: + return _resource() + return _unknown(*outcome.finding_codes) + + +def _bracket( + inner: ExactRectilinearRegionV2, + outer: ExactRectilinearRegionV2, +) -> VisibilityDomainOutcomeV2: + return VisibilityDomainOutcomeV2( + kind=VisibilityDomainKindV2.BRACKET, + inner_allowed_delta=inner, + outer_allowed_delta=outer, + ) + + +def _empty(*findings: str) -> VisibilityDomainOutcomeV2: + return VisibilityDomainOutcomeV2( + kind=VisibilityDomainKindV2.EMPTY, + finding_codes=tuple(findings), + ) + + +def _unknown(*findings: str) -> VisibilityDomainOutcomeV2: + return VisibilityDomainOutcomeV2( + kind=VisibilityDomainKindV2.UNKNOWN, + finding_codes=tuple(findings), + ) + + +def _resource() -> VisibilityDomainOutcomeV2: + return VisibilityDomainOutcomeV2( + kind=VisibilityDomainKindV2.RESOURCE_LIMIT, + finding_codes=("RESOURCE_LIMIT:ATOMIC_CELLS",), + ) + + +# Migrated from continuous_yaw_visibility.py. +"""Directed fixed-camera visibility projection for continuous-yaw candidates.""" + + +import hashlib +import warnings +from dataclasses import dataclass +from enum import StrEnum +from fractions import Fraction + +from spatialcf.core._internal.kernels import so2 as so2_interval +from spatialcf.core._internal.kernels.convex_translation import ( + _directed_world_corner_boxes, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, SO2IntervalKindV2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetExhaustedV2, StrictConvexIntersectionBudgetV2, StrictConvexIntersectionCellV2, StrictConvexIntersectionComplexV2, ) -from spatialcf.core.v2.visibility_domain import _visibility_semantics_findings -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - NumericPolicyV2, - UncertaintyBudgetV2, +from spatialcf.core._internal.kernels.upright_box import ( + OrientedUprightBoxBoundsV2, + compile_oriented_upright_box_bounds_v2, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - OccluderSoundnessPolicyV2, - VisibilityConstraintV2, - VisibilityMaskPolicyV2, +from spatialcf.core._internal.compilation.target import ( + _copy_stage as _copy_target_stage, +) +from spatialcf.core._internal.compilation.target import ( + _TargetAwareCandidateStageV2, ) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_candidate import ( +from spatialcf.core._internal.compilation.collision import ( + MultiObstacleStrictConvexCandidateResourceUsageV2, + _artifact_bytes, + _copy_intersection_complex, + _InvalidInputV2, + _require_finding_codes, + _strict_problem, +) +from spatialcf.core._internal.compilation.visibility import ( + _visibility_semantics_findings, +) +from spatialcf.domain.artifacts import ( CanonicalObjectV2_2, GeometryInstanceV2_2, PinholeCameraV2_2, SemanticProblemV2_2, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.base import ( + FactAvailabilityV2, + FactCompletenessV2, + NumericPolicyV2, + UncertaintyBudgetV2, +) +from spatialcf.domain.constraints import ( + BoundaryPolicy, + OccluderSoundnessPolicy, + VisibilityConstraint, + VisibilityMaskPolicy, +) +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, GeometryApproximationV2, GeometryRoleV2, UprightBox3DV2, ) -from spatialcf.domain.v2.scene import ( - BaselineObservationV2, - CameraAxesV2, - CameraDepthConventionV2, - CameraDistortionModelV2, - CameraMatrixLayoutV2, - CameraPixelConventionV2, +from spatialcf.domain.scene import ( + BaselineObservation, + CameraAxes, + CameraDepthConvention, + CameraDistortionModel, + CameraMatrixLayout, + CameraPixelConvention, ) CONTINUOUS_YAW_VISIBILITY_KERNEL_ID_V2 = ( @@ -409,7 +1232,7 @@ def _extract_visibility_subset( target_stage: _TargetAwareCandidateStageV2, budget: StrictConvexIntersectionBudgetV2, ) -> tuple[ - VisibilityConstraintV2, + VisibilityConstraint, PinholeCameraV2_2, CanonicalObjectV2_2, GeometryInstanceV2_2, @@ -424,10 +1247,10 @@ def _extract_visibility_subset( constraint = problem.constraints.visibility_constraints[0] if ( constraint.constraint_id != target_stage.remaining_constraint_ids[0] - or constraint.threshold_boundary_policy is not BoundaryPolicyV2.CLOSED - or constraint.mask_policy is not VisibilityMaskPolicyV2.FULL_OBJECT + or constraint.threshold_boundary_policy is not BoundaryPolicy.CLOSED + or constraint.mask_policy is not VisibilityMaskPolicy.FULL_OBJECT or constraint.occluder_soundness_policy - is not OccluderSoundnessPolicyV2.EXACT_OR_OUTER_SHAPE_BOUND + is not OccluderSoundnessPolicy.EXACT_OR_OUTER_SHAPE_BOUND or constraint.occluder_geometry_ids or constraint.accepted_baseline_completeness != (FactCompletenessV2.EXACT,) or target_stage.subject_id not in constraint.query_object_ids @@ -496,12 +1319,12 @@ def _require_camera_subset(camera: PinholeCameraV2_2) -> None: if ( type(transform) is not DirectedYawIntervalTransformV2_2 or transform.yaw_radians != 0.0 - or camera.distortion_model is not CameraDistortionModelV2.NONE + or camera.distortion_model is not CameraDistortionModel.NONE or camera.calibration_uncertainty != UncertaintyBudgetV2() - or camera.matrix_layout is not CameraMatrixLayoutV2.ROW_MAJOR - or camera.camera_axes is not CameraAxesV2.X_RIGHT_Y_DOWN_Z_FORWARD - or camera.pixel_convention is not CameraPixelConventionV2.CENTER_AT_HALF - or camera.depth_convention is not CameraDepthConventionV2.POSITIVE_Z_FORWARD + or camera.matrix_layout is not CameraMatrixLayout.ROW_MAJOR + or camera.camera_axes is not CameraAxes.X_RIGHT_Y_DOWN_Z_FORWARD + or camera.pixel_convention is not CameraPixelConvention.CENTER_AT_HALF + or camera.depth_convention is not CameraDepthConvention.POSITIVE_Z_FORWARD or intrinsics[1] != 0.0 or intrinsics[3] != 0.0 or intrinsics[6:] != (0.0, 0.0, 1.0) @@ -530,7 +1353,7 @@ def _exact_values(facts: object, label: str, budget: StrictConvexIntersectionBud def _require_baseline_pass( problem: SemanticProblemV2_2, - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, budget: StrictConvexIntersectionBudgetV2, ) -> None: observations = _exact_values( @@ -546,7 +1369,7 @@ def _require_baseline_pass( item.metric_definition_version, ): item for item in observations - if type(item) is BaselineObservationV2 + if type(item) is BaselineObservation } specifications = ( ( @@ -665,7 +1488,7 @@ def _projection_geometry( def _classify_cell( cell: StrictConvexIntersectionCellV2, camera: PinholeCameraV2_2, - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, geometry: _ProjectionGeometryV2, ) -> _CellClassificationV2: vertices = cell.closure_polygon.vertices_ccw diff --git a/src/spatialcf/core/_internal/kernels/__init__.py b/src/spatialcf/core/_internal/kernels/__init__.py new file mode 100644 index 0000000..03b624a --- /dev/null +++ b/src/spatialcf/core/_internal/kernels/__init__.py @@ -0,0 +1 @@ +"""Private pure numeric geometry kernels.""" diff --git a/src/spatialcf/core/v2/convex_translation_partition.py b/src/spatialcf/core/_internal/kernels/convex_partition.py similarity index 98% rename from src/spatialcf/core/v2/convex_translation_partition.py rename to src/spatialcf/core/_internal/kernels/convex_partition.py index 340f354..b44de6e 100644 --- a/src/spatialcf/core/v2/convex_translation_partition.py +++ b/src/spatialcf/core/_internal/kernels/convex_partition.py @@ -13,8 +13,11 @@ from fractions import Fraction from math import gcd, lcm -from spatialcf.core.v2 import convex_translation_domain, so2_interval -from spatialcf.core.v2.convex_translation_domain import ( +from spatialcf.core._internal.kernels import ( + convex_translation as convex_translation_domain, +) +from spatialcf.core._internal.kernels import so2 as so2_interval +from spatialcf.core._internal.kernels.convex_translation import ( CONVEX_TRANSLATION_KERNEL_ID_V2, CONVEX_TRANSLATION_KERNEL_VERSION_V2, CONVEX_TRANSLATION_MAX_POLYGON_VERTICES_V2, @@ -23,17 +26,16 @@ RationalConvexPolygonV2, RationalPoint2V2, ) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels.rect import ( ExactAxisAlignedRectV2, RectCoordinateSpaceV2, RectTopologyV2, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, ) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.geometry import UprightBox3DV2 +from spatialcf.domain.geometry import DirectedYawIntervalTransformV2_2, UprightBox3DV2 CONVEX_ALLOWED_PARTITION_KERNEL_ID_V2 = ( "geometry-kernel:rational-convex-complement-partition-v2" diff --git a/src/spatialcf/core/v2/convex_translation_domain.py b/src/spatialcf/core/_internal/kernels/convex_translation.py similarity index 99% rename from src/spatialcf/core/v2/convex_translation_domain.py rename to src/spatialcf/core/_internal/kernels/convex_translation.py index 1ba802b..644b90e 100644 --- a/src/spatialcf/core/v2/convex_translation_domain.py +++ b/src/spatialcf/core/_internal/kernels/convex_translation.py @@ -14,23 +14,22 @@ from pydantic import ValidationError from pydantic_core import PydanticSerializationError -from spatialcf.core.v2 import so2_interval -from spatialcf.core.v2.oriented_upright_box import ( - OrientedUprightBoxBoundsV2, - compile_oriented_upright_box_bounds_v2, -) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels import so2 as so2_interval +from spatialcf.core._internal.kernels.rect import ( ExactAxisAlignedRectV2, RectCoordinateSpaceV2, RectTopologyV2, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, SO2IntervalKindV2, ) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.geometry import UprightBox3DV2 +from spatialcf.core._internal.kernels.upright_box import ( + OrientedUprightBoxBoundsV2, + compile_oriented_upright_box_bounds_v2, +) +from spatialcf.domain.geometry import DirectedYawIntervalTransformV2_2, UprightBox3DV2 CONVEX_TRANSLATION_KERNEL_ID_V2 = ( "geometry-kernel:rational-convex-translation-bracket-v2" diff --git a/src/spatialcf/core/v2/projected_bounding_box_visibility.py b/src/spatialcf/core/_internal/kernels/projected_visibility.py similarity index 100% rename from src/spatialcf/core/v2/projected_bounding_box_visibility.py rename to src/spatialcf/core/_internal/kernels/projected_visibility.py diff --git a/src/spatialcf/core/v2/rect_kernel.py b/src/spatialcf/core/_internal/kernels/rect.py similarity index 98% rename from src/spatialcf/core/v2/rect_kernel.py rename to src/spatialcf/core/_internal/kernels/rect.py index fff8823..bad4f62 100644 --- a/src/spatialcf/core/v2/rect_kernel.py +++ b/src/spatialcf/core/_internal/kernels/rect.py @@ -20,8 +20,8 @@ from fractions import Fraction from typing import Self -from spatialcf.domain.v2.base import Vec2V2 -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.base import Vec2 +from spatialcf.domain.geometry import ( PlanarPolygonComponentV2, PlanarRegionV2, PlanarRingV2, @@ -651,10 +651,10 @@ def to_planar_region(self) -> PlanarRegionV2: exterior=PlanarRingV2( winding=RingWindingV2.COUNTERCLOCKWISE, vertices=( - Vec2V2(x=min_x, y=min_y), - Vec2V2(x=max_x, y=min_y), - Vec2V2(x=max_x, y=max_y), - Vec2V2(x=min_x, y=max_y), + Vec2(x=min_x, y=min_y), + Vec2(x=max_x, y=min_y), + Vec2(x=max_x, y=max_y), + Vec2(x=min_x, y=max_y), ), ) ), diff --git a/src/spatialcf/core/v2/rectilinear_kernel.py b/src/spatialcf/core/_internal/kernels/rectilinear.py similarity index 99% rename from src/spatialcf/core/v2/rectilinear_kernel.py rename to src/spatialcf/core/_internal/kernels/rectilinear.py index 774fa9e..5b37d28 100644 --- a/src/spatialcf/core/v2/rectilinear_kernel.py +++ b/src/spatialcf/core/_internal/kernels/rectilinear.py @@ -15,15 +15,15 @@ from enum import StrEnum from fractions import Fraction -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels.rect import ( DirectedRectRoundingV2, ExactAxisAlignedRectV2, RectCoordinateSpaceV2, RectKernelProjectionErrorV2, RectTopologyV2, ) -from spatialcf.domain.v2.base import Vec2V2 -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.base import Vec2 +from spatialcf.domain.geometry import ( PlanarPolygonComponentV2, PlanarRegionV2, PlanarRingV2, @@ -1264,7 +1264,7 @@ def _to_planar_ring( ) -> PlanarRingV2: return PlanarRingV2( winding=winding, - vertices=tuple(Vec2V2(x=float(x), y=float(y)) for x, y in vertices), + vertices=tuple(Vec2(x=float(x), y=float(y)) for x, y in vertices), ) diff --git a/src/spatialcf/core/v2/so2_interval.py b/src/spatialcf/core/_internal/kernels/so2.py similarity index 100% rename from src/spatialcf/core/v2/so2_interval.py rename to src/spatialcf/core/_internal/kernels/so2.py diff --git a/src/spatialcf/core/v2/strict_convex_intersection.py b/src/spatialcf/core/_internal/kernels/strict_convex.py similarity index 99% rename from src/spatialcf/core/v2/strict_convex_intersection.py rename to src/spatialcf/core/_internal/kernels/strict_convex.py index ea60f3d..5d5b448 100644 --- a/src/spatialcf/core/v2/strict_convex_intersection.py +++ b/src/spatialcf/core/_internal/kernels/strict_convex.py @@ -13,23 +13,23 @@ from itertools import product from math import prod -from spatialcf.core.v2.convex_translation_domain import ( - RationalConvexPolygonV2, - RationalPoint2V2, -) -from spatialcf.core.v2.convex_translation_partition import ( +from spatialcf.core._internal.kernels.convex_partition import ( ConvexAllowedCellComplexV2, RationalConvexAllowedCellV2, RationalHalfPlane2V2, RationalHalfPlaneRelationV2, _universe_planes_unbudgeted, ) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels.convex_translation import ( + RationalConvexPolygonV2, + RationalPoint2V2, +) +from spatialcf.core._internal.kernels.rect import ( ExactAxisAlignedRectV2, RectCoordinateSpaceV2, RectTopologyV2, ) -from spatialcf.core.v2.so2_interval import SO2_INTERVAL_MAX_FRACTION_BITS_V2 +from spatialcf.core._internal.kernels.so2 import SO2_INTERVAL_MAX_FRACTION_BITS_V2 _MAX_DETERMINISTIC_LIMIT_V2 = 2**63 - 1 diff --git a/src/spatialcf/core/v2/oriented_upright_box.py b/src/spatialcf/core/_internal/kernels/upright_box.py similarity index 98% rename from src/spatialcf/core/v2/oriented_upright_box.py rename to src/spatialcf/core/_internal/kernels/upright_box.py index 2e8d5c9..ffecab3 100644 --- a/src/spatialcf/core/v2/oriented_upright_box.py +++ b/src/spatialcf/core/_internal/kernels/upright_box.py @@ -11,8 +11,8 @@ from pydantic import ValidationError from pydantic_core import PydanticSerializationError -from spatialcf.core.v2 import so2_interval -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels import so2 as so2_interval +from spatialcf.core._internal.kernels.so2 import ( DirectedSinCosBoundsV2, RationalEnclosureV2, SO2AtomicBudgetExhaustedV2, @@ -20,9 +20,8 @@ SO2IntervalKindV2, compile_directed_sin_cos_v2, ) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.geometry import UprightBox3DV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 +from spatialcf.domain.geometry import DirectedYawIntervalTransformV2_2, UprightBox3DV2 +from spatialcf.domain.serialization import canonical_json_bytes class _InvalidOrientedBoxInputV2(ValueError): @@ -668,7 +667,7 @@ def _box_operand_key( transform: DirectedYawIntervalTransformV2_2, shape: UprightBox3DV2, ) -> bytes: - return canonical_json_bytes_v2(transform) + b"\0" + canonical_json_bytes_v2(shape) + return canonical_json_bytes(transform) + b"\0" + canonical_json_bytes(shape) def _compile_axis_gap( diff --git a/src/spatialcf/core/_internal/objective/__init__.py b/src/spatialcf/core/_internal/objective/__init__.py new file mode 100644 index 0000000..8b4a4c9 --- /dev/null +++ b/src/spatialcf/core/_internal/objective/__init__.py @@ -0,0 +1 @@ +"""Focused private stages for the current objective compiler.""" diff --git a/src/spatialcf/core/v2/continuous_yaw_objective.py b/src/spatialcf/core/_internal/objective/base.py similarity index 96% rename from src/spatialcf/core/v2/continuous_yaw_objective.py rename to src/spatialcf/core/_internal/objective/base.py index 7e5562d..ad5a0de 100644 --- a/src/spatialcf/core/v2/continuous_yaw_objective.py +++ b/src/spatialcf/core/_internal/objective/base.py @@ -9,16 +9,22 @@ from fractions import Fraction from sys import float_info -from spatialcf.core.v2.continuous_yaw_visibility import ( +from spatialcf.core._internal.compilation.collision import ( + _artifact_bytes, + _strict_problem, +) +from spatialcf.core._internal.compilation.visibility import ( _CompleteContinuousYawCandidateStageV2, _copy_complete_stage, ) -from spatialcf.core.v2.convex_translation_domain import RationalPoint2V2 -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - _artifact_bytes, - _strict_problem, +from spatialcf.core._internal.kernels.convex_translation import RationalPoint2V2 +from spatialcf.core._internal.kernels.so2 import SO2AtomicBudgetV2 +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionCellV2, ) -from spatialcf.core.v2.objective_numeric import ( +from spatialcf.core._internal.objective.numeric import ( ConstraintSafetyInputV2, ObjectiveNumericKindV2, SafetyRawComponentIntervalV2, @@ -29,28 +35,22 @@ aggregate_visibility_change_bounds_v2, compile_constraint_slack_bounds_v2, ) -from spatialcf.core.v2.so2_interval import SO2AtomicBudgetV2 -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionCellV2, -) -from spatialcf.domain.v2.artifacts import ( +from spatialcf.domain.artifacts import ( ConstraintSlackV2, NonNegativeIntervalV2, ObjectiveTermBoundsV2, RelationDamageBoundV2, + SemanticProblemV2_2, ) -from spatialcf.domain.v2.base import Vec2V2 -from spatialcf.domain.v2.continuous_yaw_candidate import SemanticProblemV2_2 -from spatialcf.domain.v2.continuous_yaw_solver import ( +from spatialcf.domain.base import Vec2 +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.objective import SafetySlackUnitV2 +from spatialcf.domain.solver import ( ContinuousYawObjectiveCellV2_8, ContinuousYawResourceUsageV2_8, ContinuousYawSolverConfigV2_8, ContinuousYawWitnessEvaluationV2_8, ) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.objective import SafetySlackUnitV2 _STAGE_HASH_DOMAIN_V2 = b"spatialcf.continuous-yaw-objective-stage.v2.8\0" _OUTER_DOMAIN_HASH_V2 = b"spatialcf.continuous-yaw-outer-cell.v2.8\0" @@ -73,17 +73,17 @@ class _ContinuousYawWitnessNotProvenV2(ValueError): @dataclass(frozen=True, slots=True) class _ContinuousYawWitnessProposalV2: objective_cell_id: str - edit: CanonicalEditV2 + edit: CanonicalEdit def __post_init__(self) -> None: if type(self.objective_cell_id) is not str or not self.objective_cell_id: raise ValueError("objective proposal cell ID must be non-blank") - if type(self.edit) is not CanonicalEditV2: + if type(self.edit) is not CanonicalEdit: raise TypeError("objective proposal edit has the wrong exact type") object.__setattr__( self, "edit", - CanonicalEditV2.model_validate( + CanonicalEdit.model_validate( self.edit.model_dump(mode="python", warnings="error"), strict=True ), ) @@ -291,7 +291,7 @@ def _evaluate_continuous_yaw_point_v2( config: ContinuousYawSolverConfigV2_8, candidate_stage: _CompleteContinuousYawCandidateStageV2, objective_stage: _ContinuousYawObjectiveStageV2, - edit: CanonicalEditV2, + edit: CanonicalEdit, *, atomic_budget: SO2AtomicBudgetV2, intersection_budget: StrictConvexIntersectionBudgetV2, @@ -301,9 +301,9 @@ def _evaluate_continuous_yaw_point_v2( ) if type(objective_stage) is not _ContinuousYawObjectiveStageV2: raise TypeError("objective_stage has the wrong exact type") - if type(edit) is not CanonicalEditV2: + if type(edit) is not CanonicalEdit: raise TypeError("edit has the wrong exact type") - checked_edit = CanonicalEditV2.model_validate( + checked_edit = CanonicalEdit.model_validate( edit.model_dump(mode="python", warnings="error"), strict=True ) if ( @@ -686,10 +686,10 @@ def _proposal_for_inner(problem_sha, subject_id, objective_id, assigned, outer): _, _, _, x, y = min(candidates) return _ContinuousYawWitnessProposalV2( objective_cell_id=objective_id, - edit=CanonicalEditV2( + edit=CanonicalEdit( semantic_problem_sha256=problem_sha, subject_id=subject_id, - translation_xy_m=Vec2V2(x=x, y=y), + translation_xy_m=Vec2(x=x, y=y), ), ) diff --git a/src/spatialcf/core/v2/objective_numeric.py b/src/spatialcf/core/_internal/objective/numeric.py similarity index 99% rename from src/spatialcf/core/v2/objective_numeric.py rename to src/spatialcf/core/_internal/objective/numeric.py index b3cb8a9..bcf748e 100644 --- a/src/spatialcf/core/v2/objective_numeric.py +++ b/src/spatialcf/core/_internal/objective/numeric.py @@ -15,17 +15,17 @@ from fractions import Fraction from typing import TypeVar -from spatialcf.core.v2.rect_kernel import ExactAxisAlignedRectV2 -from spatialcf.core.v2.rectilinear_kernel import ( +from spatialcf.core._internal.kernels.rect import ExactAxisAlignedRectV2 +from spatialcf.core._internal.kernels.rectilinear import ( ExactRectilinearRegionV2, RectilinearAtomicBudgetExhaustedV2, RectilinearAtomicBudgetV2, RectilinearNearestKindV2, nearest_point_to_origin_rectilinear_v2, ) -from spatialcf.domain.v2.artifacts import RelationDamageBoundV2 -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.objective import ( +from spatialcf.domain.artifacts import RelationDamageBoundV2 +from spatialcf.domain.base import CanonicalModel +from spatialcf.domain.objective import ( ConstraintSafetyTargetV2, ObjectCameraKeyV2, RelationDamageAggregationV2, @@ -308,7 +308,7 @@ def __init__(self, finding_code: str) -> None: super().__init__(finding_code) -ModelT = TypeVar("ModelT", bound=V2Model) +ModelT = TypeVar("ModelT", bound=CanonicalModel) def publish_nonnegative_fraction_interval_v2( diff --git a/src/spatialcf/core/v2/continuous_yaw_directional_relation.py b/src/spatialcf/core/_internal/objective/relation.py similarity index 92% rename from src/spatialcf/core/v2/continuous_yaw_directional_relation.py rename to src/spatialcf/core/_internal/objective/relation.py index 31ca16c..8d0eaa8 100644 --- a/src/spatialcf/core/v2/continuous_yaw_directional_relation.py +++ b/src/spatialcf/core/_internal/objective/relation.py @@ -8,34 +8,35 @@ from enum import StrEnum from fractions import Fraction -from spatialcf.core.v2.continuous_yaw_camera_frame import ( - UprightCameraContextV2_9, - bound_world_point_in_upright_camera_v2_9, - prepare_camera_independent_candidate_problem_v2_9, +from spatialcf.core._internal.compilation.collision import ( + MultiObstacleStrictConvexCandidateResourceUsageV2, + _artifact_bytes, + _copy_intersection_complex, + _require_finding_codes, +) +from spatialcf.core._internal.compilation.support import ( + SupportStrictConvexCandidateDomainArtifactV2_2, +) +from spatialcf.core._internal.compilation.support import ( + _copy_artifact as _copy_t15_artifact, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _compile_target_aware_candidate_v2, _TargetAwareCandidateKindV2, ) -from spatialcf.core.v2.convex_translation_domain import ( - RationalConvexPolygonV2, -) -from spatialcf.core.v2.convex_translation_partition import ( +from spatialcf.core._internal.kernels.convex_partition import ( RationalHalfPlane2V2, RationalHalfPlaneRelationV2, _canonical_half_plane_v2, ) -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - MultiObstacleStrictConvexCandidateResourceUsageV2, - _artifact_bytes, - _copy_intersection_complex, - _require_finding_codes, +from spatialcf.core._internal.kernels.convex_translation import ( + RationalConvexPolygonV2, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetExhaustedV2, StrictConvexIntersectionBudgetV2, StrictConvexIntersectionCellV2, @@ -45,34 +46,33 @@ _find_strict_witness, _universe_planes, ) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( - SupportStrictConvexCandidateDomainArtifactV2_2, +from spatialcf.core.problem import ( + UprightCameraContextV2_9, + bound_world_point_in_upright_camera, + prepare_camera_independent_candidate_problem, ) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( - _copy_artifact as _copy_t15_artifact, +from spatialcf.domain.artifacts import ( + GeometryInstanceV2_2, + SemanticProblemV2_2, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, NumericPolicyV2, UncertaintyBudgetV2, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - MeasurementComparatorV2, - RelationMeasurementV2, - RelationV2, -) -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - GeometryInstanceV2_2, - SemanticProblemV2_2, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + MeasurementComparator, + Relation, + RelationMeasurement, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.geometry import ( GeometryApproximationV2, GeometryRoleV2, UprightBox3DV2, ) +from spatialcf.domain.problem import SemanticProblemV2_3 _STAGE_HASH_DOMAIN_V2_9 = b"spatialcf.directional-target-stage.v2.9\0" _IntervalV2 = tuple[Fraction, Fraction] @@ -95,7 +95,7 @@ class DirectionalTargetCandidateStageV2_9: upstream_t15_artifact_sha256: str subject_id: str target_constraint_id: str - target_relation: RelationV2 + target_relation: Relation target_threshold: Fraction requires_both_visible: bool inner_allowed: StrictConvexIntersectionComplexV2 @@ -118,8 +118,8 @@ def __post_init__(self) -> None: ): if type(value) is not str or not value.strip(): raise ValueError(f"{label} must be a non-blank exact string") - if type(self.target_relation) is not RelationV2: - raise TypeError("target_relation must be an exact RelationV2") + if type(self.target_relation) is not Relation: + raise TypeError("target_relation must be an exact Relation") if type(self.target_threshold) is not Fraction: raise TypeError("target_threshold must be an exact Fraction") if type(self.requires_both_visible) is not bool: @@ -211,7 +211,7 @@ def compile_directional_target_candidate_v2_9( target, definition, subject, reference = _extract_target( original, projected, context, intersection_budget ) - if target.relation_after in (RelationV2.NEAR, RelationV2.FAR): + if target.relation_after in (Relation.NEAR, Relation.FAR): inner, outer = _compile_shape_gap_on_continued_ledgers( projected, artifact, @@ -234,8 +234,7 @@ def compile_directional_target_candidate_v2_9( affine, definition.comparator, Fraction.from_float(definition.threshold) - if definition.measurement - is RelationMeasurementV2.CAMERA_DEPTH_DELTA + if definition.measurement is RelationMeasurement.CAMERA_DEPTH_DELTA else Fraction(), inner=True, atomic_budget=atomic_budget, @@ -246,8 +245,7 @@ def compile_directional_target_candidate_v2_9( affine, definition.comparator, Fraction.from_float(definition.threshold) - if definition.measurement - is RelationMeasurementV2.CAMERA_DEPTH_DELTA + if definition.measurement is RelationMeasurement.CAMERA_DEPTH_DELTA else Fraction(), inner=False, atomic_budget=atomic_budget, @@ -351,7 +349,7 @@ def _strict_inputs( sine=camera.sine, cosine=camera.cosine, ) - replayed = prepare_camera_independent_candidate_problem_v2_9(original) + replayed = prepare_camera_independent_candidate_problem(original) if replayed != projected: raise ValueError("projected candidate problem does not match the original") if checked_artifact.semantic_problem_sha256 != projected.semantic_problem_sha256: @@ -383,22 +381,22 @@ def _extract_target( if item.relation is target.relation_after ) expected_measurement = { - RelationV2.LEFT: RelationMeasurementV2.PROJECTED_CENTER_DELTA_X, - RelationV2.RIGHT: RelationMeasurementV2.PROJECTED_CENTER_DELTA_X, - RelationV2.FRONT: RelationMeasurementV2.CAMERA_DEPTH_DELTA, - RelationV2.BEHIND: RelationMeasurementV2.CAMERA_DEPTH_DELTA, - RelationV2.NEAR: RelationMeasurementV2.SHAPE_GAP_XY, - RelationV2.FAR: RelationMeasurementV2.SHAPE_GAP_XY, + Relation.LEFT: RelationMeasurement.PROJECTED_CENTER_DELTA_X, + Relation.RIGHT: RelationMeasurement.PROJECTED_CENTER_DELTA_X, + Relation.FRONT: RelationMeasurement.CAMERA_DEPTH_DELTA, + Relation.BEHIND: RelationMeasurement.CAMERA_DEPTH_DELTA, + Relation.NEAR: RelationMeasurement.SHAPE_GAP_XY, + Relation.FAR: RelationMeasurement.SHAPE_GAP_XY, }[target.relation_after] expected_comparator = ( - MeasurementComparatorV2.LESS_THAN - if target.relation_after in (RelationV2.LEFT, RelationV2.FRONT, RelationV2.NEAR) - else MeasurementComparatorV2.GREATER_THAN + MeasurementComparator.LESS_THAN + if target.relation_after in (Relation.LEFT, Relation.FRONT, Relation.NEAR) + else MeasurementComparator.GREATER_THAN ) if ( definition.measurement is not expected_measurement or definition.comparator is not expected_comparator - or definition.boundary_policy is not BoundaryPolicyV2.CLOSED + or definition.boundary_policy is not BoundaryPolicy.CLOSED or definition.tolerance != 0.0 or original.numeric_policy != NumericPolicyV2() or target.camera_id != camera.camera_id @@ -477,8 +475,8 @@ def _extract_target( def _directional_affine( - relation: RelationV2, - measurement: RelationMeasurementV2, + relation: Relation, + measurement: RelationMeasurement, threshold: Fraction, subject: tuple[Fraction, Fraction, Fraction], reference: tuple[Fraction, Fraction, Fraction], @@ -489,7 +487,7 @@ def _directional_affine( universe_bounds = artifact.search_universe.bounds if universe_bounds is None: raise RuntimeError("T15 search universe cannot be empty") - if measurement is RelationMeasurementV2.CAMERA_DEPTH_DELTA: + if measurement is RelationMeasurement.CAMERA_DEPTH_DELTA: # Camera depth is affine over world XY for every upright azimuth. It # remains meaningful outside the positive-depth half-space; the # following visibility stage independently enforces near/far clipping @@ -506,14 +504,14 @@ def _directional_affine( y=camera.cosine, constant=constant, ) - subject_bounds = bound_world_point_in_upright_camera_v2_9( + subject_bounds = bound_world_point_in_upright_camera( camera, world_xyz=subject, delta_x=(universe_bounds[0], universe_bounds[2]), delta_y=(universe_bounds[1], universe_bounds[3]), atomic_budget=budget, ) - reference_bounds = bound_world_point_in_upright_camera_v2_9( + reference_bounds = bound_world_point_in_upright_camera( camera, world_xyz=reference, delta_x=(Fraction(), Fraction()), @@ -524,7 +522,7 @@ def _directional_affine( raise _UnsupportedDirectionalTargetV2( "UNSUPPORTED_MODEL:CONTINUOUS_YAW_DIRECTIONAL_TARGET_DEPTH" ) - if measurement is not RelationMeasurementV2.PROJECTED_CENTER_DELTA_X: + if measurement is not RelationMeasurement.PROJECTED_CENTER_DELTA_X: raise RuntimeError("directional target escaped its measurement partition") fx = camera.intrinsics[0] if fx <= 0: @@ -568,7 +566,7 @@ def _directional_affine( budget, ) constant = _subtract(subject_x0, _multiply(k, subject_z0, budget), budget) - if relation not in (RelationV2.LEFT, RelationV2.RIGHT): + if relation not in (Relation.LEFT, Relation.RIGHT): raise RuntimeError("horizontal affine received a non-horizontal relation") return _AffineIntervalV2( x=x_coefficient, @@ -580,7 +578,7 @@ def _directional_affine( def _clip_bracket_complex( upstream: StrictConvexIntersectionComplexV2, affine: _AffineIntervalV2, - comparator: MeasurementComparatorV2, + comparator: MeasurementComparator, threshold: Fraction, *, inner: bool, @@ -619,7 +617,7 @@ def _clip_bracket_complex( ) lower_x, upper_x = _coefficient_for_sign(affine.x, x_nonnegative) lower_y, upper_y = _coefficient_for_sign(affine.y, y_nonnegative) - if comparator is MeasurementComparatorV2.LESS_THAN: + if comparator is MeasurementComparator.LESS_THAN: coefficients = ( (upper_x, upper_y, affine.constant[1]) if inner @@ -632,7 +630,7 @@ def _clip_bracket_complex( RationalHalfPlaneRelationV2.LE, atomic_budget, ) - elif comparator is MeasurementComparatorV2.GREATER_THAN: + elif comparator is MeasurementComparator.GREATER_THAN: coefficients = ( (lower_x, lower_y, affine.constant[0]) if inner diff --git a/src/spatialcf/core/v2/continuous_yaw_relation_damage.py b/src/spatialcf/core/_internal/objective/relation_damage.py similarity index 94% rename from src/spatialcf/core/v2/continuous_yaw_relation_damage.py rename to src/spatialcf/core/_internal/objective/relation_damage.py index 5983201..d1c659e 100644 --- a/src/spatialcf/core/v2/continuous_yaw_relation_damage.py +++ b/src/spatialcf/core/_internal/objective/relation_damage.py @@ -8,65 +8,65 @@ from enum import StrEnum from fractions import Fraction -from spatialcf.core.v2.continuous_yaw_camera_frame import ( - UprightCameraContextV2_9, - bound_world_point_in_upright_camera_v2_9, - compile_upright_camera_context_v2_9, -) -from spatialcf.core.v2.continuous_yaw_visibility_v2_9 import ( - ContinuousYawVisibilityStageV2_9, - VisibilityCellMetricBoundsV2_9, -) -from spatialcf.core.v2.continuous_yaw_visibility_v2_9 import ( - _copy_stage as _copy_visibility_stage, -) -from spatialcf.core.v2.convex_translation_domain import RationalPoint2V2 -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( +from spatialcf.core._internal.compilation.collision import ( MultiObstacleStrictConvexCandidateResourceUsageV2, _artifact_bytes, _require_finding_codes, ) -from spatialcf.core.v2.oriented_upright_box import ( +from spatialcf.core._internal.kernels.convex_translation import RationalPoint2V2 +from spatialcf.core._internal.kernels.so2 import ( + SO2AtomicBudgetExhaustedV2, + SO2AtomicBudgetV2, + SO2IntervalKindV2, +) +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionCellV2, +) +from spatialcf.core._internal.kernels.upright_box import ( OrientedUprightBoxBoundsV2, compile_oriented_upright_box_bounds_v2, ) -from spatialcf.core.v2.relation_cost_partition import ( +from spatialcf.core._internal.objective.relation_partition import ( _axis_definitions, _distance_label_set, _linear_label_set, ) -from spatialcf.core.v2.so2_interval import ( - SO2AtomicBudgetExhaustedV2, - SO2AtomicBudgetV2, - SO2IntervalKindV2, +from spatialcf.core._internal.objective.visibility import ( + ContinuousYawVisibilityStageV2_9, + VisibilityCellMetricBoundsV2_9, ) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionCellV2, +from spatialcf.core._internal.objective.visibility import ( + _copy_stage as _copy_visibility_stage, ) -from spatialcf.domain.v2.artifacts import RelationDamageBoundV2 -from spatialcf.domain.v2.base import ( +from spatialcf.core.problem import ( + UprightCameraContextV2_9, + bound_world_point_in_upright_camera, + compile_upright_camera_context, +) +from spatialcf.domain.artifacts import ( + CanonicalObjectV2_2, + GeometryInstanceV2_2, + RelationDamageBoundV2, +) +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, NumericPolicyV2, UncertaintyBudgetV2, - Vec3V2, -) -from spatialcf.domain.v2.constraints import RelationAxisV2, RelationDefinitionV2 -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - CanonicalObjectV2_2, - GeometryInstanceV2_2, + Vec3, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.constraints import RelationAxis, RelationDefinition +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, GeometryApproximationV2, GeometryRoleV2, UprightBox3DV2, ) -from spatialcf.domain.v2.objective import PairAxisKeyV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 +from spatialcf.domain.objective import PairAxisKeyV2 +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.serialization import canonical_json_bytes _STAGE_HASH_DOMAIN_V2_9 = b"spatialcf.continuous-yaw-relation-damage.v2.9\0" _IntervalV2 = tuple[Fraction, Fraction] @@ -158,7 +158,7 @@ def stage_sha256(self) -> str: for cell in self.cells: digest.update(_artifact_bytes(cell.cell)) # type: ignore[arg-type] for bound in cell.vector: - digest.update(canonical_json_bytes_v2(bound)) + digest.update(canonical_json_bytes(bound)) digest.update(_artifact_bytes(self.resource_usage)) # type: ignore[arg-type] return digest.hexdigest() @@ -219,7 +219,7 @@ def compile_relation_damage_bounds_v2_9( ) with warnings.catch_warnings(): warnings.simplefilter("error", Warning) - camera = compile_upright_camera_context_v2_9( + camera = compile_upright_camera_context( checked_problem, atomic_budget=atomic_budget, domain_budget=intersection_budget, @@ -326,7 +326,7 @@ def evaluate_relation_damage_point_v2_9( key=lambda item: item.key.sort_key, ) ) - camera = compile_upright_camera_context_v2_9( + camera = compile_upright_camera_context( checked_problem, atomic_budget=atomic_budget, domain_budget=intersection_budget, @@ -482,7 +482,7 @@ def _bound_key( budget: SO2AtomicBudgetV2, ) -> RelationDamageBoundV2: vertices = cell.closure_polygon.vertices_ccw - if key.axis in (RelationAxisV2.HORIZONTAL, RelationAxisV2.DEPTH): + if key.axis in (RelationAxis.HORIZONTAL, RelationAxis.DEPTH): definitions = _axis_definitions(context.problem, key.axis) if definitions is None or not _visibility_gate( context.problem, visibility, cell.cell_id, key, definitions @@ -524,7 +524,7 @@ def _bound_key_for_delta( ): return RelationDamageBoundV2(key=key, lower_bound=0.0, upper_bound=1.0) _require_key_objects(context, key) - if key.axis in (RelationAxisV2.HORIZONTAL, RelationAxisV2.DEPTH): + if key.axis in (RelationAxis.HORIZONTAL, RelationAxis.DEPTH): baseline = _linear_measurement( context, key, (Fraction(), Fraction()), (Fraction(), Fraction()), budget ) @@ -576,7 +576,7 @@ def _linear_measurement_over_convex_cell( def _linear_damage_bound( key: PairAxisKeyV2, - definitions: tuple[RelationDefinitionV2, ...], + definitions: tuple[RelationDefinition, ...], baseline: _IntervalV2, candidate: _IntervalV2, ) -> RelationDamageBoundV2: @@ -608,18 +608,18 @@ def _linear_measurement( geometry = context.geometries[object_id] centroid = _centroid(object_, geometry) moving = object_id == context.subject_id - point = bound_world_point_in_upright_camera_v2_9( + point = bound_world_point_in_upright_camera( context.camera, world_xyz=centroid, delta_x=delta_x if moving else (Fraction(), Fraction()), delta_y=delta_y if moving else (Fraction(), Fraction()), atomic_budget=budget, ) - if key.axis is RelationAxisV2.HORIZONTAL and not point.positive_depth: + if key.axis is RelationAxis.HORIZONTAL and not point.positive_depth: raise _UnsupportedRelationDamageV2_9( "UNSUPPORTED_MODEL:RELATION_DAMAGE_DEPTH_V2_9" ) - if key.axis is RelationAxisV2.HORIZONTAL: + if key.axis is RelationAxis.HORIZONTAL: projected = _add_exact( _scale_exact( _divide_positive(point.x_camera, point.z_camera, budget), @@ -682,7 +682,7 @@ def _oriented_relation_box( ) -> OrientedUprightBoxBoundsV2: transform = object_.pose.world_from_object shifted = DirectedYawIntervalTransformV2_2( - translation=Vec3V2( + translation=Vec3( x=transform.translation.x, y=transform.translation.y, z=transform.translation.z + geometry.anchor_from_geometry.translation.z, diff --git a/src/spatialcf/core/v2/relation_cost_partition.py b/src/spatialcf/core/_internal/objective/relation_partition.py similarity index 93% rename from src/spatialcf/core/v2/relation_cost_partition.py rename to src/spatialcf/core/_internal/objective/relation_partition.py index 26b554b..1609ff0 100644 --- a/src/spatialcf/core/v2/relation_cost_partition.py +++ b/src/spatialcf/core/_internal/objective/relation_partition.py @@ -15,10 +15,11 @@ from pydantic import ValidationError -from spatialcf.core.v2._internal.resources.domain_operations import ( - RemainingDomainOperationBudgetV2, +from spatialcf.core._internal.compilation.visibility import ( + VisibilityDomainKindV2, + compile_visibility_domain_v2, ) -from spatialcf.core.v2.rectilinear_kernel import ( +from spatialcf.core._internal.kernels.rectilinear import ( RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M, RECTILINEAR_KERNEL_ID_V2, RECTILINEAR_KERNEL_VERSION_V2, @@ -31,11 +32,10 @@ lift_planar_region_v2, normalize_rectilinear_region_v2, ) -from spatialcf.core.v2.visibility_domain import ( - VisibilityDomainKindV2, - compile_visibility_domain_v2, +from spatialcf.core._internal.resources import ( + RemainingDomainOperationBudgetV2, ) -from spatialcf.domain.v2.artifacts import ( +from spatialcf.domain.artifacts import ( ArtifactCoverageV2, CandidateCompilationCoverageV2, CandidateConstraintKindV2, @@ -49,47 +49,47 @@ RelationCostPartitionV2, RelationDamageBoundV2, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, FactSetV2, NumericPolicyV2, - QuaternionV2, + Quaternion, RigidTransformV2, UncertaintyBudgetV2, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - MeasurementComparatorV2, - MeasurementOperandOrderV2, - RelationAxisV2, - RelationDefinitionV2, - RelationMeasurementV2, - RelationRepresentativePointV2, - RelationV2, - VisibilityConstraintV2, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + MeasurementComparator, + MeasurementOperandOrder, + Relation, + RelationAxis, + RelationDefinition, + RelationMeasurement, + RelationRepresentativePoint, + VisibilityConstraint, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.geometry import ( GeometryApproximationV2, GeometryInstanceV2, GeometryRoleV2, UprightBox3DV2, ) -from spatialcf.domain.v2.objective import PairAxisKeyV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( +from spatialcf.domain.objective import PairAxisKeyV2 +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.result import ( CoreSolverConfigV2, DirectedOutwardGeometryKernelSpecV2, UncertifiedReasonV2, ) -from spatialcf.domain.v2.scene import ( - CameraAxesV2, - CameraDepthConventionV2, - CameraDistortionModelV2, - CameraMatrixLayoutV2, - CameraPixelConventionV2, - CanonicalObjectV2, - PinholeCameraV2, +from spatialcf.domain.scene import ( + CameraAxes, + CameraDepthConvention, + CameraDistortionModel, + CameraMatrixLayout, + CameraPixelConvention, + CanonicalObject, + PinholeCamera, ) RELATION_COST_PARTITION_ALGORITHM_ID_V2 = "solver:canonical-branch-and-bound-v2" @@ -169,9 +169,9 @@ class _ExactContextV2: candidate: CandidateDomainArtifactV2 outer_domain: ExactRectilinearRegionV2 subject_id: str - objects: dict[str, CanonicalObjectV2] + objects: dict[str, CanonicalObject] relation_geometries: dict[str, GeometryInstanceV2] - camera: PinholeCameraV2 + camera: PinholeCamera visibility_proofs: _VisibilityProofStateV2 @@ -789,7 +789,7 @@ def _exact_damage_indicator( return None try: - if key.axis is RelationAxisV2.HORIZONTAL: + if key.axis is RelationAxis.HORIZONTAL: baseline, candidate_interval = _horizontal_intervals( context, key, @@ -804,7 +804,7 @@ def _exact_damage_indicator( candidate_interval[0], candidate_interval[1], ) - elif key.axis is RelationAxisV2.DEPTH: + elif key.axis is RelationAxis.DEPTH: baseline = ( _centroid(first, first_geometry)[2] - _centroid(second, second_geometry)[2] @@ -839,33 +839,33 @@ def _exact_damage_indicator( def _axis_definitions( problem: SemanticProblemV2, - axis: RelationAxisV2, -) -> tuple[RelationDefinitionV2, ...] | None: + axis: RelationAxis, +) -> tuple[RelationDefinition, ...] | None: definitions = tuple( item for item in problem.relation_semantics.definitions if item.relation.axis is axis ) expected_measurement = { - RelationAxisV2.HORIZONTAL: RelationMeasurementV2.PROJECTED_CENTER_DELTA_X, - RelationAxisV2.DEPTH: RelationMeasurementV2.CAMERA_DEPTH_DELTA, - RelationAxisV2.DISTANCE: RelationMeasurementV2.SHAPE_GAP_XY, + RelationAxis.HORIZONTAL: RelationMeasurement.PROJECTED_CENTER_DELTA_X, + RelationAxis.DEPTH: RelationMeasurement.CAMERA_DEPTH_DELTA, + RelationAxis.DISTANCE: RelationMeasurement.SHAPE_GAP_XY, }[axis] if len(definitions) != 2 or any( item.measurement is not expected_measurement - or item.operand_order is not MeasurementOperandOrderV2.FIRST_MINUS_SECOND - or item.boundary_policy is not BoundaryPolicyV2.CLOSED + or item.operand_order is not MeasurementOperandOrder.FIRST_MINUS_SECOND + or item.boundary_policy is not BoundaryPolicy.CLOSED or item.tolerance != 0.0 for item in definitions ): return None - if axis is not RelationAxisV2.DISTANCE and any( + if axis is not RelationAxis.DISTANCE and any( item.representative_point - is not RelationRepresentativePointV2.RELATION_GEOMETRY_VOLUME_CENTROID + is not RelationRepresentativePoint.RELATION_GEOMETRY_VOLUME_CENTROID for item in definitions ): return None - if axis is RelationAxisV2.DISTANCE and any( + if axis is RelationAxis.DISTANCE and any( item.representative_point is not None for item in definitions ): return None @@ -875,7 +875,7 @@ def _axis_definitions( def _visibility_gate_holds( context: _ExactContextV2, key: PairAxisKeyV2, - definitions: tuple[RelationDefinitionV2, ...], + definitions: tuple[RelationDefinition, ...], ) -> bool: if not any(item.requires_both_visible for item in definitions): return True @@ -904,7 +904,7 @@ def _endpoint_visibility_is_proven( def _qualifying_visibility_constraints( context: _ExactContextV2, object_id: str, -) -> tuple[VisibilityConstraintV2, ...]: +) -> tuple[VisibilityConstraint, ...]: return tuple( sorted( ( @@ -922,14 +922,14 @@ def _qualifying_visibility_constraints( def _baseline_visibility_worst_case_passes( problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, object_id: str, ) -> bool: facts = problem.scene.baseline_observations if ( not _facts_are_exact_zero(facts) or FactCompletenessV2.EXACT not in constraint.accepted_baseline_completeness - or constraint.threshold_boundary_policy is not BoundaryPolicyV2.CLOSED + or constraint.threshold_boundary_policy is not BoundaryPolicy.CLOSED ): return False observations = { @@ -980,7 +980,7 @@ def _baseline_visibility_worst_case_passes( def _after_visibility_is_proven( context: _ExactContextV2, - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, ) -> bool: state = context.visibility_proofs cache_key = (constraint.constraint_id, context.outer_domain) @@ -1024,9 +1024,9 @@ def _after_visibility_is_proven( def _horizontal_intervals( context: _ExactContextV2, key: PairAxisKeyV2, - first: CanonicalObjectV2, + first: CanonicalObject, first_geometry: GeometryInstanceV2, - second: CanonicalObjectV2, + second: CanonicalObject, second_geometry: GeometryInstanceV2, ) -> tuple[Fraction, tuple[Fraction, Fraction]]: first_center = _centroid(first, first_geometry) @@ -1063,9 +1063,9 @@ def _horizontal_intervals( def _distance_squared_intervals( context: _ExactContextV2, - first: CanonicalObjectV2, + first: CanonicalObject, first_geometry: GeometryInstanceV2, - second: CanonicalObjectV2, + second: CanonicalObject, second_geometry: GeometryInstanceV2, ) -> tuple[Fraction, tuple[Fraction, Fraction]]: if first.object_id == context.subject_id: @@ -1134,15 +1134,15 @@ def _axis_aligned_gap_squared( def _linear_label_set( - definitions: tuple[RelationDefinitionV2, ...], + definitions: tuple[RelationDefinition, ...], lower: Fraction, upper: Fraction, -) -> frozenset[RelationV2] | None: - labels: set[RelationV2] = set() +) -> frozenset[Relation] | None: + labels: set[Relation] = set() for definition in definitions: threshold = Fraction.from_float(definition.threshold) _require_fraction_size(threshold) - if definition.comparator is MeasurementComparatorV2.LESS_THAN: + if definition.comparator is MeasurementComparator.LESS_THAN: always_true = upper <= threshold always_false = lower > threshold else: @@ -1156,15 +1156,15 @@ def _linear_label_set( def _distance_label_set( - definitions: tuple[RelationDefinitionV2, ...], + definitions: tuple[RelationDefinition, ...], lower_squared: Fraction, upper_squared: Fraction, -) -> frozenset[RelationV2] | None: - labels: set[RelationV2] = set() +) -> frozenset[Relation] | None: + labels: set[Relation] = set() for definition in definitions: threshold = Fraction.from_float(definition.threshold) _require_fraction_size(threshold) - if definition.comparator is MeasurementComparatorV2.LESS_THAN: + if definition.comparator is MeasurementComparator.LESS_THAN: if threshold < 0: always_true, always_false = False, True else: @@ -1186,7 +1186,7 @@ def _distance_label_set( def _centroid( - object_: CanonicalObjectV2, + object_: CanonicalObject, geometry: GeometryInstanceV2, ) -> tuple[Fraction, Fraction, Fraction]: object_translation = object_.pose.world_from_object.translation @@ -1205,7 +1205,7 @@ def _centroid( def _world_xy_bounds( - object_: CanonicalObjectV2, + object_: CanonicalObject, geometry: GeometryInstanceV2, ) -> tuple[Fraction, Fraction, Fraction, Fraction]: if not isinstance(geometry.shape, UprightBox3DV2): @@ -1248,7 +1248,7 @@ def _interval_interval_distance( def _object_geometry_is_exact_identity( - object_: CanonicalObjectV2, + object_: CanonicalObject, geometry: GeometryInstanceV2, ) -> bool: return ( @@ -1262,20 +1262,20 @@ def _object_geometry_is_exact_identity( ) -def _camera_is_exact_identity(camera: PinholeCameraV2) -> bool: +def _camera_is_exact_identity(camera: PinholeCamera) -> bool: intrinsics = camera.intrinsics_row_major return ( _is_identity_transform(camera.world_to_camera) - and camera.distortion_model is CameraDistortionModelV2.NONE + and camera.distortion_model is CameraDistortionModel.NONE and camera.brown_conrady_coefficients is None and _uncertainty_is_zero(camera.calibration_uncertainty) and intrinsics[1] == 0.0 and intrinsics[3] == 0.0 and intrinsics[6:] == (0.0, 0.0, 1.0) - and camera.matrix_layout is CameraMatrixLayoutV2.ROW_MAJOR - and camera.camera_axes is CameraAxesV2.X_RIGHT_Y_DOWN_Z_FORWARD - and camera.pixel_convention is CameraPixelConventionV2.CENTER_AT_HALF - and camera.depth_convention is CameraDepthConventionV2.POSITIVE_Z_FORWARD + and camera.matrix_layout is CameraMatrixLayout.ROW_MAJOR + and camera.camera_axes is CameraAxes.X_RIGHT_Y_DOWN_Z_FORWARD + and camera.pixel_convention is CameraPixelConvention.CENTER_AT_HALF + and camera.depth_convention is CameraDepthConvention.POSITIVE_Z_FORWARD ) @@ -1289,7 +1289,7 @@ def _facts_are_exact_zero(facts: FactSetV2) -> bool: ) -def _is_identity_rotation(rotation: QuaternionV2) -> bool: +def _is_identity_rotation(rotation: Quaternion) -> bool: return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) diff --git a/src/spatialcf/core/v2/continuous_yaw_safety_v2_9.py b/src/spatialcf/core/_internal/objective/safety.py similarity index 95% rename from src/spatialcf/core/v2/continuous_yaw_safety_v2_9.py rename to src/spatialcf/core/_internal/objective/safety.py index 7a748a8..730606d 100644 --- a/src/spatialcf/core/v2/continuous_yaw_safety_v2_9.py +++ b/src/spatialcf/core/_internal/objective/safety.py @@ -9,25 +9,27 @@ from enum import StrEnum from fractions import Fraction -from spatialcf.core.v2.continuous_yaw_camera_frame import ( - compile_upright_camera_context_v2_9, +from spatialcf.core._internal.compilation.collision import ( + MultiObstacleStrictConvexCandidateResourceUsageV2, + _require_finding_codes, ) -from spatialcf.core.v2.continuous_yaw_relation_damage import ( - ContinuousYawRelationDamageStageV2_9, - RelationDamageCellV2_9, - _distance_squared, - _linear_measurement, - _relation_context, +from spatialcf.core._internal.kernels.convex_translation import RationalPoint2V2 +from spatialcf.core._internal.kernels.rect import ExactAxisAlignedRectV2 +from spatialcf.core._internal.kernels.so2 import ( + SO2AtomicBudgetExhaustedV2, + SO2AtomicBudgetV2, + SO2IntervalKindV2, ) -from spatialcf.core.v2.continuous_yaw_visibility_v2_9 import ( - ContinuousYawVisibilityStageV2_9, +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionCellV2, ) -from spatialcf.core.v2.convex_translation_domain import RationalPoint2V2 -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - MultiObstacleStrictConvexCandidateResourceUsageV2, - _require_finding_codes, +from spatialcf.core._internal.kernels.upright_box import ( + compile_oriented_upright_box_bounds_v2, + compile_oriented_upright_box_pair_bounds_v2, ) -from spatialcf.core.v2.objective_numeric import ( +from spatialcf.core._internal.objective.numeric import ( ConstraintSafetyInputV2, ExactPublishedIntervalV2, ObjectiveNumericKindV2, @@ -38,47 +40,47 @@ aggregate_safety_penalty_bounds_v2, compile_constraint_slack_bounds_v2, ) -from spatialcf.core.v2.objective_safety_bounds import ConstraintSlackBoundsV2 -from spatialcf.core.v2.oriented_upright_box import ( - compile_oriented_upright_box_bounds_v2, - compile_oriented_upright_box_pair_bounds_v2, +from spatialcf.core._internal.objective.relation_damage import ( + ContinuousYawRelationDamageStageV2_9, + RelationDamageCellV2_9, + _distance_squared, + _linear_measurement, + _relation_context, ) -from spatialcf.core.v2.rect_kernel import ExactAxisAlignedRectV2 -from spatialcf.core.v2.so2_interval import ( - SO2AtomicBudgetExhaustedV2, - SO2AtomicBudgetV2, - SO2IntervalKindV2, +from spatialcf.core._internal.objective.safety_bounds import ConstraintSlackBoundsV2 +from spatialcf.core._internal.objective.visibility import ( + ContinuousYawVisibilityStageV2_9, ) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionCellV2, +from spatialcf.core.problem import ( + compile_upright_camera_context, ) -from spatialcf.domain.v2.artifacts import ConstraintSlackV2, NonNegativeIntervalV2 -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - CollisionClearanceMetricV2, - MeasurementComparatorV2, - PositionRegionInterpretationV2, - RegionAggregationV2, - RelationAxisV2, +from spatialcf.domain.artifacts import ( + ConstraintSlackV2, + GeometryInstanceV2_2, + NonNegativeIntervalV2, ) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import GeometryInstanceV2_2 -from spatialcf.domain.v2.geometry import UprightBox3DV2 -from spatialcf.domain.v2.objective import ( - PairAxisKeyV2, - SafetyComponentKindV2, - SafetySlackUnitV2, +from spatialcf.domain.base import UncertaintyBudgetV2, Vec3 +from spatialcf.domain.constraints import ( + BoundaryPolicy, + CollisionClearanceMetric, + MeasurementComparator, + PositionRegionInterpretation, + RegionAggregation, + RelationAxis, ) -from spatialcf.domain.v2.scene import ( +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, GeometryApproximationV2, GeometryRoleV2, - RegionBoundaryPolicyV2, - UncertaintyBudgetV2, - Vec3V2, + UprightBox3DV2, +) +from spatialcf.domain.objective import ( + PairAxisKeyV2, + SafetyComponentKindV2, + SafetySlackUnitV2, ) +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.scene import RegionBoundaryPolicy _STAGE_HASH_DOMAIN_V2_9 = b"spatialcf.continuous-yaw-safety.v2.9\0" _IntervalV2 = tuple[Fraction, Fraction] @@ -258,7 +260,7 @@ def compile_continuous_yaw_safety_bounds_v2_9( ) with warnings.catch_warnings(): warnings.simplefilter("error", Warning) - camera = compile_upright_camera_context_v2_9( + camera = compile_upright_camera_context( problem, atomic_budget=atomic_budget, domain_budget=intersection_budget, @@ -332,7 +334,7 @@ def evaluate_continuous_yaw_safety_point_v2_9( ) if not matching: raise ValueError("safety point escaped the relation outer partition") - camera = compile_upright_camera_context_v2_9( + camera = compile_upright_camera_context( problem, atomic_budget=atomic_budget, domain_budget=intersection_budget, @@ -521,10 +523,10 @@ def _raw_components( def _position_components(problem, target, dx, dy, *, cell): constraint = problem.constraints.position_domain if ( - constraint.boundary_policy is not BoundaryPolicyV2.CLOSED + constraint.boundary_policy is not BoundaryPolicy.CLOSED or constraint.region_interpretation - is not PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS - or constraint.workspace_aggregation is not RegionAggregationV2.INTERSECTION + is not PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS + or constraint.workspace_aggregation is not RegionAggregation.INTERSECTION or len(constraint.workspace_fact_ids) != 1 or constraint.known_free_space_fact_ids ): @@ -536,7 +538,7 @@ def _position_components(problem, target, dx, dy, *, cell): ) if ( workspace.region_approximation is not GeometryApproximationV2.EXACT - or workspace.boundary_policy is not RegionBoundaryPolicyV2.CLOSED + or workspace.boundary_policy is not RegionBoundaryPolicy.CLOSED or workspace.geometry_uncertainty != UncertaintyBudgetV2() ): raise _UnsupportedSafetyV2_9("UNSUPPORTED_MODEL:POSITION_SAFETY_WORKSPACE_V2_9") @@ -639,8 +641,8 @@ def value(point: RationalPoint2V2) -> Fraction: def _collision_components(problem, constraint, target, dx, dy, budget): if ( constraint.clearance_metric - is not CollisionClearanceMetricV2.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE - or constraint.boundary_policy is not BoundaryPolicyV2.CLOSED + is not CollisionClearanceMetric.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE + or constraint.boundary_policy is not BoundaryPolicy.CLOSED or constraint.minimum_clearance_m != 0.0 or constraint.support_contact_exceptions ): @@ -671,7 +673,7 @@ def _collision_components(problem, constraint, target, dx, dy, budget): if not math.isfinite(rounded_x) or not math.isfinite(rounded_y): raise ArithmeticError("collision safety point translation overflow") moved = DirectedYawIntervalTransformV2_2( - translation=Vec3V2( + translation=Vec3( x=rounded_x, y=rounded_y, z=subject_transform.translation.z, @@ -773,7 +775,7 @@ def _collision_operand(problem, body): or geometry.approximation is not GeometryApproximationV2.EXACT or geometry.uncertainty != UncertaintyBudgetV2() or type(geometry.shape) is not UprightBox3DV2 - or geometry.anchor_from_geometry.translation != Vec3V2(x=0.0, y=0.0, z=0.0) + or geometry.anchor_from_geometry.translation != Vec3(x=0.0, y=0.0, z=0.0) or geometry.anchor_from_geometry.yaw_radians != 0.0 or type(object_.pose.world_from_object) is not DirectedYawIntervalTransformV2_2 ): @@ -801,7 +803,7 @@ def _support_components( owner.pose.world_from_object if owner is not None else DirectedYawIntervalTransformV2_2( - translation=Vec3V2(x=0.0, y=0.0, z=0.0), + translation=Vec3(x=0.0, y=0.0, z=0.0), yaw_radians=0.0, ) ) @@ -811,13 +813,13 @@ def _support_components( or geometry.approximation is not GeometryApproximationV2.EXACT or geometry.uncertainty != UncertaintyBudgetV2() or type(geometry.shape) is not UprightBox3DV2 - or geometry.anchor_from_geometry.translation != Vec3V2(x=0.0, y=0.0, z=0.0) + or geometry.anchor_from_geometry.translation != Vec3(x=0.0, y=0.0, z=0.0) or geometry.anchor_from_geometry.yaw_radians != 0.0 or type(subject.pose.world_from_object) is not DirectedYawIntervalTransformV2_2 or type(owner_transform) is not DirectedYawIntervalTransformV2_2 or owner_transform.yaw_radians != 0.0 or surface.region_approximation is not GeometryApproximationV2.EXACT - or surface.boundary_policy is not RegionBoundaryPolicyV2.CLOSED + or surface.boundary_policy is not RegionBoundaryPolicy.CLOSED or surface.geometry_uncertainty != UncertaintyBudgetV2() or surface.anchor_from_surface.yaw_radians != 0.0 ): @@ -1026,7 +1028,7 @@ def _target_components( second_object_id=target.reference_id, axis=target.relation_after.axis, ) - if key.axis in (RelationAxisV2.HORIZONTAL, RelationAxisV2.DEPTH): + if key.axis in (RelationAxis.HORIZONTAL, RelationAxis.DEPTH): measurement = _linear_measurement(context, key, dx, dy, budget) if key.first_object_id != target.subject_id: measurement = -measurement[1], -measurement[0] @@ -1039,9 +1041,9 @@ def _target_components( raise ArithmeticError("target safety distance overflow") measurement = Fraction.from_float(lower[0]), Fraction.from_float(upper[1]) threshold = Fraction.from_float(definition.threshold) - if definition.comparator is MeasurementComparatorV2.LESS_THAN: + if definition.comparator is MeasurementComparator.LESS_THAN: raw = threshold - measurement[1], threshold - measurement[0] - elif definition.comparator is MeasurementComparatorV2.GREATER_THAN: + elif definition.comparator is MeasurementComparator.GREATER_THAN: raw = measurement[0] - threshold, measurement[1] - threshold else: # pragma: no cover - closed enum raise RuntimeError("target comparator escaped its closed enum") diff --git a/src/spatialcf/core/v2/objective_safety_bounds.py b/src/spatialcf/core/_internal/objective/safety_bounds.py similarity index 95% rename from src/spatialcf/core/v2/objective_safety_bounds.py rename to src/spatialcf/core/_internal/objective/safety_bounds.py index d95b5fc..50b84ea 100644 --- a/src/spatialcf/core/v2/objective_safety_bounds.py +++ b/src/spatialcf/core/_internal/objective/safety_bounds.py @@ -11,20 +11,13 @@ from pydantic import TypeAdapter, ValidationError -from spatialcf.core.v2.objective_numeric import ( - OBJECTIVE_NUMERIC_MAX_FRACTION_BITS_V2, - ExactPublishedIntervalV2, - ObjectiveNumericKindV2, - SafetyRawComponentIntervalV2, - compile_constraint_slack_bounds_v2, -) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels.rect import ( ExactAxisAlignedRectV2, RectCoordinateSpaceV2, RectTopologyV2, UnsupportedRectRegionErrorV2, ) -from spatialcf.core.v2.rectilinear_kernel import ( +from spatialcf.core._internal.kernels.rectilinear import ( ExactRectilinearRegionV2, RectilinearAtomicBudgetExhaustedV2, RectilinearAtomicBudgetV2, @@ -32,45 +25,52 @@ RectilinearTopologyV2, union_rectilinear_regions_v2, ) -from spatialcf.domain.v2.base import ( +from spatialcf.core._internal.objective.numeric import ( + OBJECTIVE_NUMERIC_MAX_FRACTION_BITS_V2, + ExactPublishedIntervalV2, + ObjectiveNumericKindV2, + SafetyRawComponentIntervalV2, + compile_constraint_slack_bounds_v2, +) +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, FactAvailabilityV2, FactCompletenessV2, FactSetV2, NumericPolicyV2, - QuaternionV2, + Quaternion, UncertaintyBudgetV2, - V2Model, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - CollisionClearanceMetricV2, - CollisionConstraintV2, - MeasurementComparatorV2, - PositionRegionInterpretationV2, - RegionAggregationV2, - RelationMeasurementV2, - RelationV2, - SupportConstraintV2, - VisibilityConstraintV2, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + CollisionClearanceMetric, + CollisionConstraint, + MeasurementComparator, + PositionRegionInterpretation, + RegionAggregation, + Relation, + RelationMeasurement, + SupportConstraint, + VisibilityConstraint, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.geometry import ( CollisionBodyFactV2, GeometryApproximationV2, GeometryInstanceV2, GeometryRoleV2, UprightBox3DV2, ) -from spatialcf.domain.v2.objective import ( +from spatialcf.domain.objective import ( ConstraintSafetyTargetV2, SafetyComponentKindV2, SafetySlackUnitV2, ) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import ( - CanonicalObjectV2, - RegionBoundaryPolicyV2, - SupportSurfaceFactV2, +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.scene import ( + CanonicalObject, + RegionBoundaryPolicy, + SupportSurfaceFact, ) _MAX_FINITE_BINARY64_BITS = 0x7FEF_FFFF_FFFF_FFFF @@ -213,7 +213,7 @@ class _EmptySemanticV2(RuntimeError): pass -ModelT = TypeVar("ModelT", bound=V2Model) +ModelT = TypeVar("ModelT", bound=CanonicalModel) def compile_objective_safety_bounds_v2( @@ -431,22 +431,22 @@ def _position_components( ) -> tuple[SafetyRawComponentIntervalV2, ...]: constraint = problem.constraints.position_domain findings: list[str] = [] - if constraint.boundary_policy is not BoundaryPolicyV2.CLOSED: + if constraint.boundary_policy is not BoundaryPolicy.CLOSED: findings.append("UNSUPPORTED_POSITION_SLACK:BOUNDARY_POLICY") if ( constraint.region_interpretation - is not PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS + is not PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS ): findings.append("UNSUPPORTED_POSITION_SLACK:REGION_INTERPRETATION") if ( len(constraint.workspace_fact_ids) != 1 - or constraint.workspace_aggregation is not RegionAggregationV2.INTERSECTION + or constraint.workspace_aggregation is not RegionAggregation.INTERSECTION ): findings.append("UNSUPPORTED_POSITION_SLACK:WORKSPACE_SELECTION") if len(constraint.known_free_space_fact_ids) > 1 or ( constraint.known_free_space_fact_ids and constraint.known_free_space_aggregation - is not RegionAggregationV2.INTERSECTION + is not RegionAggregation.INTERSECTION ): findings.append("UNSUPPORTED_POSITION_SLACK:FREE_SPACE_SELECTION") findings.extend( @@ -464,7 +464,7 @@ def _position_components( }[constraint.workspace_fact_ids[0]] if ( workspace.region_approximation is not GeometryApproximationV2.EXACT - or workspace.boundary_policy is not RegionBoundaryPolicyV2.CLOSED + or workspace.boundary_policy is not RegionBoundaryPolicy.CLOSED or not _uncertainty_is_zero(workspace.geometry_uncertainty) ): raise _UnsupportedV2("UNSUPPORTED_POSITION_SLACK:WORKSPACE_FACT") @@ -479,7 +479,7 @@ def _position_components( }[constraint.known_free_space_fact_ids[0]] if ( free.region_approximation is not GeometryApproximationV2.EXACT - or free.boundary_policy is not RegionBoundaryPolicyV2.CLOSED + or free.boundary_policy is not RegionBoundaryPolicy.CLOSED or not _uncertainty_is_zero(free.geometry_uncertainty) ): raise _UnsupportedV2("UNSUPPORTED_POSITION_SLACK:FREE_SPACE_FACT") @@ -557,16 +557,16 @@ def _position_components( def _collision_components( problem: SemanticProblemV2, cell: ExactRectilinearRegionV2, - constraint: CollisionConstraintV2, + constraint: CollisionConstraint, budget: RectilinearAtomicBudgetV2, ) -> tuple[SafetyRawComponentIntervalV2, ...]: findings: list[str] = [] if ( constraint.clearance_metric - is not CollisionClearanceMetricV2.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE + is not CollisionClearanceMetric.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE ): findings.append("UNSUPPORTED_COLLISION_SLACK:CLEARANCE_METRIC") - if constraint.boundary_policy is not BoundaryPolicyV2.CLOSED: + if constraint.boundary_policy is not BoundaryPolicy.CLOSED: findings.append("UNSUPPORTED_COLLISION_SLACK:BOUNDARY_POLICY") if constraint.support_contact_exceptions: findings.append("UNSUPPORTED_COLLISION_SLACK:SUPPORT_EXCEPTIONS") @@ -625,10 +625,10 @@ def _collision_components( def _support_components( problem: SemanticProblemV2, cell: ExactRectilinearRegionV2, - constraint: SupportConstraintV2, + constraint: SupportConstraint, ) -> tuple[SafetyRawComponentIntervalV2, ...]: findings: list[str] = [] - if constraint.boundary_policy is not BoundaryPolicyV2.CLOSED: + if constraint.boundary_policy is not BoundaryPolicy.CLOSED: findings.append("UNSUPPORTED_SUPPORT_SLACK:BOUNDARY_POLICY") if len(constraint.subject_contact_geometry_ids) != 1: findings.append("UNSUPPORTED_SUPPORT_SLACK:CONTACT_CARDINALITY") @@ -660,7 +660,7 @@ def _support_components( raise _UnsupportedV2("UNSUPPORTED_SUPPORT_SLACK:CONTACT_GEOMETRY") if ( surface.region_approximation is not GeometryApproximationV2.EXACT - or surface.boundary_policy is not RegionBoundaryPolicyV2.CLOSED + or surface.boundary_policy is not RegionBoundaryPolicy.CLOSED or not _uncertainty_is_zero(surface.geometry_uncertainty) or not _identity_rotation(surface.anchor_from_surface.rotation) or ( @@ -761,7 +761,7 @@ def _support_components( def _visibility_components( - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, ) -> tuple[SafetyRawComponentIntervalV2, ...]: visible = Fraction.from_float(constraint.minimum_visible_fraction) area = Fraction.from_float(constraint.minimum_image_area_fraction) @@ -800,11 +800,11 @@ def _target_relation_components( if item.relation is target.relation_after ) findings: list[str] = [] - if target.relation_after not in {RelationV2.NEAR, RelationV2.FAR}: + if target.relation_after not in {Relation.NEAR, Relation.FAR}: findings.append("UNSUPPORTED_TARGET_SLACK:RELATION") - if definition.measurement is not RelationMeasurementV2.SHAPE_GAP_XY: + if definition.measurement is not RelationMeasurement.SHAPE_GAP_XY: findings.append("UNSUPPORTED_TARGET_SLACK:MEASUREMENT") - if definition.boundary_policy is not BoundaryPolicyV2.CLOSED: + if definition.boundary_policy is not BoundaryPolicy.CLOSED: findings.append("UNSUPPORTED_TARGET_SLACK:BOUNDARY_POLICY") if definition.tolerance != 0.0: findings.append("UNSUPPORTED_TARGET_SLACK:TOLERANCE") @@ -866,7 +866,7 @@ def _target_relation_components( measurement_lower, _ = _sqrt_fraction_bounds(squared_lower) _, measurement_upper = _sqrt_fraction_bounds(squared_upper) threshold = Fraction.from_float(definition.threshold) - if definition.comparator is MeasurementComparatorV2.LESS_THAN: + if definition.comparator is MeasurementComparator.LESS_THAN: raw_lower = threshold - measurement_upper raw_upper = threshold - measurement_lower else: @@ -952,7 +952,7 @@ def _single_body_box( def _world_box( - owner: CanonicalObjectV2 | None, + owner: CanonicalObject | None, geometry: GeometryInstanceV2, ) -> _WorldBoxV2: if not isinstance(geometry.shape, UprightBox3DV2): @@ -988,7 +988,7 @@ def _world_box( def _support_contact_world( - owner: CanonicalObjectV2, + owner: CanonicalObject, geometry: GeometryInstanceV2, ) -> tuple[tuple[Fraction, Fraction, Fraction, Fraction], Fraction]: box = _world_box(owner, geometry) @@ -996,8 +996,8 @@ def _support_contact_world( def _support_surface_world( - surface: SupportSurfaceFactV2, - objects: dict[str, CanonicalObjectV2], + surface: SupportSurfaceFact, + objects: dict[str, CanonicalObject], ) -> tuple[tuple[Fraction, Fraction, Fraction, Fraction], Fraction]: local = _planar_rect_bounds( surface.region_uv, @@ -1188,7 +1188,7 @@ def _family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: return tuple(findings) -def _objects(problem: SemanticProblemV2) -> dict[str, CanonicalObjectV2]: +def _objects(problem: SemanticProblemV2) -> dict[str, CanonicalObject]: return {item.object_id: item for item in problem.scene.objects.values or ()} @@ -1198,7 +1198,7 @@ def _geometries(problem: SemanticProblemV2) -> dict[str, GeometryInstanceV2]: } -def _identity_rotation(rotation: QuaternionV2) -> bool: +def _identity_rotation(rotation: Quaternion) -> bool: return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) diff --git a/src/spatialcf/core/v2/continuous_yaw_visibility_v2_9.py b/src/spatialcf/core/_internal/objective/visibility.py similarity index 95% rename from src/spatialcf/core/v2/continuous_yaw_visibility_v2_9.py rename to src/spatialcf/core/_internal/objective/visibility.py index d5843f0..8a00c1b 100644 --- a/src/spatialcf/core/v2/continuous_yaw_visibility_v2_9.py +++ b/src/spatialcf/core/_internal/objective/visibility.py @@ -11,71 +11,73 @@ from pydantic import ValidationError from pydantic_core import PydanticSerializationError -from spatialcf.core.v2.continuous_yaw_camera_frame import ( - UprightCameraContextV2_9, - bound_world_point_in_upright_camera_v2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - DirectionalTargetCandidateStageV2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - _copy_stage as _copy_target_stage, -) -from spatialcf.core.v2.convex_translation_domain import ( - _directed_world_corner_boxes, -) -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( +from spatialcf.core._internal.compilation.collision import ( MultiObstacleStrictConvexCandidateResourceUsageV2, _artifact_bytes, _copy_intersection_complex, _require_finding_codes, ) -from spatialcf.core.v2.oriented_upright_box import ( - OrientedUprightBoxBoundsV2, - compile_oriented_upright_box_bounds_v2, +from spatialcf.core._internal.compilation.visibility import ( + _visibility_semantics_findings, ) -from spatialcf.core.v2.projected_bounding_box_visibility import ( +from spatialcf.core._internal.kernels.convex_translation import ( + _directed_world_corner_boxes, +) +from spatialcf.core._internal.kernels.projected_visibility import ( projected_bounding_box_area_fraction_lower_bound_v2_9, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, SO2IntervalKindV2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetExhaustedV2, StrictConvexIntersectionBudgetV2, StrictConvexIntersectionCellV2, StrictConvexIntersectionComplexV2, ) -from spatialcf.core.v2.visibility_domain import _visibility_semantics_findings -from spatialcf.domain.v2.base import ( +from spatialcf.core._internal.kernels.upright_box import ( + OrientedUprightBoxBoundsV2, + compile_oriented_upright_box_bounds_v2, +) +from spatialcf.core._internal.objective.relation import ( + DirectionalTargetCandidateStageV2_9, +) +from spatialcf.core._internal.objective.relation import ( + _copy_stage as _copy_target_stage, +) +from spatialcf.core.problem import ( + UprightCameraContextV2_9, + bound_world_point_in_upright_camera, +) +from spatialcf.domain.artifacts import ( + CanonicalObjectV2_2, + GeometryInstanceV2_2, +) +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, NumericPolicyV2, UncertaintyBudgetV2, - Vec3V2, + Vec3, ) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - OccluderSoundnessPolicyV2, - VisibilityConstraintV2, - VisibilityMaskPolicyV2, - VisibilityMetricFormulaV2, - VisibilityMetricKindV2, -) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - CanonicalObjectV2_2, - GeometryInstanceV2_2, +from spatialcf.domain.constraints import ( + BoundaryPolicy, + OccluderSoundnessPolicy, + VisibilityConstraint, + VisibilityMaskPolicy, + VisibilityMetricFormula, + VisibilityMetricKind, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.geometry import ( + DirectedYawIntervalTransformV2_2, GeometryApproximationV2, GeometryRoleV2, UprightBox3DV2, ) -from spatialcf.domain.v2.scene import BaselineObservationV2 +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.scene import BaselineObservation _STAGE_HASH_DOMAIN_V2_9 = b"spatialcf.continuous-yaw-visibility-stage.v2.9\0" _IntervalV2 = tuple[Fraction, Fraction] @@ -529,10 +531,10 @@ def _extract_visibility_subset( camera: UprightCameraContextV2_9, budget: StrictConvexIntersectionBudgetV2, ) -> tuple[ - VisibilityConstraintV2, + VisibilityConstraint, tuple[tuple[CanonicalObjectV2_2, GeometryInstanceV2_2], ...], tuple[VisibilityBaselineMetricBoundsV2_9, ...], - VisibilityMetricFormulaV2, + VisibilityMetricFormula, ]: if ( len(problem.constraints.visibility_constraints) != 1 @@ -546,18 +548,18 @@ def _extract_visibility_subset( if ( constraint.constraint_id != target_stage.remaining_constraint_ids[0] or constraint.camera_id != camera.camera_id - or constraint.threshold_boundary_policy is not BoundaryPolicyV2.CLOSED - or constraint.mask_policy is not VisibilityMaskPolicyV2.FULL_OBJECT + or constraint.threshold_boundary_policy is not BoundaryPolicy.CLOSED + or constraint.mask_policy is not VisibilityMaskPolicy.FULL_OBJECT or constraint.occluder_soundness_policy - is not OccluderSoundnessPolicyV2.EXACT_OR_OUTER_SHAPE_BOUND + is not OccluderSoundnessPolicy.EXACT_OR_OUTER_SHAPE_BOUND or constraint.occluder_geometry_ids or constraint.accepted_baseline_completeness != (FactCompletenessV2.EXACT,) or _visibility_semantics_findings( problem, constraint, supported_image_area_formulas=( - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA, - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA, + VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA, + VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA, ), ) ): @@ -618,7 +620,7 @@ def _extract_visibility_subset( image_area_formula = next( definition.formula for definition in problem.visibility_semantics.definitions - if definition.kind is VisibilityMetricKindV2.IMAGE_AREA_FRACTION + if definition.kind is VisibilityMetricKind.IMAGE_AREA_FRACTION ) return constraint, tuple(pairs), baseline, image_area_formula @@ -644,7 +646,7 @@ def _exact_values( def _baseline_bounds( problem: SemanticProblemV2_3, - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, budget: StrictConvexIntersectionBudgetV2, ) -> tuple[VisibilityBaselineMetricBoundsV2_9, ...]: observations = _exact_values( @@ -658,7 +660,7 @@ def _baseline_bounds( item.metric_definition_version, ): item for item in observations - if type(item) is BaselineObservationV2 + if type(item) is BaselineObservation } specs = ( ( @@ -726,7 +728,7 @@ def _compile_projection_geometry( raise RuntimeError("continuous-yaw object lost its directed transform") anchor = visual.anchor_from_geometry shifted = DirectedYawIntervalTransformV2_2( - translation=Vec3V2( + translation=Vec3( x=transform.translation.x, y=transform.translation.y, z=transform.translation.z + anchor.translation.z, @@ -760,8 +762,8 @@ def _classify_cell( geometries: dict[str, _ProjectionGeometryV2_9], fixed_results: dict[str, _ObjectClassificationV2_9], camera: UprightCameraContextV2_9, - constraint: VisibilityConstraintV2, - image_area_formula: VisibilityMetricFormulaV2, + constraint: VisibilityConstraint, + image_area_formula: VisibilityMetricFormula, budget: SO2AtomicBudgetV2, ) -> tuple[_ProjectionClassV2_9, dict[str, _MetricValuesV2_9]]: vertices = cell.closure_polygon.vertices_ccw @@ -803,8 +805,8 @@ def _classify_object( delta_x: _IntervalV2, delta_y: _IntervalV2, camera: UprightCameraContextV2_9, - constraint: VisibilityConstraintV2, - image_area_formula: VisibilityMetricFormulaV2, + constraint: VisibilityConstraint, + image_area_formula: VisibilityMetricFormula, budget: SO2AtomicBudgetV2, ) -> _ObjectClassificationV2_9: projected_u: list[_IntervalV2] = [] @@ -818,7 +820,7 @@ def _classify_object( for world_x in corner_x: for world_y in corner_y: for z_index, world_z in enumerate(geometry.world_z): - point = bound_world_point_in_upright_camera_v2_9( + point = bound_world_point_in_upright_camera( camera, world_xyz=(world_x, world_y, world_z), delta_x=delta_x, @@ -901,7 +903,7 @@ def _classify_object( and min_v >= top and max_v <= bottom ) - center = bound_world_point_in_upright_camera_v2_9( + center = bound_world_point_in_upright_camera( camera, world_xyz=(geometry.box.center_x, geometry.box.center_y, geometry.box.center_z), delta_x=delta_x, @@ -942,7 +944,7 @@ def _classify_object( def _image_area_fraction_lower_bound_v2_9( - formula: VisibilityMetricFormulaV2, + formula: VisibilityMetricFormula, *, projected_u: tuple[_IntervalV2, ...], projected_v: tuple[_IntervalV2, ...], @@ -956,7 +958,7 @@ def _image_area_fraction_lower_bound_v2_9( ) -> Fraction: if ( formula - is VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA + is VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA ): return min( Fraction(1), @@ -973,7 +975,7 @@ def _image_area_fraction_lower_bound_v2_9( ) if ( formula - is VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA + is VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA ): return min( Fraction(1), diff --git a/src/spatialcf/core/v2/continuous_yaw_visibility_objective_v2_9.py b/src/spatialcf/core/_internal/objective/visibility_objective.py similarity index 97% rename from src/spatialcf/core/v2/continuous_yaw_visibility_objective_v2_9.py rename to src/spatialcf/core/_internal/objective/visibility_objective.py index 465ae87..30781b1 100644 --- a/src/spatialcf/core/v2/continuous_yaw_visibility_objective_v2_9.py +++ b/src/spatialcf/core/_internal/objective/visibility_objective.py @@ -7,32 +7,32 @@ from dataclasses import dataclass from enum import StrEnum -from spatialcf.core.v2.continuous_yaw_safety_v2_9 import ( - ContinuousYawSafetyStageV2_9, -) -from spatialcf.core.v2.continuous_yaw_visibility_v2_9 import ( - ContinuousYawVisibilityStageV2_9, -) -from spatialcf.core.v2.convex_translation_domain import RationalPoint2V2 -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( +from spatialcf.core._internal.compilation.collision import ( MultiObstacleStrictConvexCandidateResourceUsageV2, _require_finding_codes, ) -from spatialcf.core.v2.objective_numeric import ( - ObjectiveNumericKindV2, - VisibilityMetricIntervalV2, - aggregate_visibility_change_bounds_v2, -) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.convex_translation import RationalPoint2V2 +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetExhaustedV2, StrictConvexIntersectionBudgetV2, ) -from spatialcf.domain.v2.artifacts import NonNegativeIntervalV2 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 +from spatialcf.core._internal.objective.numeric import ( + ObjectiveNumericKindV2, + VisibilityMetricIntervalV2, + aggregate_visibility_change_bounds_v2, +) +from spatialcf.core._internal.objective.safety import ( + ContinuousYawSafetyStageV2_9, +) +from spatialcf.core._internal.objective.visibility import ( + ContinuousYawVisibilityStageV2_9, +) +from spatialcf.domain.artifacts import NonNegativeIntervalV2 +from spatialcf.domain.problem import SemanticProblemV2_3 _STAGE_HASH_DOMAIN_V2_9 = b"spatialcf.continuous-yaw-visibility-objective.v2.9\0" diff --git a/src/spatialcf/core/v2/_internal/resources/domain_operations.py b/src/spatialcf/core/_internal/resources.py similarity index 100% rename from src/spatialcf/core/v2/_internal/resources/domain_operations.py rename to src/spatialcf/core/_internal/resources.py diff --git a/src/spatialcf/core/candidate.py b/src/spatialcf/core/candidate.py new file mode 100644 index 0000000..8394ba7 --- /dev/null +++ b/src/spatialcf/core/candidate.py @@ -0,0 +1,103 @@ +"""Public current candidate-domain compilation boundary.""" + +from __future__ import annotations + +import warnings + +from spatialcf.core._internal.compilation.support import ( + SupportStrictConvexCandidateCompilationKindV2, + SupportStrictConvexCandidateCompilationOutcomeV2, + compile_support_strict_convex_candidate_domain_v2_7, +) +from spatialcf.core.problem import ( + prepare_camera_independent_candidate_problem, +) +from spatialcf.domain.constraints import ( + AllowedPositionDomainConstraint, + CollisionConstraint, + SupportConstraint, + TargetRelationConstraint, + VisibilityConstraint, +) +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.solver import ContinuousYawSolverConfigV2_9 + + +def compile_candidate_domain( + problem: SemanticProblemV2_3, + config: ContinuousYawSolverConfigV2_9, +) -> SupportStrictConvexCandidateCompilationOutcomeV2: + """Compile the current camera-independent T15 candidate prefix.""" + + if type(problem) is not SemanticProblemV2_3: + raise TypeError("problem must be an exact SemanticProblemV2_3") + if type(config) is not ContinuousYawSolverConfigV2_9: + raise TypeError("config must be an exact ContinuousYawSolverConfigV2_9") + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + checked_problem = SemanticProblemV2_3.model_validate( + problem.model_dump(mode="python", warnings="error"), strict=True + ) + checked_config = ContinuousYawSolverConfigV2_9.model_validate( + config.model_dump(mode="python", warnings="error"), strict=True + ) + projected = prepare_camera_independent_candidate_problem(checked_problem) + outcome = compile_support_strict_convex_candidate_domain_v2_7( + projected, checked_config.candidate_config + ) + if outcome.kind is SupportStrictConvexCandidateCompilationKindV2.ARTIFACT: + if outcome.artifact is None: + raise RuntimeError("T15 ARTIFACT outcome omitted its artifact") + _require_current_family_partition( + checked_problem, + outcome.artifact.ordered_constraint_ids, + outcome.artifact.remaining_constraint_ids, + ) + return outcome + + +def _require_current_family_partition( + checked_problem: SemanticProblemV2_3, + ordered_constraint_ids: tuple[str, ...], + remaining_constraint_ids: tuple[str, ...], +) -> None: + constraints = checked_problem.constraints + if ( + type(constraints.position_domain) is not AllowedPositionDomainConstraint + or any( + type(constraint) is not CollisionConstraint + for constraint in constraints.collision_constraints + ) + or any( + type(constraint) is not SupportConstraint + for constraint in constraints.support_constraints + ) + or type(constraints.target_relation) is not TargetRelationConstraint + or any( + type(constraint) is not VisibilityConstraint + for constraint in constraints.visibility_constraints + ) + ): + raise RuntimeError("current candidate family order changed") + + expected_ordered = ( + constraints.position_domain.constraint_id, + *(item.constraint_id for item in constraints.collision_constraints), + *(item.constraint_id for item in constraints.support_constraints), + ) + expected_remaining = tuple( + sorted( + ( + constraints.target_relation.constraint_id, + *(item.constraint_id for item in constraints.visibility_constraints), + ) + ) + ) + if ( + ordered_constraint_ids != expected_ordered + or remaining_constraint_ids != expected_remaining + ): + raise RuntimeError("current candidate family order changed") + + +__all__ = ["compile_candidate_domain"] diff --git a/src/spatialcf/core/v2/continuous_yaw_certificate_v2_9.py b/src/spatialcf/core/certificate.py similarity index 95% rename from src/spatialcf/core/v2/continuous_yaw_certificate_v2_9.py rename to src/spatialcf/core/certificate.py index e27016d..5518bd2 100644 --- a/src/spatialcf/core/v2/continuous_yaw_certificate_v2_9.py +++ b/src/spatialcf/core/certificate.py @@ -5,11 +5,11 @@ from fractions import Fraction from sys import float_info -from spatialcf.core.v2.continuous_yaw_objective_v2_9 import ( +from spatialcf.core.objective import ( ContinuousYawObjectiveStageV2_9, ) -from spatialcf.domain.v2.certificate import OptimalityClaimV2 -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.certificate import OptimalityClaimV2 +from spatialcf.domain.solver import ( ContinuousYawCandidateRefsV2_9, ContinuousYawGlobalOptimalityCertificateV2_9, ContinuousYawResourceUsageV2_9, diff --git a/src/spatialcf/solver/feasible.py b/src/spatialcf/core/feasibility.py similarity index 81% rename from src/spatialcf/solver/feasible.py rename to src/spatialcf/core/feasibility.py index 419a1d6..07af875 100644 --- a/src/spatialcf/solver/feasible.py +++ b/src/spatialcf/core/feasibility.py @@ -6,8 +6,8 @@ from shapely.geometry.base import BaseGeometry from shapely.ops import unary_union -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import Camera, InterventionSpec, Scene, SceneObject +from spatialcf.domain.request import InterventionSpec, Relation +from spatialcf.domain.scene import Camera, Scene, SceneObject from spatialcf.geometry.obb import ( OBB_INTERSECTION_Z_OVERLAP_TOLERANCE, obb_footprint, @@ -75,10 +75,12 @@ def value(point: tuple[float, float]) -> float: output.append(start) if (start_value >= 0) != (end_value >= 0): ratio = start_value / (start_value - end_value) - output.append(( - start[0] + ratio * (end[0] - start[0]), - start[1] + ratio * (end[1] - start[1]), - )) + output.append( + ( + start[0] + ratio * (end[0] - start[0]), + start[1] + ratio * (end[1] - start[1]), + ) + ) return Polygon(output) if len(output) >= 3 else Polygon() @@ -103,9 +105,7 @@ def _center_locus( first_x, first_y = relative_vertices[0] locus = _polygonal(translate(container, xoff=-first_x, yoff=-first_y)) for x, y in relative_vertices[1:]: - locus = _polygonal( - locus.intersection(translate(container, xoff=-x, yoff=-y)) - ) + locus = _polygonal(locus.intersection(translate(container, xoff=-x, yoff=-y))) if locus.is_empty: break return locus @@ -117,11 +117,13 @@ def _convex_minkowski_sum( ) -> Polygon: """The exact Minkowski sum of convex polygons via vertex sums and a hull.""" first_vertices = list(first.exterior.coords)[:-1] - return MultiPoint([ - (first_x + second_x, first_y + second_y) - for first_x, first_y in first_vertices - for second_x, second_y in second_vertices - ]).convex_hull + return MultiPoint( + [ + (first_x + second_x, first_y + second_y) + for first_x, first_y in first_vertices + for second_x, second_y in second_vertices + ] + ).convex_hull def _configuration_obstacle( @@ -192,8 +194,9 @@ def _minimum_side_apothem(vertices: list[tuple[float, float]]) -> float: return min( abs(start_x * end_y - start_y * end_x) / math.hypot(end_x - start_x, end_y - start_y) - for (start_x, start_y), (end_x, end_y) - in zip(vertices, vertices[1:] + vertices[:1]) + for (start_x, start_y), (end_x, end_y) in zip( + vertices, vertices[1:] + vertices[:1] + ) ) @@ -231,20 +234,30 @@ def _intersect_half_plane( constant += tolerance else: constant -= tolerance - return _polygonal(region.intersection( - _half_plane_mask(region, nx, ny, constant, keep_greater) - )) + return _polygonal( + region.intersection(_half_plane_mask(region, nx, ny, constant, keep_greater)) + ) -def _camera_row(camera: Camera, row: int, subject_z: float) -> tuple[float, float, float]: +def _camera_row( + camera: Camera, row: int, subject_z: float +) -> tuple[float, float, float]: matrix = camera.world_to_camera start = row * 4 - return matrix[start], matrix[start + 1], matrix[start + 2] * subject_z + matrix[start + 3] + return ( + matrix[start], + matrix[start + 1], + matrix[start + 2] * subject_z + matrix[start + 3], + ) -def _require_query_views(subject: SceneObject, reference: SceneObject, camera_id: str) -> None: +def _require_query_views( + subject: SceneObject, reference: SceneObject, camera_id: str +) -> None: if subject.views.get(camera_id) is None or reference.views.get(camera_id) is None: - raise ValueError("camera-relative feasible regions require subject and reference query views") + raise ValueError( + "camera-relative feasible regions require subject and reference query views" + ) class FeasibleRegionBuilder: @@ -257,36 +270,57 @@ def build(self, scene: Scene, spec: InterventionSpec) -> PolygonalGeometry: for position_region in scene.subject_position_regions: if position_region.subject_object_id == subject.object_id: - region = _polygonal(region.intersection( - subject_position_region_geometry(position_region) - )) + region = _polygonal( + region.intersection( + subject_position_region_geometry(position_region) + ) + ) support_id = subject.support_object_id if support_id is not None: support = scene.object_by_id(support_id) - region = _polygonal(region.intersection( - _center_locus(obb_footprint(support.obb), relative_vertices) - )) + region = _polygonal( + region.intersection( + _center_locus(obb_footprint(support.obb), relative_vertices) + ) + ) - if spec.relation_after in {Relation.LEFT, Relation.RIGHT, Relation.FRONT, Relation.BEHIND}: + if spec.relation_after in { + Relation.LEFT, + Relation.RIGHT, + Relation.FRONT, + Relation.BEHIND, + }: _require_query_views(subject, reference, spec.camera_id) camera = scene.camera_by_id(spec.camera_id) if spec.relation_after in {Relation.LEFT, Relation.RIGHT}: - region = self._left_right_region(region, subject, reference, camera, spec) + region = self._left_right_region( + region, subject, reference, camera, spec + ) else: - region = self._front_behind_region(region, subject, reference, camera, spec) + region = self._front_behind_region( + region, subject, reference, camera, spec + ) elif spec.relation_after is Relation.NEAR: - configuration = _configuration_obstacle(obb_footprint(reference.obb), relative_vertices) + configuration = _configuration_obstacle( + obb_footprint(reference.obb), relative_vertices + ) near_locus = _convex_minkowski_sum( configuration, - list(_regular_disk_polygon(0.50, circumscribed=True).exterior.coords)[:-1], + list(_regular_disk_polygon(0.50, circumscribed=True).exterior.coords)[ + :-1 + ], ) region = _polygonal(region.intersection(near_locus)) else: - configuration = _configuration_obstacle(obb_footprint(reference.obb), relative_vertices) + configuration = _configuration_obstacle( + obb_footprint(reference.obb), relative_vertices + ) far_exclusion = _convex_minkowski_sum( configuration, - list(_regular_disk_polygon(1.50, circumscribed=False).exterior.coords)[:-1], + list(_regular_disk_polygon(1.50, circumscribed=False).exterior.coords)[ + :-1 + ], ) region = _polygonal(region.difference(far_exclusion)) @@ -298,7 +332,9 @@ def build(self, scene: Scene, spec: InterventionSpec) -> PolygonalGeometry: <= OBB_INTERSECTION_Z_OVERLAP_TOLERANCE ): continue - configuration = _configuration_obstacle(obb_footprint(obstacle.obb), relative_vertices) + configuration = _configuration_obstacle( + obb_footprint(obstacle.obb), relative_vertices + ) region = _polygonal(region.difference(configuration)) for obstacle in scene.collision_obstacles: conservative_obb = obstacle.conservative_obb() @@ -323,7 +359,9 @@ def _left_right_region( ) -> PolygonalGeometry: reference_view = reference.views[spec.camera_id] target = reference_view.bbox.center_x + ( - camera.width * 0.05 if spec.relation_after is Relation.RIGHT else -camera.width * 0.05 + camera.width * 0.05 + if spec.relation_after is Relation.RIGHT + else -camera.width * 0.05 ) x_nx, x_ny, x_constant = _camera_row(camera, 0, subject.position.z) depth_nx, depth_ny, depth_constant = _camera_row(camera, 2, subject.position.z) diff --git a/src/spatialcf/core/v2/continuous_yaw_objective_v2_9.py b/src/spatialcf/core/objective.py similarity index 94% rename from src/spatialcf/core/v2/continuous_yaw_objective_v2_9.py rename to src/spatialcf/core/objective.py index 09d82fb..f67b79d 100644 --- a/src/spatialcf/core/v2/continuous_yaw_objective_v2_9.py +++ b/src/spatialcf/core/objective.py @@ -8,50 +8,50 @@ from dataclasses import dataclass from fractions import Fraction -from spatialcf.core.v2.continuous_yaw_objective import ( +from spatialcf.core._internal.kernels.convex_translation import RationalPoint2V2 +from spatialcf.core._internal.kernels.so2 import SO2AtomicBudgetV2 +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, + StrictConvexIntersectionCellV2, + StrictConvexIntersectionComplexV2, + StrictConvexIntersectionKindV2, + intersect_strict_convex_allowed_complexes_v2, +) +from spatialcf.core._internal.objective.base import ( _cell_distance_extrema, _cell_sha, _inner_sha, ) -from spatialcf.core.v2.continuous_yaw_relation_damage import ( +from spatialcf.core._internal.objective.numeric import ( + _directed_sqrt_binary64_bounds, + aggregate_relation_damage_bounds_v2, +) +from spatialcf.core._internal.objective.relation_damage import ( ContinuousYawRelationDamageStageV2_9, evaluate_relation_damage_point_v2_9, ) -from spatialcf.core.v2.continuous_yaw_safety_v2_9 import ( +from spatialcf.core._internal.objective.safety import ( ContinuousYawSafetyStageV2_9, evaluate_continuous_yaw_safety_point_v2_9, ) -from spatialcf.core.v2.continuous_yaw_visibility_objective_v2_9 import ( - ContinuousYawVisibilityObjectiveStageV2_9, - evaluate_continuous_yaw_visibility_objective_point_v2_9, -) -from spatialcf.core.v2.continuous_yaw_visibility_v2_9 import ( +from spatialcf.core._internal.objective.visibility import ( ContinuousYawVisibilityStageV2_9, ) -from spatialcf.core.v2.convex_translation_domain import RationalPoint2V2 -from spatialcf.core.v2.objective_numeric import ( - _directed_sqrt_binary64_bounds, - aggregate_relation_damage_bounds_v2, -) -from spatialcf.core.v2.so2_interval import SO2AtomicBudgetV2 -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionCellV2, - StrictConvexIntersectionComplexV2, - StrictConvexIntersectionKindV2, - intersect_strict_convex_allowed_complexes_v2, +from spatialcf.core._internal.objective.visibility_objective import ( + ContinuousYawVisibilityObjectiveStageV2_9, + evaluate_continuous_yaw_visibility_objective_point_v2_9, ) -from spatialcf.domain.v2.artifacts import NonNegativeIntervalV2, ObjectiveTermBoundsV2 -from spatialcf.domain.v2.base import Vec2V2 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.artifacts import NonNegativeIntervalV2, ObjectiveTermBoundsV2 +from spatialcf.domain.base import Vec2 +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.solver import ( ContinuousYawObjectiveCellV2_9, ContinuousYawResourceUsageV2_9, ContinuousYawSolverConfigV2_9, ContinuousYawWitnessEvaluationV2_9, ) -from spatialcf.domain.v2.edit import CanonicalEditV2 _STAGE_HASH_DOMAIN_V2_9 = b"spatialcf.continuous-yaw-joint-objective.v2.9\0" _OUTER_HASH_DOMAIN_V2_9 = b"spatialcf.continuous-yaw-outer-cell.v2.9\0" @@ -65,17 +65,17 @@ class ContinuousYawObjectiveNotProvenV2_9(ValueError): @dataclass(frozen=True, slots=True) class _ContinuousYawObjectiveProposalV2_9: objective_cell_id: str - edit: CanonicalEditV2 + edit: CanonicalEdit def __post_init__(self) -> None: if type(self.objective_cell_id) is not str or not self.objective_cell_id: raise TypeError("objective_cell_id must be a non-empty exact string") - if type(self.edit) is not CanonicalEditV2: - raise TypeError("edit must be an exact CanonicalEditV2") + if type(self.edit) is not CanonicalEdit: + raise TypeError("edit must be an exact CanonicalEdit") object.__setattr__( self, "edit", - CanonicalEditV2.model_validate( + CanonicalEdit.model_validate( self.edit.model_dump(mode="python", warnings="error"), strict=True ), ) @@ -349,7 +349,7 @@ def evaluate_continuous_yaw_objective_point_v2_9( safety_stage: ContinuousYawSafetyStageV2_9, visibility_objective_stage: ContinuousYawVisibilityObjectiveStageV2_9, objective_stage: ContinuousYawObjectiveStageV2_9, - edit: CanonicalEditV2, + edit: CanonicalEdit, *, atomic_budget: SO2AtomicBudgetV2, intersection_budget: StrictConvexIntersectionBudgetV2, @@ -367,9 +367,9 @@ def evaluate_continuous_yaw_objective_point_v2_9( intersection_budget, expected_objective=objective_stage, ) - if type(edit) is not CanonicalEditV2: - raise TypeError("edit must be an exact CanonicalEditV2") - edit = CanonicalEditV2.model_validate( + if type(edit) is not CanonicalEdit: + raise TypeError("edit must be an exact CanonicalEdit") + edit = CanonicalEdit.model_validate( edit.model_dump(mode="python", warnings="error"), strict=True ) if ( @@ -518,10 +518,10 @@ def _nearest_binary64_proposal_v2_9( _, _, _, x, y = min(candidates) return _ContinuousYawObjectiveProposalV2_9( objective_cell_id=objective_id, - edit=CanonicalEditV2( + edit=CanonicalEdit( semantic_problem_sha256=problem_sha, subject_id=subject_id, - translation_xy_m=Vec2V2(x=x, y=y), + translation_xy_m=Vec2(x=x, y=y), ), ) @@ -631,7 +631,7 @@ def _usage(atomic, domain, *, objective_cells): def _base_usage(atomic, domain): - from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( + from spatialcf.core._internal.compilation.collision import ( MultiObstacleStrictConvexCandidateResourceUsageV2, ) diff --git a/src/spatialcf/core/v2/continuous_yaw_camera_frame.py b/src/spatialcf/core/problem.py similarity index 91% rename from src/spatialcf/core/v2/continuous_yaw_camera_frame.py rename to src/spatialcf/core/problem.py index b395c22..602afc9 100644 --- a/src/spatialcf/core/v2/continuous_yaw_camera_frame.py +++ b/src/spatialcf/core/problem.py @@ -1,4 +1,4 @@ -"""Directed upright-camera frame and private T15 problem projection for v2.9.""" +"""Current camera-normalized semantic-problem compilation owner.""" from __future__ import annotations @@ -7,33 +7,33 @@ from dataclasses import dataclass from fractions import Fraction -from spatialcf.core.v2 import so2_interval -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels import so2 as so2_interval +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, SO2IntervalKindV2, compile_directed_sin_cos_v2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetV2, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.artifacts import SemanticProblemV2_2 +from spatialcf.domain.base import ( FactAvailabilityV2, FactCompletenessV2, UncertaintyBudgetV2, ) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_camera import ( +from spatialcf.domain.geometry import DirectedYawIntervalTransformV2_2 +from spatialcf.domain.problem import ( PinholeCameraV2_3, SemanticProblemV2_3, ) -from spatialcf.domain.v2.continuous_yaw_candidate import SemanticProblemV2_2 -from spatialcf.domain.v2.scene import ( - CameraAxesV2, - CameraDepthConventionV2, - CameraDistortionModelV2, - CameraMatrixLayoutV2, - CameraPixelConventionV2, +from spatialcf.domain.scene import ( + CameraAxes, + CameraDepthConvention, + CameraDistortionModel, + CameraMatrixLayout, + CameraPixelConvention, ) _CAMERA_CONTEXT_HASH_DOMAIN_V2_9 = b"spatialcf.upright-camera-context.v2.9\0" @@ -111,7 +111,7 @@ def context_sha256(self) -> str: return hashlib.sha256(_CAMERA_CONTEXT_HASH_DOMAIN_V2_9 + payload).hexdigest() -def prepare_camera_independent_candidate_problem_v2_9( +def prepare_camera_independent_candidate_problem( problem: SemanticProblemV2_3, ) -> SemanticProblemV2_2: """Project only the camera wire for the camera-independent T15 compiler.""" @@ -151,7 +151,7 @@ def prepare_camera_independent_candidate_problem_v2_9( return projected_problem -def compile_upright_camera_context_v2_9( +def compile_upright_camera_context( problem: SemanticProblemV2_3, *, atomic_budget: SO2AtomicBudgetV2, @@ -205,7 +205,7 @@ def compile_upright_camera_context_v2_9( ) -def bound_world_point_in_upright_camera_v2_9( +def bound_world_point_in_upright_camera( context: UprightCameraContextV2_9, *, world_xyz: tuple[Fraction, Fraction, Fraction], @@ -292,13 +292,13 @@ def _evaluation_camera(problem: SemanticProblemV2_3) -> PinholeCameraV2_3: raise ValueError("v2.9 camera references must select one evaluation camera") intrinsics = camera.intrinsics_row_major if ( - camera.distortion_model is not CameraDistortionModelV2.NONE + camera.distortion_model is not CameraDistortionModel.NONE or camera.brown_conrady_coefficients is not None or camera.calibration_uncertainty != UncertaintyBudgetV2() - or camera.matrix_layout is not CameraMatrixLayoutV2.ROW_MAJOR - or camera.camera_axes is not CameraAxesV2.X_RIGHT_Y_DOWN_Z_FORWARD - or camera.pixel_convention is not CameraPixelConventionV2.CENTER_AT_HALF - or camera.depth_convention is not CameraDepthConventionV2.POSITIVE_Z_FORWARD + or camera.matrix_layout is not CameraMatrixLayout.ROW_MAJOR + or camera.camera_axes is not CameraAxes.X_RIGHT_Y_DOWN_Z_FORWARD + or camera.pixel_convention is not CameraPixelConvention.CENTER_AT_HALF + or camera.depth_convention is not CameraDepthConvention.POSITIVE_Z_FORWARD or intrinsics[1] != 0.0 or intrinsics[3] != 0.0 or intrinsics[6:] != (0.0, 0.0, 1.0) @@ -395,7 +395,7 @@ def _fraction_text(value: Fraction) -> str: __all__ = ( "UprightCameraContextV2_9", "UprightCameraPointBoundsV2_9", - "bound_world_point_in_upright_camera_v2_9", - "compile_upright_camera_context_v2_9", - "prepare_camera_independent_candidate_problem_v2_9", + "bound_world_point_in_upright_camera", + "compile_upright_camera_context", + "prepare_camera_independent_candidate_problem", ) diff --git a/src/spatialcf/core/v2/continuous_yaw_solver_v2_9.py b/src/spatialcf/core/solver.py similarity index 85% rename from src/spatialcf/core/v2/continuous_yaw_solver_v2_9.py rename to src/spatialcf/core/solver.py index a7236b7..31824c3 100644 --- a/src/spatialcf/core/v2/continuous_yaw_solver_v2_9.py +++ b/src/spatialcf/core/solver.py @@ -1,4 +1,4 @@ -"""Public raw-input minimum-cost solve for competition Canonical v2.9.""" +"""Current platform-neutral minimum-cost solve owner.""" from __future__ import annotations @@ -7,53 +7,74 @@ from pydantic import ValidationError from pydantic_core import PydanticSerializationError -from spatialcf.core.v2.continuous_yaw_camera_frame import ( - compile_upright_camera_context_v2_9, - prepare_camera_independent_candidate_problem_v2_9, +from spatialcf.core._internal.compilation.support import ( + SupportStrictConvexCandidateCompilationKindV2, ) -from spatialcf.core.v2.continuous_yaw_certificate_v2_9 import ( - ContinuousYawCertificateNotProvenV2_9, - assemble_continuous_yaw_certificate_v2_9, +from spatialcf.core._internal.compilation.support import ( + compile_support_strict_convex_candidate_domain_v2_7 as _compile_candidate_domain, +) +from spatialcf.core._internal.kernels.so2 import ( + SO2AtomicBudgetExhaustedV2, + SO2AtomicBudgetV2, +) +from spatialcf.core._internal.kernels.strict_convex import ( + StrictConvexIntersectionBudgetExhaustedV2, + StrictConvexIntersectionBudgetV2, ) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( +from spatialcf.core._internal.objective.relation import ( DirectionalTargetCandidateKindV2_9, - compile_directional_target_candidate_v2_9, ) -from spatialcf.core.v2.continuous_yaw_objective_v2_9 import ( - ContinuousYawObjectiveNotProvenV2_9, - compile_continuous_yaw_objective_v2_9, - evaluate_continuous_yaw_objective_point_v2_9, +from spatialcf.core._internal.objective.relation import ( + compile_directional_target_candidate_v2_9 as _compile_target_candidates, ) -from spatialcf.core.v2.continuous_yaw_relation_damage import ( +from spatialcf.core._internal.objective.relation_damage import ( ContinuousYawRelationDamageKindV2_9, - compile_relation_damage_bounds_v2_9, ) -from spatialcf.core.v2.continuous_yaw_safety_v2_9 import ( +from spatialcf.core._internal.objective.relation_damage import ( + compile_relation_damage_bounds_v2_9 as _compile_relation_damage, +) +from spatialcf.core._internal.objective.safety import ( ContinuousYawSafetyKindV2_9, - compile_continuous_yaw_safety_bounds_v2_9, ) -from spatialcf.core.v2.continuous_yaw_visibility_objective_v2_9 import ( - ContinuousYawVisibilityObjectiveKindV2_9, - compile_continuous_yaw_visibility_objective_v2_9, +from spatialcf.core._internal.objective.safety import ( + compile_continuous_yaw_safety_bounds_v2_9 as _compile_safety_bounds, ) -from spatialcf.core.v2.continuous_yaw_visibility_v2_9 import ( +from spatialcf.core._internal.objective.visibility import ( ContinuousYawVisibilityKindV2_9, - compile_continuous_yaw_visibility_v2_9, ) -from spatialcf.core.v2.so2_interval import ( - SO2AtomicBudgetExhaustedV2, - SO2AtomicBudgetV2, +from spatialcf.core._internal.objective.visibility import ( + compile_continuous_yaw_visibility_v2_9 as _compile_visibility_domain, ) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, +from spatialcf.core._internal.objective.visibility_objective import ( + ContinuousYawVisibilityObjectiveKindV2_9, ) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( - SupportStrictConvexCandidateCompilationKindV2, - compile_support_strict_convex_candidate_domain_v2_7, +from spatialcf.core._internal.objective.visibility_objective import ( + compile_continuous_yaw_visibility_objective_v2_9 as _compile_visibility_objective, +) +from spatialcf.core.certificate import ( + ContinuousYawCertificateNotProvenV2_9, +) +from spatialcf.core.certificate import ( + assemble_continuous_yaw_certificate_v2_9 as _assemble_certificate, +) +from spatialcf.core.objective import ( + ContinuousYawObjectiveNotProvenV2_9, +) +from spatialcf.core.objective import ( + compile_continuous_yaw_objective_v2_9 as _compile_objective_partition, ) -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.core.objective import ( + evaluate_continuous_yaw_objective_point_v2_9 as _evaluate_objective_point, +) +from spatialcf.core.problem import ( + compile_upright_camera_context as _compile_camera_context, +) +from spatialcf.core.problem import ( + prepare_camera_independent_candidate_problem as _prepare_candidate_problem, +) +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.result import UncertifiedReasonV2 +from spatialcf.domain.solver import ( ContinuousYawCandidateRefsV2_9, ContinuousYawCertifiedSuccessResultV2_9, ContinuousYawMinimumCostSolveOutcomeV2_9, @@ -62,10 +83,9 @@ ContinuousYawSolverConfigV2_9, ContinuousYawUncertifiedResultV2_9, ) -from spatialcf.domain.v2.result import UncertifiedReasonV2 -def solve_continuous_yaw_minimum_cost_v2_9( +def solve_minimum_cost( problem: SemanticProblemV2_3, config: ContinuousYawSolverConfigV2_9, ) -> ContinuousYawMinimumCostSolveOutcomeV2_9: @@ -81,10 +101,8 @@ def solve_continuous_yaw_minimum_cost_v2_9( try: with warnings.catch_warnings(): warnings.simplefilter("error", Warning) - projected = prepare_camera_independent_candidate_problem_v2_9(problem) - t15 = compile_support_strict_convex_candidate_domain_v2_7( - projected, config.candidate_config - ) + projected = _prepare_candidate_problem(problem) + t15 = _compile_candidate_domain(projected, config.candidate_config) except (ArithmeticError, RuntimeWarning): return _uncertified( problem, @@ -123,7 +141,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( try: with warnings.catch_warnings(): warnings.simplefilter("error", Warning) - camera = compile_upright_camera_context_v2_9( + camera = _compile_camera_context( problem, atomic_budget=atomic, domain_budget=domain ) except (ArithmeticError, RuntimeWarning): @@ -157,7 +175,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( ) try: - target = compile_directional_target_candidate_v2_9( + target = _compile_target_candidates( problem, projected, artifact, @@ -196,7 +214,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( domain, ) - visibility = compile_continuous_yaw_visibility_v2_9( + visibility = _compile_visibility_domain( problem, target_stage, camera, @@ -230,7 +248,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( domain, ) - relation = compile_relation_damage_bounds_v2_9( + relation = _compile_relation_damage( problem, visibility_stage, atomic_budget=atomic, @@ -249,7 +267,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( ) if relation.stage is None: raise RuntimeError("relation STAGE outcome omitted its stage") - safety = compile_continuous_yaw_safety_bounds_v2_9( + safety = _compile_safety_bounds( problem, visibility_stage, relation.stage, @@ -269,7 +287,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( ) if safety.stage is None: raise RuntimeError("safety STAGE outcome omitted its stage") - visible = compile_continuous_yaw_visibility_objective_v2_9( + visible = _compile_visibility_objective( problem, visibility_stage, safety.stage, @@ -290,7 +308,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( if visible.stage is None: raise RuntimeError("visibility objective STAGE omitted its stage") try: - objective = compile_continuous_yaw_objective_v2_9( + objective = _compile_objective_partition( problem, config, visibility_stage, @@ -334,7 +352,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( try: with warnings.catch_warnings(): warnings.simplefilter("error", Warning) - evaluation = evaluate_continuous_yaw_objective_point_v2_9( + evaluation = _evaluate_objective_point( problem, config, visibility_stage, @@ -380,7 +398,7 @@ def solve_continuous_yaw_minimum_cost_v2_9( evaluations.append(evaluation) final_usage = _usage(atomic, domain, len(objective.cells)) try: - certificate, selected = assemble_continuous_yaw_certificate_v2_9( + certificate, selected = _assemble_certificate( config, refs, objective, tuple(evaluations), final_usage ) except ContinuousYawCertificateNotProvenV2_9: @@ -416,12 +434,6 @@ def solve_continuous_yaw_minimum_cost_v2_9( ) -class ContinuousYawMinimumCostSolverV2_9: - @staticmethod - def solve(problem, config): - return solve_continuous_yaw_minimum_cost_v2_9(problem, config) - - def _strict_public_inputs(problem, config): if ( type(problem) is not SemanticProblemV2_3 @@ -557,7 +569,4 @@ def _numeric_failure(problem, projected, config, refs, atomic, domain, label): ) -__all__ = ( - "ContinuousYawMinimumCostSolverV2_9", - "solve_continuous_yaw_minimum_cost_v2_9", -) +__all__ = ("solve_minimum_cost",) diff --git a/src/spatialcf/core/v2/__init__.py b/src/spatialcf/core/v2/__init__.py deleted file mode 100644 index 71579ab..0000000 --- a/src/spatialcf/core/v2/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Canonical v2 platform-neutral core algorithms. - -Concrete compiler types are imported from their defining modules so this -package initializer remains free of eager solver or platform dependencies. -""" diff --git a/src/spatialcf/core/v2/_internal/__init__.py b/src/spatialcf/core/v2/_internal/__init__.py deleted file mode 100644 index 0752a8d..0000000 --- a/src/spatialcf/core/v2/_internal/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Private implementation packages for the Canonical v2 core.""" diff --git a/src/spatialcf/core/v2/_internal/boundary/__init__.py b/src/spatialcf/core/v2/_internal/boundary/__init__.py deleted file mode 100644 index abc33e1..0000000 --- a/src/spatialcf/core/v2/_internal/boundary/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Strict private boundaries shared by Canonical v2 entry points.""" - -from spatialcf.core.v2._internal.boundary.errors import ( - InvalidCallerInputV2, - NumericBoundaryGapV2, -) -from spatialcf.core.v2._internal.boundary.strict import ( - strict_fresh_solve_result_v2, - strict_input_model_v2, - strict_submitted_solve_result_v2, -) - -__all__ = ( - "InvalidCallerInputV2", - "NumericBoundaryGapV2", - "strict_fresh_solve_result_v2", - "strict_input_model_v2", - "strict_submitted_solve_result_v2", -) diff --git a/src/spatialcf/core/v2/_internal/boundary/errors.py b/src/spatialcf/core/v2/_internal/boundary/errors.py deleted file mode 100644 index c7c1bb4..0000000 --- a/src/spatialcf/core/v2/_internal/boundary/errors.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Typed errors for strict caller-owned Canonical v2 boundaries.""" - - -class InvalidCallerInputV2(RuntimeError): - """A caller-owned value failed exact-type or strict validation.""" - - def __init__(self, finding_code: str) -> None: - if type(finding_code) is not str or not finding_code.strip(): - raise TypeError("finding_code must be an exact non-blank string") - self.finding_code = finding_code - super().__init__(finding_code) - - -class NumericBoundaryGapV2(RuntimeError): - """A designated caller-boundary numeric operation could not be certified.""" - - def __init__(self, finding_code: str) -> None: - if type(finding_code) is not str or not finding_code.strip(): - raise TypeError("finding_code must be an exact non-blank string") - self.finding_code = finding_code - super().__init__(finding_code) diff --git a/src/spatialcf/core/v2/_internal/boundary/strict.py b/src/spatialcf/core/v2/_internal/boundary/strict.py deleted file mode 100644 index 306be4f..0000000 --- a/src/spatialcf/core/v2/_internal/boundary/strict.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Exact-type strict rebuild helpers for Canonical v2 boundaries.""" - -from __future__ import annotations - -import warnings -from typing import TypeVar - -from pydantic import ValidationError - -from spatialcf.core.v2._internal.boundary.errors import ( - InvalidCallerInputV2, - NumericBoundaryGapV2, -) -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) - -ModelT = TypeVar("ModelT", bound=V2Model) -_RESULT_TYPES = ( - CertifiedSuccessResultV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) - - -def strict_input_model_v2( - value: object, - model_type: type[ModelT], - label: str, -) -> ModelT: - """Strictly rebuild one exact caller-owned model.""" - - if type(value) is not model_type: - raise InvalidCallerInputV2(f"INVALID_INPUT:{label}:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise NumericBoundaryGapV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise InvalidCallerInputV2(f"INVALID_INPUT:{label}") from error - - -def strict_submitted_solve_result_v2(value: object) -> V2Model: - """Strictly rebuild one caller-submitted exact solve result.""" - - result_type = type(value) - if result_type not in _RESULT_TYPES: - raise InvalidCallerInputV2("INVALID_SUBMITTED_RESULT:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return result_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise NumericBoundaryGapV2( - "NUMERIC_GAP:SUBMITTED_RESULT_REVALIDATION" - ) from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise InvalidCallerInputV2("INVALID_SUBMITTED_RESULT") from error - - -def strict_fresh_solve_result_v2(value: object) -> V2Model: - """Strictly rebuild an internal result without hiding invariants.""" - - result_type = type(value) - if result_type not in _RESULT_TYPES: - raise TypeError("fresh result has the wrong exact type") - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return result_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) diff --git a/src/spatialcf/core/v2/_internal/certification/__init__.py b/src/spatialcf/core/v2/_internal/certification/__init__.py deleted file mode 100644 index 99f01a8..0000000 --- a/src/spatialcf/core/v2/_internal/certification/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Private certification implementations for the Canonical v2 core.""" diff --git a/src/spatialcf/core/v2/_internal/certification/solve_replay.py b/src/spatialcf/core/v2/_internal/certification/solve_replay.py deleted file mode 100644 index d9bcc47..0000000 --- a/src/spatialcf/core/v2/_internal/certification/solve_replay.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Common private replay engine for Canonical v2 solve verifiers.""" - -from __future__ import annotations - -import warnings -from collections.abc import Callable -from dataclasses import dataclass - -from spatialcf.core.v2._internal.boundary import ( - InvalidCallerInputV2, - NumericBoundaryGapV2, - strict_fresh_solve_result_v2, - strict_input_model_v2, - strict_submitted_solve_result_v2, -) -from spatialcf.core.v2.minimum_cost_solver import ( - CanonicalMinimumCostSolveOutcomeV2, -) -from spatialcf.core.v2.solve_verifier import ( - CanonicalSolveVerificationKindV2, - CanonicalSolveVerificationOutcomeV2, -) -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.result import ( - CoreSolverConfigV2, - UncertifiedReasonV2, -) - - -@dataclass(frozen=True, slots=True) -class SolveReplayBindingsV2: - """Fixed private dependencies selected by one compatibility wrapper.""" - - problem_type: type[V2Model] - solve: Callable[[V2Model, CoreSolverConfigV2], CanonicalMinimumCostSolveOutcomeV2] - canonical_json_bytes: Callable[[object], bytes] - exact_schema_identity_type: type[V2Model] | None = None - exact_schema_version: str | None = None - schema_mismatch_finding: str | None = None - - def __post_init__(self) -> None: - if not isinstance(self.problem_type, type) or not issubclass( - self.problem_type, V2Model - ): - raise TypeError("problem_type must be a V2Model type") - if not callable(self.solve): - raise TypeError("solve must be callable") - if not callable(self.canonical_json_bytes): - raise TypeError("canonical_json_bytes must be callable") - - schema_values = ( - self.exact_schema_identity_type, - self.exact_schema_version, - self.schema_mismatch_finding, - ) - if all(value is None for value in schema_values): - return - if any(value is None for value in schema_values): - raise ValueError("exact schema precheck fields must be supplied together") - if not isinstance(self.exact_schema_identity_type, type) or not issubclass( - self.exact_schema_identity_type, V2Model - ): - raise TypeError("exact_schema_identity_type must be a V2Model type") - if ( - type(self.exact_schema_version) is not str - or not self.exact_schema_version.strip() - ): - raise TypeError("exact_schema_version must be an exact non-blank string") - if ( - type(self.schema_mismatch_finding) is not str - or not self.schema_mismatch_finding.strip() - ): - raise TypeError("schema_mismatch_finding must be an exact non-blank string") - - -def verify_solve_replay_v2( - problem: object, - expected_config: object, - submitted_result: object, - *, - bindings: SolveReplayBindingsV2, -) -> CanonicalSolveVerificationOutcomeV2: - """Replay one fixed solver and compare the complete canonical result.""" - - if type(bindings) is not SolveReplayBindingsV2: - raise TypeError("bindings must be an exact SolveReplayBindingsV2") - schema_mismatch = bindings.schema_mismatch_finding - if schema_mismatch is not None and ( - type(problem) is not bindings.problem_type - or type(getattr(problem, "schema_identity", None)) - is not bindings.exact_schema_identity_type - or getattr(problem.schema_identity, "schema_version", None) # type: ignore[union-attr] - != bindings.exact_schema_version - ): - return _uncertified(UncertifiedReasonV2.UNSUPPORTED_MODEL, schema_mismatch) - - try: - checked_problem = strict_input_model_v2( - problem, - bindings.problem_type, - "SEMANTIC_PROBLEM", - ) - checked_config = strict_input_model_v2( - expected_config, - CoreSolverConfigV2, - "EXPECTED_CORE_SOLVER_CONFIG", - ) - except NumericBoundaryGapV2 as error: - return _uncertified(UncertifiedReasonV2.NUMERIC_GAP, error.finding_code) - except InvalidCallerInputV2 as error: - return _uncertified(UncertifiedReasonV2.UNSUPPORTED_MODEL, error.finding_code) - - try: - checked_submitted = strict_submitted_solve_result_v2(submitted_result) - except (InvalidCallerInputV2, NumericBoundaryGapV2) as error: - return _mismatch(error.finding_code) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - problem_sha256 = checked_problem.semantic_problem_sha256 - submitted_problem_sha256 = checked_submitted.semantic_problem_sha256 - submitted_config = checked_submitted.core_solver_config - submitted_config_sha256 = submitted_config.core_solver_config_sha256 - expected_config_sha256 = checked_config.core_solver_config_sha256 - submitted_config_bytes = bindings.canonical_json_bytes(submitted_config) - expected_config_bytes = bindings.canonical_json_bytes(checked_config) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CONFIG_CANONICALIZATION", - ) - if submitted_problem_sha256 != problem_sha256: - return _mismatch("SUBMITTED_REFERENCE_MISMATCH:SEMANTIC_PROBLEM") - if ( - submitted_config_sha256 != expected_config_sha256 - or submitted_config_bytes != expected_config_bytes - ): - return _mismatch("SUBMITTED_REFERENCE_MISMATCH:EXPECTED_CONFIG") - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - replay = bindings.solve(checked_problem, checked_config) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:MINIMUM_SOLVE_REPLAY", - ) - if type(replay) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("minimum solver returned an invalid internal outcome") - if replay.result is None: - reason = ( - UncertifiedReasonV2.NUMERIC_GAP - if any(code.startswith("NUMERIC_GAP:") for code in replay.finding_codes) - else UncertifiedReasonV2.COMPILATION_INCOMPLETE - ) - return _uncertified( - reason, - *(replay.finding_codes or ("SOLVE_REPLAY_HAS_NO_RESULT",)), - replay=replay, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - fresh_result = strict_fresh_solve_result_v2(replay.result) - submitted_bytes = bindings.canonical_json_bytes(checked_submitted) - fresh_bytes = bindings.canonical_json_bytes(fresh_result) - fresh_sha256 = fresh_result.solve_result_sha256 - submitted_sha256 = checked_submitted.solve_result_sha256 - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:SOLVE_RESULT_REVALIDATION", - replay=replay, - ) - - findings: list[str] = [] - if type(fresh_result) is not type(checked_submitted): - findings.append("SOLVE_RESULT_MISMATCH:TYPE") - if fresh_result.status is not checked_submitted.status: - findings.append("SOLVE_RESULT_MISMATCH:STATUS") - if fresh_result != checked_submitted: - findings.append("SOLVE_RESULT_MISMATCH:MODEL") - if fresh_bytes != submitted_bytes: - findings.append("SOLVE_RESULT_MISMATCH:CANONICAL_BYTES") - if fresh_sha256 != submitted_sha256: - findings.append("SOLVE_RESULT_MISMATCH:SHA256") - if findings: - return _mismatch(*findings, replay=replay) - - return CanonicalSolveVerificationOutcomeV2( - kind=CanonicalSolveVerificationKindV2.VERIFIED, - semantic_problem_sha256=problem_sha256, - core_solver_config_sha256=expected_config_sha256, - submitted_solve_result_sha256=submitted_sha256, - verified_status=checked_submitted.status, - replay_generation_usage=replay.cumulative_generation_usage, - proposal_count=replay.proposal_count, - evaluated_proposal_count=replay.evaluated_proposal_count, - ) - - -def _telemetry(replay: CanonicalMinimumCostSolveOutcomeV2 | None) -> dict[str, object]: - if replay is None: - return {} - return { - "replay_generation_usage": replay.cumulative_generation_usage, - "proposal_count": replay.proposal_count, - "evaluated_proposal_count": replay.evaluated_proposal_count, - } - - -def _mismatch( - *finding_codes: str, - replay: CanonicalMinimumCostSolveOutcomeV2 | None = None, -) -> CanonicalSolveVerificationOutcomeV2: - return CanonicalSolveVerificationOutcomeV2( - kind=CanonicalSolveVerificationKindV2.MISMATCH, - finding_codes=tuple(finding_codes), - **_telemetry(replay), - ) - - -def _uncertified( - reason: UncertifiedReasonV2, - *finding_codes: str, - replay: CanonicalMinimumCostSolveOutcomeV2 | None = None, -) -> CanonicalSolveVerificationOutcomeV2: - return CanonicalSolveVerificationOutcomeV2( - kind=CanonicalSolveVerificationKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=tuple(finding_codes), - **_telemetry(replay), - ) diff --git a/src/spatialcf/core/v2/_internal/orchestration/__init__.py b/src/spatialcf/core/v2/_internal/orchestration/__init__.py deleted file mode 100644 index 1f382cb..0000000 --- a/src/spatialcf/core/v2/_internal/orchestration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Private capability resolution and solve orchestration.""" diff --git a/src/spatialcf/core/v2/_internal/orchestration/capabilities.py b/src/spatialcf/core/v2/_internal/orchestration/capabilities.py deleted file mode 100644 index 8a7e177..0000000 --- a/src/spatialcf/core/v2/_internal/orchestration/capabilities.py +++ /dev/null @@ -1,186 +0,0 @@ -"""Frozen private values describing Canonical v2 solve capabilities.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from enum import StrEnum - -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - SemanticProblemV2_2, - StrictConvexCandidateCompilerConfigV2_5, - StrictConvexCandidateCompilerConfigV2_6, - StrictConvexCandidateCompilerConfigV2_7, -) -from spatialcf.domain.v2.continuous_yaw_solver import ( - ContinuousYawMinimumCostSolveOutcomeV2_8, - ContinuousYawSolverConfigV2_8, -) -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( - ContinuousYawMinimumCostSolveOutcomeV2_9, - ContinuousYawSolverConfigV2_9, -) - - -class SolveCapabilityKeyV2(StrEnum): - """Closed identities authorized to run the complete solve pipeline.""" - - V2_0 = "algorithm:2.0" - V2_1 = "algorithm:2.1" - V2_2 = "algorithm:2.2" - V2_3 = "algorithm:2.3" - V2_4 = "algorithm:2.4" - V2_8 = "algorithm:2.8" - V2_9 = "algorithm:2.9" - - -class SolveStageKeyV2(StrEnum): - """Closed normalization stages composed ahead of the base solve.""" - - CARDINAL_YAW = "cardinal-yaw" - ZERO_DISTORTION = "zero-distortion" - CAMERA_TRANSLATION = "camera-translation" - CAMERA_CARDINAL_REBASE = "camera-cardinal-rebase" - - -class CompilerPrefixKeyV2(StrEnum): - """Closed identities for incomplete candidate compiler prefixes.""" - - V2_5 = "algorithm:2.5" - V2_6 = "algorithm:2.6" - V2_7 = "algorithm:2.7" - - -@dataclass(frozen=True, slots=True) -class SolveCapabilityV2: - """One immutable, module-owned complete solve capability.""" - - algorithm_id: str - algorithm_version: SolveCapabilityKeyV2 - problem_type: type[V2Model] - stage_keys: tuple[SolveStageKeyV2, ...] - - def __post_init__(self) -> None: - if type(self.algorithm_id) is not str or not self.algorithm_id.strip(): - raise TypeError("algorithm_id must be an exact non-blank string") - if type(self.algorithm_version) is not SolveCapabilityKeyV2: - raise TypeError("algorithm_version has the wrong exact type") - if self.algorithm_version in ( - SolveCapabilityKeyV2.V2_8, - SolveCapabilityKeyV2.V2_9, - ): - raise ValueError( - f"{self.algorithm_version.value} requires DirectSolveCapabilityV2" - ) - if not isinstance(self.problem_type, type) or not issubclass( - self.problem_type, - V2Model, - ): - raise TypeError("problem_type must be a V2Model type") - if type(self.stage_keys) is not tuple or any( - type(item) is not SolveStageKeyV2 for item in self.stage_keys - ): - raise TypeError("stage_keys must be an exact tuple of SolveStageKeyV2") - if len(set(self.stage_keys)) != len(self.stage_keys): - raise ValueError("stage keys must be unique") - - -@dataclass(frozen=True, slots=True) -class DirectSolveCapabilityV2: - """One raw-input complete solver that does not use normalization stages.""" - - algorithm_id: str - algorithm_version: SolveCapabilityKeyV2 - problem_type: type[SemanticProblemV2_2 | SemanticProblemV2_3] - config_type: type[ContinuousYawSolverConfigV2_8 | ContinuousYawSolverConfigV2_9] - solver: ( - Callable[ - [SemanticProblemV2_2, ContinuousYawSolverConfigV2_8], - ContinuousYawMinimumCostSolveOutcomeV2_8, - ] - | Callable[ - [SemanticProblemV2_3, ContinuousYawSolverConfigV2_9], - ContinuousYawMinimumCostSolveOutcomeV2_9, - ] - ) - - def __post_init__(self) -> None: - if type(self.algorithm_id) is not str or not self.algorithm_id.strip(): - raise TypeError("algorithm_id must be an exact non-blank string") - expected_types = { - SolveCapabilityKeyV2.V2_8: ( - SemanticProblemV2_2, - ContinuousYawSolverConfigV2_8, - ), - SolveCapabilityKeyV2.V2_9: ( - SemanticProblemV2_3, - ContinuousYawSolverConfigV2_9, - ), - } - if self.algorithm_version not in expected_types: - raise TypeError("direct solver version is not registered") - expected_problem, expected_config = expected_types[self.algorithm_version] - if self.problem_type is not expected_problem: - raise TypeError("direct solver problem_type is not exact") - if self.config_type is not expected_config: - raise TypeError("direct solver config_type is not exact") - if not callable(self.solver): - raise TypeError("direct solver must be callable") - - -@dataclass(frozen=True, slots=True) -class AvailableCompilerPrefixV2: - """One exact compiler-only capability with no certificate authority.""" - - algorithm_version: CompilerPrefixKeyV2 - problem_type: type[SemanticProblemV2_2] - config_type: type[ - StrictConvexCandidateCompilerConfigV2_5 - | StrictConvexCandidateCompilerConfigV2_6 - | StrictConvexCandidateCompilerConfigV2_7 - ] - compiler: Callable[..., object] - - def __post_init__(self) -> None: - if type(self.algorithm_version) is not CompilerPrefixKeyV2: - raise TypeError( - "available prefix algorithm_version has the wrong exact type" - ) - if self.problem_type is not SemanticProblemV2_2: - raise TypeError("available prefix problem_type is not exact") - expected_config_type = { - CompilerPrefixKeyV2.V2_5: StrictConvexCandidateCompilerConfigV2_5, - CompilerPrefixKeyV2.V2_6: StrictConvexCandidateCompilerConfigV2_6, - CompilerPrefixKeyV2.V2_7: StrictConvexCandidateCompilerConfigV2_7, - }[self.algorithm_version] - if self.config_type is not expected_config_type: - raise TypeError("available prefix config_type is not exact") - if not callable(self.compiler): - raise TypeError("available prefix compiler must be callable") - - -@dataclass(frozen=True, slots=True) -class ReservedCompilerPrefixV2: - """A frozen route identity that deliberately carries no compiler.""" - - algorithm_version: CompilerPrefixKeyV2 - route: str - - def __post_init__(self) -> None: - if self.algorithm_version is not CompilerPrefixKeyV2.V2_7: - raise TypeError("reserved prefix must be exact algorithm:2.7") - if type(self.route) is not str or not self.route.strip(): - raise TypeError("reserved prefix route must be an exact non-blank string") - - -__all__ = ( - "AvailableCompilerPrefixV2", - "CompilerPrefixKeyV2", - "DirectSolveCapabilityV2", - "ReservedCompilerPrefixV2", - "SolveCapabilityKeyV2", - "SolveCapabilityV2", - "SolveStageKeyV2", -) diff --git a/src/spatialcf/core/v2/_internal/orchestration/registry.py b/src/spatialcf/core/v2/_internal/orchestration/registry.py deleted file mode 100644 index e085a70..0000000 --- a/src/spatialcf/core/v2/_internal/orchestration/registry.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Immutable private registries for Canonical v2 orchestration.""" - -from __future__ import annotations - -from collections.abc import Mapping -from types import MappingProxyType - -from spatialcf.core.v2._internal.orchestration.capabilities import ( - AvailableCompilerPrefixV2, - CompilerPrefixKeyV2, - DirectSolveCapabilityV2, - ReservedCompilerPrefixV2, - SolveCapabilityKeyV2, - SolveCapabilityV2, - SolveStageKeyV2, -) -from spatialcf.core.v2.continuous_yaw_solver import ( - solve_continuous_yaw_minimum_cost_v2_8, -) -from spatialcf.core.v2.continuous_yaw_solver_v2_9 import ( - solve_continuous_yaw_minimum_cost_v2_9, -) -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - MultiObstacleStrictConvexCandidateCompilationOutcomeV2, - compile_multi_obstacle_strict_convex_candidate_domain_v2_6, -) -from spatialcf.core.v2.strict_convex_candidate_domain import ( - StrictConvexCandidateCompilationOutcomeV2, - compile_strict_convex_candidate_domain_v2_5, -) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( - SupportStrictConvexCandidateCompilationOutcomeV2, - compile_support_strict_convex_candidate_domain_v2_7, -) -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - SemanticProblemV2_2, - StrictConvexCandidateCompilerConfigV2_5, - StrictConvexCandidateCompilerConfigV2_6, - StrictConvexCandidateCompilerConfigV2_7, -) -from spatialcf.domain.v2.continuous_yaw_solver import ContinuousYawSolverConfigV2_8 -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( - ContinuousYawSolverConfigV2_9, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 - -_ALGORITHM_ID_V2 = "solver:canonical-branch-and-bound-v2" - - -def _freeze_solve_registry_v2( - entries: tuple[SolveCapabilityV2 | DirectSolveCapabilityV2, ...], -) -> Mapping[SolveCapabilityKeyV2, SolveCapabilityV2 | DirectSolveCapabilityV2]: - if type(entries) is not tuple: - raise TypeError("solve capability entries must be an exact tuple") - result: dict[SolveCapabilityKeyV2, SolveCapabilityV2 | DirectSolveCapabilityV2] = {} - for entry in entries: - if type(entry) not in (SolveCapabilityV2, DirectSolveCapabilityV2): - raise TypeError("registry entries must be exact SolveCapabilityV2") - key = entry.algorithm_version - if key in result: - raise ValueError(f"duplicate solve capability: {key.value}") - result[key] = entry - return MappingProxyType(result) - - -SOLVE_CAPABILITIES_V2 = _freeze_solve_registry_v2( - ( - SolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_0, - problem_type=SemanticProblemV2, - stage_keys=(), - ), - DirectSolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_8, - problem_type=SemanticProblemV2_2, - config_type=ContinuousYawSolverConfigV2_8, - solver=solve_continuous_yaw_minimum_cost_v2_8, - ), - DirectSolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_9, - problem_type=SemanticProblemV2_3, - config_type=ContinuousYawSolverConfigV2_9, - solver=solve_continuous_yaw_minimum_cost_v2_9, - ), - SolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_1, - problem_type=SemanticProblemV2_1, - stage_keys=(SolveStageKeyV2.CARDINAL_YAW,), - ), - SolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_2, - problem_type=SemanticProblemV2_1, - stage_keys=( - SolveStageKeyV2.ZERO_DISTORTION, - SolveStageKeyV2.CARDINAL_YAW, - ), - ), - SolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_3, - problem_type=SemanticProblemV2_1, - stage_keys=( - SolveStageKeyV2.CAMERA_TRANSLATION, - SolveStageKeyV2.ZERO_DISTORTION, - SolveStageKeyV2.CARDINAL_YAW, - ), - ), - SolveCapabilityV2( - algorithm_id=_ALGORITHM_ID_V2, - algorithm_version=SolveCapabilityKeyV2.V2_4, - problem_type=SemanticProblemV2_1, - stage_keys=( - SolveStageKeyV2.CAMERA_CARDINAL_REBASE, - SolveStageKeyV2.CAMERA_TRANSLATION, - SolveStageKeyV2.ZERO_DISTORTION, - SolveStageKeyV2.CARDINAL_YAW, - ), - ), - ) -) - -CompilerPrefixCapabilityV2 = AvailableCompilerPrefixV2 | ReservedCompilerPrefixV2 - - -def _freeze_compiler_prefix_registry_v2( - entries: tuple[CompilerPrefixCapabilityV2, ...], -) -> Mapping[CompilerPrefixKeyV2, CompilerPrefixCapabilityV2]: - if type(entries) is not tuple: - raise TypeError("compiler prefix entries must be an exact tuple") - result: dict[CompilerPrefixKeyV2, CompilerPrefixCapabilityV2] = {} - for entry in entries: - if type(entry) not in (AvailableCompilerPrefixV2, ReservedCompilerPrefixV2): - raise TypeError("compiler prefix entry has the wrong exact type") - key = entry.algorithm_version - if key in result: - raise ValueError(f"duplicate compiler prefix: {key.value}") - result[key] = entry - return MappingProxyType(result) - - -COMPILER_PREFIX_CAPABILITIES_V2 = _freeze_compiler_prefix_registry_v2( - ( - AvailableCompilerPrefixV2( - algorithm_version=CompilerPrefixKeyV2.V2_5, - problem_type=SemanticProblemV2_2, - config_type=StrictConvexCandidateCompilerConfigV2_5, - compiler=compile_strict_convex_candidate_domain_v2_5, - ), - AvailableCompilerPrefixV2( - algorithm_version=CompilerPrefixKeyV2.V2_6, - problem_type=SemanticProblemV2_2, - config_type=StrictConvexCandidateCompilerConfigV2_6, - compiler=compile_multi_obstacle_strict_convex_candidate_domain_v2_6, - ), - AvailableCompilerPrefixV2( - algorithm_version=CompilerPrefixKeyV2.V2_7, - problem_type=SemanticProblemV2_2, - config_type=StrictConvexCandidateCompilerConfigV2_7, - compiler=compile_support_strict_convex_candidate_domain_v2_7, - ), - ) -) - - -def resolve_solve_capability_v2( - key: SolveCapabilityKeyV2, -) -> SolveCapabilityV2 | DirectSolveCapabilityV2: - """Resolve one exact internal capability without a fallback.""" - - if type(key) is not SolveCapabilityKeyV2: - raise TypeError("key must be an exact SolveCapabilityKeyV2") - try: - return SOLVE_CAPABILITIES_V2[key] - except KeyError as error: - raise KeyError(f"unknown solve capability: {key.value}") from error - - -def resolve_compiler_prefix_v2( - key: CompilerPrefixKeyV2, -) -> CompilerPrefixCapabilityV2: - """Resolve one exact compiler-prefix capability without fallback.""" - - if type(key) is not CompilerPrefixKeyV2: - raise TypeError("key must be an exact CompilerPrefixKeyV2") - try: - return COMPILER_PREFIX_CAPABILITIES_V2[key] - except KeyError as error: - raise KeyError(f"unknown compiler prefix: {key.value}") from error - - -def dispatch_compiler_prefix_v2( - key: CompilerPrefixKeyV2, - problem: object, - config: object, -) -> ( - StrictConvexCandidateCompilationOutcomeV2 - | MultiObstacleStrictConvexCandidateCompilationOutcomeV2 - | SupportStrictConvexCandidateCompilationOutcomeV2 -): - """Run an available compiler prefix; reserved routes cannot dispatch.""" - - capability = resolve_compiler_prefix_v2(key) - if type(capability) is ReservedCompilerPrefixV2: - raise RuntimeError(f"reserved compiler prefix: {capability.algorithm_version}") - if type(capability) is not AvailableCompilerPrefixV2: - raise TypeError("compiler prefix registry returned an invalid entry") - if type(problem) is not capability.problem_type: - raise TypeError("problem has the wrong exact type") - if type(config) is not capability.config_type: - raise TypeError("config has the wrong exact type") - outcome = capability.compiler(problem, config) - expected_outcome_type = { - CompilerPrefixKeyV2.V2_5: StrictConvexCandidateCompilationOutcomeV2, - CompilerPrefixKeyV2.V2_6: MultiObstacleStrictConvexCandidateCompilationOutcomeV2, - CompilerPrefixKeyV2.V2_7: SupportStrictConvexCandidateCompilationOutcomeV2, - }[key] - if type(outcome) is not expected_outcome_type: - raise TypeError("compiler prefix returned an invalid outcome") - return outcome - - -__all__ = ( - "COMPILER_PREFIX_CAPABILITIES_V2", - "SOLVE_CAPABILITIES_V2", - "dispatch_compiler_prefix_v2", - "resolve_compiler_prefix_v2", - "resolve_solve_capability_v2", -) diff --git a/src/spatialcf/core/v2/_internal/orchestration/solve.py b/src/spatialcf/core/v2/_internal/orchestration/solve.py deleted file mode 100644 index f2985bf..0000000 --- a/src/spatialcf/core/v2/_internal/orchestration/solve.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Private complete-solve dispatch for registered Canonical v2 capabilities.""" - -from __future__ import annotations - -import warnings - -import spatialcf.core.v2.minimum_cost_solver as _base_solver -from spatialcf.core.v2._internal.orchestration import stages as _stages -from spatialcf.core.v2._internal.orchestration.capabilities import ( - DirectSolveCapabilityV2, - SolveCapabilityKeyV2, - SolveStageKeyV2, -) -from spatialcf.core.v2._internal.orchestration.registry import ( - resolve_solve_capability_v2, -) -from spatialcf.core.v2.certificate_builder import _ExactCardinalSelectionFrameV2 -from spatialcf.core.v2.minimum_cost_solver import CanonicalMinimumCostSolveOutcomeV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import CoreSolverConfigV2 - - -def solve_registered_capability_v2( - key: SolveCapabilityKeyV2, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Run one exact registered complete-solve capability. - - The registry remains closed throughout migration; no compiler-only prefix - is accepted here. - """ - - return _solve_registered_capability_in_selection_frame_v2( - key, - problem, - config, - _ExactCardinalSelectionFrameV2(), - ) - - -def _solve_registered_capability_in_selection_frame_v2( - key: SolveCapabilityKeyV2, - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Run one registered capability in an exact private selection frame.""" - - if type(key) is not SolveCapabilityKeyV2: - raise TypeError("key must be an exact SolveCapabilityKeyV2") - if type(selection_frame) is not _ExactCardinalSelectionFrameV2: - raise TypeError("selection_frame has the wrong exact type") - capability = resolve_solve_capability_v2(key) - if type(capability) is DirectSolveCapabilityV2: - raise RuntimeError( - "direct solve capabilities must use their typed public entrypoint" - ) - migrated_stages = { - SolveStageKeyV2.CARDINAL_YAW, - SolveStageKeyV2.ZERO_DISTORTION, - SolveStageKeyV2.CAMERA_TRANSLATION, - SolveStageKeyV2.CAMERA_CARDINAL_REBASE, - } - if any(stage_key not in migrated_stages for stage_key in capability.stage_keys): - raise NotImplementedError( - f"registered staged solve has not been migrated: {key.value}" - ) - return _solve_stage_chain_v2( - capability.stage_keys, - 0, - problem, - config, - selection_frame, - ) - - -def _solve_stage_chain_v2( - stage_keys: tuple[SolveStageKeyV2, ...], - index: int, - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - if index == len(stage_keys): - outcome = _base_solver._solve_canonical_minimum_cost_in_selection_frame_v2( - problem, # type: ignore[arg-type] - config, # type: ignore[arg-type] - selection_frame, - ) - if type(outcome) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("base solve pipeline returned an invalid internal outcome") - return outcome - - prepared_or_outcome = _stages.prepare_solve_stage_v2( - stage_keys[index], - problem, - config, - selection_frame, - ) - if type(prepared_or_outcome) is CanonicalMinimumCostSolveOutcomeV2: - return prepared_or_outcome - if type(prepared_or_outcome) is not _stages.PreparedSolveStageV2: - raise TypeError("solve stage returned an invalid internal preparation") - prepared = prepared_or_outcome - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - replay = _solve_prepared_child_v2( - stage_keys, - index + 1, - prepared, - ) - except (ArithmeticError, RuntimeWarning): - return _stages.child_numeric_outcome_v2(prepared) - if type(replay) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("child solve pipeline returned an invalid internal outcome") - if replay.result is None: - return _stages.child_missing_outcome_v2(prepared, replay) - return _stages.rebind_solve_stage_v2(prepared, replay) - - -def _solve_prepared_child_v2( - stage_keys: tuple[SolveStageKeyV2, ...], - index: int, - prepared: _stages.PreparedSolveStageV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Execute one prepared stage's remaining private child chain.""" - - if type(prepared) is not _stages.PreparedSolveStageV2: - raise TypeError("prepared stage has the wrong exact type") - return _solve_stage_chain_v2( - stage_keys, - index, - prepared.normalized_problem, - prepared.internal_config, - prepared.child_selection_frame, - ) - - -__all__ = ("solve_registered_capability_v2",) diff --git a/src/spatialcf/core/v2/_internal/orchestration/stages.py b/src/spatialcf/core/v2/_internal/orchestration/stages.py deleted file mode 100644 index 2bc6c69..0000000 --- a/src/spatialcf/core/v2/_internal/orchestration/stages.py +++ /dev/null @@ -1,734 +0,0 @@ -"""Private normalization-stage adapters for registered Canonical v2 solves.""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass - -import spatialcf.core.v2.camera_translation as _camera_translation -import spatialcf.core.v2.cardinal_yaw as _cardinal_yaw -import spatialcf.core.v2.zero_distortion as _zero_distortion -from spatialcf.core.v2._internal.boundary import ( - InvalidCallerInputV2 as _InvalidInputV2, -) -from spatialcf.core.v2._internal.boundary import ( - NumericBoundaryGapV2 as _NumericInputV2, -) -from spatialcf.core.v2._internal.boundary import strict_input_model_v2 -from spatialcf.core.v2._internal.orchestration.capabilities import SolveStageKeyV2 -from spatialcf.core.v2.certificate_builder import _ExactCardinalSelectionFrameV2 -from spatialcf.core.v2.minimum_cost_solver import ( - CanonicalMinimumCostSolveOutcomeV2, - _valid_uncertified, -) -from spatialcf.domain.v2.base import FactSetV2, V2Model -from spatialcf.domain.v2.cardinal import ( - CanonicalSceneV2_1, - SchemaIdentityV2_1, - SemanticProblemV2_1, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( - CoreSolverConfigV2, - UncertifiedReasonV2, - UncertifiedResultV2, -) - - -@dataclass(frozen=True, slots=True) -class PreparedSolveStageV2: - """One checked outer stage and the normalized inputs for its child.""" - - stage_key: SolveStageKeyV2 - checked_problem: SemanticProblemV2 | SemanticProblemV2_1 - checked_config: CoreSolverConfigV2 - normalized_problem: SemanticProblemV2 | SemanticProblemV2_1 - internal_config: CoreSolverConfigV2 - preprocessing_domain_operations: int - child_selection_frame: _ExactCardinalSelectionFrameV2 - child_numeric_finding: str - child_missing_finding: str - rebind_numeric_finding: str - original_to_internal_quarter_turns_ccw: int | None = None - - def __post_init__(self) -> None: - if type(self.stage_key) is not SolveStageKeyV2: - raise TypeError("stage_key must be an exact SolveStageKeyV2") - if not isinstance(self.checked_problem, V2Model) or not isinstance( - self.normalized_problem, - V2Model, - ): - raise TypeError("stage problems must be Canonical V2 models") - if ( - type(self.checked_config) is not CoreSolverConfigV2 - or type(self.internal_config) is not CoreSolverConfigV2 - ): - raise TypeError("stage configs must be exact CoreSolverConfigV2") - if ( - type(self.preprocessing_domain_operations) is not int - or self.preprocessing_domain_operations < 0 - ): - raise TypeError("preprocessing usage must be a non-negative exact int") - if type(self.child_selection_frame) is not _ExactCardinalSelectionFrameV2: - raise TypeError("child selection frame has the wrong exact type") - for name in ( - "child_numeric_finding", - "child_missing_finding", - "rebind_numeric_finding", - ): - value = getattr(self, name) - if type(value) is not str or not value.strip(): - raise TypeError(f"{name} must be an exact non-blank string") - quarter_turns = self.original_to_internal_quarter_turns_ccw - if self.stage_key is SolveStageKeyV2.CAMERA_CARDINAL_REBASE: - if type(quarter_turns) is not int or quarter_turns not in range(4): - raise TypeError("camera-cardinal stage requires exact quarter turns") - elif quarter_turns is not None: - raise ValueError("only camera-cardinal stage can carry quarter turns") - - -def prepare_solve_stage_v2( - stage_key: SolveStageKeyV2, - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> PreparedSolveStageV2 | CanonicalMinimumCostSolveOutcomeV2: - """Validate and normalize one registered forward stage.""" - - if type(stage_key) is not SolveStageKeyV2: - raise TypeError("stage_key must be an exact SolveStageKeyV2") - if type(selection_frame) is not _ExactCardinalSelectionFrameV2: - raise TypeError("selection_frame has the wrong exact type") - if stage_key is SolveStageKeyV2.CARDINAL_YAW: - return _prepare_cardinal_stage(problem, config, selection_frame) - if stage_key is SolveStageKeyV2.ZERO_DISTORTION: - return _prepare_zero_distortion_stage(problem, config, selection_frame) - if stage_key is SolveStageKeyV2.CAMERA_TRANSLATION: - return _prepare_camera_translation_stage(problem, config, selection_frame) - if stage_key is SolveStageKeyV2.CAMERA_CARDINAL_REBASE: - return _prepare_camera_cardinal_stage(problem, config, selection_frame) - raise NotImplementedError(f"solve stage has not been migrated: {stage_key.value}") - - -def child_numeric_outcome_v2( - prepared: PreparedSolveStageV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Map an arithmetic failure from a normalized child replay.""" - - _require_prepared_stage(prepared) - return _valid_uncertified( - prepared.checked_problem, - prepared.checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - prepared.child_numeric_finding, - ) - - -def child_missing_outcome_v2( - prepared: PreparedSolveStageV2, - replay: CanonicalMinimumCostSolveOutcomeV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Close a normalized child replay that did not return a result.""" - - _require_prepared_stage(prepared) - if type(replay) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("child replay has the wrong exact outcome type") - if replay.result is not None: - raise ValueError("child replay is not missing its result") - return _valid_uncertified( - prepared.checked_problem, - prepared.checked_config, - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(replay.finding_codes or (prepared.child_missing_finding,)), - ) - - -def rebind_solve_stage_v2( - prepared: PreparedSolveStageV2, - replay: CanonicalMinimumCostSolveOutcomeV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Rebind one normalized child result into its checked outer problem.""" - - _require_prepared_stage(prepared) - if type(replay) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("child replay has the wrong exact outcome type") - if prepared.stage_key is SolveStageKeyV2.CAMERA_CARDINAL_REBASE: - return _rebind_camera_cardinal_stage(prepared, replay) - if prepared.stage_key not in ( - SolveStageKeyV2.CARDINAL_YAW, - SolveStageKeyV2.ZERO_DISTORTION, - SolveStageKeyV2.CAMERA_TRANSLATION, - ): - raise NotImplementedError( - f"solve stage has not been migrated: {prepared.stage_key.value}" - ) - - from spatialcf.core.v2 import minimum_cost_solver_v2_1 as legacy_cardinal - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate_variable_override = None - if prepared.stage_key is SolveStageKeyV2.CAMERA_TRANSLATION: - candidate_variable_override = ( - _camera_translation._candidate_variable_for_original_problem_v2_3( - prepared.checked_problem - ) - ) - return legacy_cardinal._rebind_outcome( - replay, - prepared.checked_problem, - prepared.checked_config, - prepared.preprocessing_domain_operations, - candidate_variable_override=candidate_variable_override, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - prepared.checked_problem, - prepared.checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - prepared.rebind_numeric_finding, - ) - - -def _prepare_cardinal_stage( - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> PreparedSolveStageV2 | CanonicalMinimumCostSolveOutcomeV2: - if ( - type(problem) is not SemanticProblemV2_1 - or type(getattr(problem, "schema_identity", None)) is not SchemaIdentityV2_1 - ): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - if problem.schema_identity.schema_version != "2.1": - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - try: - checked_config = strict_input_model_v2( - config, - CoreSolverConfigV2, - "CORE_SOLVER_CONFIG", - ) - except (_NumericInputV2, _InvalidInputV2) as error: - return _missing_result(error.finding_code) - - budget = _cardinal_yaw.CardinalDomainBudgetV2( - limit=checked_config.max_domain_operations - ) - try: - _cardinal_yaw.reserve_cardinal_problem_structure_v2_1(problem, budget) - except _cardinal_yaw.CardinalResourceLimitV2: - return _missing_result("RESOURCE_LIMIT:CARDINAL_PREPROCESSING") - except (AttributeError, TypeError): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM") - try: - checked_problem = strict_input_model_v2( - problem, - SemanticProblemV2_1, - "SEMANTIC_PROBLEM", - ) - except (_NumericInputV2, _InvalidInputV2) as error: - return _missing_result(error.finding_code) - - registry_finding = _cardinal_yaw.registry_finding_v2_1(checked_config) - if registry_finding is not None: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - registry_finding, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - cardinal = _cardinal_yaw.prepare_cardinal_problem_v2_1( - checked_problem, - checked_config, - budget.used, - ) - except _cardinal_yaw.CardinalResourceLimitV2: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - "RESOURCE_LIMIT:CARDINAL_PREPROCESSING", - ) - except _cardinal_yaw.CardinalUnsupportedModelV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CARDINAL_NORMALIZATION", - ) - - return PreparedSolveStageV2( - stage_key=SolveStageKeyV2.CARDINAL_YAW, - checked_problem=checked_problem, - checked_config=checked_config, - normalized_problem=cardinal.normalized_problem, - internal_config=cardinal.internal_config, - preprocessing_domain_operations=cardinal.preprocessing_domain_operations, - child_selection_frame=selection_frame, - child_numeric_finding="NUMERIC_GAP:V2_0_SOLVE_REPLAY", - child_missing_finding="NORMALIZED_REPLAY_HAS_NO_RESULT", - rebind_numeric_finding="NUMERIC_GAP:CARDINAL_RESULT_REBIND", - ) - - -def _prepare_zero_distortion_stage( - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> PreparedSolveStageV2 | CanonicalMinimumCostSolveOutcomeV2: - if ( - type(problem) is not SemanticProblemV2_1 - or type(getattr(problem, "schema_identity", None)) is not SchemaIdentityV2_1 - ): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - if problem.schema_identity.schema_version != "2.1": - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - try: - checked_config = strict_input_model_v2( - config, - CoreSolverConfigV2, - "CORE_SOLVER_CONFIG", - ) - except (_NumericInputV2, _InvalidInputV2) as error: - return _missing_result(error.finding_code) - - budget = _zero_distortion.ZeroDistortionDomainBudgetV2( - limit=checked_config.max_domain_operations - ) - if not _zero_reservation_input_is_shallow_valid(problem): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM") - try: - _zero_distortion.reserve_zero_distortion_problem_structure_v2_2(problem, budget) - except _zero_distortion.ZeroDistortionResourceLimitV2: - return _missing_result("RESOURCE_LIMIT:ZERO_DISTORTION_PREPROCESSING") - try: - checked_problem = strict_input_model_v2( - problem, - SemanticProblemV2_1, - "SEMANTIC_PROBLEM", - ) - except (_NumericInputV2, _InvalidInputV2) as error: - return _missing_result(error.finding_code) - - registry_finding = _zero_distortion.registry_finding_v2_2(checked_config) - if registry_finding is not None: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - registry_finding, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - zero_distortion = _zero_distortion.prepare_zero_distortion_problem_v2_2( - checked_problem, - checked_config, - budget.used, - ) - except _zero_distortion.ZeroDistortionResourceLimitV2: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - "RESOURCE_LIMIT:ZERO_DISTORTION_PREPROCESSING", - ) - except _zero_distortion.ZeroDistortionUnsupportedModelV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:ZERO_DISTORTION_NORMALIZATION", - ) - - return PreparedSolveStageV2( - stage_key=SolveStageKeyV2.ZERO_DISTORTION, - checked_problem=checked_problem, - checked_config=checked_config, - normalized_problem=zero_distortion.normalized_problem, - internal_config=zero_distortion.internal_config, - preprocessing_domain_operations=( - zero_distortion.preprocessing_domain_operations - ), - child_selection_frame=selection_frame, - child_numeric_finding="NUMERIC_GAP:V2_1_SOLVE_REPLAY", - child_missing_finding="NORMALIZED_REPLAY_HAS_NO_RESULT", - rebind_numeric_finding="NUMERIC_GAP:ZERO_DISTORTION_RESULT_REBIND", - ) - - -def _prepare_camera_translation_stage( - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> PreparedSolveStageV2 | CanonicalMinimumCostSolveOutcomeV2: - if ( - type(problem) is not SemanticProblemV2_1 - or type(getattr(problem, "schema_identity", None)) is not SchemaIdentityV2_1 - ): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - if problem.schema_identity.schema_version != "2.1": - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - try: - checked_config = strict_input_model_v2( - config, - CoreSolverConfigV2, - "CORE_SOLVER_CONFIG", - ) - except ( - _NumericInputV2, - _InvalidInputV2, - ) as error: - return _missing_result(error.finding_code) - - budget = _camera_translation.CameraTranslationDomainBudgetV2( - limit=checked_config.max_domain_operations - ) - if not _camera_reservation_input_is_shallow_valid(problem): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM") - try: - _camera_translation.reserve_camera_translation_problem_structure_v2_3( - problem, - budget, - ) - except _camera_translation.CameraTranslationResourceLimitV2: - return _missing_result("RESOURCE_LIMIT:CAMERA_TRANSLATION_PREPROCESSING") - try: - checked_problem = strict_input_model_v2( - problem, - SemanticProblemV2_1, - "SEMANTIC_PROBLEM", - ) - except ( - _NumericInputV2, - _InvalidInputV2, - ) as error: - return _missing_result(error.finding_code) - - registry_finding = _camera_translation.registry_finding_v2_3(checked_config) - if registry_finding is not None: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - registry_finding, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - camera_translation = ( - _camera_translation.prepare_camera_translation_problem_v2_3( - checked_problem, - checked_config, - budget.used, - ) - ) - except _camera_translation.CameraTranslationResourceLimitV2: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - "RESOURCE_LIMIT:CAMERA_TRANSLATION_PREPROCESSING", - ) - except _camera_translation.CameraTranslationUnsupportedModelV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except _camera_translation.CameraTranslationNumericGapV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - error.finding_code, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CAMERA_TRANSLATION_NORMALIZATION", - ) - - return PreparedSolveStageV2( - stage_key=SolveStageKeyV2.CAMERA_TRANSLATION, - checked_problem=checked_problem, - checked_config=checked_config, - normalized_problem=camera_translation.normalized_problem, - internal_config=camera_translation.internal_config, - preprocessing_domain_operations=( - camera_translation.preprocessing_domain_operations - ), - child_selection_frame=selection_frame, - child_numeric_finding="NUMERIC_GAP:V2_2_SOLVE_REPLAY", - child_missing_finding="NORMALIZED_REPLAY_HAS_NO_RESULT", - rebind_numeric_finding="NUMERIC_GAP:CAMERA_TRANSLATION_RESULT_REBIND", - ) - - -def _prepare_camera_cardinal_stage( - problem: object, - config: object, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> PreparedSolveStageV2 | CanonicalMinimumCostSolveOutcomeV2: - from spatialcf.core.v2 import camera_cardinal_rebase as camera_cardinal - - if ( - type(problem) is not SemanticProblemV2_1 - or type(getattr(problem, "schema_identity", None)) is not SchemaIdentityV2_1 - ): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - if problem.schema_identity.schema_version != "2.1": - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION") - try: - checked_config = strict_input_model_v2( - config, - CoreSolverConfigV2, - "CORE_SOLVER_CONFIG", - ) - except ( - _NumericInputV2, - _InvalidInputV2, - ) as error: - return _missing_result(error.finding_code) - - budget = camera_cardinal.CameraCardinalRebaseDomainBudgetV2( - limit=checked_config.max_domain_operations - ) - if not _camera_reservation_input_is_shallow_valid(problem): - return _missing_result("INVALID_INPUT:SEMANTIC_PROBLEM") - try: - camera_cardinal.reserve_camera_cardinal_rebase_problem_structure_v2_4( - problem, - budget, - ) - except camera_cardinal.CameraCardinalRebaseResourceLimitV2: - return _missing_result("RESOURCE_LIMIT:CAMERA_CARDINAL_PREPROCESSING") - try: - checked_problem = strict_input_model_v2( - problem, - SemanticProblemV2_1, - "SEMANTIC_PROBLEM", - ) - except ( - _NumericInputV2, - _InvalidInputV2, - ) as error: - return _missing_result(error.finding_code) - - registry_finding = camera_cardinal.registry_finding_v2_4(checked_config) - if registry_finding is not None: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - registry_finding, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - camera_cardinal = ( - camera_cardinal.prepare_camera_cardinal_rebase_problem_v2_4( - checked_problem, - checked_config, - budget.used, - ) - ) - except camera_cardinal.CameraCardinalRebaseResourceLimitV2: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - "RESOURCE_LIMIT:CAMERA_CARDINAL_PREPROCESSING", - ) - except camera_cardinal.CameraCardinalRebaseUnsupportedModelV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except camera_cardinal.CameraCardinalRebaseNumericGapV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - error.finding_code, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CAMERA_CARDINAL_NORMALIZATION", - ) - - quarter_turns = camera_cardinal.original_to_internal_quarter_turns_ccw - return PreparedSolveStageV2( - stage_key=SolveStageKeyV2.CAMERA_CARDINAL_REBASE, - checked_problem=checked_problem, - checked_config=checked_config, - normalized_problem=camera_cardinal.normalized_problem, - internal_config=camera_cardinal.internal_config, - preprocessing_domain_operations=( - camera_cardinal.preprocessing_domain_operations - ), - child_selection_frame=_ExactCardinalSelectionFrameV2( - normalized_to_semantic_quarter_turns_ccw=(-quarter_turns) % 4 - ), - child_numeric_finding="NUMERIC_GAP:V2_3_SOLVE_REPLAY", - child_missing_finding="NORMALIZED_REPLAY_HAS_NO_RESULT", - rebind_numeric_finding="NUMERIC_GAP:CAMERA_CARDINAL_RESULT_PULLBACK", - original_to_internal_quarter_turns_ccw=quarter_turns, - ) - - -def _rebind_camera_cardinal_stage( - prepared: PreparedSolveStageV2, - replay: CanonicalMinimumCostSolveOutcomeV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - from spatialcf.core.v2 import camera_cardinal_result - - if replay.cumulative_generation_usage is None: - if ( - type(replay.result) is not UncertifiedResultV2 - or replay.result.candidate_domain is not None - or replay.result.relation_cost_partition is not None - or replay.result.objective_partition is not None - ): - raise ValueError("normalized replay omitted usage for a published artifact") - return _valid_uncertified( - prepared.checked_problem, - prepared.checked_config, - replay.result.uncertified_reason, - *(replay.finding_codes or ("NORMALIZED_REPLAY_UNCERTIFIED",)), - ) - - postprocessing_budget = camera_cardinal_result.CameraCardinalResultDomainBudgetV2( - limit=prepared.checked_config.max_domain_operations, - used=( - prepared.preprocessing_domain_operations - + replay.cumulative_generation_usage.domain_operations - ), - ) - quarter_turns = prepared.original_to_internal_quarter_turns_ccw - if type(quarter_turns) is not int: - raise RuntimeError("camera-cardinal stage omitted exact quarter turns") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return camera_cardinal_result.pullback_camera_cardinal_outcome_v2_4( - replay, - prepared.checked_problem, - prepared.checked_config, - preprocessing_domain_operations=( - prepared.preprocessing_domain_operations - ), - original_to_internal_quarter_turns_ccw=quarter_turns, - postprocessing_budget=postprocessing_budget, - ) - except camera_cardinal_result.CameraCardinalResultResourceLimitV2: - return _valid_uncertified( - prepared.checked_problem, - prepared.checked_config, - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - "RESOURCE_LIMIT:CAMERA_CARDINAL_POSTPROCESSING", - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - prepared.checked_problem, - prepared.checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - prepared.rebind_numeric_finding, - ) - - -def _require_prepared_stage(value: PreparedSolveStageV2) -> None: - if type(value) is not PreparedSolveStageV2: - raise TypeError("prepared stage has the wrong exact type") - - -def _missing_result(finding_code: str) -> CanonicalMinimumCostSolveOutcomeV2: - return CanonicalMinimumCostSolveOutcomeV2( - result=None, - finding_codes=(finding_code,), - ) - - -def _zero_reservation_input_is_shallow_valid(problem: object) -> bool: - scene = getattr(problem, "scene", None) - if type(scene) is not CanonicalSceneV2_1: - return False - cameras = getattr(scene, "cameras", None) - if not isinstance(cameras, FactSetV2): - return False - return all( - values is None or type(values) is tuple - for values in ( - getattr(cameras, "values", None), - getattr(cameras, "inner_values", None), - getattr(cameras, "outer_values", None), - ) - ) - - -def _camera_reservation_input_is_shallow_valid(problem: object) -> bool: - scene = getattr(problem, "scene", None) - if type(scene) is not CanonicalSceneV2_1: - return False - for family_name in ( - "objects", - "geometry_instances", - "collision_bodies", - "workspace_boundaries", - "known_free_spaces", - "support_surfaces", - "cameras", - "baseline_observations", - ): - facts = getattr(scene, family_name, None) - if not isinstance(facts, FactSetV2): - return False - if not all( - values is None or type(values) is tuple - for values in ( - getattr(facts, "values", None), - getattr(facts, "inner_values", None), - getattr(facts, "outer_values", None), - ) - ): - return False - return True - - -__all__ = ( - "PreparedSolveStageV2", - "child_missing_outcome_v2", - "child_numeric_outcome_v2", - "prepare_solve_stage_v2", - "rebind_solve_stage_v2", -) diff --git a/src/spatialcf/core/v2/_internal/resources/__init__.py b/src/spatialcf/core/v2/_internal/resources/__init__.py deleted file mode 100644 index 61e88aa..0000000 --- a/src/spatialcf/core/v2/_internal/resources/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Private resource ledgers for the platform-neutral Canonical v2 core.""" diff --git a/src/spatialcf/core/v2/artifact_verifier.py b/src/spatialcf/core/v2/artifact_verifier.py deleted file mode 100644 index 9d8b4f7..0000000 --- a/src/spatialcf/core/v2/artifact_verifier.py +++ /dev/null @@ -1,421 +0,0 @@ -"""Deterministic replay verification for candidate and relation artifacts.""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from enum import StrEnum -from typing import TypeVar - -from pydantic import TypeAdapter, ValidationError - -from spatialcf.core.v2.candidate_domain import CandidateDomainCompilerV2 -from spatialcf.core.v2.relation_cost_partition import ( - RelationCostPartitionCompilationKindV2, - RelationCostPartitionCompilationOutcomeV2, - compile_relation_cost_partition_v2, -) -from spatialcf.domain.v2.artifacts import ( - CandidateDomainArtifactV2, - CompilationResourceUsageV2, - RelationCostPartitionV2, -) -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import CoreSolverConfigV2, UncertifiedReasonV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - -_SHA256_DIGEST_ADAPTER = TypeAdapter(Sha256Digest) - - -class CoreArtifactVerificationKindV2(StrEnum): - VERIFIED = "VERIFIED" - MISMATCH = "MISMATCH" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class CoreArtifactVerificationOutcomeV2: - """Closed replay result; verified references exist only after exact replay. - - ``verification_resource_usage`` records the deterministic public compiler - replay under the original solver configuration. It is not added to the - submitted compilation ledger and is not an unforgeable capability. - """ - - kind: CoreArtifactVerificationKindV2 - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - candidate_domain_artifact_sha256: Sha256Digest | None = None - relation_cost_partition_sha256: Sha256Digest | None = None - verification_resource_usage: CompilationResourceUsageV2 | None = None - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.kind, CoreArtifactVerificationKindV2): - raise TypeError("kind must be a CoreArtifactVerificationKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be an exact tuple of non-blank strings") - object.__setattr__( - self, - "finding_codes", - tuple(sorted(set(self.finding_codes))), - ) - usage = self.verification_resource_usage - if usage is not None: - if not isinstance(usage, CompilationResourceUsageV2): - raise TypeError( - "verification_resource_usage must be a CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "verification_resource_usage must pass strict validation" - ) from error - object.__setattr__(self, "verification_resource_usage", usage) - - for field_name in ( - "semantic_problem_sha256", - "core_solver_config_sha256", - "candidate_domain_artifact_sha256", - "relation_cost_partition_sha256", - ): - reference = getattr(self, field_name) - if reference is None: - continue - try: - reference = _SHA256_DIGEST_ADAPTER.validate_python( - reference, - strict=True, - ) - except (ValidationError, TypeError, ValueError) as error: - raise ValueError(f"{field_name} must be a Sha256Digest") from error - object.__setattr__(self, field_name, reference) - - references = ( - self.semantic_problem_sha256, - self.core_solver_config_sha256, - self.candidate_domain_artifact_sha256, - self.relation_cost_partition_sha256, - ) - if self.kind is CoreArtifactVerificationKindV2.VERIFIED: - if any(value is None for value in references): - raise ValueError("VERIFIED requires all four verified references") - if self.verification_resource_usage is None: - raise ValueError("VERIFIED requires verification resource usage") - if self.uncertified_reason is not None or self.finding_codes: - raise ValueError("VERIFIED cannot carry failure diagnostics") - return - - if any(value is not None for value in references): - raise ValueError("non-VERIFIED outcomes cannot carry verified references") - if not self.finding_codes: - raise ValueError(f"{self.kind.value} requires at least one finding") - if self.kind is CoreArtifactVerificationKindV2.MISMATCH: - if self.uncertified_reason is not None: - raise ValueError("MISMATCH cannot carry an uncertified reason") - return - if not isinstance(self.uncertified_reason, UncertifiedReasonV2): - raise TypeError("UNCERTIFIED requires an uncertified reason") - - -CoreArtifactVerifyOutcomeV2 = CoreArtifactVerificationOutcomeV2 - - -class _InvalidInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _NumericInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def verify_core_artifacts_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionCompilationOutcomeV2, -) -> CoreArtifactVerificationOutcomeV2: - """Replay both public compilers and compare their complete frozen outputs.""" - - try: - checked_problem = _strict_model( - problem, - SemanticProblemV2, - label="SEMANTIC_PROBLEM", - ) - checked_config = _strict_model( - config, - CoreSolverConfigV2, - label="CORE_SOLVER_CONFIG", - ) - checked_candidate = _strict_model( - candidate, - CandidateDomainArtifactV2, - label="CANDIDATE_DOMAIN", - ) - checked_relation = _strict_relation_outcome(relation) - except _NumericInputV2 as error: - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - error.finding_code, - ) - except _InvalidInputV2 as error: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate_replay = CandidateDomainCompilerV2().compile( - checked_problem, - checked_config, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_DOMAIN_REPLAY", - ) - - expected_candidate = candidate_replay.candidate_domain - if expected_candidate is None: - return _uncertified( - candidate_replay.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(candidate_replay.finding_codes or ("REPLAY_NO_CANDIDATE_DOMAIN",)), - ) - - candidate_mismatches = _artifact_mismatches( - checked_candidate, - expected_candidate, - label="CANDIDATE_DOMAIN", - actual_hash=checked_candidate.candidate_domain_artifact_sha256, - expected_hash=expected_candidate.candidate_domain_artifact_sha256, - ) - if candidate_mismatches: - return _mismatch( - *candidate_mismatches, - verification_resource_usage=expected_candidate.resource_usage, - ) - if candidate_replay.uncertified_reason is not None: - return _uncertified( - candidate_replay.uncertified_reason, - *(candidate_replay.finding_codes or ("REPLAY_CANDIDATE_UNCERTIFIED",)), - verification_resource_usage=expected_candidate.resource_usage, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - relation_replay = compile_relation_cost_partition_v2( - checked_problem, - checked_config, - expected_candidate, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:RELATION_COST_PARTITION_REPLAY", - verification_resource_usage=expected_candidate.resource_usage, - ) - - verification_usage = ( - relation_replay.cumulative_resource_usage or expected_candidate.resource_usage - ) - if relation_replay.kind is not RelationCostPartitionCompilationKindV2.PARTITION: - return _uncertified( - relation_replay.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(relation_replay.finding_codes or ("REPLAY_NO_RELATION_COST_PARTITION",)), - verification_resource_usage=verification_usage, - ) - - expected_partition = relation_replay.relation_cost_partition - if expected_partition is None: # pragma: no cover - outcome invariant - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - "REPLAY_NO_RELATION_COST_PARTITION", - verification_resource_usage=verification_usage, - ) - if checked_relation.kind is not RelationCostPartitionCompilationKindV2.PARTITION: - return _mismatch( - "ARTIFACT_MISMATCH:RELATION_COST_PARTITION:OUTCOME_KIND", - verification_resource_usage=verification_usage, - ) - - actual_partition = checked_relation.relation_cost_partition - if actual_partition is None: # pragma: no cover - outcome invariant - return _mismatch( - "ARTIFACT_MISMATCH:RELATION_COST_PARTITION:MISSING", - verification_resource_usage=verification_usage, - ) - relation_mismatches = list( - _artifact_mismatches( - actual_partition, - expected_partition, - label="RELATION_COST_PARTITION", - actual_hash=actual_partition.relation_cost_partition_sha256, - expected_hash=expected_partition.relation_cost_partition_sha256, - ) - ) - if ( - checked_relation.cumulative_resource_usage - != relation_replay.cumulative_resource_usage - ): - relation_mismatches.append( - "ARTIFACT_MISMATCH:RELATION_COST_PARTITION:CUMULATIVE_RESOURCE_USAGE" - ) - if relation_mismatches: - return _mismatch( - *relation_mismatches, - verification_resource_usage=verification_usage, - ) - - return CoreArtifactVerificationOutcomeV2( - kind=CoreArtifactVerificationKindV2.VERIFIED, - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - core_solver_config_sha256=checked_config.core_solver_config_sha256, - candidate_domain_artifact_sha256=( - expected_candidate.candidate_domain_artifact_sha256 - ), - relation_cost_partition_sha256=( - expected_partition.relation_cost_partition_sha256 - ), - verification_resource_usage=verification_usage, - ) - - -class CoreArtifactVerifierV2: - """Stateless wrapper for pipeline composition.""" - - def verify( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionCompilationOutcomeV2, - ) -> CoreArtifactVerificationOutcomeV2: - return verify_core_artifacts_v2(problem, config, candidate, relation) - - -def _strict_model( - value: object, - model_type: type[ModelT], - *, - label: str, -) -> ModelT: - if not isinstance(value, model_type): - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _strict_relation_outcome( - value: object, -) -> RelationCostPartitionCompilationOutcomeV2: - label = "RELATION_COST_PARTITION_OUTCOME" - if not isinstance(value, RelationCostPartitionCompilationOutcomeV2): - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - partition = value.relation_cost_partition - if partition is not None and not isinstance(partition, RelationCostPartitionV2): - raise _InvalidInputV2(f"INVALID_INPUT:{label}") - usage = value.cumulative_resource_usage - if usage is not None and not isinstance(usage, CompilationResourceUsageV2): - raise _InvalidInputV2(f"INVALID_INPUT:{label}") - if type(value.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in value.finding_codes - ): - raise _InvalidInputV2(f"INVALID_INPUT:{label}") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - if partition is not None: - partition = RelationCostPartitionV2.model_validate( - partition.model_dump(mode="python"), - strict=True, - ) - if usage is not None: - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - return RelationCostPartitionCompilationOutcomeV2( - kind=value.kind, - relation_cost_partition=partition, - uncertified_reason=value.uncertified_reason, - finding_codes=tuple(value.finding_codes), - cumulative_resource_usage=usage, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _artifact_mismatches( - actual: V2Model, - expected: V2Model, - *, - label: str, - actual_hash: str, - expected_hash: str, -) -> tuple[str, ...]: - findings: list[str] = [] - if actual != expected: - findings.append(f"ARTIFACT_MISMATCH:{label}:MODEL") - if canonical_json_bytes_v2(actual) != canonical_json_bytes_v2(expected): - findings.append(f"ARTIFACT_MISMATCH:{label}:CANONICAL_BYTES") - if actual_hash != expected_hash: - findings.append(f"ARTIFACT_MISMATCH:{label}:HASH") - return tuple(findings) - - -def _mismatch( - *finding_codes: str, - verification_resource_usage: CompilationResourceUsageV2 | None, -) -> CoreArtifactVerificationOutcomeV2: - return CoreArtifactVerificationOutcomeV2( - kind=CoreArtifactVerificationKindV2.MISMATCH, - finding_codes=finding_codes, - verification_resource_usage=verification_resource_usage, - ) - - -def _uncertified( - reason: UncertifiedReasonV2, - *finding_codes: str, - verification_resource_usage: CompilationResourceUsageV2 | None = None, -) -> CoreArtifactVerificationOutcomeV2: - return CoreArtifactVerificationOutcomeV2( - kind=CoreArtifactVerificationKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=finding_codes, - verification_resource_usage=verification_resource_usage, - ) diff --git a/src/spatialcf/core/v2/camera_cardinal_rebase.py b/src/spatialcf/core/v2/camera_cardinal_rebase.py deleted file mode 100644 index a3e5b7c..0000000 --- a/src/spatialcf/core/v2/camera_cardinal_rebase.py +++ /dev/null @@ -1,448 +0,0 @@ -"""Exact cardinal-camera rebase into the translated-camera 2.3 frame.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import ClassVar, TypeVar - -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.camera_translation import ( - CAMERA_TRANSLATION_ALGORITHM_VERSION_V2_3, - reserve_camera_translation_problem_structure_v2_3, -) -from spatialcf.core.v2.candidate_domain import CANDIDATE_DOMAIN_ALGORITHM_ID_V2 -from spatialcf.core.v2.cardinal_yaw import CARDINAL_KERNEL_VERSION_V2_1 -from spatialcf.core.v2.rectilinear_kernel import ( - RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M, - RECTILINEAR_KERNEL_ID_V2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - FactSetV2, - Vec2V2, - Vec3V2, -) -from spatialcf.domain.v2.cardinal import ( - CanonicalObjectV2_1, - CanonicalSceneV2_1, - ExactCardinalYawTransformV2, - GeometryInstanceV2_1, - ObjectPoseV2_1, - PinholeCameraV2_1, - SemanticProblemV2_1, - SupportSurfaceFactV2_1, -) -from spatialcf.domain.v2.geometry import ( - PlanarPolygonComponentV2, - PlanarRegionV2, - PlanarRingV2, -) -from spatialcf.domain.v2.result import ( - CoreSolverConfigV2, - DirectedOutwardGeometryKernelSpecV2, -) -from spatialcf.domain.v2.scene import ( - KnownFreeSpaceFactV2, - WorkspaceBoundaryFactV2, -) - -FactItemT = TypeVar("FactItemT") - -CAMERA_CARDINAL_REBASE_ALGORITHM_VERSION_V2_4 = "algorithm:2.4" - - -class CameraCardinalRebaseResourceLimitV2(RuntimeError): - pass - - -class CameraCardinalRebaseUnsupportedModelV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class CameraCardinalRebaseNumericGapV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -@dataclass(slots=True) -class CameraCardinalRebaseDomainBudgetV2(DomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = ( - CameraCardinalRebaseResourceLimitV2 - ) - - -@dataclass(frozen=True, slots=True) -class PreparedCameraCardinalRebaseProblemV2_4: - normalized_problem: SemanticProblemV2_1 - internal_config: CoreSolverConfigV2 - preprocessing_domain_operations: int - original_to_internal_quarter_turns_ccw: int - - -def registry_finding_v2_4(config: CoreSolverConfigV2) -> str | None: - if ( - config.algorithm_id != CANDIDATE_DOMAIN_ALGORITHM_ID_V2 - or config.algorithm_version != CAMERA_CARDINAL_REBASE_ALGORITHM_VERSION_V2_4 - ): - return ( - f"UNREGISTERED_ALGORITHM:{config.algorithm_id}@{config.algorithm_version}" - ) - kernel = config.geometry_kernel - registered = ( - type(kernel) is DirectedOutwardGeometryKernelSpecV2 - and kernel.kernel_id == RECTILINEAR_KERNEL_ID_V2 - and kernel.kernel_version == CARDINAL_KERNEL_VERSION_V2_1 - and kernel.certified_outward_error_m - == RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M - ) - if registered: - return None - error = getattr(kernel, "certified_outward_error_m", "NONE") - return ( - f"UNREGISTERED_GEOMETRY_KERNEL:{kernel.kernel_id}" - f"@{kernel.kernel_version}:{kernel.soundness.value}:{error}" - ) - - -def reserve_camera_cardinal_rebase_problem_structure_v2_4( - problem: SemanticProblemV2_1, - budget: CameraCardinalRebaseDomainBudgetV2, -) -> None: - """Reserve downstream and new rebase traversals before rotating coordinates.""" - - reserve_camera_translation_problem_structure_v2_3(problem, budget) - scene = problem.scene - if type(scene) is not CanonicalSceneV2_1: - raise TypeError("camera-cardinal scene has the wrong exact type") - - budget.consume() - references = _camera_reference_ids(problem) - budget.consume(len(references)) - for family_name in ( - "objects", - "geometry_instances", - "support_surfaces", - "cameras", - ): - facts = getattr(scene, family_name) - if not isinstance(facts, FactSetV2): - raise TypeError(f"{family_name} has the wrong fact-set type") - budget.consume(len(_fact_items(facts))) - - for family_name in ("workspace_boundaries", "known_free_spaces"): - facts = getattr(scene, family_name) - if not isinstance(facts, FactSetV2): - raise TypeError(f"{family_name} has the wrong fact-set type") - for item in _fact_items(facts): - budget.consume() - components = item.region_world_xy.components - budget.consume(len(components)) - for component in components: - budget.consume(1 + len(component.exterior.vertices)) - budget.consume(len(component.holes)) - for hole in component.holes: - budget.consume(len(hole.vertices)) - budget.consume(2) - - -def prepare_camera_cardinal_rebase_problem_v2_4( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - preprocessing_domain_operations: int, -) -> PreparedCameraCardinalRebaseProblemV2_4: - scene = problem.scene - camera = _single_evaluation_camera(problem) - quarter_turns = camera.world_to_camera.quarter_turns_ccw - - objects = _map_fact_set( - scene.objects, - CanonicalObjectV2_1, - lambda item: _rotate_object(item, quarter_turns), - ) - geometries = _map_fact_set( - scene.geometry_instances, - GeometryInstanceV2_1, - lambda item: _rotate_geometry(item, quarter_turns), - ) - surfaces = _map_fact_set( - scene.support_surfaces, - SupportSurfaceFactV2_1, - lambda item: _rotate_surface(item, quarter_turns), - ) - workspaces = _map_fact_set( - scene.workspace_boundaries, - WorkspaceBoundaryFactV2, - lambda item: _rotate_world_region_fact(item, quarter_turns), - ) - free_spaces = _map_fact_set( - scene.known_free_spaces, - KnownFreeSpaceFactV2, - lambda item: _rotate_world_region_fact(item, quarter_turns), - ) - cameras = _map_fact_set( - scene.cameras, - PinholeCameraV2_1, - _zero_camera_rotation, - ) - normalized_scene = CanonicalSceneV2_1.model_validate( - scene.model_copy( - update={ - "objects": objects, - "geometry_instances": geometries, - "workspace_boundaries": workspaces, - "known_free_spaces": free_spaces, - "support_surfaces": surfaces, - "cameras": cameras, - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - normalized_problem = SemanticProblemV2_1.model_validate( - problem.model_copy(update={"scene": normalized_scene}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - remaining = config.max_domain_operations - preprocessing_domain_operations - if remaining < 1: - raise CameraCardinalRebaseResourceLimitV2 - config_payload = config.model_dump(mode="python", warnings="error") - config_payload["algorithm_version"] = CAMERA_TRANSLATION_ALGORITHM_VERSION_V2_3 - config_payload["max_domain_operations"] = remaining - return PreparedCameraCardinalRebaseProblemV2_4( - normalized_problem=normalized_problem, - internal_config=CoreSolverConfigV2.model_validate( - config_payload, - strict=True, - ), - preprocessing_domain_operations=preprocessing_domain_operations, - original_to_internal_quarter_turns_ccw=quarter_turns, - ) - - -def _single_evaluation_camera(problem: SemanticProblemV2_1) -> PinholeCameraV2_1: - facts = problem.scene.cameras - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.values is None - or len(facts.values) != 1 - or facts.inner_values is not None - or facts.outer_values is not None - ): - raise CameraCardinalRebaseUnsupportedModelV2( - "UNSUPPORTED_MODEL:SINGLE_EXACT_CAMERA_REQUIRED" - ) - camera = facts.values[0] - if type(camera) is not PinholeCameraV2_1: - raise TypeError("camera fact has the wrong exact type") - if _camera_reference_ids(problem) != {camera.camera_id}: - raise CameraCardinalRebaseUnsupportedModelV2( - "UNSUPPORTED_MODEL:CAMERA_REFERENCE_MISMATCH" - ) - return camera - - -def _map_fact_set( - facts: FactSetV2, - output_type: type[FactItemT], - mapper: Callable[[object], FactItemT], -) -> FactSetV2[FactItemT]: - payload: dict[str, object] = { - "availability": facts.availability, - "completeness": facts.completeness, - "uncertainty": facts.uncertainty, - } - for field_name in ("values", "inner_values", "outer_values"): - values = getattr(facts, field_name) - payload[field_name] = ( - None if values is None else tuple(mapper(item) for item in values) - ) - return FactSetV2[output_type].model_validate(payload, strict=True) - - -def _rotate_object( - item: CanonicalObjectV2_1, - quarter_turns: int, -) -> CanonicalObjectV2_1: - if type(item) is not CanonicalObjectV2_1: - raise TypeError("object fact has the wrong exact type") - pose = ObjectPoseV2_1( - world_from_object=_rotate_transform( - item.pose.world_from_object, - quarter_turns, - ) - ) - return CanonicalObjectV2_1.model_validate( - item.model_copy(update={"pose": pose}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _rotate_geometry( - item: GeometryInstanceV2_1, - quarter_turns: int, -) -> GeometryInstanceV2_1: - if type(item) is not GeometryInstanceV2_1: - raise TypeError("geometry fact has the wrong exact type") - if item.owner_object_id is not None: - return item - transform = _rotate_transform(item.anchor_from_geometry, quarter_turns) - return GeometryInstanceV2_1.model_validate( - item.model_copy(update={"anchor_from_geometry": transform}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _rotate_surface( - item: SupportSurfaceFactV2_1, - quarter_turns: int, -) -> SupportSurfaceFactV2_1: - if type(item) is not SupportSurfaceFactV2_1: - raise TypeError("support surface fact has the wrong exact type") - if item.owner_object_id is not None: - return item - transform = _rotate_transform(item.anchor_from_surface, quarter_turns) - return SupportSurfaceFactV2_1.model_validate( - item.model_copy(update={"anchor_from_surface": transform}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _rotate_world_region_fact( - item: WorkspaceBoundaryFactV2 | KnownFreeSpaceFactV2, - quarter_turns: int, -) -> WorkspaceBoundaryFactV2 | KnownFreeSpaceFactV2: - if type(item) not in (WorkspaceBoundaryFactV2, KnownFreeSpaceFactV2): - raise TypeError("world-region fact has the wrong exact type") - region = _rotate_region(item.region_world_xy, quarter_turns) - return type(item).model_validate( - item.model_copy(update={"region_world_xy": region}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _rotate_region(region: PlanarRegionV2, quarter_turns: int) -> PlanarRegionV2: - return PlanarRegionV2( - components=tuple( - PlanarPolygonComponentV2( - exterior=_rotate_ring(component.exterior, quarter_turns), - holes=tuple( - _rotate_ring(hole, quarter_turns) for hole in component.holes - ), - ) - for component in region.components - ) - ) - - -def _rotate_ring(ring: PlanarRingV2, quarter_turns: int) -> PlanarRingV2: - return PlanarRingV2( - winding=ring.winding, - vertices=tuple( - Vec2V2(x=x, y=y) - for x, y in ( - _rotate_xy(point.x, point.y, quarter_turns) for point in ring.vertices - ) - ), - ) - - -def _rotate_transform( - transform: ExactCardinalYawTransformV2, - quarter_turns: int, -) -> ExactCardinalYawTransformV2: - translation = transform.translation - x, y = _rotate_xy(translation.x, translation.y, quarter_turns) - return ExactCardinalYawTransformV2( - translation=Vec3V2(x=x, y=y, z=translation.z), - quarter_turns_ccw=(quarter_turns + transform.quarter_turns_ccw) % 4, - ) - - -def _zero_camera_rotation(camera: PinholeCameraV2_1) -> PinholeCameraV2_1: - if type(camera) is not PinholeCameraV2_1: - raise TypeError("camera fact has the wrong exact type") - transform = camera.world_to_camera - return PinholeCameraV2_1.model_validate( - camera.model_copy( - update={ - "world_to_camera": ExactCardinalYawTransformV2( - translation=transform.translation, - quarter_turns_ccw=0, - ) - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - - -def _rotate_xy(x: float, y: float, quarter_turns: int) -> tuple[float, float]: - if quarter_turns == 0: - rotated = x, y - elif quarter_turns == 1: - rotated = -y, x - elif quarter_turns == 2: - rotated = -x, -y - elif quarter_turns == 3: - rotated = y, -x - else: # pragma: no cover - exact transform invariant - raise ValueError("quarter_turns must lie in 0..3") - return tuple(0.0 if value == 0.0 else value for value in rotated) # type: ignore[return-value] - - -def _fact_items(facts: FactSetV2) -> tuple[object, ...]: - return tuple( - item - for values in (facts.values, facts.inner_values, facts.outer_values) - if values is not None - for item in values - ) - - -def _camera_reference_ids(problem: SemanticProblemV2_1) -> set[str]: - constraints = problem.constraints - objective = problem.objective - return { - constraints.target_relation.camera_id, - objective.relation_damage.evaluation_camera_id, - *(item.camera_id for item in constraints.visibility_constraints), - *( - item.key.camera_id - for item in objective.visibility_change.object_camera_weights - ), - } - - -__all__ = ( - "CAMERA_CARDINAL_REBASE_ALGORITHM_VERSION_V2_4", - "CameraCardinalRebaseDomainBudgetV2", - "CameraCardinalRebaseNumericGapV2", - "CameraCardinalRebaseResourceLimitV2", - "CameraCardinalRebaseUnsupportedModelV2", - "PreparedCameraCardinalRebaseProblemV2_4", - "prepare_camera_cardinal_rebase_problem_v2_4", - "registry_finding_v2_4", - "reserve_camera_cardinal_rebase_problem_structure_v2_4", -) diff --git a/src/spatialcf/core/v2/camera_cardinal_result.py b/src/spatialcf/core/v2/camera_cardinal_result.py deleted file mode 100644 index fe27783..0000000 --- a/src/spatialcf/core/v2/camera_cardinal_result.py +++ /dev/null @@ -1,518 +0,0 @@ -"""Pull exact cardinal-camera delta artifacts back into semantic world XY.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import ClassVar - -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.camera_cardinal_rebase import _rotate_xy -from spatialcf.core.v2.camera_translation import ( - _candidate_variable_for_original_problem_v2_3, -) -from spatialcf.core.v2.minimum_cost_solver import CanonicalMinimumCostSolveOutcomeV2 -from spatialcf.core.v2.minimum_cost_solver_v2_1 import _rebind_outcome -from spatialcf.domain.v2.artifacts import ( - CandidateDomainArtifactV2, - ConstraintDomainShrinkStepV2, - ObjectivePartitionArtifactV2, - ObjectivePartitionCellV2, - PlanarDomainBoundsV2, - PlanarRegionBoundV2, - RegionBoundStatusV2, - RelationCostCellV2, - RelationCostPartitionV2, -) -from spatialcf.domain.v2.base import Vec2V2 -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.certificate import ( - GlobalOptimalityCertificateV2, - ProvenUnsatCertificateV2, -) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.geometry import ( - PlanarPolygonComponentV2, - PlanarRegionV2, - PlanarRingV2, -) -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) - - -class CameraCardinalResultResourceLimitV2(RuntimeError): - pass - - -@dataclass(slots=True) -class CameraCardinalResultDomainBudgetV2(DomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = ( - CameraCardinalResultResourceLimitV2 - ) - - -def pullback_camera_cardinal_outcome_v2_4( - replay: CanonicalMinimumCostSolveOutcomeV2, - original_problem: SemanticProblemV2_1, - original_config: CoreSolverConfigV2, - *, - preprocessing_domain_operations: int, - original_to_internal_quarter_turns_ccw: int, - postprocessing_budget: CameraCardinalResultDomainBudgetV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Pull one fresh normalized replay back and rebuild every hash reference.""" - - if type(replay) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("camera-cardinal replay has the wrong exact type") - if type(original_problem) is not SemanticProblemV2_1: - raise TypeError("camera-cardinal original problem has the wrong exact type") - if type(original_config) is not CoreSolverConfigV2: - raise TypeError("camera-cardinal original config has the wrong exact type") - if type(postprocessing_budget) is not CameraCardinalResultDomainBudgetV2: - raise TypeError("postprocessing budget has the wrong exact type") - if ( - type(preprocessing_domain_operations) is not int - or preprocessing_domain_operations < 0 - ): - raise TypeError("preprocessing domain operations must be non-negative") - _inverse_quarter_turns(original_to_internal_quarter_turns_ccw) - usage = replay.cumulative_generation_usage - if replay.result is None or usage is None: - raise ValueError("camera-cardinal replay omitted its result or usage") - expected_start = preprocessing_domain_operations + usage.domain_operations - if postprocessing_budget.used != expected_start: - raise ValueError("camera-cardinal postprocessing ledger did not resume replay") - - start = postprocessing_budget.used - candidate_post = reserve_camera_cardinal_outcome_structure_v2_4( - replay, - postprocessing_budget, - ) - total_post = postprocessing_budget.used - start - mapped_replay = _map_replay_outcome( - replay, - original_to_internal_quarter_turns_ccw, - ) - candidate_increment = preprocessing_domain_operations + candidate_post - if type(replay.result) is ProvenUnsatResultV2: - candidate_increment = preprocessing_domain_operations + total_post - return _rebind_outcome( - mapped_replay, - original_problem, - original_config, - candidate_increment, - candidate_variable_override=( - _candidate_variable_for_original_problem_v2_3(original_problem) - ), - cumulative_domain_operations_increment=( - preprocessing_domain_operations + total_post - ), - ) - - -def reserve_camera_cardinal_outcome_structure_v2_4( - replay: CanonicalMinimumCostSolveOutcomeV2, - budget: CameraCardinalResultDomainBudgetV2, -) -> int: - """Reserve a complete pullback pass before reading any domain coordinate.""" - - if type(replay) is not CanonicalMinimumCostSolveOutcomeV2: - raise TypeError("camera-cardinal replay has the wrong exact type") - result = replay.result - if result is None: - raise ValueError("camera-cardinal replay omitted its result") - budget.consume() - candidate = getattr(result, "candidate_domain", None) - candidate_start = budget.used - if candidate is not None: - if type(candidate) is not CandidateDomainArtifactV2: - raise TypeError("replay candidate has the wrong exact type") - budget.consume(2 + len(candidate.shrink_ledger)) - _reserve_domain(candidate.search_universe, budget) - _reserve_domain(candidate.hard_domain, budget) - for step in candidate.shrink_ledger: - _reserve_domain(step.input_domain, budget) - if step.constraint_domain is not None: - _reserve_domain(step.constraint_domain, budget) - _reserve_domain(step.output_domain, budget) - candidate_post = budget.used - candidate_start - - relation = getattr(result, "relation_cost_partition", None) - if relation is not None: - if type(relation) is not RelationCostPartitionV2: - raise TypeError("replay relation partition has the wrong exact type") - budget.consume(1 + len(relation.cells)) - for cell in relation.cells: - _reserve_domain(cell.domain, budget) - - objective = getattr(result, "objective_partition", None) - if objective is not None: - if type(objective) is not ObjectivePartitionArtifactV2: - raise TypeError("replay objective partition has the wrong exact type") - budget.consume(1 + len(objective.cells)) - for cell in objective.cells: - _reserve_domain(cell.domain, budget) - - if type(result) is CertifiedSuccessResultV2: - budget.consume(3) - elif type(result) is ProvenUnsatResultV2: - budget.consume(2) - elif type(result) is UncertifiedResultV2: - budget.consume() - else: - raise TypeError("camera-cardinal replay returned an invalid result type") - budget.consume() - return candidate_post - - -def _reserve_domain( - domain: PlanarDomainBoundsV2, - budget: CameraCardinalResultDomainBudgetV2, -) -> None: - if type(domain) is not PlanarDomainBoundsV2: - raise TypeError("delta domain has the wrong exact type") - budget.consume() - for bound in (domain.inner_bound, domain.outer_bound): - budget.consume() - if bound.status is not RegionBoundStatusV2.NON_EMPTY: - continue - region = bound.region - if region is None: # pragma: no cover - model invariant - raise RuntimeError("NON_EMPTY region bound omitted its region") - budget.consume(1 + len(region.components)) - for component in region.components: - budget.consume(1 + len(component.exterior.vertices)) - budget.consume(len(component.holes)) - for hole in component.holes: - budget.consume(len(hole.vertices)) - - -def _pullback_planar_domain_v2_4( - domain: PlanarDomainBoundsV2, - *, - original_to_internal_quarter_turns_ccw: int, -) -> PlanarDomainBoundsV2: - if type(domain) is not PlanarDomainBoundsV2: - raise TypeError("delta domain has the wrong exact type") - inverse_quarter_turns = _inverse_quarter_turns( - original_to_internal_quarter_turns_ccw - ) - return PlanarDomainBoundsV2( - inner_bound=_pullback_region_bound(domain.inner_bound, inverse_quarter_turns), - outer_bound=_pullback_region_bound(domain.outer_bound, inverse_quarter_turns), - completeness=domain.completeness, - coverage=domain.coverage, - ) - - -def _map_replay_outcome( - replay: CanonicalMinimumCostSolveOutcomeV2, - original_to_internal_quarter_turns_ccw: int, -) -> CanonicalMinimumCostSolveOutcomeV2: - old_result = replay.result - if old_result is None: # pragma: no cover - caller gate - raise RuntimeError("cannot map a missing replay result") - old_candidate = getattr(old_result, "candidate_domain", None) - candidate = ( - None - if old_candidate is None - else _pullback_candidate( - old_candidate, - original_to_internal_quarter_turns_ccw, - ) - ) - old_relation = getattr(old_result, "relation_cost_partition", None) - relation = None - if old_relation is not None: - if candidate is None: - raise RuntimeError("relation replay omitted its candidate") - relation = _pullback_relation( - old_relation, - candidate, - original_to_internal_quarter_turns_ccw, - ) - old_objective = getattr(old_result, "objective_partition", None) - objective = None - if old_objective is not None: - if candidate is None or relation is None: - raise RuntimeError("objective replay omitted its upstream artifacts") - objective = _pullback_objective( - old_objective, - candidate, - relation, - original_to_internal_quarter_turns_ccw, - ) - - if type(old_result) is CertifiedSuccessResultV2: - if candidate is None or relation is None or objective is None: - raise RuntimeError("success replay omitted its artifact chain") - edit = _pullback_edit( - old_result.edit, - original_to_internal_quarter_turns_ccw, - ) - certificate = GlobalOptimalityCertificateV2.model_validate( - old_result.certificate.model_copy( - update={ - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - "relation_cost_partition_sha256": ( - relation.relation_cost_partition_sha256 - ), - "objective_partition_artifact_sha256": ( - objective.objective_partition_artifact_sha256 - ), - "edit_sha256": edit.edit_sha256, - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - mapped_result = CertifiedSuccessResultV2( - semantic_problem_sha256=old_result.semantic_problem_sha256, - core_solver_config=old_result.core_solver_config, - candidate_domain=candidate, - relation_cost_partition=relation, - objective_partition=objective, - edit=edit, - global_loss_lower_bound=old_result.global_loss_lower_bound, - witness_loss_bounds=old_result.witness_loss_bounds, - certificate=certificate, - ) - elif type(old_result) is ProvenUnsatResultV2: - if candidate is None: - raise RuntimeError("UNSAT replay omitted its candidate") - certificate = ProvenUnsatCertificateV2.model_validate( - old_result.certificate.model_copy( - update={ - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ) - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - mapped_result = ProvenUnsatResultV2( - semantic_problem_sha256=old_result.semantic_problem_sha256, - core_solver_config=old_result.core_solver_config, - candidate_domain=candidate, - certificate=certificate, - ) - elif type(old_result) is UncertifiedResultV2: - mapped_result = UncertifiedResultV2( - semantic_problem_sha256=old_result.semantic_problem_sha256, - core_solver_config=old_result.core_solver_config, - uncertified_reason=old_result.uncertified_reason, - candidate_domain=candidate, - relation_cost_partition=relation, - objective_partition=objective, - ) - else: # pragma: no cover - caller reservation gate - raise TypeError("camera-cardinal replay returned an invalid result type") - return CanonicalMinimumCostSolveOutcomeV2( - result=mapped_result, - finding_codes=replay.finding_codes, - cumulative_generation_usage=replay.cumulative_generation_usage, - proposal_count=replay.proposal_count, - evaluated_proposal_count=replay.evaluated_proposal_count, - ) - - -def _pullback_candidate( - candidate: CandidateDomainArtifactV2, - quarter_turns: int, -) -> CandidateDomainArtifactV2: - steps = tuple( - ConstraintDomainShrinkStepV2( - step_index=step.step_index, - constraint_id=step.constraint_id, - constraint_kind=step.constraint_kind, - disposition=step.disposition, - constraint_domain=( - None - if step.constraint_domain is None - else _pullback_planar_domain_v2_4( - step.constraint_domain, - original_to_internal_quarter_turns_ccw=quarter_turns, - ) - ), - input_domain=_pullback_planar_domain_v2_4( - step.input_domain, - original_to_internal_quarter_turns_ccw=quarter_turns, - ), - output_domain=_pullback_planar_domain_v2_4( - step.output_domain, - original_to_internal_quarter_turns_ccw=quarter_turns, - ), - ) - for step in candidate.shrink_ledger - ) - return CandidateDomainArtifactV2.model_validate( - candidate.model_copy( - update={ - "search_universe": _pullback_planar_domain_v2_4( - candidate.search_universe, - original_to_internal_quarter_turns_ccw=quarter_turns, - ), - "hard_domain": _pullback_planar_domain_v2_4( - candidate.hard_domain, - original_to_internal_quarter_turns_ccw=quarter_turns, - ), - "shrink_ledger": steps, - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - - -def _pullback_relation( - relation: RelationCostPartitionV2, - candidate: CandidateDomainArtifactV2, - quarter_turns: int, -) -> RelationCostPartitionV2: - cells = tuple( - RelationCostCellV2( - cell_id=cell.cell_id, - domain=_pullback_planar_domain_v2_4( - cell.domain, - original_to_internal_quarter_turns_ccw=quarter_turns, - ), - relation_damage_vector=cell.relation_damage_vector, - ) - for cell in relation.cells - ) - return RelationCostPartitionV2.model_validate( - relation.model_copy( - update={ - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - "cells": cells, - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - - -def _pullback_objective( - objective: ObjectivePartitionArtifactV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionV2, - quarter_turns: int, -) -> ObjectivePartitionArtifactV2: - cells = tuple( - ObjectivePartitionCellV2( - cell_id=cell.cell_id, - parent_relation_cell_id=cell.parent_relation_cell_id, - domain=_pullback_planar_domain_v2_4( - cell.domain, - original_to_internal_quarter_turns_ccw=quarter_turns, - ), - relation_damage_vector=cell.relation_damage_vector, - term_loss_bounds=cell.term_loss_bounds, - constraint_slacks=cell.constraint_slacks, - ) - for cell in objective.cells - ) - return ObjectivePartitionArtifactV2.model_validate( - objective.model_copy( - update={ - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - "relation_cost_partition_sha256": ( - relation.relation_cost_partition_sha256 - ), - "cells": cells, - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - - -def _pullback_edit(edit: CanonicalEditV2, quarter_turns: int) -> CanonicalEditV2: - inverse = _inverse_quarter_turns(quarter_turns) - translation = edit.translation_xy_m - x, y = _rotate_xy(translation.x, translation.y, inverse) - return CanonicalEditV2( - semantic_problem_sha256=edit.semantic_problem_sha256, - subject_id=edit.subject_id, - translation_xy_m=Vec2V2(x=x, y=y), - ) - - -def _pullback_region_bound( - bound: PlanarRegionBoundV2, - inverse_quarter_turns: int, -) -> PlanarRegionBoundV2: - if type(bound) is not PlanarRegionBoundV2: - raise TypeError("region bound has the wrong exact type") - if bound.status is RegionBoundStatusV2.EMPTY: - return PlanarRegionBoundV2.empty() - if bound.status is RegionBoundStatusV2.UNAVAILABLE: - return PlanarRegionBoundV2.unavailable() - region = bound.region - if region is None: # pragma: no cover - model invariant - raise RuntimeError("NON_EMPTY region bound omitted its region") - return PlanarRegionBoundV2.non_empty( - _pullback_region(region, inverse_quarter_turns) - ) - - -def _pullback_region( - region: PlanarRegionV2, - inverse_quarter_turns: int, -) -> PlanarRegionV2: - if type(region) is not PlanarRegionV2: - raise TypeError("planar region has the wrong exact type") - return PlanarRegionV2( - components=tuple( - PlanarPolygonComponentV2( - exterior=_pullback_ring( - component.exterior, - inverse_quarter_turns, - ), - holes=tuple( - _pullback_ring(hole, inverse_quarter_turns) - for hole in component.holes - ), - ) - for component in region.components - ) - ) - - -def _pullback_ring( - ring: PlanarRingV2, - inverse_quarter_turns: int, -) -> PlanarRingV2: - if type(ring) is not PlanarRingV2: - raise TypeError("planar ring has the wrong exact type") - return PlanarRingV2( - winding=ring.winding, - vertices=tuple( - Vec2V2(x=x, y=y) - for x, y in ( - _rotate_xy(point.x, point.y, inverse_quarter_turns) - for point in ring.vertices - ) - ), - ) - - -def _inverse_quarter_turns(quarter_turns: int) -> int: - if type(quarter_turns) is not int or not 0 <= quarter_turns <= 3: - raise TypeError("quarter_turns must be an exact int in 0..3") - return (-quarter_turns) % 4 - - -__all__ = ( - "CameraCardinalResultDomainBudgetV2", - "CameraCardinalResultResourceLimitV2", - "pullback_camera_cardinal_outcome_v2_4", - "reserve_camera_cardinal_outcome_structure_v2_4", -) diff --git a/src/spatialcf/core/v2/camera_translation.py b/src/spatialcf/core/v2/camera_translation.py deleted file mode 100644 index 8292747..0000000 --- a/src/spatialcf/core/v2/camera_translation.py +++ /dev/null @@ -1,501 +0,0 @@ -"""Exact camera-translation capability and preprocessing budget.""" - -from __future__ import annotations - -import math -from collections.abc import Callable -from dataclasses import dataclass -from fractions import Fraction -from typing import ClassVar, TypeVar - -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.candidate_domain import CANDIDATE_DOMAIN_ALGORITHM_ID_V2 -from spatialcf.core.v2.cardinal_yaw import CARDINAL_KERNEL_VERSION_V2_1 -from spatialcf.core.v2.rectilinear_kernel import ( - RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M, - RECTILINEAR_KERNEL_ID_V2, -) -from spatialcf.core.v2.zero_distortion import ( - ZERO_DISTORTION_ALGORITHM_VERSION_V2_2, - reserve_zero_distortion_problem_structure_v2_2, -) -from spatialcf.domain.v2.artifacts import CandidateDomainVariableV2 -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - FactSetV2, - Vec2V2, - Vec3V2, -) -from spatialcf.domain.v2.cardinal import ( - CanonicalObjectV2_1, - CanonicalSceneV2_1, - ExactCardinalYawTransformV2, - GeometryInstanceV2_1, - ObjectPoseV2_1, - PinholeCameraV2_1, - SemanticProblemV2_1, - SupportSurfaceFactV2_1, -) -from spatialcf.domain.v2.geometry import ( - PlanarPolygonComponentV2, - PlanarRegionV2, - PlanarRingV2, -) -from spatialcf.domain.v2.result import ( - CoreSolverConfigV2, - DirectedOutwardGeometryKernelSpecV2, -) -from spatialcf.domain.v2.scene import ( - KnownFreeSpaceFactV2, - WorkspaceBoundaryFactV2, -) - -FactItemT = TypeVar("FactItemT") - -CAMERA_TRANSLATION_ALGORITHM_VERSION_V2_3 = "algorithm:2.3" - - -class CameraTranslationResourceLimitV2(RuntimeError): - pass - - -class CameraTranslationUnsupportedModelV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class CameraTranslationNumericGapV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -@dataclass(slots=True) -class CameraTranslationDomainBudgetV2(DomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = ( - CameraTranslationResourceLimitV2 - ) - - -@dataclass(frozen=True, slots=True) -class PreparedCameraTranslationProblemV2_3: - normalized_problem: SemanticProblemV2_1 - internal_config: CoreSolverConfigV2 - preprocessing_domain_operations: int - - -def registry_finding_v2_3(config: CoreSolverConfigV2) -> str | None: - if ( - config.algorithm_id != CANDIDATE_DOMAIN_ALGORITHM_ID_V2 - or config.algorithm_version != CAMERA_TRANSLATION_ALGORITHM_VERSION_V2_3 - ): - return ( - f"UNREGISTERED_ALGORITHM:{config.algorithm_id}@{config.algorithm_version}" - ) - kernel = config.geometry_kernel - registered = ( - type(kernel) is DirectedOutwardGeometryKernelSpecV2 - and kernel.kernel_id == RECTILINEAR_KERNEL_ID_V2 - and kernel.kernel_version == CARDINAL_KERNEL_VERSION_V2_1 - and kernel.certified_outward_error_m - == RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M - ) - if registered: - return None - error = getattr(kernel, "certified_outward_error_m", "NONE") - return ( - f"UNREGISTERED_GEOMETRY_KERNEL:{kernel.kernel_id}" - f"@{kernel.kernel_version}:{kernel.soundness.value}:{error}" - ) - - -def reserve_camera_translation_problem_structure_v2_3( - problem: SemanticProblemV2_1, - budget: CameraTranslationDomainBudgetV2, -) -> None: - """Reserve all frozen rebasing loops before inspecting coordinates.""" - - reserve_zero_distortion_problem_structure_v2_2(problem, budget) - scene = problem.scene - if type(scene) is not CanonicalSceneV2_1: - raise TypeError("camera-translation scene has the wrong exact type") - - budget.consume() - references = _camera_reference_ids(problem) - budget.consume(len(references)) - - for family_name in ( - "objects", - "geometry_instances", - "support_surfaces", - "cameras", - ): - facts = getattr(scene, family_name) - if not isinstance(facts, FactSetV2): - raise TypeError(f"{family_name} has the wrong fact-set type") - budget.consume(len(_fact_items(facts))) - - for family_name in ("workspace_boundaries", "known_free_spaces"): - facts = getattr(scene, family_name) - if not isinstance(facts, FactSetV2): - raise TypeError(f"{family_name} has the wrong fact-set type") - for item in _fact_items(facts): - budget.consume() - components = item.region_world_xy.components - budget.consume(len(components)) - for component in components: - budget.consume(1 + len(component.exterior.vertices)) - budget.consume(len(component.holes)) - for hole in component.holes: - budget.consume(len(hole.vertices)) - - budget.consume(2) - - -def prepare_camera_translation_problem_v2_3( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - preprocessing_domain_operations: int, -) -> PreparedCameraTranslationProblemV2_3: - scene = problem.scene - camera = _single_evaluation_camera(problem) - transform = camera.world_to_camera - if transform.quarter_turns_ccw != 0: - raise CameraTranslationUnsupportedModelV2("UNSUPPORTED_MODEL:CAMERA_ROTATION") - shift = transform.translation - - objects = _map_fact_set( - scene.objects, - CanonicalObjectV2_1, - lambda item: _shift_object(item, shift), - ) - geometries = _map_fact_set( - scene.geometry_instances, - GeometryInstanceV2_1, - lambda item: _shift_geometry(item, shift), - ) - surfaces = _map_fact_set( - scene.support_surfaces, - SupportSurfaceFactV2_1, - lambda item: _shift_surface(item, shift), - ) - workspaces = _map_fact_set( - scene.workspace_boundaries, - WorkspaceBoundaryFactV2, - lambda item: _shift_world_region_fact(item, shift), - ) - free_spaces = _map_fact_set( - scene.known_free_spaces, - KnownFreeSpaceFactV2, - lambda item: _shift_world_region_fact(item, shift), - ) - cameras = _map_fact_set( - scene.cameras, - PinholeCameraV2_1, - _zero_camera_translation, - ) - normalized_scene = CanonicalSceneV2_1.model_validate( - scene.model_copy( - update={ - "objects": objects, - "geometry_instances": geometries, - "workspace_boundaries": workspaces, - "known_free_spaces": free_spaces, - "support_surfaces": surfaces, - "cameras": cameras, - } - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - normalized_problem = SemanticProblemV2_1.model_validate( - problem.model_copy(update={"scene": normalized_scene}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - remaining = config.max_domain_operations - preprocessing_domain_operations - if remaining < 1: - raise CameraTranslationResourceLimitV2 - config_payload = config.model_dump(mode="python", warnings="error") - config_payload["algorithm_version"] = ZERO_DISTORTION_ALGORITHM_VERSION_V2_2 - config_payload["max_domain_operations"] = remaining - return PreparedCameraTranslationProblemV2_3( - normalized_problem=normalized_problem, - internal_config=CoreSolverConfigV2.model_validate( - config_payload, - strict=True, - ), - preprocessing_domain_operations=preprocessing_domain_operations, - ) - - -def _candidate_variable_for_original_problem_v2_3( - problem: SemanticProblemV2_1, -) -> CandidateDomainVariableV2: - """Return the edit variable in the original world coordinate frame.""" - - subject_id = problem.constraints.allowed_edit.subject_id - objects = problem.scene.objects.values - if objects is None: - raise TypeError("camera-translation problem omitted exact object facts") - subject = next((item for item in objects if item.object_id == subject_id), None) - if type(subject) is not CanonicalObjectV2_1: - raise TypeError("camera-translation subject has the wrong exact type") - translation = subject.pose.world_from_object.translation - return CandidateDomainVariableV2( - subject_id=subject_id, - baseline_anchor_world_xy_m=Vec2V2( - x=translation.x, - y=translation.y, - ), - ) - - -def _single_evaluation_camera(problem: SemanticProblemV2_1) -> PinholeCameraV2_1: - facts = problem.scene.cameras - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.values is None - or len(facts.values) != 1 - or facts.inner_values is not None - or facts.outer_values is not None - ): - raise CameraTranslationUnsupportedModelV2( - "UNSUPPORTED_MODEL:SINGLE_EXACT_CAMERA_REQUIRED" - ) - camera = facts.values[0] - if type(camera) is not PinholeCameraV2_1: - raise TypeError("camera fact has the wrong exact type") - if _camera_reference_ids(problem) != {camera.camera_id}: - raise CameraTranslationUnsupportedModelV2( - "UNSUPPORTED_MODEL:CAMERA_REFERENCE_MISMATCH" - ) - return camera - - -def _map_fact_set( - facts: FactSetV2, - output_type: type[FactItemT], - mapper: Callable[[object], FactItemT], -) -> FactSetV2[FactItemT]: - payload: dict[str, object] = { - "availability": facts.availability, - "completeness": facts.completeness, - "uncertainty": facts.uncertainty, - } - for field_name in ("values", "inner_values", "outer_values"): - values = getattr(facts, field_name) - payload[field_name] = ( - None if values is None else tuple(mapper(item) for item in values) - ) - return FactSetV2[output_type].model_validate(payload, strict=True) - - -def _shift_object( - item: CanonicalObjectV2_1, - shift: Vec3V2, -) -> CanonicalObjectV2_1: - if type(item) is not CanonicalObjectV2_1: - raise TypeError("object fact has the wrong exact type") - pose = ObjectPoseV2_1( - world_from_object=_shift_transform( - item.pose.world_from_object, - shift, - f"object:{item.object_id}", - ) - ) - return CanonicalObjectV2_1.model_validate( - item.model_copy(update={"pose": pose}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _shift_geometry( - item: GeometryInstanceV2_1, - shift: Vec3V2, -) -> GeometryInstanceV2_1: - if type(item) is not GeometryInstanceV2_1: - raise TypeError("geometry fact has the wrong exact type") - if item.owner_object_id is not None: - return item - transform = _shift_transform( - item.anchor_from_geometry, - shift, - f"geometry:{item.geometry_id}", - ) - return GeometryInstanceV2_1.model_validate( - item.model_copy(update={"anchor_from_geometry": transform}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _shift_surface( - item: SupportSurfaceFactV2_1, - shift: Vec3V2, -) -> SupportSurfaceFactV2_1: - if type(item) is not SupportSurfaceFactV2_1: - raise TypeError("support surface fact has the wrong exact type") - if item.owner_object_id is not None: - return item - transform = _shift_transform( - item.anchor_from_surface, - shift, - f"support:{item.surface_id}", - ) - return SupportSurfaceFactV2_1.model_validate( - item.model_copy(update={"anchor_from_surface": transform}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _shift_world_region_fact( - item: WorkspaceBoundaryFactV2 | KnownFreeSpaceFactV2, - shift: Vec3V2, -) -> WorkspaceBoundaryFactV2 | KnownFreeSpaceFactV2: - if type(item) not in (WorkspaceBoundaryFactV2, KnownFreeSpaceFactV2): - raise TypeError("world-region fact has the wrong exact type") - region = _shift_region(item.region_world_xy, shift, item.fact_id) - return type(item).model_validate( - item.model_copy(update={"region_world_xy": region}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - - -def _shift_region( - region: PlanarRegionV2, - shift: Vec3V2, - field_name: str, -) -> PlanarRegionV2: - return PlanarRegionV2( - components=tuple( - PlanarPolygonComponentV2( - exterior=_shift_ring( - component.exterior, - shift, - f"{field_name}:exterior", - ), - holes=tuple( - _shift_ring(hole, shift, f"{field_name}:hole") - for hole in component.holes - ), - ) - for component in region.components - ) - ) - - -def _shift_ring( - ring: PlanarRingV2, - shift: Vec3V2, - field_name: str, -) -> PlanarRingV2: - return PlanarRingV2( - winding=ring.winding, - vertices=tuple( - Vec2V2( - x=_exact_add_binary64(point.x, shift.x, f"{field_name}:x"), - y=_exact_add_binary64(point.y, shift.y, f"{field_name}:y"), - ) - for point in ring.vertices - ), - ) - - -def _shift_transform( - transform: ExactCardinalYawTransformV2, - shift: Vec3V2, - field_name: str, -) -> ExactCardinalYawTransformV2: - translation = transform.translation - return ExactCardinalYawTransformV2( - translation=Vec3V2( - x=_exact_add_binary64(translation.x, shift.x, f"{field_name}:x"), - y=_exact_add_binary64(translation.y, shift.y, f"{field_name}:y"), - z=_exact_add_binary64(translation.z, shift.z, f"{field_name}:z"), - ), - quarter_turns_ccw=transform.quarter_turns_ccw, - ) - - -def _zero_camera_translation(camera: PinholeCameraV2_1) -> PinholeCameraV2_1: - if type(camera) is not PinholeCameraV2_1: - raise TypeError("camera fact has the wrong exact type") - transform = camera.world_to_camera - if transform.quarter_turns_ccw != 0: - raise CameraTranslationUnsupportedModelV2("UNSUPPORTED_MODEL:CAMERA_ROTATION") - return PinholeCameraV2_1.model_validate( - camera.model_copy( - update={"world_to_camera": ExactCardinalYawTransformV2.identity()} - ).model_dump(mode="python", warnings="error"), - strict=True, - ) - - -def _exact_add_binary64(left: float, right: float, field_name: str) -> float: - exact = Fraction.from_float(left) + Fraction.from_float(right) - try: - candidate = float(exact) - except (OverflowError, ValueError) as error: - raise CameraTranslationNumericGapV2( - f"NUMERIC_GAP:CAMERA_TRANSLATION:{field_name}" - ) from error - if not math.isfinite(candidate) or Fraction.from_float(candidate) != exact: - raise CameraTranslationNumericGapV2( - f"NUMERIC_GAP:CAMERA_TRANSLATION:{field_name}" - ) - return 0.0 if candidate == 0.0 else candidate - - -def _fact_items(facts: FactSetV2) -> tuple[object, ...]: - return tuple( - item - for values in (facts.values, facts.inner_values, facts.outer_values) - if values is not None - for item in values - ) - - -def _camera_reference_ids(problem: SemanticProblemV2_1) -> set[str]: - constraints = problem.constraints - objective = problem.objective - return { - constraints.target_relation.camera_id, - objective.relation_damage.evaluation_camera_id, - *(item.camera_id for item in constraints.visibility_constraints), - *( - item.key.camera_id - for item in objective.visibility_change.object_camera_weights - ), - } - - -__all__ = ( - "CAMERA_TRANSLATION_ALGORITHM_VERSION_V2_3", - "CameraTranslationDomainBudgetV2", - "CameraTranslationNumericGapV2", - "CameraTranslationResourceLimitV2", - "CameraTranslationUnsupportedModelV2", - "PreparedCameraTranslationProblemV2_3", - "prepare_camera_translation_problem_v2_3", - "registry_finding_v2_3", - "reserve_camera_translation_problem_structure_v2_3", -) diff --git a/src/spatialcf/core/v2/cardinal_yaw.py b/src/spatialcf/core/v2/cardinal_yaw.py deleted file mode 100644 index 39eaf20..0000000 --- a/src/spatialcf/core/v2/cardinal_yaw.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Exact Cardinal 2.1 capability checks and normalization to Canonical 2.0.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import ClassVar - -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.candidate_domain import ( - CANDIDATE_DOMAIN_ALGORITHM_ID_V2, - CANDIDATE_DOMAIN_ALGORITHM_VERSION_V2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M, - RECTILINEAR_KERNEL_ID_V2, - RECTILINEAR_KERNEL_VERSION_V2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactSetV2, - NumericPolicyV2, - RigidTransformV2, - UncertaintyBudgetV2, - Vec2V2, - Vec3V2, -) -from spatialcf.domain.v2.cardinal import ( - CanonicalObjectV2_1, - CanonicalSceneV2_1, - GeometryInstanceV2_1, - PinholeCameraV2_1, - SemanticProblemV2_1, - SupportSurfaceFactV2_1, -) -from spatialcf.domain.v2.geometry import ( - GeometryApproximationV2, - GeometryInstanceV2, - GeometryRoleV2, - PlanarPolygonComponentV2, - PlanarRegionV2, - PlanarRingV2, - UprightBox3DV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( - CoreSolverConfigV2, - DirectedOutwardGeometryKernelSpecV2, -) -from spatialcf.domain.v2.scene import ( - CameraDistortionModelV2, - CanonicalObjectV2, - CanonicalSceneV2, - ObjectPoseV2, - PinholeCameraV2, - RegionBoundaryPolicyV2, - SupportSurfaceFactV2, -) - -CARDINAL_ALGORITHM_VERSION_V2_1 = "algorithm:2.1" -CARDINAL_KERNEL_VERSION_V2_1 = "kernel:2.1-cardinal-yaw" - - -class CardinalResourceLimitV2(RuntimeError): - pass - - -class CardinalUnsupportedModelV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -@dataclass(slots=True) -class CardinalDomainBudgetV2(DomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = CardinalResourceLimitV2 - - -@dataclass(frozen=True, slots=True) -class PreparedCardinalProblemV2_1: - normalized_problem: SemanticProblemV2 - internal_config: CoreSolverConfigV2 - preprocessing_domain_operations: int - - -def reserve_cardinal_problem_structure_v2_1( - problem: SemanticProblemV2_1, - budget: CardinalDomainBudgetV2, -) -> None: - """Reserve the frozen structural pass before deep strict reconstruction.""" - - budget.consume() - scene = problem.scene - if type(scene) is not CanonicalSceneV2_1: - raise TypeError("cardinal problem scene has the wrong exact type") - for family_name in ( - "objects", - "geometry_instances", - "collision_bodies", - "workspace_boundaries", - "known_free_spaces", - "support_surfaces", - "cameras", - "baseline_observations", - ): - budget.consume() - facts = getattr(scene, family_name) - if not isinstance(facts, FactSetV2): - raise TypeError(f"{family_name} has the wrong fact-set type") - branches = tuple( - values - for values in (facts.values, facts.inner_values, facts.outer_values) - if values is not None - ) - budget.consume(sum(len(values) for values in branches)) - if family_name != "support_surfaces": - continue - for values in branches: - for surface in values: - if type(surface) is not SupportSurfaceFactV2_1: - raise TypeError("support surface has the wrong exact type") - components = surface.region_uv.components - budget.consume(len(components)) - for component in components: - budget.consume(len(component.exterior.vertices)) - for hole in component.holes: - budget.consume(1 + len(hole.vertices)) - - -def registry_finding_v2_1(config: CoreSolverConfigV2) -> str | None: - if ( - config.algorithm_id != CANDIDATE_DOMAIN_ALGORITHM_ID_V2 - or config.algorithm_version != CARDINAL_ALGORITHM_VERSION_V2_1 - ): - return ( - f"UNREGISTERED_ALGORITHM:{config.algorithm_id}@{config.algorithm_version}" - ) - kernel = config.geometry_kernel - registered = ( - type(kernel) is DirectedOutwardGeometryKernelSpecV2 - and kernel.kernel_id == RECTILINEAR_KERNEL_ID_V2 - and kernel.kernel_version == CARDINAL_KERNEL_VERSION_V2_1 - and kernel.certified_outward_error_m - == RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M - ) - if registered: - return None - error = getattr(kernel, "certified_outward_error_m", "NONE") - return ( - f"UNREGISTERED_GEOMETRY_KERNEL:{kernel.kernel_id}" - f"@{kernel.kernel_version}:{kernel.soundness.value}:{error}" - ) - - -def prepare_cardinal_problem_v2_1( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - preprocessing_domain_operations: int, -) -> PreparedCardinalProblemV2_1: - if config.max_branch_nodes != 0 or config.max_refinement_steps != 0: - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:BRANCH_OR_REFINEMENT") - if not _zero_numeric_policy(problem.numeric_policy): - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:NONZERO_UNCERTAINTY") - normalized_scene = _normalize_scene(problem.scene) - normalized_problem = SemanticProblemV2( - scene=normalized_scene, - constraints=problem.constraints, - relation_semantics=problem.relation_semantics, - visibility_semantics=problem.visibility_semantics, - objective=problem.objective, - numeric_policy=problem.numeric_policy, - ) - remaining = config.max_domain_operations - preprocessing_domain_operations - if remaining < 1: - raise CardinalResourceLimitV2 - payload = config.model_dump(mode="python", warnings="error") - payload["algorithm_version"] = CANDIDATE_DOMAIN_ALGORITHM_VERSION_V2 - payload["max_domain_operations"] = remaining - kernel = dict(payload["geometry_kernel"]) - kernel["kernel_version"] = RECTILINEAR_KERNEL_VERSION_V2 - payload["geometry_kernel"] = kernel - return PreparedCardinalProblemV2_1( - normalized_problem=normalized_problem, - internal_config=CoreSolverConfigV2.model_validate(payload, strict=True), - preprocessing_domain_operations=preprocessing_domain_operations, - ) - - -def _normalize_scene(scene: CanonicalSceneV2_1) -> CanonicalSceneV2: - for family_name in ( - "objects", - "geometry_instances", - "collision_bodies", - "workspace_boundaries", - "known_free_spaces", - "support_surfaces", - "cameras", - "baseline_observations", - ): - _require_zero_fact_uncertainty(getattr(scene, family_name)) - - objects = _map_fact_set( - scene.objects, - CanonicalObjectV2, - _normalize_object, - ) - object_turns = { - item.object_id: item.pose.world_from_object.quarter_turns_ccw - for item in scene.objects.values or () - } - geometries = _map_fact_set( - scene.geometry_instances, - GeometryInstanceV2, - lambda item: _normalize_geometry(item, object_turns), - ) - surfaces = _map_fact_set( - scene.support_surfaces, - SupportSurfaceFactV2, - lambda item: _normalize_support_surface(item, object_turns), - ) - cameras = _map_fact_set(scene.cameras, PinholeCameraV2, _normalize_camera) - - for item in _fact_items(scene.workspace_boundaries): - if item.region_approximation is not GeometryApproximationV2.EXACT: - raise CardinalUnsupportedModelV2( - "UNSUPPORTED_MODEL:NONEXACT_RECTILINEAR_FACT" - ) - for facts in (scene.workspace_boundaries, scene.known_free_spaces): - for item in _fact_items(facts): - if item.boundary_policy is not RegionBoundaryPolicyV2.CLOSED: - raise CardinalUnsupportedModelV2( - "UNSUPPORTED_MODEL:NONCLOSED_RECTILINEAR_FACT" - ) - _require_zero_budget(item.geometry_uncertainty) - - return CanonicalSceneV2( - scene_id=scene.scene_id, - coordinate_system=scene.coordinate_system, - objects=objects, - geometry_instances=geometries, - collision_bodies=scene.collision_bodies, - workspace_boundaries=scene.workspace_boundaries, - known_free_spaces=scene.known_free_spaces, - support_surfaces=surfaces, - cameras=cameras, - baseline_observations=scene.baseline_observations, - ) - - -def _map_fact_set( - facts: FactSetV2, - output_type: type, - mapper: object, -) -> FactSetV2: - map_item = mapper - payload: dict[str, object] = { - "availability": facts.availability, - "completeness": facts.completeness, - "uncertainty": facts.uncertainty, - } - for field_name in ("values", "inner_values", "outer_values"): - values = getattr(facts, field_name) - payload[field_name] = ( - None if values is None else tuple(map_item(item) for item in values) - ) - return FactSetV2[output_type].model_validate(payload, strict=True) - - -def _fact_items(facts: FactSetV2) -> tuple[object, ...]: - return tuple( - item - for values in (facts.values, facts.inner_values, facts.outer_values) - if values is not None - for item in values - ) - - -def _normalize_object(item: CanonicalObjectV2_1) -> CanonicalObjectV2: - if type(item) is not CanonicalObjectV2_1: - raise TypeError("object fact has the wrong exact type") - transform = item.pose.world_from_object - return CanonicalObjectV2( - object_id=item.object_id, - category_id=item.category_id, - movable=item.movable, - pose=ObjectPoseV2( - world_from_object=_identity_rotation_transform(transform.translation) - ), - support_assignment=item.support_assignment, - ) - - -def _normalize_geometry( - item: GeometryInstanceV2_1, - object_turns: dict[str, int], -) -> GeometryInstanceV2: - if type(item) is not GeometryInstanceV2_1: - raise TypeError("geometry fact has the wrong exact type") - if item.role is GeometryRoleV2.OCCLUDER: - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:OCCLUDER_GEOMETRY") - if type(item.shape) is not UprightBox3DV2: - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:NONBOX_GEOMETRY") - if item.approximation is not GeometryApproximationV2.EXACT: - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:GEOMETRY_APPROXIMATION") - _require_zero_budget(item.uncertainty) - owner_turns = 0 - if item.owner_object_id is not None: - owner_turns = object_turns[item.owner_object_id] - anchor = item.anchor_from_geometry - translation = ( - anchor.translation - if item.owner_object_id is None - else _rotate_vec3(owner_turns, anchor.translation) - ) - total_turns = (owner_turns + anchor.quarter_turns_ccw) % 4 - size = item.shape.size_m - normalized_size = Vec3V2(x=size.y, y=size.x, z=size.z) if total_turns % 2 else size - return GeometryInstanceV2( - geometry_id=item.geometry_id, - owner_object_id=item.owner_object_id, - role=item.role, - anchor_from_geometry=_identity_rotation_transform(translation), - approximation=item.approximation, - uncertainty=item.uncertainty, - shape=UprightBox3DV2(size_m=normalized_size), - ) - - -def _normalize_support_surface( - item: SupportSurfaceFactV2_1, - object_turns: dict[str, int], -) -> SupportSurfaceFactV2: - if type(item) is not SupportSurfaceFactV2_1: - raise TypeError("support surface fact has the wrong exact type") - if item.region_approximation is not GeometryApproximationV2.EXACT: - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:SUPPORT_APPROXIMATION") - if item.boundary_policy is not RegionBoundaryPolicyV2.CLOSED: - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:SUPPORT_BOUNDARY") - _require_zero_budget(item.geometry_uncertainty) - owner_turns = 0 - if item.owner_object_id is not None: - owner_turns = object_turns[item.owner_object_id] - anchor = item.anchor_from_surface - translation = ( - anchor.translation - if item.owner_object_id is None - else _rotate_vec3(owner_turns, anchor.translation) - ) - total_turns = (owner_turns + anchor.quarter_turns_ccw) % 4 - return SupportSurfaceFactV2( - surface_id=item.surface_id, - owner_object_id=item.owner_object_id, - supporting_body_id=item.supporting_body_id, - anchor_from_surface=_identity_rotation_transform(translation), - normal_in_anchor=item.normal_in_anchor, - region_uv=_rotate_region(total_turns, item.region_uv), - region_approximation=item.region_approximation, - boundary_policy=item.boundary_policy, - geometry_uncertainty=item.geometry_uncertainty, - ) - - -def _normalize_camera(item: PinholeCameraV2_1) -> PinholeCameraV2: - if type(item) is not PinholeCameraV2_1: - raise TypeError("camera fact has the wrong exact type") - transform = item.world_to_camera - if transform.quarter_turns_ccw != 0 or transform.translation != Vec3V2( - x=0.0, y=0.0, z=0.0 - ): - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:CAMERA_NOT_IDENTITY") - if ( - item.distortion_model is not CameraDistortionModelV2.NONE - or item.brown_conrady_coefficients is not None - ): - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:CAMERA_DISTORTION") - _require_zero_budget(item.calibration_uncertainty) - payload = item.model_dump(mode="python", warnings="error") - payload["world_to_camera"] = RigidTransformV2.identity().model_dump( - mode="python", warnings="error" - ) - return PinholeCameraV2.model_validate(payload, strict=True) - - -def _rotate_region(quarter_turns: int, region: PlanarRegionV2) -> PlanarRegionV2: - return PlanarRegionV2( - components=tuple( - PlanarPolygonComponentV2( - exterior=_rotate_ring(quarter_turns, component.exterior), - holes=tuple( - _rotate_ring(quarter_turns, hole) for hole in component.holes - ), - ) - for component in region.components - ) - ) - - -def _rotate_ring(quarter_turns: int, ring: PlanarRingV2) -> PlanarRingV2: - return PlanarRingV2( - winding=ring.winding, - vertices=tuple( - Vec2V2(x=x, y=y) - for point in ring.vertices - for x, y in (_rotate_xy(quarter_turns, point.x, point.y),) - ), - ) - - -def _rotate_vec3(quarter_turns: int, value: Vec3V2) -> Vec3V2: - x, y = _rotate_xy(quarter_turns, value.x, value.y) - return Vec3V2(x=x, y=y, z=value.z) - - -def _rotate_xy(quarter_turns: int, x: float, y: float) -> tuple[float, float]: - if quarter_turns == 0: - result = x, y - elif quarter_turns == 1: - result = -y, x - elif quarter_turns == 2: - result = -x, -y - else: - result = y, -x - return tuple(0.0 if value == 0.0 else value for value in result) - - -def _identity_rotation_transform(translation: Vec3V2) -> RigidTransformV2: - return RigidTransformV2( - translation=translation, - rotation=RigidTransformV2.identity().rotation, - ) - - -def _require_zero_fact_uncertainty(facts: FactSetV2) -> None: - if facts.availability is FactAvailabilityV2.KNOWN: - if facts.uncertainty is None: - raise TypeError("KNOWN facts omitted their uncertainty budget") - _require_zero_budget(facts.uncertainty) - - -def _require_zero_budget(budget: UncertaintyBudgetV2) -> None: - if not ( - _zero_numeric_policy(budget.source_error) - and _zero_numeric_policy(budget.shape_approximation) - ): - raise CardinalUnsupportedModelV2("UNSUPPORTED_MODEL:NONZERO_UNCERTAINTY") - - -def _zero_numeric_policy(policy: NumericPolicyV2) -> bool: - return all(value == 0.0 for value in policy.model_dump(mode="python").values()) - - -__all__ = ( - "CARDINAL_ALGORITHM_VERSION_V2_1", - "CARDINAL_KERNEL_VERSION_V2_1", - "CardinalDomainBudgetV2", - "CardinalResourceLimitV2", - "CardinalUnsupportedModelV2", - "PreparedCardinalProblemV2_1", - "prepare_cardinal_problem_v2_1", - "registry_finding_v2_1", - "reserve_cardinal_problem_structure_v2_1", -) diff --git a/src/spatialcf/core/v2/certificate_builder.py b/src/spatialcf/core/v2/certificate_builder.py deleted file mode 100644 index 6ed4db3..0000000 --- a/src/spatialcf/core/v2/certificate_builder.py +++ /dev/null @@ -1,1752 +0,0 @@ -"""Pure-core certificate builders backed by fresh semantic replay. - -Both public entry points accept only raw semantic inputs. They rebuild every -artifact used by the claim and never consume a submitted artifact, point -outcome, certificate, or previously issued verification token. -""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction -from typing import TypeVar - -from pydantic import TypeAdapter, ValidationError - -from spatialcf.core.v2.candidate_domain import ( - CandidateDomainCompilationOutcomeV2, - CandidateDomainCompilerV2, -) -from spatialcf.core.v2.candidate_domain import ( - _registry_finding as _candidate_registry_finding, -) -from spatialcf.core.v2.edit_feasibility import ( - _DomainOperationBudgetExhaustedV2, - _EditFeasibilityDomainBudgetV2, -) -from spatialcf.core.v2.objective_partition import ( - ObjectivePartitionCompilationKindV2, - ObjectivePartitionCompilationOutcomeV2, - ObjectiveWitnessProposalV2, - _compile_verified_partition, -) -from spatialcf.core.v2.objective_partition import ( - _CompilationIncompleteV2 as _ObjectiveCompilationIncompleteV2, -) -from spatialcf.core.v2.objective_partition import ( - _DomainOperationBudgetV2 as _ObjectiveDomainOperationBudgetV2, -) -from spatialcf.core.v2.objective_partition import ( - _NumericGapV2 as _ObjectiveNumericGapV2, -) -from spatialcf.core.v2.objective_partition import ( - _ResourceLimitV2 as _ObjectiveResourceLimitV2, -) -from spatialcf.core.v2.objective_partition import ( - _UnsupportedV2 as _ObjectiveUnsupportedV2, -) -from spatialcf.core.v2.point_objective import ( - PointObjectiveEvaluationKindV2, - PointObjectiveEvaluationOutcomeV2, - _context_usage, - _evaluate_point_objective_from_replay_v2, - _PointObjectiveReplayContextV2, - _require_candidate_closure, - _require_objective_closure, - _require_relation_closure, - _require_relation_usage, -) -from spatialcf.core.v2.rectilinear_kernel import ( - RectilinearAtomicBudgetExhaustedV2, - RectilinearAtomicBudgetV2, -) -from spatialcf.core.v2.relation_cost_partition import ( - RelationCostPartitionCompilationKindV2, - RelationCostPartitionCompilationOutcomeV2, - compile_relation_cost_partition_v2, -) -from spatialcf.domain.v2.artifacts import ( - CandidateDomainArtifactV2, - CompilationResourceUsageV2, - ConstraintCompilationDispositionV2, - DomainCompletenessV2, - ObjectivePartitionCellV2, - ObjectiveTermBoundsV2, - RegionBoundStatusV2, - RelationCostPartitionV2, -) -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.certificate import ( - EmptyOuterProofMethodV2, - GlobalOptimalityCertificateV2, - OptimalityClaimV2, - ProvenUnsatCertificateV2, - _directed_binary64_gap_ceil, -) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedReasonV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - -_SHA256_DIGEST_ADAPTER = TypeAdapter(Sha256Digest) - - -class CertificateBuildKindV2(StrEnum): - """Closed result kinds currently implemented by this builder module.""" - - PROVEN_UNSAT = "PROVEN_UNSAT" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class CertificateBuildOutcomeV2: - """Closed result of a fresh certificate-building replay. - - Verified hashes exist only alongside the strictly reconstructed result. - Like every in-process value, this outcome is not an unforgeable token; - consumers that make a semantic claim must independently replay the raw - inputs. - """ - - kind: CertificateBuildKindV2 - proven_unsat_result: ProvenUnsatResultV2 | None = None - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - candidate_domain_artifact_sha256: Sha256Digest | None = None - certificate_sha256: Sha256Digest | None = None - solve_result_sha256: Sha256Digest | None = None - verification_resource_usage: CompilationResourceUsageV2 | None = None - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not CertificateBuildKindV2: - raise TypeError("kind must be a CertificateBuildKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be an exact tuple of non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - - result = self.proven_unsat_result - if result is not None: - if type(result) is not ProvenUnsatResultV2: - raise TypeError("proven_unsat_result must be a ProvenUnsatResultV2") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - result = ProvenUnsatResultV2.model_validate( - result.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "proven_unsat_result must pass strict validation" - ) from error - - usage = self.verification_resource_usage - if usage is not None: - if type(usage) is not CompilationResourceUsageV2: - raise TypeError( - "verification_resource_usage must be a CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "verification_resource_usage must pass strict validation" - ) from error - - for field_name in ( - "semantic_problem_sha256", - "core_solver_config_sha256", - "candidate_domain_artifact_sha256", - "certificate_sha256", - "solve_result_sha256", - ): - reference = getattr(self, field_name) - if reference is None: - continue - try: - reference = _SHA256_DIGEST_ADAPTER.validate_python( - reference, - strict=True, - ) - except (ValidationError, TypeError, ValueError) as error: - raise ValueError(f"{field_name} must be a Sha256Digest") from error - object.__setattr__(self, field_name, reference) - - object.__setattr__(self, "finding_codes", findings) - object.__setattr__(self, "proven_unsat_result", result) - object.__setattr__(self, "verification_resource_usage", usage) - - references = ( - self.semantic_problem_sha256, - self.core_solver_config_sha256, - self.candidate_domain_artifact_sha256, - self.certificate_sha256, - self.solve_result_sha256, - ) - if self.kind is CertificateBuildKindV2.PROVEN_UNSAT: - if ( - result is None - or usage is None - or any(item is None for item in references) - ): - raise ValueError( - "PROVEN_UNSAT requires a result, usage, and all verified references" - ) - if self.uncertified_reason is not None or findings: - raise ValueError("PROVEN_UNSAT cannot carry failure diagnostics") - expected_references = ( - result.semantic_problem_sha256, - result.core_solver_config.core_solver_config_sha256, - result.candidate_domain.candidate_domain_artifact_sha256, - result.certificate.certificate_sha256, - result.solve_result_sha256, - ) - if references != expected_references: - raise ValueError("PROVEN_UNSAT verified references are not closed") - if usage != result.candidate_domain.resource_usage: - raise ValueError("PROVEN_UNSAT usage is not closed to fresh replay") - return - - if result is not None or any(item is not None for item in references): - raise ValueError("UNCERTIFIED cannot carry a result or verified references") - if type(self.uncertified_reason) is not UncertifiedReasonV2: - raise TypeError("UNCERTIFIED requires an UncertifiedReasonV2") - if not findings: - raise ValueError("UNCERTIFIED requires at least one finding") - - -ProvenUnsatBuildOutcomeV2 = CertificateBuildOutcomeV2 - - -class GlobalOptimalityBuildKindV2(StrEnum): - """Closed outcomes of the fresh global-optimality replay.""" - - CERTIFIED_SUCCESS = "CERTIFIED_SUCCESS" - NOT_PROVEN = "NOT_PROVEN" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class GlobalOptimalityBuildOutcomeV2: - """Auditable value returned by the global builder, never a proof token. - - A consumer making a semantic claim must replay the raw problem, - configuration, and edit again. The references below only close a - successful value to the strictly reconstructed result built by this call. - """ - - kind: GlobalOptimalityBuildKindV2 - certified_success_result: CertifiedSuccessResultV2 | None = None - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - candidate_domain_artifact_sha256: Sha256Digest | None = None - relation_cost_partition_sha256: Sha256Digest | None = None - objective_partition_artifact_sha256: Sha256Digest | None = None - canonical_edit_sha256: Sha256Digest | None = None - certificate_sha256: Sha256Digest | None = None - solve_result_sha256: Sha256Digest | None = None - cumulative_generation_usage: CompilationResourceUsageV2 | None = None - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not GlobalOptimalityBuildKindV2: - raise TypeError("kind must be a GlobalOptimalityBuildKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be exact non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - - result = self.certified_success_result - if result is not None: - if type(result) is not CertifiedSuccessResultV2: - raise TypeError( - "certified_success_result must be a CertifiedSuccessResultV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - result = CertifiedSuccessResultV2.model_validate( - result.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "certified_success_result must pass strict validation" - ) from error - - usage = self.cumulative_generation_usage - if usage is not None: - if type(usage) is not CompilationResourceUsageV2: - raise TypeError( - "cumulative_generation_usage must be CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "cumulative_generation_usage must pass strict validation" - ) from error - - reference_names = ( - "semantic_problem_sha256", - "core_solver_config_sha256", - "candidate_domain_artifact_sha256", - "relation_cost_partition_sha256", - "objective_partition_artifact_sha256", - "canonical_edit_sha256", - "certificate_sha256", - "solve_result_sha256", - ) - for field_name in reference_names: - reference = getattr(self, field_name) - if reference is None: - continue - try: - reference = _SHA256_DIGEST_ADAPTER.validate_python( - reference, - strict=True, - ) - except (ValidationError, TypeError, ValueError) as error: - raise ValueError(f"{field_name} must be a Sha256Digest") from error - object.__setattr__(self, field_name, reference) - - object.__setattr__(self, "finding_codes", findings) - object.__setattr__(self, "certified_success_result", result) - object.__setattr__(self, "cumulative_generation_usage", usage) - references = tuple(getattr(self, name) for name in reference_names) - - if self.kind is GlobalOptimalityBuildKindV2.CERTIFIED_SUCCESS: - if ( - result is None - or usage is None - or any(item is None for item in references) - ): - raise ValueError( - "CERTIFIED_SUCCESS requires a result, usage, and every reference" - ) - if self.uncertified_reason is not None or findings: - raise ValueError("CERTIFIED_SUCCESS cannot carry failure diagnostics") - expected = ( - result.semantic_problem_sha256, - result.core_solver_config.core_solver_config_sha256, - result.candidate_domain.candidate_domain_artifact_sha256, - result.relation_cost_partition.relation_cost_partition_sha256, - result.objective_partition.objective_partition_artifact_sha256, - result.edit.edit_sha256, - result.certificate.certificate_sha256, - result.solve_result_sha256, - ) - if references != expected: - raise ValueError("CERTIFIED_SUCCESS verified references are not closed") - base = result.candidate_domain.resource_usage - limits = result.core_solver_config - if ( - usage.domain_operations < base.domain_operations - or usage.partition_cells < base.partition_cells - or usage.refinement_steps != base.refinement_steps - or usage.domain_operations > limits.max_domain_operations - or usage.partition_cells > limits.max_partition_cells - ): - raise ValueError( - "CERTIFIED_SUCCESS cumulative usage is not closed to its replay" - ) - return - - if result is not None or any(item is not None for item in references): - raise ValueError("non-success outcomes cannot carry verified payload") - if not findings: - raise ValueError(f"{self.kind.value} requires at least one finding") - if self.kind is GlobalOptimalityBuildKindV2.NOT_PROVEN: - if self.uncertified_reason is not None: - raise ValueError("NOT_PROVEN cannot carry an uncertified reason") - return - if type(self.uncertified_reason) is not UncertifiedReasonV2: - raise TypeError("UNCERTIFIED requires an UncertifiedReasonV2") - - -GlobalOptimalityCertificateBuildOutcomeV2 = GlobalOptimalityBuildOutcomeV2 - - -class _InvalidInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _NumericInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def build_proven_unsat_result_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, -) -> CertificateBuildOutcomeV2: - """Build PROVEN_UNSAT only from a fresh complete empty-outer replay.""" - - try: - checked_problem = _strict_model( - problem, - SemanticProblemV2, - label="SEMANTIC_PROBLEM", - ) - checked_config = _strict_model( - config, - CoreSolverConfigV2, - label="CORE_SOLVER_CONFIG", - ) - except _NumericInputV2 as error: - return _uncertified(UncertifiedReasonV2.NUMERIC_GAP, error.finding_code) - except _InvalidInputV2 as error: - return _uncertified(UncertifiedReasonV2.UNSUPPORTED_MODEL, error.finding_code) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - replay = CandidateDomainCompilerV2().compile( - checked_problem, - checked_config, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_DOMAIN_REPLAY", - ) - - if type(replay) is not CandidateDomainCompilationOutcomeV2: - raise TypeError( - "CandidateDomainCompilerV2 returned an invalid internal outcome" - ) - candidate = replay.candidate_domain - if candidate is None: - return _uncertified( - replay.uncertified_reason or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(replay.finding_codes or ("REPLAY_NO_CANDIDATE_DOMAIN",)), - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate = CandidateDomainArtifactV2.model_validate( - candidate.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_DOMAIN_REVALIDATION", - ) - - usage = candidate.resource_usage - _require_candidate_reference_closure(checked_problem, checked_config, candidate) - if replay.uncertified_reason is not None: - return _uncertified( - replay.uncertified_reason, - *(replay.finding_codes or ("REPLAY_CANDIDATE_UNCERTIFIED",)), - verification_resource_usage=usage, - ) - - return _build_proven_unsat_from_fresh_candidate_v2( - checked_problem, - checked_config, - candidate, - ) - - -def _build_proven_unsat_from_fresh_candidate_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, -) -> CertificateBuildOutcomeV2: - """Assemble UNSAT from a caller-owned fresh candidate without replay. - - This private seam is not a verification token. Its caller must have just - produced ``candidate`` from the same raw inputs; strict reconstruction and - reference closure here turn any violated internal promise into an - invariant error rather than silently accepting a submitted artifact. - """ - - checked_problem = _strict_fresh_internal_model( - problem, - SemanticProblemV2, - "SEMANTIC_PROBLEM", - ) - checked_config = _strict_fresh_internal_model( - config, - CoreSolverConfigV2, - "CORE_SOLVER_CONFIG", - ) - checked_candidate = _strict_fresh_internal_model( - candidate, - CandidateDomainArtifactV2, - "CANDIDATE_DOMAIN", - ) - _require_candidate_reference_closure( - checked_problem, - checked_config, - checked_candidate, - ) - usage = checked_candidate.resource_usage - if ( - usage.domain_operations > checked_config.max_domain_operations - or usage.partition_cells > checked_config.max_partition_cells - or usage.refinement_steps > checked_config.max_refinement_steps - ): - raise RuntimeError("fresh UNSAT candidate exceeded its configured resource cap") - registry_finding = _candidate_registry_finding(checked_config) - if registry_finding is not None: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - registry_finding, - verification_resource_usage=usage, - ) - unsat_findings = _unsat_eligibility_findings(checked_candidate) - if unsat_findings: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *unsat_findings, - verification_resource_usage=usage, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - certificate = ProvenUnsatCertificateV2( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - core_solver_config_sha256=(checked_config.core_solver_config_sha256), - candidate_domain_artifact_sha256=( - checked_candidate.candidate_domain_artifact_sha256 - ), - empty_outer_proof_method=( - EmptyOuterProofMethodV2.CERTIFIED_EMPTY_OUTER_DOMAIN - ), - ) - certificate = ProvenUnsatCertificateV2.model_validate( - certificate.model_dump(mode="python"), - strict=True, - ) - result = ProvenUnsatResultV2( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - core_solver_config=checked_config, - candidate_domain=checked_candidate, - certificate=certificate, - ) - result = ProvenUnsatResultV2.model_validate( - result.model_dump(mode="python"), - strict=True, - ) - _require_canonical_round_trip(certificate, result) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:PROVEN_UNSAT_CONSTRUCTION", - verification_resource_usage=usage, - ) - - return CertificateBuildOutcomeV2( - kind=CertificateBuildKindV2.PROVEN_UNSAT, - proven_unsat_result=result, - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - core_solver_config_sha256=checked_config.core_solver_config_sha256, - candidate_domain_artifact_sha256=( - checked_candidate.candidate_domain_artifact_sha256 - ), - certificate_sha256=certificate.certificate_sha256, - solve_result_sha256=result.solve_result_sha256, - verification_resource_usage=usage, - ) - - -class ProvenUnsatCertificateBuilderV2: - """Stateless wrapper for pure-core pipeline composition.""" - - def build( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - ) -> CertificateBuildOutcomeV2: - return build_proven_unsat_result_v2(problem, config) - - -@dataclass(frozen=True, slots=True) -class _GlobalReplayBundleV2: - """Fresh artifacts and the two mutable ledgers owned by one replay.""" - - problem: SemanticProblemV2 - config: CoreSolverConfigV2 - candidate: CandidateDomainArtifactV2 - relation: RelationCostPartitionV2 - objective_outcome: ObjectivePartitionCompilationOutcomeV2 - point_context: _PointObjectiveReplayContextV2 - - -@dataclass(frozen=True, slots=True) -class _ExactCardinalSelectionFrameV2: - """Exact map from normalized edit XY into the semantic tie-break frame.""" - - normalized_to_semantic_quarter_turns_ccw: int = 0 - - def __post_init__(self) -> None: - quarter_turns = self.normalized_to_semantic_quarter_turns_ccw - if type(quarter_turns) is not int or not 0 <= quarter_turns <= 3: - raise TypeError("selection frame quarter-turn must be an exact int in 0..3") - - def semantic_xy( - self, - x: Fraction, - y: Fraction, - ) -> tuple[Fraction, Fraction]: - if type(x) is not Fraction or type(y) is not Fraction: - raise TypeError("selection frame coordinates must be exact Fractions") - quarter_turns = self.normalized_to_semantic_quarter_turns_ccw - if quarter_turns == 0: - return x, y - if quarter_turns == 1: - return -y, x - if quarter_turns == 2: - return -x, -y - return y, -x - - -@dataclass(frozen=True, slots=True) -class _EvaluatedGlobalProposalV2: - proposal: ObjectiveWitnessProposalV2 - point: PointObjectiveEvaluationOutcomeV2 - singleton_values: tuple[Fraction, Fraction, Fraction, Fraction] | None - singleton_total: Fraction | None - - -@dataclass(frozen=True, slots=True) -class _SelectedGlobalBuildOutcomeV2: - """Private counted result for a solver-owned fresh replay bundle.""" - - outcome: GlobalOptimalityBuildOutcomeV2 - proposal_count: int - evaluated_proposal_count: int - - def __post_init__(self) -> None: - if type(self.outcome) is not GlobalOptimalityBuildOutcomeV2: - raise TypeError("outcome must be a GlobalOptimalityBuildOutcomeV2") - if type(self.proposal_count) is not int or self.proposal_count < 0: - raise TypeError("proposal_count must be a non-negative exact int") - if ( - type(self.evaluated_proposal_count) is not int - or self.evaluated_proposal_count < 0 - or self.evaluated_proposal_count > self.proposal_count - ): - raise ValueError("evaluated_proposal_count must lie within proposal_count") - if ( - self.outcome.kind is GlobalOptimalityBuildKindV2.CERTIFIED_SUCCESS - and self.evaluated_proposal_count != self.proposal_count - ): - raise ValueError("successful selected build must evaluate every proposal") - - -def build_global_optimality_result_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - edit: CanonicalEditV2, -) -> GlobalOptimalityBuildOutcomeV2: - """Build a certified global result from one complete fresh core replay. - - This conservative first implementation accepts only exact, non-empty - objective cells whose non-translation loss intervals are singletons. It - evaluates exactly one canonical inner proposal per cell with shared ledgers. - """ - - try: - checked_problem = _strict_model( - problem, - SemanticProblemV2, - label="SEMANTIC_PROBLEM", - ) - checked_config = _strict_model( - config, - CoreSolverConfigV2, - label="CORE_SOLVER_CONFIG", - ) - checked_edit = _strict_model( - edit, - CanonicalEditV2, - label="CANONICAL_EDIT", - ) - except _NumericInputV2 as error: - return _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - error.finding_code, - ) - except _InvalidInputV2 as error: - return _global_uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - - edit_findings = _global_edit_reference_findings(checked_problem, checked_edit) - if edit_findings: - return _global_not_proven(*edit_findings) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate_outcome = CandidateDomainCompilerV2().compile( - checked_problem, - checked_config, - ) - except (ArithmeticError, RuntimeWarning): - return _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_CANDIDATE_REPLAY", - ) - if type(candidate_outcome) is not CandidateDomainCompilationOutcomeV2: - raise TypeError("candidate compiler returned an invalid internal outcome") - candidate = candidate_outcome.candidate_domain - if candidate is None: - return _global_uncertified( - candidate_outcome.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(candidate_outcome.finding_codes or ("GLOBAL_REPLAY_NO_CANDIDATE",)), - ) - if type(candidate) is not CandidateDomainArtifactV2: - raise TypeError("candidate compiler returned an invalid artifact") - _require_candidate_closure(checked_problem, checked_config, candidate) - if candidate_outcome.uncertified_reason is not None: - return _global_uncertified( - candidate_outcome.uncertified_reason, - *(candidate_outcome.finding_codes or ("GLOBAL_CANDIDATE_UNCERTIFIED",)), - cumulative_generation_usage=candidate.resource_usage, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - relation_outcome = compile_relation_cost_partition_v2( - checked_problem, - checked_config, - candidate, - ) - except (ArithmeticError, RuntimeWarning): - return _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_RELATION_REPLAY", - cumulative_generation_usage=candidate.resource_usage, - ) - if type(relation_outcome) is not RelationCostPartitionCompilationOutcomeV2: - raise TypeError("relation compiler returned an invalid internal outcome") - relation_usage = relation_outcome.cumulative_resource_usage - if relation_outcome.kind is not RelationCostPartitionCompilationKindV2.PARTITION: - return _global_uncertified( - relation_outcome.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(relation_outcome.finding_codes or ("GLOBAL_REPLAY_NO_RELATION",)), - cumulative_generation_usage=relation_usage or candidate.resource_usage, - ) - relation = relation_outcome.relation_cost_partition - if relation is None or relation_usage is None: - raise RuntimeError("PARTITION relation replay omitted artifact or usage") - _require_relation_usage(checked_config, candidate, relation_usage) - _require_relation_closure( - checked_problem, - checked_config, - candidate, - relation, - ) - - objective_domain_budget = _ObjectiveDomainOperationBudgetV2( - limit=checked_config.max_domain_operations, - base_used=relation_usage.domain_operations, - ) - atomic_budget = RectilinearAtomicBudgetV2( - limit=checked_config.max_partition_cells, - used=relation_usage.partition_cells, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - objective_outcome = _compile_verified_partition( - checked_problem, - checked_config, - candidate, - relation, - relation_usage, - objective_domain_budget, - atomic_budget, - ) - except (RectilinearAtomicBudgetExhaustedV2, _ObjectiveResourceLimitV2) as error: - return _global_uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - str(error) or "RESOURCE_LIMIT:max_partition_cells", - cumulative_generation_usage=_global_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveNumericGapV2 as error: - return _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - *error.finding_codes, - cumulative_generation_usage=_global_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveUnsupportedV2 as error: - return _global_uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - *error.finding_codes, - cumulative_generation_usage=_global_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveCompilationIncompleteV2 as error: - return _global_uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *error.finding_codes, - cumulative_generation_usage=_global_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except (ArithmeticError, RuntimeWarning): - return _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_OBJECTIVE_REPLAY", - cumulative_generation_usage=_global_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - - if type(objective_outcome) is not ObjectivePartitionCompilationOutcomeV2: - raise TypeError("objective compiler returned an invalid internal outcome") - if objective_outcome.kind is not ObjectivePartitionCompilationKindV2.PARTITION: - raise RuntimeError("private objective compiler returned a non-partition") - objective = objective_outcome.objective_partition - objective_usage = objective_outcome.cumulative_resource_usage - if objective is None or objective_usage is None: - raise RuntimeError("PARTITION objective replay omitted artifact or usage") - if ( - relation_usage.domain_operations + objective_domain_budget.used - != objective_usage.domain_operations - ): - raise RuntimeError("global objective domain ledger drift") - if atomic_budget.used != objective_usage.partition_cells: - raise RuntimeError("global objective atomic ledger drift") - if objective_usage.refinement_steps != relation_usage.refinement_steps: - raise RuntimeError("global objective refinement ledger drift") - _require_objective_closure( - checked_problem, - checked_config, - candidate, - relation, - objective, - ) - - point_context = _PointObjectiveReplayContextV2( - problem=checked_problem, - config=checked_config, - candidate=candidate, - relation=relation, - objective=objective, - domain_budget=_EditFeasibilityDomainBudgetV2( - limit=checked_config.max_domain_operations, - used=objective_usage.domain_operations, - ), - atomic_budget=atomic_budget, - replay_usage=objective_usage, - ) - return _build_global_from_fresh_replay_v2( - _GlobalReplayBundleV2( - problem=checked_problem, - config=checked_config, - candidate=candidate, - relation=relation, - objective_outcome=objective_outcome, - point_context=point_context, - ), - checked_edit, - ) - - -class GlobalOptimalityCertificateBuilderV2: - """Stateless wrapper for the raw-input global certificate builder.""" - - def build( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - edit: CanonicalEditV2, - ) -> GlobalOptimalityBuildOutcomeV2: - return build_global_optimality_result_v2(problem, config, edit) - - -def _build_global_from_fresh_replay_v2( - bundle: _GlobalReplayBundleV2, - requested_edit: CanonicalEditV2, -) -> GlobalOptimalityBuildOutcomeV2: - """Build for a caller edit while preserving the historical private API.""" - - return _build_global_from_fresh_replay_counted_v2( - bundle, - requested_edit, - _ExactCardinalSelectionFrameV2(), - ).outcome - - -def _build_selected_global_from_fresh_replay_v2( - bundle: _GlobalReplayBundleV2, -) -> _SelectedGlobalBuildOutcomeV2: - """Auto-select and assemble from one solver-owned fresh replay bundle.""" - - return _build_selected_global_from_fresh_replay_in_frame_v2( - bundle, - _ExactCardinalSelectionFrameV2(), - ) - - -def _build_selected_global_from_fresh_replay_in_frame_v2( - bundle: _GlobalReplayBundleV2, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> _SelectedGlobalBuildOutcomeV2: - """Auto-select in an exact caller-owned semantic coordinate frame.""" - - if type(selection_frame) is not _ExactCardinalSelectionFrameV2: - raise TypeError("selection_frame has the wrong exact type") - return _build_global_from_fresh_replay_counted_v2( - bundle, - None, - selection_frame, - ) - - -def _build_global_from_fresh_replay_counted_v2( - bundle: _GlobalReplayBundleV2, - requested_edit: CanonicalEditV2 | None, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> _SelectedGlobalBuildOutcomeV2: - """Assemble from caller-owned *fresh* replay state and shared ledgers. - - This private seam is for a future solver that already owns the same fresh - prefix. It is not a capability or proof token and must never be exposed as - accepting submitted artifacts. - """ - - _validate_global_replay_bundle_prefix(bundle, requested_edit) - objective = bundle.point_context.objective - proposals = bundle.objective_outcome.witness_proposals - proposal_count = len(proposals) - evaluated_proposal_count = 0 - - def finish( - outcome: GlobalOptimalityBuildOutcomeV2, - ) -> _SelectedGlobalBuildOutcomeV2: - return _SelectedGlobalBuildOutcomeV2( - outcome=outcome, - proposal_count=proposal_count, - evaluated_proposal_count=evaluated_proposal_count, - ) - - try: - _reserve_global_builder_work(bundle.point_context, proposal_count) - except _DomainOperationBudgetExhaustedV2: - return finish( - _global_uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - "RESOURCE_LIMIT:max_domain_operations", - cumulative_generation_usage=_context_usage(bundle.point_context), - ) - ) - _validate_global_replay_bundle_closure(bundle, requested_edit) - - eligibility_findings = _global_eligibility_findings(bundle, proposals) - if eligibility_findings: - return finish( - _global_uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *eligibility_findings, - cumulative_generation_usage=_context_usage(bundle.point_context), - ) - ) - - global_lower = _global_loss_lower_bound_v2(objective.cells) - evaluated: list[_EvaluatedGlobalProposalV2] = [] - for proposal in proposals: - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - point = _evaluate_point_objective_from_replay_v2( - bundle.point_context, - proposal.edit, - ) - except (ArithmeticError, RuntimeWarning): - return finish( - _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_POINT_EVALUATION", - cumulative_generation_usage=_context_usage(bundle.point_context), - ) - ) - evaluated_proposal_count += 1 - if type(point) is not PointObjectiveEvaluationOutcomeV2: - raise TypeError("point evaluator returned an invalid internal outcome") - if point.kind is PointObjectiveEvaluationKindV2.NOT_PROVEN: - findings = point.finding_codes or ("GLOBAL_PROPOSAL_NOT_PROVEN",) - raise RuntimeError( - "fresh objective proposal lost inner feasibility: " + "|".join(findings) - ) - if point.kind is PointObjectiveEvaluationKindV2.UNCERTIFIED: - return finish( - _global_uncertified( - point.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(point.finding_codes or ("GLOBAL_PROPOSAL_UNCERTIFIED",)), - cumulative_generation_usage=_context_usage(bundle.point_context), - ) - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _require_global_point_closure(bundle, proposal, point) - bounds = point.witness_loss_bounds - if bounds is None: # pragma: no cover - point outcome invariant - raise RuntimeError( - "bounded point outcome omitted witness loss bounds" - ) - singleton_values = _singleton_term_values(bounds) - evaluated.append( - _EvaluatedGlobalProposalV2( - proposal=proposal, - point=point, - singleton_values=singleton_values, - singleton_total=( - sum(singleton_values, Fraction()) - if singleton_values is not None - else None - ), - ) - ) - except (ArithmeticError, RuntimeWarning): - return finish( - _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_POINT_POSTPROCESS", - cumulative_generation_usage=_context_usage(bundle.point_context), - ) - ) - - final_usage = _context_usage(bundle.point_context) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - selected = _select_certifiable_global_winner( - tuple(evaluated), - selection_frame=selection_frame, - ) - except (ArithmeticError, RuntimeWarning): - return finish( - _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_SELECTION", - cumulative_generation_usage=final_usage, - ) - ) - if selected is None: - return finish( - _global_uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - "TIE_BREAK_UNRESOLVED", - cumulative_generation_usage=final_usage, - ) - ) - selected_edit = selected.proposal.edit - if requested_edit is not None and canonical_json_bytes_v2( - requested_edit - ) != canonical_json_bytes_v2(selected_edit): - return finish( - _global_not_proven( - "SELECTED_EDIT_MISMATCH", - cumulative_generation_usage=final_usage, - ) - ) - result_edit = selected_edit if requested_edit is None else requested_edit - - witness = selected.point.witness_loss_bounds - if witness is None: # pragma: no cover - point outcome invariant - raise RuntimeError("selected point omitted witness loss bounds") - witness_upper = witness.total_upper_bound - if global_lower > witness_upper: - raise RuntimeError("fresh global lower bound exceeds its feasible witness") - try: - gap = _directed_binary64_gap_ceil(global_lower, witness_upper) - except (ArithmeticError, RuntimeWarning): - return finish( - _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_OPTIMALITY_GAP", - cumulative_generation_usage=final_usage, - ) - ) - if gap > bundle.config.target_optimality_gap: - return finish( - _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:TARGET_OPTIMALITY_GAP_EXCEEDED", - cumulative_generation_usage=final_usage, - ) - ) - claim = OptimalityClaimV2.EXACT if gap == 0.0 else OptimalityClaimV2.EPSILON_OPTIMAL - epsilon = None if claim is OptimalityClaimV2.EXACT else gap - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - certificate = GlobalOptimalityCertificateV2( - semantic_problem_sha256=bundle.problem.semantic_problem_sha256, - core_solver_config_sha256=bundle.config.core_solver_config_sha256, - candidate_domain_artifact_sha256=( - bundle.candidate.candidate_domain_artifact_sha256 - ), - relation_cost_partition_sha256=( - bundle.relation.relation_cost_partition_sha256 - ), - objective_partition_artifact_sha256=( - objective.objective_partition_artifact_sha256 - ), - edit_sha256=result_edit.edit_sha256, - loss_lower_bound=global_lower, - loss_upper_bound=witness_upper, - optimality_gap=gap, - optimality_claim=claim, - epsilon=epsilon, - ) - certificate = GlobalOptimalityCertificateV2.model_validate( - certificate.model_dump(mode="python"), - strict=True, - ) - result = CertifiedSuccessResultV2( - semantic_problem_sha256=bundle.problem.semantic_problem_sha256, - core_solver_config=bundle.config, - candidate_domain=bundle.candidate, - relation_cost_partition=bundle.relation, - objective_partition=objective, - edit=result_edit, - global_loss_lower_bound=global_lower, - witness_loss_bounds=witness, - certificate=certificate, - ) - result = CertifiedSuccessResultV2.model_validate( - result.model_dump(mode="python"), - strict=True, - ) - _require_global_canonical_round_trip(certificate, result) - except (ArithmeticError, RuntimeWarning): - return finish( - _global_uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:GLOBAL_RESULT_CONSTRUCTION", - cumulative_generation_usage=final_usage, - ) - ) - if result_edit.subject_id != bundle.candidate.candidate_variable.subject_id: - raise RuntimeError("final edit subject is not closed to the candidate") - - return finish( - GlobalOptimalityBuildOutcomeV2( - kind=GlobalOptimalityBuildKindV2.CERTIFIED_SUCCESS, - certified_success_result=result, - semantic_problem_sha256=bundle.problem.semantic_problem_sha256, - core_solver_config_sha256=bundle.config.core_solver_config_sha256, - candidate_domain_artifact_sha256=( - bundle.candidate.candidate_domain_artifact_sha256 - ), - relation_cost_partition_sha256=( - bundle.relation.relation_cost_partition_sha256 - ), - objective_partition_artifact_sha256=( - objective.objective_partition_artifact_sha256 - ), - canonical_edit_sha256=result_edit.edit_sha256, - certificate_sha256=certificate.certificate_sha256, - solve_result_sha256=result.solve_result_sha256, - cumulative_generation_usage=final_usage, - ) - ) - - -def _strict_model( - value: object, - model_type: type[ModelT], - *, - label: str, -) -> ModelT: - if type(value) is not model_type: - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _strict_fresh_internal_model( - value: object, - model_type: type[ModelT], - label: str, -) -> ModelT: - if type(value) is not model_type: - raise TypeError(f"fresh {label} has the wrong exact type") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError(f"fresh {label} failed strict reconstruction") from error - - -def _validate_global_replay_bundle_prefix( - bundle: object, - requested_edit: object, -) -> None: - if type(bundle) is not _GlobalReplayBundleV2: - raise TypeError("bundle must be a _GlobalReplayBundleV2") - if requested_edit is not None and type(requested_edit) is not CanonicalEditV2: - raise TypeError("requested_edit must be an exact CanonicalEditV2") - if type(bundle.objective_outcome) is not ObjectivePartitionCompilationOutcomeV2: - raise TypeError("bundle objective outcome has the wrong type") - if type(bundle.point_context) is not _PointObjectiveReplayContextV2: - raise TypeError("bundle point context has the wrong type") - objective = bundle.objective_outcome.objective_partition - usage = bundle.objective_outcome.cumulative_resource_usage - if ( - bundle.objective_outcome.kind - is not ObjectivePartitionCompilationKindV2.PARTITION - or objective is None - or usage is None - ): - raise ValueError("fresh global bundle requires a complete objective partition") - context = bundle.point_context - if ( - context.problem is not bundle.problem - or context.config is not bundle.config - or context.candidate is not bundle.candidate - or context.relation is not bundle.relation - or context.objective is not objective - or context.replay_usage != usage - ): - raise ValueError("fresh global bundle/context identity is not closed") - if ( - context.domain_budget.used != usage.domain_operations - or context.atomic_budget.used != usage.partition_cells - or usage.refinement_steps != bundle.candidate.resource_usage.refinement_steps - ): - raise ValueError("fresh global bundle ledger prefix is not closed") - if ( - context.domain_budget.limit != bundle.config.max_domain_operations - or context.atomic_budget.limit != bundle.config.max_partition_cells - ): - raise ValueError("fresh global bundle ledger limits are not closed") - - -def _validate_global_replay_bundle_closure( - bundle: _GlobalReplayBundleV2, - requested_edit: CanonicalEditV2 | None, -) -> None: - objective = bundle.objective_outcome.objective_partition - usage = bundle.objective_outcome.cumulative_resource_usage - if objective is None or usage is None: # pragma: no cover - prefix invariant - raise RuntimeError("fresh global bundle lost its objective prefix") - _require_candidate_closure(bundle.problem, bundle.config, bundle.candidate) - _require_relation_usage(bundle.config, bundle.candidate, usage) - _require_relation_closure( - bundle.problem, - bundle.config, - bundle.candidate, - bundle.relation, - ) - _require_objective_closure( - bundle.problem, - bundle.config, - bundle.candidate, - bundle.relation, - objective, - ) - if requested_edit is not None and _global_edit_reference_findings( - bundle.problem, - requested_edit, - ): - raise ValueError("fresh global bundle edit references are not closed") - - -def _reserve_global_builder_work( - context: _PointObjectiveReplayContextV2, - proposal_count: int, -) -> None: - """Atomically reserve all builder-owned deep passes before inspection. - - Point evaluation reserves its own work separately on the same ledger. The - formula below covers structural eligibility, cell/term/proposal closure, - cell-total lower-bound reduction, exact tie tuples, every ordered winner - comparison, and final result assembly. A cap miss therefore exposes no - inspected-cell or evaluated-proposal prefix. - """ - - if type(proposal_count) is not int or proposal_count < 0: - raise TypeError("proposal_count must be a non-negative exact int") - candidate_steps = len(context.candidate.shrink_ledger) - relation_cells = len(context.relation.cells) - objective_cells = len(context.objective.cells) - ordered_comparisons = proposal_count * max(0, proposal_count - 1) - units = ( - 8 - + candidate_steps - + 2 * relation_cells - + 8 * objective_cells - + 8 * proposal_count - + ordered_comparisons - ) - context.domain_budget.consume(units) - - -def _global_eligibility_findings( - bundle: _GlobalReplayBundleV2, - proposals: tuple[ObjectiveWitnessProposalV2, ...], -) -> tuple[str, ...]: - findings: list[str] = [] - if not bundle.candidate.is_global_verification_eligible: - findings.append("GLOBAL_CANDIDATE_NOT_ELIGIBLE") - if not bundle.relation.is_global_verification_eligible: - findings.append("GLOBAL_RELATION_NOT_ELIGIBLE") - objective = bundle.point_context.objective - if not objective.is_global_verification_eligible: - findings.append("GLOBAL_OBJECTIVE_NOT_ELIGIBLE") - if not objective.cells: - findings.append("GLOBAL_OBJECTIVE_HAS_NO_CELLS") - - cell_ids: list[str] = [] - relation_by_cell: dict[str, str] = {} - for cell in objective.cells: - cell_ids.append(cell.cell_id) - relation_by_cell[cell.cell_id] = cell.parent_relation_cell_id - domain = cell.domain - if domain.completeness is not DomainCompletenessV2.EXACT: - findings.append(f"GLOBAL_CELL_DOMAIN_NOT_EXACT:{cell.cell_id}") - if ( - domain.inner_bound.status is not RegionBoundStatusV2.NON_EMPTY - or domain.outer_bound.status is not RegionBoundStatusV2.NON_EMPTY - or domain.inner_bound != domain.outer_bound - ): - findings.append(f"GLOBAL_CELL_DOMAIN_NOT_EXACT_NONEMPTY:{cell.cell_id}") - bounds = cell.term_loss_bounds - if any( - interval.lower_bound != interval.upper_bound - for interval in ( - bounds.relation_damage_loss, - bounds.visibility_change_loss, - bounds.safety_margin_loss, - ) - ): - findings.append(f"GLOBAL_CELL_NONTRANSLATION_NOT_CONSTANT:{cell.cell_id}") - - proposal_ids = tuple(item.objective_cell_id for item in proposals) - if len(proposal_ids) != len(cell_ids) or set(proposal_ids) != set(cell_ids): - findings.append("GLOBAL_PROPOSAL_COVERAGE_NOT_EXACT") - for proposal in proposals: - expected_parent = relation_by_cell.get(proposal.objective_cell_id) - if expected_parent != proposal.parent_relation_cell_id: - raise RuntimeError("fresh objective proposal parent reference drift") - if ( - proposal.edit.semantic_problem_sha256 - != bundle.problem.semantic_problem_sha256 - ): - raise RuntimeError("fresh objective proposal problem hash drift") - if proposal.edit.subject_id != bundle.candidate.candidate_variable.subject_id: - raise RuntimeError("fresh objective proposal subject drift") - return tuple(sorted(set(findings))) - - -def _global_loss_lower_bound_v2( - cells: tuple[ObjectivePartitionCellV2, ...], -) -> float: - """Reduce complete cell totals; never mix component minima across cells.""" - - if ( - type(cells) is not tuple - or not cells - or any(type(cell) is not ObjectivePartitionCellV2 for cell in cells) - ): - raise ValueError("global loss lower bound requires objective cells") - return min(cell.term_loss_bounds.total_lower_bound for cell in cells) - - -def _require_global_point_closure( - bundle: _GlobalReplayBundleV2, - proposal: ObjectiveWitnessProposalV2, - point: PointObjectiveEvaluationOutcomeV2, -) -> None: - if point.kind is not PointObjectiveEvaluationKindV2.BOUNDED_FEASIBLE: - raise RuntimeError("global point closure requires a bounded feasible outcome") - expected = ( - bundle.problem.semantic_problem_sha256, - bundle.config.core_solver_config_sha256, - bundle.candidate.candidate_domain_artifact_sha256, - bundle.relation.relation_cost_partition_sha256, - bundle.point_context.objective.objective_partition_artifact_sha256, - proposal.edit.edit_sha256, - ) - actual = ( - point.semantic_problem_sha256, - point.core_solver_config_sha256, - point.candidate_domain_artifact_sha256, - point.relation_cost_partition_sha256, - point.objective_partition_artifact_sha256, - point.canonical_edit_sha256, - ) - if actual != expected: - raise RuntimeError("fresh global point reference closure drift") - if point.cumulative_generation_usage != _context_usage(bundle.point_context): - raise RuntimeError("fresh global point cumulative ledger drift") - if proposal.objective_cell_id not in point.covering_objective_cell_ids: - raise RuntimeError("fresh proposal escaped its objective cell") - if proposal.parent_relation_cell_id not in point.covering_relation_cell_ids: - raise RuntimeError("fresh proposal escaped its relation cell") - - -def _singleton_term_values( - bounds: ObjectiveTermBoundsV2, -) -> tuple[Fraction, Fraction, Fraction, Fraction] | None: - intervals = ( - bounds.translation_loss, - bounds.relation_damage_loss, - bounds.visibility_change_loss, - bounds.safety_margin_loss, - ) - if any(item.lower_bound != item.upper_bound for item in intervals): - return None - return tuple(Fraction.from_float(item.lower_bound) for item in intervals) # type: ignore[return-value] - - -def _provably_precedes_global( - left: _EvaluatedGlobalProposalV2, - right: _EvaluatedGlobalProposalV2, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> bool: - left_bounds = left.point.witness_loss_bounds - right_bounds = right.point.witness_loss_bounds - if left_bounds is None or right_bounds is None: - raise RuntimeError("evaluated global proposal omitted point bounds") - if left_bounds.total_upper_bound < right_bounds.total_lower_bound: - return True - left_nontranslation = _singleton_nontranslation_values(left_bounds) - right_nontranslation = _singleton_nontranslation_values(right_bounds) - if left_nontranslation is not None and left_nontranslation == right_nontranslation: - left_edit = left.proposal.edit.translation_xy_m - right_edit = right.proposal.edit.translation_xy_m - left_x, left_y = selection_frame.semantic_xy( - Fraction.from_float(left_edit.x), - Fraction.from_float(left_edit.y), - ) - right_x, right_y = selection_frame.semantic_xy( - Fraction.from_float(right_edit.x), - Fraction.from_float(right_edit.y), - ) - left_squared = left_x**2 + left_y**2 - right_squared = right_x**2 + right_y**2 - if left_squared != right_squared: - return left_squared < right_squared - return (left_x, left_y) < (right_x, right_y) - if ( - left.singleton_values is None - or right.singleton_values is None - or left.singleton_total is None - or right.singleton_total is None - ): - return False - if left.singleton_total != right.singleton_total: - return left.singleton_total < right.singleton_total - left_edit = left.proposal.edit.translation_xy_m - right_edit = right.proposal.edit.translation_xy_m - left_x, left_y = selection_frame.semantic_xy( - Fraction.from_float(left_edit.x), - Fraction.from_float(left_edit.y), - ) - right_x, right_y = selection_frame.semantic_xy( - Fraction.from_float(right_edit.x), - Fraction.from_float(right_edit.y), - ) - left_key = ( - *left.singleton_values, - left_x, - left_y, - ) - right_key = ( - *right.singleton_values, - right_x, - right_y, - ) - return left_key < right_key - - -def _singleton_nontranslation_values( - bounds: ObjectiveTermBoundsV2, -) -> tuple[Fraction, Fraction, Fraction] | None: - intervals = ( - bounds.relation_damage_loss, - bounds.visibility_change_loss, - bounds.safety_margin_loss, - ) - if any(item.lower_bound != item.upper_bound for item in intervals): - return None - return tuple(Fraction.from_float(item.lower_bound) for item in intervals) # type: ignore[return-value] - - -def _global_point_semantic_signature( - point: PointObjectiveEvaluationOutcomeV2, -) -> tuple[object, ...]: - return ( - point.semantic_problem_sha256, - point.core_solver_config_sha256, - point.candidate_domain_artifact_sha256, - point.relation_cost_partition_sha256, - point.objective_partition_artifact_sha256, - point.canonical_edit_sha256, - point.witness_loss_bounds, - point.relation_damage_vector, - point.constraint_slacks, - point.covering_objective_cell_ids, - point.covering_relation_cell_ids, - ) - - -def _select_certifiable_global_winner( - proposals: tuple[_EvaluatedGlobalProposalV2, ...], - *, - selection_frame: _ExactCardinalSelectionFrameV2 | None = None, -) -> _EvaluatedGlobalProposalV2 | None: - if selection_frame is None: - selection_frame = _ExactCardinalSelectionFrameV2() - elif type(selection_frame) is not _ExactCardinalSelectionFrameV2: - raise TypeError("selection_frame has the wrong exact type") - groups: dict[tuple[str, bytes], list[_EvaluatedGlobalProposalV2]] = {} - for proposal in proposals: - edit = proposal.proposal.edit - groups.setdefault( - (edit.edit_sha256, canonical_json_bytes_v2(edit)), - [], - ).append(proposal) - - representatives: list[_EvaluatedGlobalProposalV2] = [] - for group in groups.values(): - signature = _global_point_semantic_signature(group[0].point) - if any( - _global_point_semantic_signature(item.point) != signature - for item in group[1:] - ): - raise RuntimeError( - "same fresh proposal edit produced different semantic point evidence" - ) - representatives.append( - min( - group, - key=lambda item: ( - item.proposal.objective_cell_id, - item.proposal.parent_relation_cell_id, - ), - ) - ) - - winners = tuple( - left - for left in representatives - if all( - left is right or _provably_precedes_global(left, right, selection_frame) - for right in representatives - ) - ) - return winners[0] if len(winners) == 1 else None - - -def _global_edit_reference_findings( - problem: SemanticProblemV2, - edit: CanonicalEditV2, -) -> tuple[str, ...]: - findings: list[str] = [] - if edit.semantic_problem_sha256 != problem.semantic_problem_sha256: - findings.append("EDIT_REFERENCE_MISMATCH:SEMANTIC_PROBLEM_HASH") - if edit.subject_id != problem.constraints.allowed_edit.subject_id: - findings.append("EDIT_REFERENCE_MISMATCH:SUBJECT_ID") - return tuple(findings) - - -def _global_objective_usage( - base: CompilationResourceUsageV2, - domain_budget: _ObjectiveDomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=base.domain_operations + domain_budget.used, - partition_cells=atomic_budget.used, - refinement_steps=base.refinement_steps, - ) - - -def _require_candidate_reference_closure( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, -) -> None: - if candidate.semantic_problem_sha256 != problem.semantic_problem_sha256: - raise RuntimeError("candidate replay problem hash is not closed") - if candidate.core_solver_config_sha256 != config.core_solver_config_sha256: - raise RuntimeError("candidate replay config hash is not closed") - if candidate.candidate_variable.subject_id != ( - problem.constraints.allowed_edit.subject_id - ): - raise RuntimeError("candidate replay subject is not closed") - - -def _unsat_eligibility_findings( - candidate: CandidateDomainArtifactV2, -) -> tuple[str, ...]: - findings: list[str] = [] - if candidate.hard_domain.outer_bound.status is not RegionBoundStatusV2.EMPTY: - findings.append("UNSAT_NOT_PROVEN:NON_EMPTY_HARD_OUTER") - if any( - step.disposition is not ConstraintCompilationDispositionV2.APPLIED - for step in candidate.shrink_ledger - ): - findings.append("UNSAT_NOT_PROVEN:UNKNOWN_BEFORE_EMPTY") - if not candidate.is_unsat_verification_eligible and not findings: - findings.append("UNSAT_NOT_PROVEN:CANDIDATE_NOT_ELIGIBLE") - return tuple(sorted(set(findings))) - - -def _require_canonical_round_trip( - certificate: ProvenUnsatCertificateV2, - result: ProvenUnsatResultV2, -) -> None: - certificate_bytes = canonical_json_bytes_v2(certificate) - result_bytes = canonical_json_bytes_v2(result) - restored_certificate = ProvenUnsatCertificateV2.model_validate_json( - certificate_bytes, - strict=True, - ) - restored_result = ProvenUnsatResultV2.model_validate_json( - result_bytes, - strict=True, - ) - if ( - restored_certificate != certificate - or restored_result != result - or canonical_json_bytes_v2(restored_certificate) != certificate_bytes - or canonical_json_bytes_v2(restored_result) != result_bytes - or restored_certificate.certificate_sha256 != certificate.certificate_sha256 - or restored_result.solve_result_sha256 != result.solve_result_sha256 - or restored_result.certificate != restored_certificate - ): - raise RuntimeError("PROVEN_UNSAT canonical round-trip closure drift") - - -def _require_global_canonical_round_trip( - certificate: GlobalOptimalityCertificateV2, - result: CertifiedSuccessResultV2, -) -> None: - certificate_bytes = canonical_json_bytes_v2(certificate) - result_bytes = canonical_json_bytes_v2(result) - restored_certificate = GlobalOptimalityCertificateV2.model_validate_json( - certificate_bytes, - strict=True, - ) - restored_result = CertifiedSuccessResultV2.model_validate_json( - result_bytes, - strict=True, - ) - if ( - restored_certificate != certificate - or restored_result != result - or canonical_json_bytes_v2(restored_certificate) != certificate_bytes - or canonical_json_bytes_v2(restored_result) != result_bytes - or restored_certificate.certificate_sha256 != certificate.certificate_sha256 - or restored_result.solve_result_sha256 != result.solve_result_sha256 - or restored_result.certificate != restored_certificate - or restored_result.edit.subject_id - != restored_result.candidate_domain.candidate_variable.subject_id - ): - raise RuntimeError("CERTIFIED_SUCCESS canonical round-trip closure drift") - - -def _global_uncertified( - reason: UncertifiedReasonV2, - *finding_codes: str, - cumulative_generation_usage: CompilationResourceUsageV2 | None = None, -) -> GlobalOptimalityBuildOutcomeV2: - return GlobalOptimalityBuildOutcomeV2( - kind=GlobalOptimalityBuildKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=tuple(finding_codes), - cumulative_generation_usage=cumulative_generation_usage, - ) - - -def _global_not_proven( - *finding_codes: str, - cumulative_generation_usage: CompilationResourceUsageV2 | None = None, -) -> GlobalOptimalityBuildOutcomeV2: - return GlobalOptimalityBuildOutcomeV2( - kind=GlobalOptimalityBuildKindV2.NOT_PROVEN, - finding_codes=tuple(finding_codes), - cumulative_generation_usage=cumulative_generation_usage, - ) - - -def _uncertified( - reason: UncertifiedReasonV2, - *finding_codes: str, - verification_resource_usage: CompilationResourceUsageV2 | None = None, -) -> CertificateBuildOutcomeV2: - return CertificateBuildOutcomeV2( - kind=CertificateBuildKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=finding_codes, - verification_resource_usage=verification_resource_usage, - ) diff --git a/src/spatialcf/core/v2/collision_domain.py b/src/spatialcf/core/v2/collision_domain.py deleted file mode 100644 index 370778e..0000000 --- a/src/spatialcf/core/v2/collision_domain.py +++ /dev/null @@ -1,682 +0,0 @@ -"""Sound collision-domain compilation for the exact Canonical v2 box subset. - -The only variable is the subject's world-XY translation delta. Exact rational -configuration obstacles are subtracted from a caller-supplied finite search -universe while retaining their contact boundary. Positive Euclidean -clearance is published as a conservative inner/outer rectilinear bracket. -""" - -from __future__ import annotations - -import itertools -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction - -from spatialcf.core.v2.rect_kernel import ( - AxisMarginXYV2, - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, - RectTopologyV2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - ExactRectilinearRegionV2, - RectilinearAtomicBudgetExhaustedV2, - RectilinearAtomicBudgetV2, - RectilinearOutcomeKindV2, - RectilinearTopologyV2, - difference_rectilinear_region_v2, - intersect_rectilinear_regions_v2, - normalize_rectilinear_region_v2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - FactSetV2, - NumericPolicyV2, - QuaternionV2, - RigidTransformV2, - UncertaintyBudgetV2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - CollisionClearanceMetricV2, - CollisionConstraintV2, -) -from spatialcf.domain.v2.geometry import ( - CollisionBodyFactV2, - GeometryApproximationV2, - GeometryInstanceV2, - GeometryRoleV2, - UprightBox3DV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import CanonicalObjectV2 - - -class CollisionDomainKindV2(StrEnum): - """Mathematical effect of one collision predicate on finite edit deltas.""" - - REGION_BRACKET = "REGION_BRACKET" - IDENTITY = "IDENTITY" - EMPTY = "EMPTY" - UNKNOWN = "UNKNOWN" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - - -@dataclass(frozen=True, slots=True) -class CollisionDomainCompilationOutcomeV2: - """Closed result of compiling one collision predicate. - - ``IDENTITY`` means every point in the supplied search universe is allowed. - A ``REGION_BRACKET`` always satisfies ``inner_allowed_delta`` subset - ``outer_allowed_delta``; both are already clipped to that universe. - """ - - kind: CollisionDomainKindV2 - inner_allowed_delta: ExactRectilinearRegionV2 | None = None - outer_allowed_delta: ExactRectilinearRegionV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.kind, CollisionDomainKindV2): - raise TypeError("kind must be a CollisionDomainKindV2") - if type(self.finding_codes) is not tuple or any( - type(item) is not str for item in self.finding_codes - ): - raise TypeError("finding_codes must be an exact tuple of strings") - object.__setattr__( - self, - "finding_codes", - tuple(sorted(set(self.finding_codes))), - ) - - if self.kind is CollisionDomainKindV2.REGION_BRACKET: - if self.inner_allowed_delta is None or self.outer_allowed_delta is None: - raise ValueError("REGION_BRACKET requires both inner and outer regions") - _validate_delta_region_shell(self.inner_allowed_delta) - _validate_delta_region_shell(self.outer_allowed_delta) - if self.outer_allowed_delta.topology is RectilinearTopologyV2.EMPTY: - raise ValueError("an empty outer region must use EMPTY") - if not _rectilinear_subset( - self.inner_allowed_delta, - self.outer_allowed_delta, - ): - raise ValueError("inner allowed delta must be a subset of outer") - if self.finding_codes: - raise ValueError("REGION_BRACKET cannot carry findings") - return - - if self.inner_allowed_delta is not None or self.outer_allowed_delta is not None: - raise ValueError(f"{self.kind.value} must not carry allowed regions") - if self.kind is CollisionDomainKindV2.IDENTITY: - if self.finding_codes: - raise ValueError("IDENTITY cannot carry findings") - return - if self.kind is CollisionDomainKindV2.EMPTY: - if not self.finding_codes or any( - not item.startswith("EXACT_EMPTY:") for item in self.finding_codes - ): - raise ValueError("EMPTY requires an exact cause finding") - return - if not self.finding_codes: - raise ValueError(f"{self.kind.value} requires a finding") - - -CollisionDomainOutcomeV2 = CollisionDomainCompilationOutcomeV2 - - -@dataclass(frozen=True, slots=True) -class _WorldBoxV2: - min_x_m: Fraction - min_y_m: Fraction - min_z_m: Fraction - max_x_m: Fraction - max_y_m: Fraction - max_z_m: Fraction - - -def compile_collision_domain_v2( - problem: SemanticProblemV2, - constraint: CollisionConstraintV2 | str, - search_universe: ExactRectilinearRegionV2, - *, - max_atomic_cells: int | None = None, - atomic_budget: RectilinearAtomicBudgetV2 | None = None, -) -> CollisionDomainCompilationOutcomeV2: - """Compile one exact box-union collision predicate over XY translations. - - The semantic root, an optional caller-supplied constraint object, and the - exact search universe are independently reconstructed or normalized before - they can influence a certified result. - """ - - budget = _resolve_atomic_budget(max_atomic_cells, atomic_budget) - if not isinstance(problem, SemanticProblemV2): - raise TypeError("problem must be a SemanticProblemV2") - checked_problem = SemanticProblemV2.model_validate( - problem.model_dump(mode="python"), - strict=True, - ) - selected = _resolve_constraint(checked_problem, constraint) - checked_universe = _revalidate_search_universe( - search_universe, - atomic_budget=budget, - ) - if isinstance(checked_universe, CollisionDomainCompilationOutcomeV2): - return checked_universe - if checked_universe.topology is RectilinearTopologyV2.EMPTY: - constraint_id = ( - _requested_constraint_id(constraint) - if isinstance(selected, CollisionDomainCompilationOutcomeV2) - else selected.constraint_id - ) - return _empty(f"EXACT_EMPTY:SEARCH_UNIVERSE:{constraint_id}") - if isinstance(selected, CollisionDomainCompilationOutcomeV2): - return selected - - findings = _supported_subset_findings(checked_problem, selected) - if findings: - return _unknown(*findings) - - bodies = { - item.body_id: item - for item in checked_problem.scene.collision_bodies.values or () - } - geometries = { - item.geometry_id: item - for item in checked_problem.scene.geometry_instances.values or () - } - objects = { - item.object_id: item for item in checked_problem.scene.objects.values or () - } - subject_boxes = _boxes_for_bodies( - tuple(bodies[body_id] for body_id in selected.subject_body_ids), - geometries, - objects, - ) - obstacle_boxes = _boxes_for_bodies( - tuple(bodies[body_id] for body_id in selected.obstacle_body_ids), - geometries, - objects, - ) - pair_count = len(subject_boxes) * len(obstacle_boxes) - try: - budget.consume(pair_count) - except RectilinearAtomicBudgetExhaustedV2: - return _resource_limit() - - clearance_m = Fraction.from_float(selected.minimum_clearance_m) - outer_forbidden: list[ExactAxisAlignedRectV2] = [] - inner_forbidden: list[ExactAxisAlignedRectV2] = [] - for subject_box in subject_boxes: - for obstacle_box in obstacle_boxes: - q0 = _pair_configuration_obstacle( - subject_box, - obstacle_box, - clearance_m=clearance_m, - ) - if q0 is None: - continue - outer_forbidden.append(q0) - inner_forbidden.append( - q0 - if clearance_m == 0 - else q0.dilate_axis(AxisMarginXYV2(x_m=clearance_m, y_m=clearance_m)) - ) - - if not outer_forbidden: - return CollisionDomainCompilationOutcomeV2(kind=CollisionDomainKindV2.IDENTITY) - - outer_forbidden_region = normalize_rectilinear_region_v2( - tuple(outer_forbidden), - atomic_budget=budget, - ) - if outer_forbidden_region.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource_limit() - assert outer_forbidden_region.region is not None - - if clearance_m == 0: - inner_forbidden_region = outer_forbidden_region - else: - inner_forbidden_region = normalize_rectilinear_region_v2( - tuple(inner_forbidden), - atomic_budget=budget, - ) - if inner_forbidden_region.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource_limit() - assert inner_forbidden_region.region is not None - - outer_allowed = difference_rectilinear_region_v2( - checked_universe, - outer_forbidden_region.region, - atomic_budget=budget, - ) - if outer_allowed.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource_limit() - if clearance_m == 0: - inner_allowed = outer_allowed - else: - inner_allowed = difference_rectilinear_region_v2( - checked_universe, - inner_forbidden_region.region, - atomic_budget=budget, - ) - if inner_allowed.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource_limit() - assert outer_allowed.region is not None and inner_allowed.region is not None - - if outer_allowed.region.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:COLLISION_DOMAIN:{selected.constraint_id}") - return CollisionDomainCompilationOutcomeV2( - kind=CollisionDomainKindV2.REGION_BRACKET, - inner_allowed_delta=inner_allowed.region, - outer_allowed_delta=outer_allowed.region, - ) - - -def _resolve_atomic_budget( - max_atomic_cells: int | None, - atomic_budget: RectilinearAtomicBudgetV2 | None, -) -> RectilinearAtomicBudgetV2: - if (max_atomic_cells is None) == (atomic_budget is None): - raise ValueError("provide exactly one of max_atomic_cells or atomic_budget") - if atomic_budget is not None: - if type(atomic_budget) is not RectilinearAtomicBudgetV2: - raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") - atomic_budget.validate() - return atomic_budget - if type(max_atomic_cells) is not int: - raise TypeError("max_atomic_cells must be an exact int") - return RectilinearAtomicBudgetV2(limit=max_atomic_cells) - - -def _resolve_constraint( - problem: SemanticProblemV2, - requested: CollisionConstraintV2 | str, -) -> CollisionConstraintV2 | CollisionDomainCompilationOutcomeV2: - if isinstance(requested, CollisionConstraintV2): - checked = CollisionConstraintV2.model_validate( - requested.model_dump(mode="python"), - strict=True, - ) - constraint_id = checked.constraint_id - elif type(requested) is str: - checked = None - constraint_id = requested - else: - raise TypeError("constraint must be a CollisionConstraintV2 or exact str ID") - - registered = next( - ( - item - for item in problem.constraints.collision_constraints - if item.constraint_id == constraint_id - ), - None, - ) - if registered is None: - return _unknown(f"UNKNOWN_COLLISION_CONSTRAINT:{constraint_id}") - if checked is not None and checked != registered: - return _unknown(f"COLLISION_CONSTRAINT_MISMATCH:{constraint_id}") - return registered - - -def _requested_constraint_id(requested: CollisionConstraintV2 | str) -> str: - if isinstance(requested, CollisionConstraintV2): - return requested.constraint_id - if type(requested) is str: - return requested - raise TypeError("constraint must be a CollisionConstraintV2 or exact str ID") - - -def _revalidate_search_universe( - search_universe: ExactRectilinearRegionV2, - *, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ExactRectilinearRegionV2 | CollisionDomainCompilationOutcomeV2: - if not isinstance(search_universe, ExactRectilinearRegionV2): - raise TypeError("search_universe must be an ExactRectilinearRegionV2") - checked = intersect_rectilinear_regions_v2( - search_universe, - search_universe, - atomic_budget=atomic_budget, - ) - if checked.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource_limit() - assert checked.region is not None - return checked.region - - -def _supported_subset_findings( - problem: SemanticProblemV2, - constraint: CollisionConstraintV2, -) -> tuple[str, ...]: - findings: list[str] = [] - if ( - constraint.clearance_metric - is not CollisionClearanceMetricV2.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE - ): - findings.append( - f"UNSUPPORTED_COLLISION_DOMAIN:CLEARANCE_METRIC:{constraint.constraint_id}" - ) - if constraint.boundary_policy is not BoundaryPolicyV2.CLOSED: - findings.append( - f"UNSUPPORTED_COLLISION_DOMAIN:BOUNDARY_POLICY:{constraint.constraint_id}" - ) - if constraint.support_contact_exceptions: - findings.append( - "UNSUPPORTED_COLLISION_DOMAIN:SUPPORT_CONTACT_EXCEPTIONS:" - f"{constraint.constraint_id}" - ) - if not _numeric_policy_is_zero(problem.numeric_policy): - findings.append("UNSUPPORTED_COLLISION_DOMAIN:NUMERIC_POLICY") - - for label, facts in ( - ("OBJECTS", problem.scene.objects), - ("COLLISION_BODIES", problem.scene.collision_bodies), - ("GEOMETRY_INSTANCES", problem.scene.geometry_instances), - ): - findings.extend(_fact_family_findings(label, facts)) - - if not _all_exact_families(problem): - return tuple(sorted(set(findings))) - - objects = {item.object_id: item for item in problem.scene.objects.values or ()} - bodies = { - item.body_id: item for item in problem.scene.collision_bodies.values or () - } - geometries = { - item.geometry_id: item for item in problem.scene.geometry_instances.values or () - } - relevant_body_ids = tuple( - sorted((*constraint.subject_body_ids, *constraint.obstacle_body_ids)) - ) - relevant_geometry_ids: set[str] = set() - relevant_owner_ids: set[str] = set() - for body_id in relevant_body_ids: - body = bodies.get(body_id) - if body is None: - findings.append(f"MISSING_FACT:COLLISION_BODY:{body_id}") - continue - if body.composition != "CLOSED_SOLID_UNION": - findings.append(f"UNSUPPORTED_COLLISION_DOMAIN:BODY_COMPOSITION:{body_id}") - relevant_geometry_ids.update(body.geometry_instance_ids) - if body.owner_object_id is not None: - relevant_owner_ids.add(body.owner_object_id) - owner = objects.get(body.owner_object_id) - if owner is None: - findings.append( - f"MISSING_FACT:COLLISION_BODY_OWNER:{body.owner_object_id}" - ) - - for owner_id in sorted(relevant_owner_ids): - owner = objects.get(owner_id) - if owner is not None and not _has_exact_identity_rotation( - owner.pose.world_from_object - ): - findings.append( - "UNSUPPORTED_COLLISION_DOMAIN:NON_IDENTITY_ROTATION:" - f"OBJECT_POSE:{owner_id}" - ) - - for geometry_id in sorted(relevant_geometry_ids): - geometry = geometries.get(geometry_id) - if geometry is None: - findings.append(f"MISSING_FACT:COLLISION_GEOMETRY:{geometry_id}") - continue - if geometry.role is not GeometryRoleV2.COLLISION: - findings.append(f"UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_ROLE:{geometry_id}") - if geometry.approximation is not GeometryApproximationV2.EXACT: - findings.append( - "UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_APPROXIMATION:" - f"{geometry_id}:{geometry.approximation.value}" - ) - if not isinstance(geometry.shape, UprightBox3DV2): - findings.append( - f"UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_SHAPE:{geometry_id}" - ) - if not _uncertainty_is_zero(geometry.uncertainty): - findings.append( - f"UNSUPPORTED_COLLISION_DOMAIN:GEOMETRY_ITEM_UNCERTAINTY:{geometry_id}" - ) - if not _has_exact_identity_rotation(geometry.anchor_from_geometry): - findings.append( - "UNSUPPORTED_COLLISION_DOMAIN:NON_IDENTITY_ROTATION:" - f"GEOMETRY_ANCHOR:{geometry_id}" - ) - return tuple(sorted(set(findings))) - - -def _all_exact_families(problem: SemanticProblemV2) -> bool: - return all( - facts.availability is FactAvailabilityV2.KNOWN - and facts.completeness is FactCompletenessV2.EXACT - and facts.uncertainty is not None - and _uncertainty_is_zero(facts.uncertainty) - for facts in ( - problem.scene.objects, - problem.scene.collision_bodies, - problem.scene.geometry_instances, - ) - ) - - -def _fact_family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: - if facts.availability is FactAvailabilityV2.MISSING: - return (f"MISSING_FACT:{label}",) - if facts.availability is not FactAvailabilityV2.KNOWN: - return (f"UNSUPPORTED_COLLISION_DOMAIN:{label}_AVAILABILITY",) - findings: list[str] = [] - if facts.completeness is not FactCompletenessV2.EXACT: - completeness = ( - facts.completeness.value if facts.completeness is not None else "NONE" - ) - findings.append( - f"UNSUPPORTED_COLLISION_DOMAIN:{label}_COMPLETENESS:{completeness}" - ) - if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): - findings.append(f"UNSUPPORTED_COLLISION_DOMAIN:{label}_FACT_UNCERTAINTY") - return tuple(findings) - - -def _has_exact_identity_rotation(transform: RigidTransformV2) -> bool: - rotation: QuaternionV2 = transform.rotation - return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) - - -def _boxes_for_bodies( - bodies: tuple[CollisionBodyFactV2, ...], - geometries: dict[str, GeometryInstanceV2], - objects: dict[str, CanonicalObjectV2], -) -> tuple[_WorldBoxV2, ...]: - boxes: list[_WorldBoxV2] = [] - for body in sorted(bodies, key=lambda item: item.body_id): - owner_translation = (Fraction(), Fraction(), Fraction()) - if body.owner_object_id is not None: - translation = objects[ - body.owner_object_id - ].pose.world_from_object.translation - owner_translation = tuple( - Fraction.from_float(value) - for value in (translation.x, translation.y, translation.z) - ) - for geometry_id in sorted(body.geometry_instance_ids): - geometry = geometries[geometry_id] - assert isinstance(geometry.shape, UprightBox3DV2) - geometry_translation = geometry.anchor_from_geometry.translation - center = tuple( - owner_translation[index] + Fraction.from_float(value) - for index, value in enumerate( - ( - geometry_translation.x, - geometry_translation.y, - geometry_translation.z, - ) - ) - ) - half_size = tuple( - Fraction.from_float(value) / 2 - for value in ( - geometry.shape.size_m.x, - geometry.shape.size_m.y, - geometry.shape.size_m.z, - ) - ) - boxes.append( - _WorldBoxV2( - min_x_m=center[0] - half_size[0], - min_y_m=center[1] - half_size[1], - min_z_m=center[2] - half_size[2], - max_x_m=center[0] + half_size[0], - max_y_m=center[1] + half_size[1], - max_z_m=center[2] + half_size[2], - ) - ) - return tuple(boxes) - - -def _pair_configuration_obstacle( - subject: _WorldBoxV2, - obstacle: _WorldBoxV2, - *, - clearance_m: Fraction, -) -> ExactAxisAlignedRectV2 | None: - if clearance_m == 0: - z_interiors_overlap = ( - subject.min_z_m < obstacle.max_z_m and obstacle.min_z_m < subject.max_z_m - ) - if not z_interiors_overlap: - return None - else: - vertical_gap_m = max( - obstacle.min_z_m - subject.max_z_m, - subject.min_z_m - obstacle.max_z_m, - Fraction(), - ) - if vertical_gap_m >= clearance_m: - return None - - return ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=obstacle.min_x_m - subject.max_x_m, - min_y_m=obstacle.min_y_m - subject.max_y_m, - max_x_m=obstacle.max_x_m - subject.min_x_m, - max_y_m=obstacle.max_y_m - subject.min_y_m, - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ) - - -def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: - return all( - value == 0.0 - for value in ( - policy.linear_tolerance_m, - policy.area_tolerance_m2, - policy.angular_tolerance_rad, - policy.pixel_tolerance_px, - policy.fraction_tolerance, - ) - ) - - -def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: - return _numeric_policy_is_zero( - uncertainty.source_error - ) and _numeric_policy_is_zero(uncertainty.shape_approximation) - - -def _validate_delta_region_shell(region: ExactRectilinearRegionV2) -> None: - if not isinstance(region, ExactRectilinearRegionV2): - raise TypeError("allowed delta must be an ExactRectilinearRegionV2") - if not isinstance(region.topology, RectilinearTopologyV2): - raise TypeError("allowed delta topology is invalid") - if type(region.rectangles) is not tuple: - raise TypeError("allowed delta rectangles must be an exact tuple") - expected_topology = RectilinearTopologyV2.EMPTY - for rectangle in region.rectangles: - if type(rectangle) is not ExactAxisAlignedRectV2: - raise TypeError("allowed delta rectangles have an invalid value") - checked = ExactAxisAlignedRectV2( - coordinate_space=rectangle.coordinate_space, - topology=rectangle.topology, - min_x_m=rectangle.min_x_m, - min_y_m=rectangle.min_y_m, - max_x_m=rectangle.max_x_m, - max_y_m=rectangle.max_y_m, - ) - if checked.coordinate_space is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M: - raise ValueError("allowed delta must use translation-delta coordinates") - if checked.topology is RectTopologyV2.AREA: - expected_topology = RectilinearTopologyV2.AREA - elif ( - checked.topology is RectTopologyV2.DEGENERATE - and expected_topology is RectilinearTopologyV2.EMPTY - ): - expected_topology = RectilinearTopologyV2.DEGENERATE - if region.topology is not expected_topology: - raise ValueError("allowed delta topology does not match its rectangles") - - -def _rectilinear_subset( - inner: ExactRectilinearRegionV2, - outer: ExactRectilinearRegionV2, -) -> bool: - """Decide inclusion on the exact axis-aligned arrangement atoms.""" - - if inner.topology is RectilinearTopologyV2.EMPTY: - return True - coordinates = inner.rectangles + outer.rectangles - xs = tuple( - sorted( - { - value - for rectangle in coordinates - for value in (rectangle.min_x_m, rectangle.max_x_m) - if value is not None - } - ) - ) - ys = tuple( - sorted( - { - value - for rectangle in coordinates - for value in (rectangle.min_y_m, rectangle.max_y_m) - if value is not None - } - ) - ) - x_samples = tuple( - sorted(set(xs) | {(left + right) / 2 for left, right in itertools.pairwise(xs)}) - ) - y_samples = tuple( - sorted( - set(ys) | {(lower + upper) / 2 for lower, upper in itertools.pairwise(ys)} - ) - ) - return all( - not inner.contains_point(x_m, y_m) or outer.contains_point(x_m, y_m) - for x_m in x_samples - for y_m in y_samples - ) - - -def _unknown(*findings: str) -> CollisionDomainCompilationOutcomeV2: - return CollisionDomainCompilationOutcomeV2( - kind=CollisionDomainKindV2.UNKNOWN, - finding_codes=tuple(findings), - ) - - -def _resource_limit() -> CollisionDomainCompilationOutcomeV2: - return CollisionDomainCompilationOutcomeV2( - kind=CollisionDomainKindV2.RESOURCE_LIMIT, - finding_codes=("RESOURCE_LIMIT:COLLISION_DOMAIN_ATOMIC_CELLS",), - ) - - -def _empty(finding: str) -> CollisionDomainCompilationOutcomeV2: - return CollisionDomainCompilationOutcomeV2( - kind=CollisionDomainKindV2.EMPTY, - finding_codes=(finding,), - ) diff --git a/src/spatialcf/core/v2/continuous_yaw_certificate.py b/src/spatialcf/core/v2/continuous_yaw_certificate.py deleted file mode 100644 index 6257624..0000000 --- a/src/spatialcf/core/v2/continuous_yaw_certificate.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Private T18 certificate assembly for the continuous-yaw solve.""" - -from __future__ import annotations - -import warnings -from fractions import Fraction -from sys import float_info - -from spatialcf.core.v2.continuous_yaw_objective import ( - _ContinuousYawObjectiveStageV2, - _point_domain_cost, -) -from spatialcf.core.v2.continuous_yaw_visibility import ( - _CompleteContinuousYawCandidateStageV2, -) -from spatialcf.domain.v2.certificate import OptimalityClaimV2 -from spatialcf.domain.v2.continuous_yaw_candidate import SemanticProblemV2_2 -from spatialcf.domain.v2.continuous_yaw_solver import ( - ContinuousYawCandidateRefsV2_8, - ContinuousYawGlobalOptimalityCertificateV2_8, - ContinuousYawResourceUsageV2_8, - ContinuousYawSolverConfigV2_8, - ContinuousYawWitnessEvaluationV2_8, - _directed_binary64_gap_ceil, -) - - -class _CertificateNotProvenV2(ValueError): - pass - - -def _assemble_continuous_yaw_certificate_v2( - problem: SemanticProblemV2_2, - config: ContinuousYawSolverConfigV2_8, - candidate_stage: _CompleteContinuousYawCandidateStageV2, - objective_stage: _ContinuousYawObjectiveStageV2, - evaluations: tuple[ContinuousYawWitnessEvaluationV2_8, ...], -) -> ContinuousYawGlobalOptimalityCertificateV2_8: - checked_problem, checked_config = _strict_inputs(problem, config) - if type(candidate_stage) is not _CompleteContinuousYawCandidateStageV2: - raise TypeError("candidate_stage has the wrong exact type") - if type(objective_stage) is not _ContinuousYawObjectiveStageV2: - raise TypeError("objective_stage has the wrong exact type") - if type(evaluations) is not tuple or not evaluations: - raise _CertificateNotProvenV2("certificate requires evaluated proposals") - checked_evaluations = tuple( - ContinuousYawWitnessEvaluationV2_8.model_validate( - item.model_dump(mode="python", warnings="error"), strict=True - ) - for item in evaluations - ) - expected_edits = {item.edit.edit_sha256 for item in objective_stage.proposals} - actual_edits = {item.edit.edit_sha256 for item in checked_evaluations} - if actual_edits != expected_edits or len(checked_evaluations) != len( - expected_edits - ): - raise _CertificateNotProvenV2( - "certificate requires every canonical proposal exactly once" - ) - if ( - candidate_stage.semantic_problem_sha256 - != checked_problem.semantic_problem_sha256 - or objective_stage.semantic_problem_sha256 - != checked_problem.semantic_problem_sha256 - or objective_stage.solver_config_sha256 != checked_config.config_sha256 - or objective_stage.candidate_stage_sha256 != candidate_stage.stage_sha256 - ): - raise ValueError("certificate replay prefix is not closed") - selected = min(checked_evaluations, key=_selection_key) - lower = objective_stage.global_loss_lower_bound - upper = selected.witness_loss_bounds.total_upper_bound - if lower > upper: - raise RuntimeError("fresh witness upper fell below the global lower") - exact_gap = Fraction.from_float(upper) - Fraction.from_float(lower) - if exact_gap > Fraction.from_float(float_info.max): - raise _CertificateNotProvenV2("directed optimality gap is not finite") - gap = _directed_binary64_gap_ceil(lower, upper) - if gap > checked_config.target_optimality_gap: - raise _CertificateNotProvenV2( - "directed optimality gap exceeds the configured target" - ) - if gap == 0.0: - claim = OptimalityClaimV2.EXACT - epsilon = None - else: - claim = OptimalityClaimV2.EPSILON_OPTIMAL - epsilon = gap - final_usage = _final_usage(objective_stage, checked_evaluations) - if ( - final_usage.domain_operations - > checked_config.candidate_config.max_domain_operations - or final_usage.so2_atomic_steps - > checked_config.candidate_config.max_so2_atomic_steps - or final_usage.candidate_cells - > checked_config.candidate_config.max_candidate_cells - or final_usage.objective_partition_cells - > checked_config.max_objective_partition_cells - ): - raise _CertificateNotProvenV2("final resource usage exceeds solver policy") - refs = ContinuousYawCandidateRefsV2_8( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config_sha256=checked_config.config_sha256, - t15_candidate_artifact_sha256=candidate_stage.upstream_t15_artifact_sha256, - target_candidate_stage_sha256=candidate_stage.upstream_target_stage_sha256, - visibility_candidate_stage_sha256=candidate_stage.stage_sha256, - ) - return ContinuousYawGlobalOptimalityCertificateV2_8( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config_sha256=checked_config.config_sha256, - candidate_refs_sha256=refs.candidate_refs_sha256, - objective_cells_sha256=objective_stage.objective_cells_sha256, - witness_evaluation_sha256=selected.witness_evaluation_sha256, - edit_sha256=selected.edit.edit_sha256, - loss_lower_bound=lower, - loss_upper_bound=upper, - optimality_gap=gap, - optimality_claim=claim, - epsilon=epsilon, - final_resource_usage=final_usage, - ) - - -def _strict_inputs(problem, config): - if type(problem) is not SemanticProblemV2_2: - raise TypeError("problem has the wrong exact type") - if type(config) is not ContinuousYawSolverConfigV2_8: - raise TypeError("config has the wrong exact type") - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return ( - SemanticProblemV2_2.model_validate( - problem.model_dump(mode="python", warnings="error"), strict=True - ), - ContinuousYawSolverConfigV2_8.model_validate( - config.model_dump(mode="python", warnings="error"), strict=True - ), - ) - - -def _selection_key(item: ContinuousYawWitnessEvaluationV2_8): - x = Fraction.from_float(item.edit.translation_xy_m.x) - y = Fraction.from_float(item.edit.translation_xy_m.y) - return ( - x**2 + y**2, - item.witness_loss_bounds.total_upper_bound, - item.edit.translation_xy_m.x, - item.edit.translation_xy_m.y, - item.edit.edit_sha256, - ) - - -def _final_usage(objective_stage, evaluations): - domain = objective_stage.resource_usage.domain_operations - so2 = objective_stage.resource_usage.so2_atomic_steps - for evaluation in evaluations: - source = objective_stage.source_cell_by_objective_id( - evaluation.objective_cell_id - ) - domain += _point_domain_cost(source) - so2 += 128 - return ContinuousYawResourceUsageV2_8( - domain_operations=domain, - so2_atomic_steps=so2, - candidate_cells=objective_stage.resource_usage.candidate_cells, - objective_partition_cells=len(objective_stage.cells), - ) - - -__all__ = () diff --git a/src/spatialcf/core/v2/continuous_yaw_solve_verifier.py b/src/spatialcf/core/v2/continuous_yaw_solve_verifier.py deleted file mode 100644 index 6ba9153..0000000 --- a/src/spatialcf/core/v2/continuous_yaw_solve_verifier.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Trusted expected-config replay verifier for continuous-yaw v2.8 results.""" - -from __future__ import annotations - -import warnings - -from pydantic import TypeAdapter, ValidationError -from pydantic_core import PydanticSerializationError - -from spatialcf.core.v2.continuous_yaw_solver import ( - solve_continuous_yaw_minimum_cost_v2_8, -) -from spatialcf.domain.v2.continuous_yaw_candidate import SemanticProblemV2_2 -from spatialcf.domain.v2.continuous_yaw_solver import ( - ContinuousYawCertifiedSuccessResultV2_8, - ContinuousYawProvenUnsatResultV2_8, - ContinuousYawSolverConfigV2_8, - ContinuousYawSolveResultV2_8, - ContinuousYawSolveVerificationKindV2, - ContinuousYawSolveVerificationOutcomeV2_8, - ContinuousYawUncertifiedResultV2_8, -) -from spatialcf.domain.v2.result import UncertifiedReasonV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - -_RESULT_ADAPTER = TypeAdapter(ContinuousYawSolveResultV2_8) - - -def verify_continuous_yaw_solve_result_v2_8( - problem: SemanticProblemV2_2, - expected_config: ContinuousYawSolverConfigV2_8, - submitted_result: ContinuousYawSolveResultV2_8, -) -> ContinuousYawSolveVerificationOutcomeV2_8: - """Replay exactly once after gating the submitter's embedded config.""" - - checked = _strict_inputs(problem, expected_config, submitted_result) - if checked is None: - return _mismatch("MISMATCH:CONTINUOUS_YAW_SUBMITTED_INPUT") - checked_problem, checked_config, checked_submitted = checked - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - if ( - checked_submitted.semantic_problem_sha256 - != checked_problem.semantic_problem_sha256 - ): - return _mismatch("MISMATCH:CONTINUOUS_YAW_PROBLEM_HASH") - if ( - checked_submitted.solver_config.config_sha256 - != checked_config.config_sha256 - or canonical_json_bytes_v2(checked_submitted.solver_config) - != canonical_json_bytes_v2(checked_config) - ): - return _mismatch("MISMATCH:CONTINUOUS_YAW_EXPECTED_CONFIG") - except (ArithmeticError, RuntimeWarning): - return ContinuousYawSolveVerificationOutcomeV2_8( - kind=ContinuousYawSolveVerificationKindV2.UNCERTIFIED, - uncertified_reason=UncertifiedReasonV2.NUMERIC_GAP, - finding_codes=("NUMERIC_GAP:CONTINUOUS_YAW_VERIFICATION_GATE",), - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - replay = solve_continuous_yaw_minimum_cost_v2_8( - checked_problem, checked_config - ) - except (ArithmeticError, RuntimeWarning): - return ContinuousYawSolveVerificationOutcomeV2_8( - kind=ContinuousYawSolveVerificationKindV2.UNCERTIFIED, - uncertified_reason=UncertifiedReasonV2.NUMERIC_GAP, - finding_codes=("NUMERIC_GAP:CONTINUOUS_YAW_SOLVE_REPLAY",), - ) - if replay.result is None: - return ContinuousYawSolveVerificationOutcomeV2_8( - kind=ContinuousYawSolveVerificationKindV2.UNCERTIFIED, - uncertified_reason=UncertifiedReasonV2.COMPILATION_INCOMPLETE, - finding_codes=("COMPILATION_INCOMPLETE:CONTINUOUS_YAW_SOLVE_REPLAY",), - ) - fresh = replay.result - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - exact_match = ( - type(fresh) is type(checked_submitted) - and fresh == checked_submitted - and canonical_json_bytes_v2(fresh) - == canonical_json_bytes_v2(checked_submitted) - and fresh.solve_result_sha256 == checked_submitted.solve_result_sha256 - ) - if not exact_match: - return ContinuousYawSolveVerificationOutcomeV2_8( - kind=ContinuousYawSolveVerificationKindV2.MISMATCH, - replay_generation_usage=replay.cumulative_generation_usage, - proposal_count=replay.proposal_count, - evaluated_proposal_count=replay.evaluated_proposal_count, - finding_codes=("MISMATCH:CONTINUOUS_YAW_SOLVE_RESULT",), - ) - return ContinuousYawSolveVerificationOutcomeV2_8( - kind=ContinuousYawSolveVerificationKindV2.VERIFIED, - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config_sha256=checked_config.config_sha256, - submitted_solve_result_sha256=checked_submitted.solve_result_sha256, - verified_status=checked_submitted.status, - replay_generation_usage=replay.cumulative_generation_usage, - proposal_count=replay.proposal_count, - evaluated_proposal_count=replay.evaluated_proposal_count, - ) - - -def _strict_inputs(problem, config, submitted): - if type(problem) is not SemanticProblemV2_2 or type(config) is not ( - ContinuousYawSolverConfigV2_8 - ): - return None - if type(submitted) not in ( - ContinuousYawCertifiedSuccessResultV2_8, - ContinuousYawProvenUnsatResultV2_8, - ContinuousYawUncertifiedResultV2_8, - ): - return None - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - checked_problem = SemanticProblemV2_2.model_validate( - problem.model_dump(mode="python", warnings="error"), strict=True - ) - checked_config = ContinuousYawSolverConfigV2_8.model_validate( - config.model_dump(mode="python", warnings="error"), strict=True - ) - checked_submitted = _RESULT_ADAPTER.validate_python( - submitted.model_dump(mode="python", warnings="error"), strict=True - ) - return checked_problem, checked_config, checked_submitted - except ( - AttributeError, - ValidationError, - PydanticSerializationError, - TypeError, - ValueError, - Warning, - ): - return None - - -def _mismatch(finding): - return ContinuousYawSolveVerificationOutcomeV2_8( - kind=ContinuousYawSolveVerificationKindV2.MISMATCH, - finding_codes=(finding,), - ) - - -__all__ = ("verify_continuous_yaw_solve_result_v2_8",) diff --git a/src/spatialcf/core/v2/continuous_yaw_solver.py b/src/spatialcf/core/v2/continuous_yaw_solver.py deleted file mode 100644 index f6894d9..0000000 --- a/src/spatialcf/core/v2/continuous_yaw_solver.py +++ /dev/null @@ -1,482 +0,0 @@ -"""Public raw-input minimum-cost solve for Canonical continuous yaw v2.8.""" - -from __future__ import annotations - -import warnings - -from pydantic import ValidationError -from pydantic_core import PydanticSerializationError - -from spatialcf.core.v2.continuous_yaw_certificate import ( - _assemble_continuous_yaw_certificate_v2, - _CertificateNotProvenV2, -) -from spatialcf.core.v2.continuous_yaw_objective import ( - _compile_continuous_yaw_objective_v2, - _ContinuousYawObjectiveNumericGapV2, - _ContinuousYawWitnessNotProvenV2, - _evaluate_continuous_yaw_point_v2, - _UnsupportedContinuousYawObjectiveV2, -) -from spatialcf.core.v2.continuous_yaw_target_relation import ( - _compile_target_aware_candidate_v2, - _TargetAwareCandidateKindV2, -) -from spatialcf.core.v2.continuous_yaw_visibility import ( - _compile_complete_continuous_yaw_candidate_v2, - _CompleteContinuousYawCandidateKindV2, -) -from spatialcf.core.v2.so2_interval import ( - SO2AtomicBudgetExhaustedV2, - SO2AtomicBudgetV2, -) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, -) -from spatialcf.core.v2.support_strict_convex_candidate_domain import ( - SupportStrictConvexCandidateCompilationKindV2, - compile_support_strict_convex_candidate_domain_v2_7, -) -from spatialcf.domain.v2.continuous_yaw_candidate import SemanticProblemV2_2 -from spatialcf.domain.v2.continuous_yaw_solver import ( - ContinuousYawCandidateRefsV2_8, - ContinuousYawCertifiedSuccessResultV2_8, - ContinuousYawMinimumCostSolveOutcomeV2_8, - ContinuousYawProvenUnsatResultV2_8, - ContinuousYawResourceUsageV2_8, - ContinuousYawSolverConfigV2_8, - ContinuousYawUncertifiedResultV2_8, -) -from spatialcf.domain.v2.result import UncertifiedReasonV2 - - -def solve_continuous_yaw_minimum_cost_v2_8( - problem: SemanticProblemV2_2, - config: ContinuousYawSolverConfigV2_8, -) -> ContinuousYawMinimumCostSolveOutcomeV2_8: - """Run the T15--T18 chain once with one cumulative pair of ledgers.""" - - checked = _strict_public_inputs(problem, config) - if checked is None: - return ContinuousYawMinimumCostSolveOutcomeV2_8( - result=None, - finding_codes=("INVALID_INPUT:CONTINUOUS_YAW_SOLVE",), - ) - checked_problem, checked_config = checked - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - t15 = compile_support_strict_convex_candidate_domain_v2_7( - checked_problem, checked_config.candidate_config - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified_without_prefix( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_T15_REPLAY",), - ) - if t15.kind is not SupportStrictConvexCandidateCompilationKindV2.ARTIFACT: - return _uncertified_without_prefix( - checked_problem, - checked_config, - _reason_for_kind(t15.kind.value), - t15.finding_codes, - ) - if t15.artifact is None: - raise RuntimeError("T15 ARTIFACT outcome omitted its artifact") - artifact = t15.artifact - base_usage = _usage_from_candidate(artifact.resource_usage) - refs = ContinuousYawCandidateRefsV2_8( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config_sha256=checked_config.config_sha256, - t15_candidate_artifact_sha256=artifact.artifact_sha256, - ) - if not artifact.allowed_domain_bracket.outer_allowed.cells: - return _unsat( - checked_problem, - checked_config, - refs, - "T15", - artifact.artifact_sha256, - base_usage, - ) - - atomic_budget = SO2AtomicBudgetV2( - limit=checked_config.candidate_config.max_so2_atomic_steps, - used=artifact.resource_usage.so2_atomic_steps, - ) - intersection_budget = StrictConvexIntersectionBudgetV2( - max_domain_operations=checked_config.candidate_config.max_domain_operations, - max_candidate_cells=checked_config.candidate_config.max_candidate_cells, - domain_operations_used=artifact.resource_usage.domain_operations, - candidate_cells_used=artifact.resource_usage.candidate_cells, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - target = _compile_target_aware_candidate_v2( - checked_problem, - artifact, - atomic_budget=atomic_budget, - intersection_budget=intersection_budget, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_TARGET_REPLAY",), - ) - if target.kind is not _TargetAwareCandidateKindV2.STAGE: - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - _reason_for_kind(target.kind.value), - target.finding_codes, - ) - if target.stage is None: - raise RuntimeError("target STAGE outcome omitted its stage") - target_stage = target.stage - refs = ContinuousYawCandidateRefsV2_8( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config_sha256=checked_config.config_sha256, - t15_candidate_artifact_sha256=artifact.artifact_sha256, - target_candidate_stage_sha256=target_stage.stage_sha256, - ) - if not target_stage.outer_allowed.cells: - return _unsat( - checked_problem, - checked_config, - refs, - "TARGET_RELATION", - target_stage.stage_sha256, - _live_usage(intersection_budget, atomic_budget, 0), - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - visibility = _compile_complete_continuous_yaw_candidate_v2( - checked_problem, - target_stage, - atomic_budget=atomic_budget, - intersection_budget=intersection_budget, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_VISIBILITY_REPLAY",), - ) - if visibility.kind is not _CompleteContinuousYawCandidateKindV2.STAGE: - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - _reason_for_kind(visibility.kind.value), - visibility.finding_codes, - ) - if visibility.stage is None: - raise RuntimeError("visibility STAGE outcome omitted its stage") - candidate = visibility.stage - refs = ContinuousYawCandidateRefsV2_8( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config_sha256=checked_config.config_sha256, - t15_candidate_artifact_sha256=artifact.artifact_sha256, - target_candidate_stage_sha256=target_stage.stage_sha256, - visibility_candidate_stage_sha256=candidate.stage_sha256, - ) - if candidate.unsat_prefix_eligible: - return _unsat( - checked_problem, - checked_config, - refs, - "VISIBILITY", - candidate.stage_sha256, - _live_usage(intersection_budget, atomic_budget, 0), - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - objective = _compile_continuous_yaw_objective_v2( - checked_problem, - checked_config, - candidate, - intersection_budget=intersection_budget, - ) - except _UnsupportedContinuousYawObjectiveV2 as error: - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - UncertifiedReasonV2.UNSUPPORTED_MODEL, - (f"UNSUPPORTED_MODEL:CONTINUOUS_YAW_OBJECTIVE:{error}",), - ) - except (StrictConvexIntersectionBudgetExhaustedV2, SO2AtomicBudgetExhaustedV2): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - ("RESOURCE_LIMIT:CONTINUOUS_YAW_OBJECTIVE",), - ) - except (_ContinuousYawObjectiveNumericGapV2, ArithmeticError, RuntimeWarning): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - (), - _live_usage(intersection_budget, atomic_budget, 0), - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_OBJECTIVE",), - ) - - proposal_count = len(objective.proposals) - if proposal_count != len(objective.cells): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - objective.cells, - objective.resource_usage, - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - ("COMPILATION_INCOMPLETE:CONTINUOUS_YAW_WITNESS_COVERAGE",), - proposal_count=proposal_count, - ) - evaluations = [] - for proposal in objective.proposals: - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - evaluation = _evaluate_continuous_yaw_point_v2( - checked_problem, - checked_config, - candidate, - objective, - proposal.edit, - atomic_budget=atomic_budget, - intersection_budget=intersection_budget, - ) - except _ContinuousYawWitnessNotProvenV2 as error: - raise RuntimeError("fresh objective proposal lost feasibility") from error - except (StrictConvexIntersectionBudgetExhaustedV2, SO2AtomicBudgetExhaustedV2): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - objective.cells, - _live_usage(intersection_budget, atomic_budget, len(objective.cells)), - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - ("RESOURCE_LIMIT:CONTINUOUS_YAW_POINT_EVALUATION",), - proposal_count=proposal_count, - evaluated_count=len(evaluations), - ) - except (_ContinuousYawObjectiveNumericGapV2, ArithmeticError, RuntimeWarning): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - objective.cells, - _live_usage(intersection_budget, atomic_budget, len(objective.cells)), - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_POINT_EVALUATION",), - proposal_count=proposal_count, - evaluated_count=len(evaluations), - ) - evaluations.append(evaluation) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - certificate = _assemble_continuous_yaw_certificate_v2( - checked_problem, - checked_config, - candidate, - objective, - tuple(evaluations), - ) - except _CertificateNotProvenV2: - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - objective.cells, - _live_usage(intersection_budget, atomic_budget, len(objective.cells)), - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_OPTIMALITY_GAP",), - proposal_count=proposal_count, - evaluated_count=len(evaluations), - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified_with_prefix( - checked_problem, - checked_config, - refs, - objective.cells, - _live_usage(intersection_budget, atomic_budget, len(objective.cells)), - UncertifiedReasonV2.NUMERIC_GAP, - ("NUMERIC_GAP:CONTINUOUS_YAW_CERTIFICATE",), - proposal_count=proposal_count, - evaluated_count=len(evaluations), - ) - live_final_usage = _live_usage( - intersection_budget, atomic_budget, len(objective.cells) - ) - if certificate.final_resource_usage != live_final_usage: - raise RuntimeError("certificate resource usage drifted from the live ledgers") - selected = next( - item - for item in evaluations - if item.witness_evaluation_sha256 == certificate.witness_evaluation_sha256 - ) - result = ContinuousYawCertifiedSuccessResultV2_8( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - solver_config=checked_config, - candidate_refs=refs, - objective_cells=objective.cells, - selected_witness=selected, - global_loss_lower_bound=objective.global_loss_lower_bound, - witness_loss_bounds=selected.witness_loss_bounds, - final_resource_usage=live_final_usage, - certificate=certificate, - ) - return ContinuousYawMinimumCostSolveOutcomeV2_8( - result=result, - cumulative_generation_usage=result.final_resource_usage, - proposal_count=proposal_count, - evaluated_proposal_count=len(evaluations), - ) - - -def _strict_public_inputs(problem, config): - if type(problem) is not SemanticProblemV2_2 or type(config) is not ( - ContinuousYawSolverConfigV2_8 - ): - return None - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return ( - SemanticProblemV2_2.model_validate( - problem.model_dump(mode="python", warnings="error"), strict=True - ), - ContinuousYawSolverConfigV2_8.model_validate( - config.model_dump(mode="python", warnings="error"), strict=True - ), - ) - except ( - ValidationError, - PydanticSerializationError, - TypeError, - ValueError, - Warning, - ): - return None - - -def _reason_for_kind(kind: str) -> UncertifiedReasonV2: - if kind == "RESOURCE_LIMIT": - return UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED - if kind == "NUMERIC_GAP": - return UncertifiedReasonV2.NUMERIC_GAP - if kind == "UNSUPPORTED_MODEL": - return UncertifiedReasonV2.UNSUPPORTED_MODEL - return UncertifiedReasonV2.COMPILATION_INCOMPLETE - - -def _usage_from_candidate(usage) -> ContinuousYawResourceUsageV2_8: - return ContinuousYawResourceUsageV2_8( - domain_operations=usage.domain_operations, - so2_atomic_steps=usage.so2_atomic_steps, - candidate_cells=usage.candidate_cells, - objective_partition_cells=0, - ) - - -def _live_usage(intersection_budget, atomic_budget, objective_cells): - return ContinuousYawResourceUsageV2_8( - domain_operations=intersection_budget.domain_operations_used, - so2_atomic_steps=atomic_budget.used, - candidate_cells=intersection_budget.candidate_cells_used, - objective_partition_cells=objective_cells, - ) - - -def _uncertified_without_prefix(problem, config, reason, findings): - result = ContinuousYawUncertifiedResultV2_8( - semantic_problem_sha256=problem.semantic_problem_sha256, - solver_config=config, - uncertified_reason=reason, - finding_codes=findings or ("COMPILATION_INCOMPLETE:CONTINUOUS_YAW_T15",), - ) - return ContinuousYawMinimumCostSolveOutcomeV2_8( - result=result, - finding_codes=result.finding_codes, - cumulative_generation_usage=None, - ) - - -def _uncertified_with_prefix( - problem, - config, - refs, - cells, - usage, - reason, - findings, - *, - proposal_count=0, - evaluated_count=0, -): - result = ContinuousYawUncertifiedResultV2_8( - semantic_problem_sha256=problem.semantic_problem_sha256, - solver_config=config, - uncertified_reason=reason, - candidate_refs=refs, - objective_cells=cells, - final_resource_usage=usage, - finding_codes=findings, - ) - return ContinuousYawMinimumCostSolveOutcomeV2_8( - result=result, - finding_codes=result.finding_codes, - cumulative_generation_usage=usage, - proposal_count=proposal_count, - evaluated_proposal_count=evaluated_count, - ) - - -def _unsat(problem, config, refs, stage_name, stage_sha, usage): - result = ContinuousYawProvenUnsatResultV2_8( - semantic_problem_sha256=problem.semantic_problem_sha256, - solver_config=config, - candidate_refs=refs, - empty_outer_stage=stage_name, - empty_outer_stage_sha256=stage_sha, - final_resource_usage=usage, - ) - return ContinuousYawMinimumCostSolveOutcomeV2_8( - result=result, - cumulative_generation_usage=usage, - ) - - -__all__ = ("solve_continuous_yaw_minimum_cost_v2_8",) diff --git a/src/spatialcf/core/v2/continuous_yaw_support_projection.py b/src/spatialcf/core/v2/continuous_yaw_support_projection.py deleted file mode 100644 index 85036e7..0000000 --- a/src/spatialcf/core/v2/continuous_yaw_support_projection.py +++ /dev/null @@ -1,753 +0,0 @@ -"""Directed strict-convex SUPPORT projection for one continuously yawed box.""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction -from math import gcd, lcm - -from pydantic import ValidationError -from pydantic_core import PydanticSerializationError - -from spatialcf.core.v2 import so2_interval -from spatialcf.core.v2.convex_translation_domain import ( - RationalConvexPolygonV2, - RationalPoint2V2, -) -from spatialcf.core.v2.convex_translation_partition import ( - RationalHalfPlane2V2, - RationalHalfPlaneRelationV2, -) -from spatialcf.core.v2.oriented_upright_box import ( - OrientedUprightBoxBoundsV2, - compile_oriented_upright_box_bounds_v2, -) -from spatialcf.core.v2.rect_kernel import ( - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, - RectTopologyV2, - UnsupportedRectRegionErrorV2, -) -from spatialcf.core.v2.so2_interval import ( - SO2AtomicBudgetExhaustedV2, - SO2AtomicBudgetV2, - SO2IntervalKindV2, -) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionCellV2, - StrictConvexIntersectionComplexV2, - StrictConvexIntersectionTopologyV2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - NumericPolicyV2, - UncertaintyBudgetV2, - Vec3V2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - SupportAssignmentPolicyV2, - SupportContactAggregationV2, - SupportContactFeatureV2, - SupportOverlapMetricV2, - SupportStabilityMetricV2, -) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - GeometryInstanceV2_2, - SemanticProblemV2_2, - SupportSurfaceFactV2_2, -) -from spatialcf.domain.v2.geometry import ( - GeometryApproximationV2, - GeometryRoleV2, - UprightBox3DV2, -) -from spatialcf.domain.v2.scene import RegionBoundaryPolicyV2 - -CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 = ( - "geometry-kernel:rational-continuous-yaw-support-projection-v2" -) -CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 = ( - "kernel:2.6-exact-horizontal-support-projection" -) - - -class ContinuousYawSupportProjectionKindV2(StrEnum): - BRACKET = "BRACKET" - UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" - NUMERIC_GAP = "NUMERIC_GAP" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - INVALID_INPUT = "INVALID_INPUT" - - -class _UnsupportedSupportProjectionV2(ValueError): - def __init__(self, finding_code: str) -> None: - super().__init__(finding_code) - self.finding_code = finding_code - - -class _InvalidSupportProjectionInputV2(ValueError): - pass - - -@dataclass(frozen=True, slots=True) -class ContinuousYawSupportProjectionBracketV2: - support_constraint_id: str - surface_id: str - contact_geometry_id: str - support_projection_kernel_id: str - support_projection_kernel_version: str - inner_allowed: StrictConvexIntersectionComplexV2 - outer_allowed: StrictConvexIntersectionComplexV2 - inner_bounds: tuple[Fraction, Fraction, Fraction, Fraction] - outer_bounds: tuple[Fraction, Fraction, Fraction, Fraction] - so2_atomic_steps_used: int - domain_operations_used: int - candidate_cells_used: int - - def __post_init__(self) -> None: - for field_name in ( - "support_constraint_id", - "surface_id", - "contact_geometry_id", - ): - value = getattr(self, field_name) - if type(value) is not str or not value.strip(): - raise ValueError(f"{field_name} must be an exact non-blank string") - if self.support_projection_kernel_id != ( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 - ): - raise ValueError("unexpected support projection kernel ID") - if self.support_projection_kernel_version != ( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 - ): - raise ValueError("unexpected support projection kernel version") - for field_name in ("inner_allowed", "outer_allowed"): - value = getattr(self, field_name) - if type(value) is not StrictConvexIntersectionComplexV2: - raise TypeError(f"{field_name} must be a strict complex") - object.__setattr__( - self, - field_name, - StrictConvexIntersectionComplexV2( - cells=value.cells, - universe=value.universe, - topology=value.topology, - ), - ) - if self.inner_allowed.universe != self.outer_allowed.universe: - raise ValueError("support bracket requires one exact universe") - inner = _require_bounds_tuple(self.inner_bounds, label="inner_bounds") - outer = _require_bounds_tuple(self.outer_bounds, label="outer_bounds") - if not ( - outer[0] <= inner[0] <= inner[2] <= outer[2] - and outer[1] <= inner[1] <= inner[3] <= outer[3] - ): - raise ValueError("support inner bounds must be contained in outer bounds") - object.__setattr__(self, "inner_bounds", inner) - object.__setattr__(self, "outer_bounds", outer) - for field_name in ( - "so2_atomic_steps_used", - "domain_operations_used", - "candidate_cells_used", - ): - value = getattr(self, field_name) - if type(value) is not int or value <= 0: - raise ValueError(f"{field_name} must be a positive exact int") - - -@dataclass(frozen=True, slots=True) -class ContinuousYawSupportProjectionOutcomeV2: - kind: ContinuousYawSupportProjectionKindV2 - bracket: ContinuousYawSupportProjectionBracketV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not ContinuousYawSupportProjectionKindV2: - raise TypeError("kind must be ContinuousYawSupportProjectionKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise ValueError("finding_codes must be exact non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - object.__setattr__(self, "finding_codes", findings) - if self.kind is ContinuousYawSupportProjectionKindV2.BRACKET: - if type(self.bracket) is not ContinuousYawSupportProjectionBracketV2: - raise ValueError("BRACKET requires a support projection bracket") - if findings: - raise ValueError("BRACKET cannot carry findings") - object.__setattr__(self, "bracket", _copy_bracket(self.bracket)) - return - if self.bracket is not None or not findings: - raise ValueError("failure requires findings and no bracket") - - -def compile_exact_horizontal_support_projection_v2( - problem: SemanticProblemV2_2, - support_constraint_id: str, - universe: ExactAxisAlignedRectV2, - *, - atomic_budget: SO2AtomicBudgetV2, - intersection_budget: StrictConvexIntersectionBudgetV2, -) -> ContinuousYawSupportProjectionOutcomeV2: - """Compile one fixed-owner horizontal SUPPORT predicate into a bracket.""" - - try: - _require_budgets(atomic_budget, intersection_budget) - start_so2 = atomic_budget.used - start_domain = intersection_budget.domain_operations_used - start_cells = intersection_budget.candidate_cells_used - checked_problem, checked_id, checked_universe = _strict_inputs( - problem, - support_constraint_id, - universe, - intersection_budget, - ) - except StrictConvexIntersectionBudgetExhaustedV2: - return _failure( - ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:SUPPORT_PROJECTION", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, - "NUMERIC_GAP:SUPPORT_PROJECTION_REVALIDATION", - ) - except _InvalidSupportProjectionInputV2: - return _failure( - ContinuousYawSupportProjectionKindV2.INVALID_INPUT, - "INVALID_INPUT:SUPPORT_PROJECTION", - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - constraint, subject, geometry, surface, owner_transform = ( - _extract_supported_subset( - checked_problem, - checked_id, - intersection_budget, - ) - ) - box_outcome = compile_oriented_upright_box_bounds_v2( - subject.pose.world_from_object, - geometry.shape, - atomic_budget=atomic_budget, - ) - if box_outcome.kind is SO2IntervalKindV2.RESOURCE_LIMIT: - return _failure( - ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:SUPPORT_PROJECTION_SO2", - ) - if box_outcome.kind is SO2IntervalKindV2.NUMERIC_GAP: - return _failure( - ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, - *box_outcome.finding_codes, - ) - if box_outcome.kind is not SO2IntervalKindV2.EXACT: - raise RuntimeError("supported support box failed strict compilation") - if type(box_outcome.bounds) is not OrientedUprightBoxBoundsV2: - raise RuntimeError("EXACT oriented support box is missing bounds") - inner_bounds, outer_bounds = _support_bounds( - constraint, - subject.pose.world_from_object, - geometry, - surface, - owner_transform, - box_outcome.bounds, - intersection_budget, - ) - inner = _complex_from_bounds( - inner_bounds, - checked_universe, - cell_id="cell:support-projection:inner", - budget=intersection_budget, - ) - outer = _complex_from_bounds( - outer_bounds, - checked_universe, - cell_id="cell:support-projection:outer", - budget=intersection_budget, - ) - if not inner.cells or not outer.cells: - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_PROJECTION_NON_AREA_LOCUS" - ) - intersection_budget.consume_domain( - 8 + len(inner.cells[0].half_planes) + len(outer.cells[0].half_planes) - ) - bracket = ContinuousYawSupportProjectionBracketV2( - support_constraint_id=constraint.constraint_id, - surface_id=surface.surface_id, - contact_geometry_id=geometry.geometry_id, - support_projection_kernel_id=( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 - ), - support_projection_kernel_version=( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 - ), - inner_allowed=inner, - outer_allowed=outer, - inner_bounds=inner_bounds, - outer_bounds=outer_bounds, - so2_atomic_steps_used=atomic_budget.used - start_so2, - domain_operations_used=( - intersection_budget.domain_operations_used - start_domain - ), - candidate_cells_used=( - intersection_budget.candidate_cells_used - start_cells - ), - ) - return ContinuousYawSupportProjectionOutcomeV2( - kind=ContinuousYawSupportProjectionKindV2.BRACKET, - bracket=bracket, - ) - except _UnsupportedSupportProjectionV2 as error: - return _failure( - ContinuousYawSupportProjectionKindV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except (SO2AtomicBudgetExhaustedV2, StrictConvexIntersectionBudgetExhaustedV2): - return _failure( - ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:SUPPORT_PROJECTION", - ) - except ArithmeticError: - return _failure( - ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, - "NUMERIC_GAP:SUPPORT_PROJECTION_ARITHMETIC", - ) - except RuntimeWarning: - return _failure( - ContinuousYawSupportProjectionKindV2.NUMERIC_GAP, - "NUMERIC_GAP:SUPPORT_PROJECTION_RUNTIME_WARNING", - ) - - -def _require_budgets( - atomic_budget: SO2AtomicBudgetV2, - intersection_budget: StrictConvexIntersectionBudgetV2, -) -> None: - if type(atomic_budget) is not SO2AtomicBudgetV2: - raise TypeError("atomic_budget must be SO2AtomicBudgetV2") - atomic_budget.validate() - if type(intersection_budget) is not StrictConvexIntersectionBudgetV2: - raise TypeError("intersection_budget must be StrictConvexIntersectionBudgetV2") - intersection_budget.consume_domain(0) - intersection_budget.consume_candidate_cells(0) - - -def _strict_inputs( - problem: object, - support_constraint_id: object, - universe: object, - budget: StrictConvexIntersectionBudgetV2, -) -> tuple[SemanticProblemV2_2, str, ExactAxisAlignedRectV2]: - if type(problem) is not SemanticProblemV2_2: - raise _InvalidSupportProjectionInputV2 - if type(support_constraint_id) is not str or not support_constraint_id.strip(): - raise _InvalidSupportProjectionInputV2 - if type(universe) is not ExactAxisAlignedRectV2: - raise _InvalidSupportProjectionInputV2 - budget.consume_domain(3) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - checked_problem = SemanticProblemV2_2.model_validate( - problem.model_dump(mode="python", warnings="error"), strict=True - ) - except (ArithmeticError, RuntimeWarning): - raise - except (ValidationError, PydanticSerializationError, Warning) as error: - raise _InvalidSupportProjectionInputV2 from error - checked_universe = ExactAxisAlignedRectV2( - coordinate_space=universe.coordinate_space, - topology=universe.topology, - min_x_m=universe.min_x_m, - min_y_m=universe.min_y_m, - max_x_m=universe.max_x_m, - max_y_m=universe.max_y_m, - ) - if ( - checked_universe.coordinate_space - is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M - or checked_universe.topology is not RectTopologyV2.AREA - ): - raise _InvalidSupportProjectionInputV2 - return checked_problem, support_constraint_id, checked_universe - - -def _extract_supported_subset( - problem: SemanticProblemV2_2, - constraint_id: str, - budget: StrictConvexIntersectionBudgetV2, -): - constraints = problem.constraints.support_constraints - if len(constraints) != 1 or constraints[0].constraint_id != constraint_id: - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_CONSTRAINT_CARDINALITY" - ) - constraint = constraints[0] - if ( - constraint.supported_object_id != problem.constraints.allowed_edit.subject_id - or len(constraint.subject_contact_geometry_ids) != 1 - or constraint.contact_feature - is not SupportContactFeatureV2.LOWEST_FACE_ALONG_SURFACE_NORMAL - or constraint.contact_aggregation - is not SupportContactAggregationV2.UNION_ALL_SELECTED_FEATURES - or constraint.overlap_metric - is not SupportOverlapMetricV2.PROJECTED_CONTACT_UNION_INTERSECTION_AREA - or constraint.stability_metric - is not SupportStabilityMetricV2.FULL_CONTACT_UNION_CONTAINED_IN_SURFACE_INSET - or constraint.boundary_policy is not BoundaryPolicyV2.CLOSED - or constraint.assignment_policy is not SupportAssignmentPolicyV2.EXACT_SURFACE - or problem.numeric_policy != NumericPolicyV2() - ): - raise _UnsupportedSupportProjectionV2("UNSUPPORTED_MODEL:SUPPORT_POLICY") - for label, facts in ( - ("OBJECTS", problem.scene.objects), - ("GEOMETRIES", problem.scene.geometry_instances), - ("BODIES", problem.scene.collision_bodies), - ("SURFACES", problem.scene.support_surfaces), - ): - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.uncertainty != UncertaintyBudgetV2() - ): - raise _UnsupportedSupportProjectionV2( - f"UNSUPPORTED_MODEL:SUPPORT_{label}_FACTS" - ) - objects = {item.object_id: item for item in problem.scene.objects.values or ()} - geometries = { - item.geometry_id: item for item in problem.scene.geometry_instances.values or () - } - surfaces = { - item.surface_id: item for item in problem.scene.support_surfaces.values or () - } - bodies = { - item.body_id: item for item in problem.scene.collision_bodies.values or () - } - budget.consume_domain( - len(objects) + len(geometries) + len(surfaces) + len(bodies) + 4 - ) - try: - subject = objects[constraint.supported_object_id] - geometry = geometries[constraint.subject_contact_geometry_ids[0]] - surface = surfaces[constraint.surface_id] - body = bodies[surface.supporting_body_id] - except KeyError as error: - raise RuntimeError( - "support semantic graph lost a canonical reference" - ) from error - collision = problem.constraints.collision_constraints[0] - if not subject.movable or body.body_id not in collision.obstacle_body_ids: - raise _UnsupportedSupportProjectionV2("UNSUPPORTED_MODEL:SUPPORT_OWNER_SUBSET") - if surface.owner_object_id is None: - if body.owner_object_id is not None: - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_OWNER_SUBSET" - ) - owner_transform = DirectedYawIntervalTransformV2_2( - translation=Vec3V2(x=0.0, y=0.0, z=0.0), - yaw_radians=0.0, - ) - else: - try: - owner = objects[surface.owner_object_id] - except KeyError as error: - raise RuntimeError( - "support semantic graph lost its owner object" - ) from error - if owner.movable or body.owner_object_id != owner.object_id: - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_OWNER_SUBSET" - ) - owner_transform = owner.pose.world_from_object - if ( - type(geometry) is not GeometryInstanceV2_2 - or geometry.owner_object_id != subject.object_id - or geometry.role is not GeometryRoleV2.SUPPORT - or geometry.approximation is not GeometryApproximationV2.EXACT - or geometry.uncertainty != UncertaintyBudgetV2() - or type(geometry.shape) is not UprightBox3DV2 - or not _identity_transform( - geometry.anchor_from_geometry, require_zero_translation=True - ) - ): - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_CONTACT_GEOMETRY" - ) - if ( - type(surface) is not SupportSurfaceFactV2_2 - or surface.region_approximation is not GeometryApproximationV2.EXACT - or surface.boundary_policy is not RegionBoundaryPolicyV2.CLOSED - or surface.geometry_uncertainty != UncertaintyBudgetV2() - or ( - surface.normal_in_anchor.x, - surface.normal_in_anchor.y, - surface.normal_in_anchor.z, - ) - != (0.0, 0.0, 1.0) - or not _identity_transform(surface.anchor_from_surface) - or not _identity_transform(owner_transform) - ): - raise _UnsupportedSupportProjectionV2("UNSUPPORTED_MODEL:SUPPORT_SURFACE") - try: - ExactAxisAlignedRectV2.from_planar_region(surface.region_uv) - except UnsupportedRectRegionErrorV2 as error: - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_SURFACE_RECTANGLE" - ) from error - return constraint, subject, geometry, surface, owner_transform - - -def _identity_transform( - transform: DirectedYawIntervalTransformV2_2, - *, - require_zero_translation: bool = False, -) -> bool: - if type(transform) is not DirectedYawIntervalTransformV2_2: - return False - if transform.yaw_radians != 0.0: - return False - return not require_zero_translation or ( - transform.translation.x, - transform.translation.y, - transform.translation.z, - ) == (0.0, 0.0, 0.0) - - -def _support_bounds( - constraint, - subject_transform: DirectedYawIntervalTransformV2_2, - geometry: GeometryInstanceV2_2, - surface: SupportSurfaceFactV2_2, - owner_transform: DirectedYawIntervalTransformV2_2, - box: OrientedUprightBoxBoundsV2, - budget: StrictConvexIntersectionBudgetV2, -) -> tuple[ - tuple[Fraction, Fraction, Fraction, Fraction], - tuple[Fraction, Fraction, Fraction, Fraction], -]: - budget.consume_domain(24) - surface_rect = ExactAxisAlignedRectV2.from_planar_region(surface.region_uv) - surface_bounds = surface_rect.bounds - assert surface_bounds is not None - owner_x = Fraction.from_float(owner_transform.translation.x) - owner_y = Fraction.from_float(owner_transform.translation.y) - owner_z = Fraction.from_float(owner_transform.translation.z) - surface_x = Fraction.from_float(surface.anchor_from_surface.translation.x) - surface_y = Fraction.from_float(surface.anchor_from_surface.translation.y) - surface_z = Fraction.from_float(surface.anchor_from_surface.translation.z) - center_x = Fraction.from_float(subject_transform.translation.x) - center_y = Fraction.from_float(subject_transform.translation.y) - center_z = Fraction.from_float(subject_transform.translation.z) - contact_z = center_z - box.half_extent_z - gap = contact_z - (owner_z + surface_z) - if not ( - Fraction.from_float(constraint.contact_gap_min_m) - <= gap - <= Fraction.from_float(constraint.contact_gap_max_m) - ): - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_CONTACT_GAP_EMPTY" - ) - contact_area = Fraction.from_float(geometry.shape.size_m.x) * Fraction.from_float( - geometry.shape.size_m.y - ) - if contact_area < Fraction.from_float(constraint.minimum_overlap_area_m2): - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_CONTACT_AREA_EMPTY" - ) - margin = Fraction.from_float(constraint.stability_margin_m) - inset = ( - owner_x + surface_x + surface_bounds[0] + margin, - owner_y + surface_y + surface_bounds[1] + margin, - owner_x + surface_x + surface_bounds[2] - margin, - owner_y + surface_y + surface_bounds[3] - margin, - ) - inner = ( - inset[0] - center_x + box.x_radius.rational_upper, - inset[1] - center_y + box.y_radius.rational_upper, - inset[2] - center_x - box.x_radius.rational_upper, - inset[3] - center_y - box.y_radius.rational_upper, - ) - outer = ( - inset[0] - center_x + box.x_radius.rational_lower, - inset[1] - center_y + box.y_radius.rational_lower, - inset[2] - center_x - box.x_radius.rational_lower, - inset[3] - center_y - box.y_radius.rational_lower, - ) - for value in (*inner, *outer): - so2_interval._require_numeric_fraction_cap( - value, "NUMERIC_GAP:SUPPORT_PROJECTION_FRACTION_BIT_CAP" - ) - return inner, outer - - -def _complex_from_bounds( - bounds: tuple[Fraction, Fraction, Fraction, Fraction], - universe: ExactAxisAlignedRectV2, - *, - cell_id: str, - budget: StrictConvexIntersectionBudgetV2, -) -> StrictConvexIntersectionComplexV2: - budget.consume_domain(16) - rectangle = ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=bounds[0], - min_y_m=bounds[1], - max_x_m=bounds[2], - max_y_m=bounds[3], - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ).intersect(universe) - if rectangle.topology is RectTopologyV2.EMPTY: - return _strict_complex((), universe) - if rectangle.topology is RectTopologyV2.DEGENERATE: - raise _UnsupportedSupportProjectionV2( - "UNSUPPORTED_MODEL:SUPPORT_PROJECTION_DEGENERATE_LOCUS" - ) - clipped = rectangle.bounds - universe_bounds = universe.bounds - assert clipped is not None and universe_bounds is not None - semantic_planes = tuple( - sorted( - ( - _canonical_plane(Fraction(-1), Fraction(), -bounds[0]), - _canonical_plane(Fraction(1), Fraction(), bounds[2]), - _canonical_plane(Fraction(), Fraction(-1), -bounds[1]), - _canonical_plane(Fraction(), Fraction(1), bounds[3]), - ), - key=lambda plane: ( - plane.normal_x, - plane.normal_y, - plane.offset, - 1, - ), - ) - ) - universe_planes = ( - _canonical_plane(Fraction(-1), Fraction(), -universe_bounds[0]), - _canonical_plane(Fraction(1), Fraction(), universe_bounds[2]), - _canonical_plane(Fraction(), Fraction(-1), -universe_bounds[1]), - _canonical_plane(Fraction(), Fraction(1), universe_bounds[3]), - ) - closure = RationalConvexPolygonV2( - vertices_ccw=( - RationalPoint2V2(x=clipped[0], y=clipped[1]), - RationalPoint2V2(x=clipped[2], y=clipped[1]), - RationalPoint2V2(x=clipped[2], y=clipped[3]), - RationalPoint2V2(x=clipped[0], y=clipped[3]), - ) - ) - witness = RationalPoint2V2( - x=(clipped[0] + clipped[2]) / 2, - y=(clipped[1] + clipped[3]) / 2, - ) - budget.consume_candidate_cells() - return _strict_complex( - ( - StrictConvexIntersectionCellV2( - cell_id=cell_id, - half_planes=universe_planes + semantic_planes, - closure_polygon=closure, - strict_witness=witness, - ), - ), - universe, - ) - - -def _strict_complex( - cells: tuple[StrictConvexIntersectionCellV2, ...], - universe: ExactAxisAlignedRectV2, -) -> StrictConvexIntersectionComplexV2: - return StrictConvexIntersectionComplexV2( - cells=cells, - universe=universe, - topology=( - StrictConvexIntersectionTopologyV2.DISTRIBUTIVE_STRICT_CELL_INTERSECTION - ), - ) - - -def _canonical_plane( - normal_x: Fraction, - normal_y: Fraction, - offset: Fraction, -) -> RationalHalfPlane2V2: - for value in (normal_x, normal_y, offset): - so2_interval._require_numeric_fraction_cap( - value, "NUMERIC_GAP:SUPPORT_PROJECTION_HALF_PLANE_BIT_CAP" - ) - denominator = lcm(normal_x.denominator, normal_y.denominator, offset.denominator) - values = ( - normal_x.numerator * (denominator // normal_x.denominator), - normal_y.numerator * (denominator // normal_y.denominator), - offset.numerator * (denominator // offset.denominator), - ) - divisor = gcd(gcd(abs(values[0]), abs(values[1])), abs(values[2])) or 1 - return RationalHalfPlane2V2( - normal_x=Fraction(values[0] // divisor), - normal_y=Fraction(values[1] // divisor), - offset=Fraction(values[2] // divisor), - relation=RationalHalfPlaneRelationV2.LE, - ) - - -def _require_bounds_tuple( - value: object, *, label: str -) -> tuple[Fraction, Fraction, Fraction, Fraction]: - if type(value) is not tuple or len(value) != 4: - raise TypeError(f"{label} must be an exact four-Fraction tuple") - if any(type(item) is not Fraction for item in value): - raise TypeError(f"{label} must contain exact Fractions") - checked = value - if checked[0] > checked[2] or checked[1] > checked[3]: - raise ValueError(f"{label} must be ordered") - return checked - - -def _copy_bracket( - value: ContinuousYawSupportProjectionBracketV2, -) -> ContinuousYawSupportProjectionBracketV2: - return ContinuousYawSupportProjectionBracketV2( - support_constraint_id=value.support_constraint_id, - surface_id=value.surface_id, - contact_geometry_id=value.contact_geometry_id, - support_projection_kernel_id=value.support_projection_kernel_id, - support_projection_kernel_version=value.support_projection_kernel_version, - inner_allowed=value.inner_allowed, - outer_allowed=value.outer_allowed, - inner_bounds=value.inner_bounds, - outer_bounds=value.outer_bounds, - so2_atomic_steps_used=value.so2_atomic_steps_used, - domain_operations_used=value.domain_operations_used, - candidate_cells_used=value.candidate_cells_used, - ) - - -def _failure( - kind: ContinuousYawSupportProjectionKindV2, - *finding_codes: str, -) -> ContinuousYawSupportProjectionOutcomeV2: - return ContinuousYawSupportProjectionOutcomeV2( - kind=kind, - finding_codes=tuple(finding_codes), - ) - - -__all__ = ( - "CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2", - "CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2", - "ContinuousYawSupportProjectionBracketV2", - "ContinuousYawSupportProjectionKindV2", - "ContinuousYawSupportProjectionOutcomeV2", - "compile_exact_horizontal_support_projection_v2", -) diff --git a/src/spatialcf/core/v2/edit_feasibility.py b/src/spatialcf/core/v2/edit_feasibility.py deleted file mode 100644 index 673cce1..0000000 --- a/src/spatialcf/core/v2/edit_feasibility.py +++ /dev/null @@ -1,611 +0,0 @@ -"""Pure-core replay proof that one concrete Canonical edit is feasible. - -The public entry point deliberately accepts no submitted candidate artifact or -verification token. It recompiles the hard domain from the frozen semantic -problem and solver configuration, then proves only positive membership in the -freshly compiled inner bound. Failure to prove membership is never relabelled -as infeasibility. -""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction -from typing import ClassVar, TypeVar - -from pydantic import TypeAdapter, ValidationError - -from spatialcf.core.v2._internal.resources.domain_operations import ( - ValidatedLiveDomainOperationBudgetV2, -) -from spatialcf.core.v2.candidate_domain import ( - CandidateDomainCompilationOutcomeV2, - CandidateDomainCompilerV2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - ExactRectilinearRegionV2, - RectilinearAtomicBudgetExhaustedV2, - RectilinearAtomicBudgetV2, - RectilinearOutcomeKindV2, - lift_planar_region_v2, -) -from spatialcf.domain.v2.artifacts import ( - CandidateCompilationCoverageV2, - CandidateDomainArtifactV2, - CompilationResourceUsageV2, - DomainCompletenessV2, - RegionBoundStatusV2, -) -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import CoreSolverConfigV2, UncertifiedReasonV2 - -_SHA256_DIGEST_ADAPTER = TypeAdapter(Sha256Digest) - - -class CanonicalEditFeasibilityKindV2(StrEnum): - """Closed positive-proof result; there is intentionally no INFEASIBLE kind.""" - - VERIFIED_FEASIBLE = "VERIFIED_FEASIBLE" - NOT_PROVEN = "NOT_PROVEN" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class CanonicalEditFeasibilityVerificationOutcomeV2: - """Result of an independent concrete-edit replay. - - Hashes are present only for a successful positive proof. They make the - proof references explicit, but this value is not an unforgeable token and - downstream certificate code must perform its own replay. - """ - - kind: CanonicalEditFeasibilityKindV2 - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - candidate_domain_artifact_sha256: Sha256Digest | None = None - canonical_edit_sha256: Sha256Digest | None = None - verification_resource_usage: CompilationResourceUsageV2 | None = None - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.kind, CanonicalEditFeasibilityKindV2): - raise TypeError("kind must be a CanonicalEditFeasibilityKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be an exact tuple of non-blank strings") - object.__setattr__( - self, - "finding_codes", - tuple(sorted(set(self.finding_codes))), - ) - - usage = self.verification_resource_usage - if usage is not None: - if not isinstance(usage, CompilationResourceUsageV2): - raise TypeError( - "verification_resource_usage must be a CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "verification_resource_usage must pass strict validation" - ) from error - object.__setattr__(self, "verification_resource_usage", usage) - - for field_name in ( - "semantic_problem_sha256", - "core_solver_config_sha256", - "candidate_domain_artifact_sha256", - "canonical_edit_sha256", - ): - reference = getattr(self, field_name) - if reference is None: - continue - try: - reference = _SHA256_DIGEST_ADAPTER.validate_python( - reference, - strict=True, - ) - except (ValidationError, TypeError, ValueError) as error: - raise ValueError(f"{field_name} must be a Sha256Digest") from error - object.__setattr__(self, field_name, reference) - - references = ( - self.semantic_problem_sha256, - self.core_solver_config_sha256, - self.candidate_domain_artifact_sha256, - self.canonical_edit_sha256, - ) - if self.kind is CanonicalEditFeasibilityKindV2.VERIFIED_FEASIBLE: - if any(reference is None for reference in references): - raise ValueError( - "VERIFIED_FEASIBLE requires all four verified references" - ) - if self.verification_resource_usage is None: - raise ValueError( - "VERIFIED_FEASIBLE requires verification resource usage" - ) - if self.uncertified_reason is not None or self.finding_codes: - raise ValueError("VERIFIED_FEASIBLE cannot carry failure diagnostics") - return - - if any(reference is not None for reference in references): - raise ValueError( - "non-VERIFIED_FEASIBLE outcomes cannot carry verified references" - ) - if not self.finding_codes: - raise ValueError(f"{self.kind.value} requires at least one finding") - if self.kind is CanonicalEditFeasibilityKindV2.NOT_PROVEN: - if self.uncertified_reason is not None: - raise ValueError("NOT_PROVEN cannot carry an uncertified reason") - return - if not isinstance(self.uncertified_reason, UncertifiedReasonV2): - raise TypeError("UNCERTIFIED requires an uncertified reason") - - -CanonicalEditFeasibilityOutcomeV2 = CanonicalEditFeasibilityVerificationOutcomeV2 - - -class _InvalidInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _NumericInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _DomainOperationBudgetExhaustedV2(RuntimeError): - """The shared concrete-edit domain-operation ledger is exhausted.""" - - -@dataclass(slots=True) -class _EditFeasibilityDomainBudgetV2(ValidatedLiveDomainOperationBudgetV2): - """Mutable usage ledger shared by concrete-edit membership checks.""" - - _exhaustion_error_type: ClassVar[type[RuntimeError]] = ( - _DomainOperationBudgetExhaustedV2 - ) - - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def verify_canonical_edit_feasibility_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - edit: CanonicalEditV2, -) -> CanonicalEditFeasibilityVerificationOutcomeV2: - """Recompile the hard domain and prove ``edit`` lies in its inner bound.""" - - try: - checked_problem = _strict_model( - problem, - SemanticProblemV2, - label="SEMANTIC_PROBLEM", - ) - checked_config = _strict_model( - config, - CoreSolverConfigV2, - label="CORE_SOLVER_CONFIG", - ) - checked_edit = _strict_model( - edit, - CanonicalEditV2, - label="CANONICAL_EDIT", - ) - except _NumericInputV2 as error: - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - error.finding_code, - ) - except _InvalidInputV2 as error: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - - reference_findings: list[str] = [] - if checked_edit.semantic_problem_sha256 != ( - checked_problem.semantic_problem_sha256 - ): - reference_findings.append("EDIT_REFERENCE_MISMATCH:SEMANTIC_PROBLEM_HASH") - if checked_edit.subject_id != checked_problem.constraints.allowed_edit.subject_id: - reference_findings.append("EDIT_REFERENCE_MISMATCH:SUBJECT_ID") - if reference_findings: - return _not_proven(*reference_findings) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - replay = CandidateDomainCompilerV2().compile( - checked_problem, - checked_config, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_DOMAIN_REPLAY", - ) - - if not isinstance(replay, CandidateDomainCompilationOutcomeV2): - raise TypeError( - "CandidateDomainCompilerV2 returned an invalid internal outcome" - ) - candidate = replay.candidate_domain - if candidate is None: - return _uncertified( - replay.uncertified_reason or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(replay.finding_codes or ("REPLAY_NO_CANDIDATE_DOMAIN",)), - ) - - replay_usage = candidate.resource_usage - _require_replay_reference_closure( - checked_problem, - checked_config, - candidate.semantic_problem_sha256, - candidate.core_solver_config_sha256, - candidate.candidate_variable.subject_id, - ) - if replay.uncertified_reason is not None: - return _uncertified( - replay.uncertified_reason, - *(replay.finding_codes or ("REPLAY_CANDIDATE_UNCERTIFIED",)), - verification_resource_usage=replay_usage, - ) - atomic_budget = RectilinearAtomicBudgetV2( - limit=checked_config.max_partition_cells, - used=replay_usage.partition_cells, - ) - domain_budget = _EditFeasibilityDomainBudgetV2( - limit=checked_config.max_domain_operations, - used=replay_usage.domain_operations, - ) - return _verify_replayed_candidate_edit_membership_v2( - checked_problem, - checked_edit, - candidate, - domain_budget, - atomic_budget, - cumulative_resource_usage=replay_usage, - ) - - -def _verify_replayed_candidate_edit_membership_v2( - problem: SemanticProblemV2, - edit: CanonicalEditV2, - candidate: CandidateDomainArtifactV2, - domain_budget: _EditFeasibilityDomainBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, - *, - cumulative_resource_usage: CompilationResourceUsageV2 | None = None, -) -> CanonicalEditFeasibilityVerificationOutcomeV2: - """Verify one edit against a caller-owned fresh replay and shared ledger. - - This module-private seam exists for a solver that has already performed a - strict candidate replay and owns one global budget across many proposals. - It neither recompiles nor resets that ledger. It is not a trusted public - shortcut: callers outside that pipeline must use the public replay entry - point above. - - Candidate compilation owns its prefix of the domain-operation ledger. A - concrete inner lift/membership stage reserves one further domain operation - and consumes its exact rectilinear work from the caller-owned atomic ledger. - Refinement usage is carried forward unchanged from the supplied cumulative - usage (or the candidate replay when omitted). - """ - - if not isinstance(problem, SemanticProblemV2): - raise TypeError("problem must be a strict SemanticProblemV2") - if not isinstance(edit, CanonicalEditV2): - raise TypeError("edit must be a strict CanonicalEditV2") - if not isinstance(candidate, CandidateDomainArtifactV2): - raise TypeError("candidate must be a fresh CandidateDomainArtifactV2") - if type(domain_budget) is not _EditFeasibilityDomainBudgetV2: - raise TypeError("domain_budget must be an _EditFeasibilityDomainBudgetV2") - if type(atomic_budget) is not RectilinearAtomicBudgetV2: - raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") - domain_budget.validate() - atomic_budget.validate() - if domain_budget.used < candidate.resource_usage.domain_operations: - raise ValueError("shared domain ledger predates the candidate replay") - if atomic_budget.used < candidate.resource_usage.partition_cells: - raise ValueError("shared atomic budget predates the candidate replay") - - if cumulative_resource_usage is None: - base_usage = CompilationResourceUsageV2( - domain_operations=domain_budget.used, - partition_cells=atomic_budget.used, - refinement_steps=candidate.resource_usage.refinement_steps, - ) - else: - base_usage = cumulative_resource_usage - if type(base_usage) is not CompilationResourceUsageV2: - raise TypeError( - "cumulative_resource_usage must be a CompilationResourceUsageV2" - ) - candidate_usage = candidate.resource_usage - if ( - base_usage.domain_operations < candidate_usage.domain_operations - or base_usage.partition_cells < candidate_usage.partition_cells - or base_usage.refinement_steps < candidate_usage.refinement_steps - ): - raise ValueError("cumulative resource usage rollback below candidate usage") - if base_usage.domain_operations != domain_budget.used: - raise ValueError("cumulative usage must equal the shared domain ledger") - if base_usage.partition_cells != atomic_budget.used: - raise ValueError("cumulative usage must equal the shared atomic ledger") - - reference_findings: list[str] = [] - if edit.semantic_problem_sha256 != problem.semantic_problem_sha256: - reference_findings.append("EDIT_REFERENCE_MISMATCH:SEMANTIC_PROBLEM_HASH") - if edit.subject_id != problem.constraints.allowed_edit.subject_id: - reference_findings.append("EDIT_REFERENCE_MISMATCH:SUBJECT_ID") - if reference_findings: - return _not_proven( - *reference_findings, - verification_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - if candidate.semantic_problem_sha256 != problem.semantic_problem_sha256: - raise RuntimeError("candidate replay problem hash is not closed") - if candidate.candidate_variable.subject_id != ( - problem.constraints.allowed_edit.subject_id - ): - raise RuntimeError("candidate replay subject is not closed") - - current_usage = _cumulative_usage(base_usage, domain_budget, atomic_budget) - if candidate.compilation_coverage is not CandidateCompilationCoverageV2.COMPLETE: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - "COMPILATION_INCOMPLETE:CANDIDATE_DOMAIN_NOT_COMPLETE", - verification_resource_usage=current_usage, - ) - hard_domain = candidate.hard_domain - if hard_domain.completeness not in { - DomainCompletenessV2.EXACT, - DomainCompletenessV2.BRACKETED, - }: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - "COMPILATION_INCOMPLETE:HARD_INNER_BOUND_UNKNOWN", - verification_resource_usage=current_usage, - ) - inner_bound = hard_domain.inner_bound - if inner_bound.status is RegionBoundStatusV2.UNAVAILABLE: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - "COMPILATION_INCOMPLETE:HARD_INNER_BOUND_UNKNOWN", - verification_resource_usage=current_usage, - ) - if inner_bound.status is RegionBoundStatusV2.EMPTY: - return _not_proven( - "EDIT_NOT_IN_PROVEN_HARD_INNER", - verification_resource_usage=current_usage, - ) - if inner_bound.region is None: # pragma: no cover - schema invariant - raise RuntimeError("NON_EMPTY hard inner bound has no region") - - try: - # Reserve the whole semantic membership stage before beginning the - # potentially multi-pass rectilinear lift. - domain_budget.consume() - except _DomainOperationBudgetExhaustedV2: - return _domain_resource_uncertified( - _cumulative_usage(base_usage, domain_budget, atomic_budget), - domain_budget.limit, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - lifted = lift_planar_region_v2( - inner_bound.region, - atomic_budget=atomic_budget, - ) - except RectilinearAtomicBudgetExhaustedV2: - return _resource_uncertified( - _cumulative_usage(base_usage, domain_budget, atomic_budget), - atomic_budget.limit, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:HARD_INNER_LIFT", - verification_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - - current_usage = _cumulative_usage(base_usage, domain_budget, atomic_budget) - if lifted.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource_uncertified(current_usage, atomic_budget.limit) - if lifted.kind is RectilinearOutcomeKindV2.UNKNOWN: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(lifted.finding_codes or ("COMPILATION_INCOMPLETE:HARD_INNER_LIFT",)), - verification_resource_usage=current_usage, - ) - if lifted.region is None: # pragma: no cover - outcome invariant - raise RuntimeError("EXACT hard inner lift has no exact region") - - point_x = Fraction.from_float(edit.translation_xy_m.x) - point_y = Fraction.from_float(edit.translation_xy_m.y) - try: - contained = _contains_point_with_budget( - lifted.region, - point_x, - point_y, - atomic_budget, - ) - except RectilinearAtomicBudgetExhaustedV2: - return _resource_uncertified( - _cumulative_usage(base_usage, domain_budget, atomic_budget), - atomic_budget.limit, - ) - - final_usage = _cumulative_usage(base_usage, domain_budget, atomic_budget) - if not contained: - return _not_proven( - "EDIT_NOT_IN_PROVEN_HARD_INNER", - verification_resource_usage=final_usage, - ) - return CanonicalEditFeasibilityVerificationOutcomeV2( - kind=CanonicalEditFeasibilityKindV2.VERIFIED_FEASIBLE, - semantic_problem_sha256=problem.semantic_problem_sha256, - core_solver_config_sha256=candidate.core_solver_config_sha256, - candidate_domain_artifact_sha256=(candidate.candidate_domain_artifact_sha256), - canonical_edit_sha256=edit.edit_sha256, - verification_resource_usage=final_usage, - ) - - -class CanonicalEditFeasibilityVerifierV2: - """Stateless wrapper for pure-core pipeline composition.""" - - def verify( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - edit: CanonicalEditV2, - ) -> CanonicalEditFeasibilityVerificationOutcomeV2: - return verify_canonical_edit_feasibility_v2(problem, config, edit) - - -def _strict_model( - value: object, - model_type: type[ModelT], - *, - label: str, -) -> ModelT: - if not isinstance(value, model_type): - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _require_replay_reference_closure( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate_problem_hash: str, - candidate_config_hash: str, - candidate_subject_id: str, -) -> None: - """Treat a public compiler violating its own closure as an internal error.""" - - if candidate_problem_hash != problem.semantic_problem_sha256: - raise RuntimeError("candidate replay problem hash is not closed") - if candidate_config_hash != config.core_solver_config_sha256: - raise RuntimeError("candidate replay config hash is not closed") - if candidate_subject_id != problem.constraints.allowed_edit.subject_id: - raise RuntimeError("candidate replay subject is not closed") - - -def _contains_point_with_budget( - region: ExactRectilinearRegionV2, - x_m: Fraction, - y_m: Fraction, - atomic_budget: RectilinearAtomicBudgetV2, -) -> bool: - """Check every canonical rectangle, charging the shared ledger exactly once.""" - - contained = False - for rectangle in region.rectangles: - atomic_budget.consume() - bounds = rectangle.bounds - if bounds is None: # pragma: no cover - normalized region invariant - raise RuntimeError("normalized rectilinear region contains an empty cell") - inside_rectangle = ( - bounds[0] <= x_m <= bounds[2] and bounds[1] <= y_m <= bounds[3] - ) - contained = contained or inside_rectangle - return contained - - -def _cumulative_usage( - base_usage: CompilationResourceUsageV2, - domain_budget: _EditFeasibilityDomainBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=domain_budget.used, - partition_cells=atomic_budget.used, - refinement_steps=base_usage.refinement_steps, - ) - - -def _domain_resource_uncertified( - replay_usage: CompilationResourceUsageV2, - limit: int, -) -> CanonicalEditFeasibilityVerificationOutcomeV2: - return _uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - f"RESOURCE_LIMIT:max_domain_operations:{limit}", - verification_resource_usage=replay_usage, - ) - - -def _resource_uncertified( - replay_usage: CompilationResourceUsageV2, - limit: int, -) -> CanonicalEditFeasibilityVerificationOutcomeV2: - # ``limit`` is included to make cap/cap-1 diagnostics self-contained while - # the cumulative usage records how far the all-or-nothing operation got. - return _uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - f"RESOURCE_LIMIT:max_partition_cells:{limit}", - verification_resource_usage=replay_usage, - ) - - -def _not_proven( - *finding_codes: str, - verification_resource_usage: CompilationResourceUsageV2 | None = None, -) -> CanonicalEditFeasibilityVerificationOutcomeV2: - return CanonicalEditFeasibilityVerificationOutcomeV2( - kind=CanonicalEditFeasibilityKindV2.NOT_PROVEN, - finding_codes=finding_codes, - verification_resource_usage=verification_resource_usage, - ) - - -def _uncertified( - reason: UncertifiedReasonV2, - *finding_codes: str, - verification_resource_usage: CompilationResourceUsageV2 | None = None, -) -> CanonicalEditFeasibilityVerificationOutcomeV2: - return CanonicalEditFeasibilityVerificationOutcomeV2( - kind=CanonicalEditFeasibilityKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=finding_codes, - verification_resource_usage=verification_resource_usage, - ) diff --git a/src/spatialcf/core/v2/minimum_cost_solver.py b/src/spatialcf/core/v2/minimum_cost_solver.py deleted file mode 100644 index 5d98dfd..0000000 --- a/src/spatialcf/core/v2/minimum_cost_solver.py +++ /dev/null @@ -1,793 +0,0 @@ -"""Pure-core orchestration for the Canonical v2 minimum-cost solve. - -The solver owns one fresh artifact chain and one cumulative pair of geometry -ledgers. Certificate assembly consumes that same chain through private core -seams; submitted artifacts and public replay entry points are never inputs. -""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from typing import TypeVar - -from pydantic import ValidationError - -from spatialcf.core.v2._internal.boundary import ( - InvalidCallerInputV2 as _InvalidInputV2, -) -from spatialcf.core.v2._internal.boundary import ( - NumericBoundaryGapV2 as _NumericInputV2, -) -from spatialcf.core.v2._internal.boundary import strict_input_model_v2 -from spatialcf.core.v2.candidate_domain import ( - CandidateDomainCompilationOutcomeV2, - CandidateDomainCompilerV2, -) -from spatialcf.core.v2.certificate_builder import ( - CertificateBuildKindV2, - GlobalOptimalityBuildKindV2, - _build_proven_unsat_from_fresh_candidate_v2, - _build_selected_global_from_fresh_replay_in_frame_v2, - _ExactCardinalSelectionFrameV2, - _GlobalReplayBundleV2, - _SelectedGlobalBuildOutcomeV2, -) -from spatialcf.core.v2.edit_feasibility import _EditFeasibilityDomainBudgetV2 -from spatialcf.core.v2.objective_partition import ( - ObjectivePartitionCompilationKindV2, - ObjectivePartitionCompilationOutcomeV2, - _compile_verified_partition, -) -from spatialcf.core.v2.objective_partition import ( - _CompilationIncompleteV2 as _ObjectiveCompilationIncompleteV2, -) -from spatialcf.core.v2.objective_partition import ( - _DomainOperationBudgetV2 as _ObjectiveDomainOperationBudgetV2, -) -from spatialcf.core.v2.objective_partition import ( - _NumericGapV2 as _ObjectiveNumericGapV2, -) -from spatialcf.core.v2.objective_partition import ( - _ResourceLimitV2 as _ObjectiveResourceLimitV2, -) -from spatialcf.core.v2.objective_partition import ( - _UnsupportedV2 as _ObjectiveUnsupportedV2, -) -from spatialcf.core.v2.point_objective import ( - _PointObjectiveReplayContextV2, - _require_candidate_closure, - _require_objective_closure, - _require_relation_closure, - _require_relation_usage, -) -from spatialcf.core.v2.rectilinear_kernel import ( - RectilinearAtomicBudgetExhaustedV2, - RectilinearAtomicBudgetV2, -) -from spatialcf.core.v2.relation_cost_partition import ( - RelationCostPartitionCompilationKindV2, - RelationCostPartitionCompilationOutcomeV2, - compile_relation_cost_partition_v2, -) -from spatialcf.domain.v2.artifacts import ( - CandidateDomainArtifactV2, - CompilationResourceUsageV2, - ObjectivePartitionArtifactV2, - RegionBoundStatusV2, - RelationCostPartitionV2, -) -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedReasonV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - - -@dataclass(frozen=True, slots=True) -class CanonicalMinimumCostSolveOutcomeV2: - """Closed orchestration outcome and its exact cumulative work counters. - - ``result`` is absent only when either raw input could not be strictly - reconstructed, because no honest semantic problem/config hash exists in - that case. Every normal failure over valid inputs carries a canonical - ``UncertifiedResultV2`` with the longest hash-closed artifact prefix. - - The counters are actual telemetry only on the value returned by this - module's fresh ``solve`` replay. This publicly constructible dataclass is - neither a proof token nor a capability: its success telemetry cannot be - authenticated from a submitted result alone. Any consumer making a - semantic or resource claim must replay the raw problem and configuration. - The same-replay UNSAT path is the narrower exception whose usage is - structurally closed exactly to the candidate artifact. - """ - - result: CertifiedSuccessResultV2 | ProvenUnsatResultV2 | UncertifiedResultV2 | None - finding_codes: tuple[str, ...] = () - cumulative_generation_usage: CompilationResourceUsageV2 | None = None - proposal_count: int = 0 - evaluated_proposal_count: int = 0 - - def __post_init__(self) -> None: - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be exact non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - for name in ("proposal_count", "evaluated_proposal_count"): - value = getattr(self, name) - if type(value) is not int or value < 0: - raise TypeError(f"{name} must be a non-negative exact int") - if self.evaluated_proposal_count > self.proposal_count: - raise ValueError("evaluated proposal count cannot exceed proposal count") - - result = self.result - if result is not None: - result_type = type(result) - if result_type not in ( - CertifiedSuccessResultV2, - ProvenUnsatResultV2, - UncertifiedResultV2, - ): - raise TypeError("result must be an exact Canonical v2 solve result") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - result = result_type.model_validate( - result.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError("result must pass strict validation") from error - - usage = self.cumulative_generation_usage - if usage is not None: - if type(usage) is not CompilationResourceUsageV2: - raise TypeError( - "cumulative_generation_usage must be CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "cumulative_generation_usage must pass strict validation" - ) from error - - object.__setattr__(self, "finding_codes", findings) - object.__setattr__(self, "result", result) - object.__setattr__(self, "cumulative_generation_usage", usage) - - if result is None: - if not findings: - raise ValueError("missing result requires an input finding") - if usage is not None: - raise ValueError("missing result cannot carry trusted generation usage") - if self.proposal_count or self.evaluated_proposal_count: - raise ValueError("missing result cannot carry proposal progress") - return - - config = result.core_solver_config - if usage is not None and ( - usage.domain_operations > config.max_domain_operations - or usage.partition_cells > config.max_partition_cells - or usage.refinement_steps > config.max_refinement_steps - ): - raise ValueError("cumulative generation usage exceeds configured limits") - candidate = getattr(result, "candidate_domain", None) - if candidate is not None: - if usage is None: - raise ValueError("an artifact-bearing result requires cumulative usage") - base = candidate.resource_usage - if ( - usage.domain_operations < base.domain_operations - or usage.partition_cells < base.partition_cells - or usage.refinement_steps != base.refinement_steps - ): - raise ValueError( - "cumulative generation usage rolls back candidate work" - ) - elif usage is not None: - raise ValueError("a result without a candidate cannot carry trusted usage") - - if type(result) is CertifiedSuccessResultV2: - if findings: - raise ValueError("certified success cannot carry failure findings") - if usage is None: - raise ValueError( - "certified success requires cumulative generation usage" - ) - if self.proposal_count != len(result.objective_partition.cells): - raise ValueError( - "certified success proposal count must equal objective cell count" - ) - if self.evaluated_proposal_count != self.proposal_count: - raise ValueError("certified success must evaluate every proposal") - return - if type(result) is ProvenUnsatResultV2: - if findings: - raise ValueError("proven unsat cannot carry failure findings") - if usage is None: - raise ValueError("proven unsat requires cumulative generation usage") - if self.proposal_count or self.evaluated_proposal_count: - raise ValueError("proven unsat cannot carry proposals") - if usage != result.candidate_domain.resource_usage: - raise ValueError("proven unsat usage must equal fresh candidate usage") - return - if not findings: - raise ValueError("uncertified result requires at least one finding") - objective = result.objective_partition - if objective is None: - if self.proposal_count or self.evaluated_proposal_count: - raise ValueError( - "uncertified result without objective cannot carry proposals" - ) - elif self.proposal_count > len(objective.cells): - raise ValueError( - "uncertified proposal count cannot exceed objective cell count" - ) - - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def solve_canonical_minimum_cost_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Solve one raw Canonical problem with a single fresh pure-core replay.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - solve_registered_capability_v2, - ) - - return solve_registered_capability_v2( - SolveCapabilityKeyV2.V2_0, - problem, - config, - ) - - -def _solve_canonical_minimum_cost_in_selection_frame_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Solve once while ordering exact proposals in a private semantic frame.""" - - if type(selection_frame) is not _ExactCardinalSelectionFrameV2: - raise TypeError("selection_frame has the wrong exact type") - - if type(problem) is SemanticProblemV2_1: - return CanonicalMinimumCostSolveOutcomeV2( - result=None, - finding_codes=("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION",), - ) - - try: - checked_problem = _strict_input_model( - problem, - SemanticProblemV2, - "SEMANTIC_PROBLEM", - ) - checked_config = _strict_input_model( - config, - CoreSolverConfigV2, - "CORE_SOLVER_CONFIG", - ) - except (_InvalidInputV2, _NumericInputV2) as error: - return CanonicalMinimumCostSolveOutcomeV2( - result=None, - finding_codes=(error.finding_code,), - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate_outcome = CandidateDomainCompilerV2().compile( - checked_problem, - checked_config, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_DOMAIN", - ) - if type(candidate_outcome) is not CandidateDomainCompilationOutcomeV2: - raise TypeError("candidate compiler returned an invalid internal outcome") - - candidate = candidate_outcome.candidate_domain - if candidate is None: - return _valid_uncertified( - checked_problem, - checked_config, - candidate_outcome.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(candidate_outcome.finding_codes or ("CANDIDATE_DOMAIN_NOT_BUILT",)), - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate = _strict_internal_model( - candidate, - CandidateDomainArtifactV2, - "CANDIDATE_DOMAIN", - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_DOMAIN_REVALIDATION", - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _require_candidate_closure(checked_problem, checked_config, candidate) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:CANDIDATE_REFERENCE_CLOSURE", - ) - if candidate_outcome.uncertified_reason is not None: - return _valid_uncertified( - checked_problem, - checked_config, - candidate_outcome.uncertified_reason, - *(candidate_outcome.finding_codes or ("CANDIDATE_DOMAIN_UNCERTIFIED",)), - candidate=candidate, - usage=candidate.resource_usage, - ) - - if candidate.hard_domain.outer_bound.status is RegionBoundStatusV2.EMPTY: - return _solve_empty_candidate(checked_problem, checked_config, candidate) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - relation_outcome = compile_relation_cost_partition_v2( - checked_problem, - checked_config, - candidate, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:RELATION_COST_PARTITION", - candidate=candidate, - usage=candidate.resource_usage, - ) - if type(relation_outcome) is not RelationCostPartitionCompilationOutcomeV2: - raise TypeError("relation compiler returned an invalid internal outcome") - relation_usage = relation_outcome.cumulative_resource_usage - if relation_outcome.kind is not RelationCostPartitionCompilationKindV2.PARTITION: - return _valid_uncertified( - checked_problem, - checked_config, - relation_outcome.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(relation_outcome.finding_codes or ("RELATION_PARTITION_NOT_BUILT",)), - candidate=candidate, - usage=relation_usage or candidate.resource_usage, - ) - relation = relation_outcome.relation_cost_partition - if relation is None or relation_usage is None: - raise RuntimeError("relation PARTITION omitted artifact or usage") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - relation = _strict_internal_model( - relation, - RelationCostPartitionV2, - "RELATION_COST_PARTITION", - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:RELATION_COST_PARTITION_REVALIDATION", - candidate=candidate, - usage=relation_usage, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _require_relation_usage(checked_config, candidate, relation_usage) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:RELATION_RESOURCE_USAGE_CLOSURE", - candidate=candidate, - usage=relation_usage, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _require_relation_closure( - checked_problem, - checked_config, - candidate, - relation, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:RELATION_REFERENCE_CLOSURE", - candidate=candidate, - usage=relation_usage, - ) - - objective_domain_budget = _ObjectiveDomainOperationBudgetV2( - limit=checked_config.max_domain_operations, - base_used=relation_usage.domain_operations, - ) - atomic_budget = RectilinearAtomicBudgetV2( - limit=checked_config.max_partition_cells, - used=relation_usage.partition_cells, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - objective_outcome = _compile_verified_partition( - checked_problem, - checked_config, - candidate, - relation, - relation_usage, - objective_domain_budget, - atomic_budget, - ) - except (RectilinearAtomicBudgetExhaustedV2, _ObjectiveResourceLimitV2) as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - str(error) or "RESOURCE_LIMIT:OBJECTIVE_PARTITION", - candidate=candidate, - relation=relation, - usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveNumericGapV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - *error.finding_codes, - candidate=candidate, - relation=relation, - usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveUnsupportedV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.UNSUPPORTED_MODEL, - *error.finding_codes, - candidate=candidate, - relation=relation, - usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveCompilationIncompleteV2 as error: - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *error.finding_codes, - candidate=candidate, - relation=relation, - usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:OBJECTIVE_PARTITION", - candidate=candidate, - relation=relation, - usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - - if type(objective_outcome) is not ObjectivePartitionCompilationOutcomeV2: - raise TypeError("objective compiler returned an invalid internal outcome") - if objective_outcome.kind is not ObjectivePartitionCompilationKindV2.PARTITION: - raise RuntimeError("private objective compiler returned a non-partition") - objective = objective_outcome.objective_partition - objective_usage = objective_outcome.cumulative_resource_usage - if objective is None or objective_usage is None: - raise RuntimeError("objective PARTITION omitted artifact or usage") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - checked_objective = _strict_internal_model( - objective, - ObjectivePartitionArtifactV2, - "OBJECTIVE_PARTITION", - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:OBJECTIVE_PARTITION_REVALIDATION", - candidate=candidate, - relation=relation, - usage=objective_usage, - ) - if checked_objective != objective: - raise RuntimeError("fresh objective changed under strict reconstruction") - expected_objective_usage = _objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ) - if objective_usage != expected_objective_usage: - raise RuntimeError("objective cumulative ledger drift") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _require_objective_closure( - checked_problem, - checked_config, - candidate, - relation, - objective, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - checked_problem, - checked_config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:OBJECTIVE_REFERENCE_CLOSURE", - candidate=candidate, - relation=relation, - usage=objective_usage, - ) - - context = _PointObjectiveReplayContextV2( - problem=checked_problem, - config=checked_config, - candidate=candidate, - relation=relation, - objective=objective, - domain_budget=_EditFeasibilityDomainBudgetV2( - limit=checked_config.max_domain_operations, - used=objective_usage.domain_operations, - ), - atomic_budget=atomic_budget, - replay_usage=objective_usage, - ) - bundle = _GlobalReplayBundleV2( - problem=checked_problem, - config=checked_config, - candidate=candidate, - relation=relation, - objective_outcome=objective_outcome, - point_context=context, - ) - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - selected = _build_selected_global_from_fresh_replay_in_frame_v2( - bundle, - selection_frame, - ) - if type(selected) is not _SelectedGlobalBuildOutcomeV2: - raise TypeError("selected global assembler returned an invalid outcome") - global_outcome = selected.outcome - if global_outcome.kind is GlobalOptimalityBuildKindV2.CERTIFIED_SUCCESS: - result = global_outcome.certified_success_result - if result is None: - raise RuntimeError("global success omitted its solve result") - return CanonicalMinimumCostSolveOutcomeV2( - result=result, - cumulative_generation_usage=global_outcome.cumulative_generation_usage, - proposal_count=selected.proposal_count, - evaluated_proposal_count=selected.evaluated_proposal_count, - ) - reason = global_outcome.uncertified_reason - if global_outcome.kind is GlobalOptimalityBuildKindV2.NOT_PROVEN: - reason = UncertifiedReasonV2.COMPILATION_INCOMPLETE - return _valid_uncertified( - checked_problem, - checked_config, - reason or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(global_outcome.finding_codes or ("GLOBAL_OPTIMALITY_NOT_CERTIFIED",)), - candidate=candidate, - relation=relation, - objective=objective, - usage=global_outcome.cumulative_generation_usage or _context_usage(context), - proposal_count=selected.proposal_count, - evaluated_proposal_count=selected.evaluated_proposal_count, - ) - - -class CanonicalMinimumCostSolverV2: - """Stateless object wrapper for pure-core pipeline composition.""" - - def solve( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - ) -> CanonicalMinimumCostSolveOutcomeV2: - return solve_canonical_minimum_cost_v2(problem, config) - - -def _solve_empty_candidate( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - outcome = _build_proven_unsat_from_fresh_candidate_v2( - problem, - config, - candidate, - ) - except (ArithmeticError, RuntimeWarning): - return _valid_uncertified( - problem, - config, - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:PROVEN_UNSAT_ASSEMBLY", - candidate=candidate, - usage=candidate.resource_usage, - ) - if outcome.kind is CertificateBuildKindV2.PROVEN_UNSAT: - result = outcome.proven_unsat_result - if result is None: - raise RuntimeError("PROVEN_UNSAT builder omitted its solve result") - return CanonicalMinimumCostSolveOutcomeV2( - result=result, - cumulative_generation_usage=outcome.verification_resource_usage, - ) - return _valid_uncertified( - problem, - config, - outcome.uncertified_reason or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(outcome.finding_codes or ("PROVEN_UNSAT_NOT_CERTIFIED",)), - candidate=candidate, - usage=outcome.verification_resource_usage or candidate.resource_usage, - ) - - -def _strict_input_model( - value: object, - model_type: type[ModelT], - label: str, -) -> ModelT: - return strict_input_model_v2(value, model_type, label) - - -def _strict_internal_model( - value: object, - model_type: type[ModelT], - label: str, -) -> ModelT: - if type(value) is not model_type: - raise TypeError(f"fresh {label} has the wrong internal type") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise ArithmeticError( - f"numeric failure reconstructing fresh {label}" - ) from error - - -def _objective_usage( - base: CompilationResourceUsageV2, - domain_budget: _ObjectiveDomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=base.domain_operations + domain_budget.used, - partition_cells=atomic_budget.used, - refinement_steps=base.refinement_steps, - ) - - -def _context_usage( - context: _PointObjectiveReplayContextV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=context.domain_budget.used, - partition_cells=context.atomic_budget.used, - refinement_steps=context.replay_usage.refinement_steps, - ) - - -def _valid_uncertified( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - reason: UncertifiedReasonV2, - *finding_codes: str, - candidate: CandidateDomainArtifactV2 | None = None, - relation: RelationCostPartitionV2 | None = None, - objective: ObjectivePartitionArtifactV2 | None = None, - usage: CompilationResourceUsageV2 | None = None, - proposal_count: int = 0, - evaluated_proposal_count: int = 0, -) -> CanonicalMinimumCostSolveOutcomeV2: - if objective is not None and relation is None: - raise RuntimeError("objective prefix requires a relation prefix") - if relation is not None and candidate is None: - raise RuntimeError("relation prefix requires a candidate prefix") - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - result = UncertifiedResultV2( - semantic_problem_sha256=problem.semantic_problem_sha256, - core_solver_config=config, - uncertified_reason=reason, - candidate_domain=candidate, - relation_cost_partition=relation, - objective_partition=objective, - ) - result = UncertifiedResultV2.model_validate( - result.model_dump(mode="python"), - strict=True, - ) - encoded = canonical_json_bytes_v2(result) - restored = UncertifiedResultV2.model_validate_json(encoded, strict=True) - if ( - restored != result - or canonical_json_bytes_v2(restored) != encoded - or restored.solve_result_sha256 != result.solve_result_sha256 - ): - raise RuntimeError("UNCERTIFIED canonical round-trip closure drift") - return CanonicalMinimumCostSolveOutcomeV2( - result=result, - finding_codes=tuple(finding_codes), - cumulative_generation_usage=usage, - proposal_count=proposal_count, - evaluated_proposal_count=evaluated_proposal_count, - ) diff --git a/src/spatialcf/core/v2/minimum_cost_solver_v2_1.py b/src/spatialcf/core/v2/minimum_cost_solver_v2_1.py deleted file mode 100644 index 4a8522a..0000000 --- a/src/spatialcf/core/v2/minimum_cost_solver_v2_1.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Pure-core solve entry points for the Exact Cardinal Canonical 2.1 subset.""" - -from __future__ import annotations - -import warnings -from typing import TypeVar - -from spatialcf.core.v2.certificate_builder import _ExactCardinalSelectionFrameV2 -from spatialcf.core.v2.minimum_cost_solver import CanonicalMinimumCostSolveOutcomeV2 -from spatialcf.domain.v2.artifacts import ( - CandidateDomainArtifactV2, - CandidateDomainVariableV2, - CompilationResourceUsageV2, - ObjectivePartitionArtifactV2, - RelationCostPartitionV2, -) -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.certificate import ( - GlobalOptimalityCertificateV2, - ProvenUnsatCertificateV2, -) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def solve_canonical_minimum_cost_v2_1( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Solve one exact-cardinal problem through the common engine.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - solve_registered_capability_v2, - ) - - return solve_registered_capability_v2( - SolveCapabilityKeyV2.V2_1, - problem, - config, - ) - - -def _solve_canonical_minimum_cost_v2_1_in_selection_frame( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Compatibility seam using the common engine with an exact frame.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - _solve_registered_capability_in_selection_frame_v2, - ) - - return _solve_registered_capability_in_selection_frame_v2( - SolveCapabilityKeyV2.V2_1, - problem, - config, - selection_frame, - ) - - -class CanonicalMinimumCostSolverV2_1: - """Stateless object wrapper for the Canonical 2.1 solve.""" - - def solve( - self, - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - ) -> CanonicalMinimumCostSolveOutcomeV2: - return solve_canonical_minimum_cost_v2_1(problem, config) - - -def _rebind_outcome( - replay: CanonicalMinimumCostSolveOutcomeV2, - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - preprocessing_domain_operations: int, - *, - candidate_variable_override: CandidateDomainVariableV2 | None = None, - cumulative_domain_operations_increment: int | None = None, -) -> CanonicalMinimumCostSolveOutcomeV2: - if ( - type(preprocessing_domain_operations) is not int - or preprocessing_domain_operations < 0 - ): - raise TypeError("preprocessing domain operations must be non-negative") - cumulative_increment = ( - preprocessing_domain_operations - if cumulative_domain_operations_increment is None - else cumulative_domain_operations_increment - ) - if type(cumulative_increment) is not int or cumulative_increment < 0: - raise TypeError("cumulative domain operations increment must be non-negative") - old_result = replay.result - if old_result is None: - raise RuntimeError("cannot rebind a missing replay result") - problem_hash = problem.semantic_problem_sha256 - config_hash = config.core_solver_config_sha256 - old_candidate = getattr(old_result, "candidate_domain", None) - candidate = ( - None - if old_candidate is None - else _strict_internal( - old_candidate.model_copy( - update={ - "semantic_problem_sha256": problem_hash, - "core_solver_config_sha256": config_hash, - "candidate_variable": ( - old_candidate.candidate_variable - if candidate_variable_override is None - else candidate_variable_override - ), - "resource_usage": _bump_usage( - old_candidate.resource_usage, - preprocessing_domain_operations, - ), - } - ), - CandidateDomainArtifactV2, - ) - ) - old_relation = getattr(old_result, "relation_cost_partition", None) - relation = None - if old_relation is not None: - if candidate is None: - raise RuntimeError("relation replay omitted its candidate") - relation = _strict_internal( - old_relation.model_copy( - update={ - "semantic_problem_sha256": problem_hash, - "core_solver_config_sha256": config_hash, - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - } - ), - RelationCostPartitionV2, - ) - old_objective = getattr(old_result, "objective_partition", None) - objective = None - if old_objective is not None: - if candidate is None or relation is None: - raise RuntimeError("objective replay omitted an upstream artifact") - objective = _strict_internal( - old_objective.model_copy( - update={ - "semantic_problem_sha256": problem_hash, - "core_solver_config_sha256": config_hash, - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - "relation_cost_partition_sha256": ( - relation.relation_cost_partition_sha256 - ), - } - ), - ObjectivePartitionArtifactV2, - ) - - if type(old_result) is CertifiedSuccessResultV2: - if candidate is None or relation is None or objective is None: - raise RuntimeError("success replay omitted its artifact chain") - edit = _strict_internal( - old_result.edit.model_copy( - update={"semantic_problem_sha256": problem_hash} - ), - CanonicalEditV2, - ) - certificate = _strict_internal( - old_result.certificate.model_copy( - update={ - "semantic_problem_sha256": problem_hash, - "core_solver_config_sha256": config_hash, - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - "relation_cost_partition_sha256": ( - relation.relation_cost_partition_sha256 - ), - "objective_partition_artifact_sha256": ( - objective.objective_partition_artifact_sha256 - ), - "edit_sha256": edit.edit_sha256, - } - ), - GlobalOptimalityCertificateV2, - ) - result = CertifiedSuccessResultV2( - semantic_problem_sha256=problem_hash, - core_solver_config=config, - candidate_domain=candidate, - relation_cost_partition=relation, - objective_partition=objective, - edit=edit, - global_loss_lower_bound=old_result.global_loss_lower_bound, - witness_loss_bounds=old_result.witness_loss_bounds, - certificate=certificate, - ) - elif type(old_result) is ProvenUnsatResultV2: - if candidate is None: - raise RuntimeError("UNSAT replay omitted its candidate") - certificate = _strict_internal( - old_result.certificate.model_copy( - update={ - "semantic_problem_sha256": problem_hash, - "core_solver_config_sha256": config_hash, - "candidate_domain_artifact_sha256": ( - candidate.candidate_domain_artifact_sha256 - ), - } - ), - ProvenUnsatCertificateV2, - ) - result = ProvenUnsatResultV2( - semantic_problem_sha256=problem_hash, - core_solver_config=config, - candidate_domain=candidate, - certificate=certificate, - ) - elif type(old_result) is UncertifiedResultV2: - result = UncertifiedResultV2( - semantic_problem_sha256=problem_hash, - core_solver_config=config, - uncertified_reason=old_result.uncertified_reason, - candidate_domain=candidate, - relation_cost_partition=relation, - objective_partition=objective, - ) - else: - raise TypeError("normalized replay returned an invalid result type") - - usage = ( - None - if replay.cumulative_generation_usage is None - else _bump_usage( - replay.cumulative_generation_usage, - cumulative_increment, - ) - ) - return CanonicalMinimumCostSolveOutcomeV2( - result=result, - finding_codes=replay.finding_codes, - cumulative_generation_usage=usage, - proposal_count=replay.proposal_count, - evaluated_proposal_count=replay.evaluated_proposal_count, - ) - - -def _bump_usage( - usage: CompilationResourceUsageV2, - preprocessing_domain_operations: int, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=usage.domain_operations + preprocessing_domain_operations, - partition_cells=usage.partition_cells, - refinement_steps=usage.refinement_steps, - ) - - -def _strict_internal(value: object, model_type: type[ModelT]) -> ModelT: - if type(value) is not model_type: - raise TypeError("rebound internal model has the wrong exact type") - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - - -__all__ = ( - "CanonicalMinimumCostSolverV2_1", - "solve_canonical_minimum_cost_v2_1", -) diff --git a/src/spatialcf/core/v2/minimum_cost_solver_v2_2.py b/src/spatialcf/core/v2/minimum_cost_solver_v2_2.py deleted file mode 100644 index 516a932..0000000 --- a/src/spatialcf/core/v2/minimum_cost_solver_v2_2.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Pure-core solve entry points for the zero-distortion 2.2 capability.""" - -from __future__ import annotations - -from spatialcf.core.v2.certificate_builder import _ExactCardinalSelectionFrameV2 -from spatialcf.core.v2.minimum_cost_solver import CanonicalMinimumCostSolveOutcomeV2 -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.result import CoreSolverConfigV2 - - -def solve_canonical_minimum_cost_v2_2( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Solve an exact identity-projection 2.1 problem under algorithm 2.2.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - solve_registered_capability_v2, - ) - - return solve_registered_capability_v2( - SolveCapabilityKeyV2.V2_2, - problem, - config, - ) - - -def _solve_canonical_minimum_cost_v2_2_in_selection_frame( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Compatibility seam using the common engine with an exact frame.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - _solve_registered_capability_in_selection_frame_v2, - ) - - return _solve_registered_capability_in_selection_frame_v2( - SolveCapabilityKeyV2.V2_2, - problem, - config, - selection_frame, - ) - - -class CanonicalMinimumCostSolverV2_2: - """Stateless wrapper for the zero-distortion 2.2 solve.""" - - def solve( - self, - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - ) -> CanonicalMinimumCostSolveOutcomeV2: - return solve_canonical_minimum_cost_v2_2(problem, config) - - -__all__ = ( - "CanonicalMinimumCostSolverV2_2", - "solve_canonical_minimum_cost_v2_2", -) diff --git a/src/spatialcf/core/v2/minimum_cost_solver_v2_3.py b/src/spatialcf/core/v2/minimum_cost_solver_v2_3.py deleted file mode 100644 index 3669015..0000000 --- a/src/spatialcf/core/v2/minimum_cost_solver_v2_3.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Pure-core solve entry points for the exact camera-translation 2.3 capability.""" - -from __future__ import annotations - -from spatialcf.core.v2.certificate_builder import _ExactCardinalSelectionFrameV2 -from spatialcf.core.v2.minimum_cost_solver import CanonicalMinimumCostSolveOutcomeV2 -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.result import CoreSolverConfigV2 - - -def solve_canonical_minimum_cost_v2_3( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Solve one exact translated-camera problem through the common engine.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - solve_registered_capability_v2, - ) - - return solve_registered_capability_v2( - SolveCapabilityKeyV2.V2_3, - problem, - config, - ) - - -def _solve_canonical_minimum_cost_v2_3_in_selection_frame( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - selection_frame: _ExactCardinalSelectionFrameV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Compatibility seam using the common engine with an exact frame.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - _solve_registered_capability_in_selection_frame_v2, - ) - - return _solve_registered_capability_in_selection_frame_v2( - SolveCapabilityKeyV2.V2_3, - problem, - config, - selection_frame, - ) - - -class CanonicalMinimumCostSolverV2_3: - """Stateless wrapper for the exact camera-translation 2.3 solve.""" - - def solve( - self, - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - ) -> CanonicalMinimumCostSolveOutcomeV2: - return solve_canonical_minimum_cost_v2_3(problem, config) - - -__all__ = ( - "CanonicalMinimumCostSolverV2_3", - "solve_canonical_minimum_cost_v2_3", -) diff --git a/src/spatialcf/core/v2/minimum_cost_solver_v2_4.py b/src/spatialcf/core/v2/minimum_cost_solver_v2_4.py deleted file mode 100644 index fb14c76..0000000 --- a/src/spatialcf/core/v2/minimum_cost_solver_v2_4.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Pure-core solve for an exact translated cardinal-yaw camera.""" - -from __future__ import annotations - -from spatialcf.core.v2.minimum_cost_solver import CanonicalMinimumCostSolveOutcomeV2 -from spatialcf.domain.v2.cardinal import SemanticProblemV2_1 -from spatialcf.domain.v2.result import CoreSolverConfigV2 - - -def solve_canonical_minimum_cost_v2_4( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, -) -> CanonicalMinimumCostSolveOutcomeV2: - """Solve through one exact cardinal rebase and one private 2.3 replay.""" - - from spatialcf.core.v2._internal.orchestration.capabilities import ( - SolveCapabilityKeyV2, - ) - from spatialcf.core.v2._internal.orchestration.solve import ( - solve_registered_capability_v2, - ) - - return solve_registered_capability_v2( - SolveCapabilityKeyV2.V2_4, - problem, - config, - ) - - -class CanonicalMinimumCostSolverV2_4: - """Stateless wrapper for exact cardinal-camera rebasing.""" - - def solve( - self, - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - ) -> CanonicalMinimumCostSolveOutcomeV2: - return solve_canonical_minimum_cost_v2_4(problem, config) - - -__all__ = ( - "CanonicalMinimumCostSolverV2_4", - "solve_canonical_minimum_cost_v2_4", -) diff --git a/src/spatialcf/core/v2/multi_obstacle_strict_convex_candidate_domain.py b/src/spatialcf/core/v2/multi_obstacle_strict_convex_candidate_domain.py deleted file mode 100644 index 5c774b0..0000000 --- a/src/spatialcf/core/v2/multi_obstacle_strict_convex_candidate_domain.py +++ /dev/null @@ -1,953 +0,0 @@ -"""Raw multi-obstacle strict-convex candidate compilation for Canonical v2.2.""" - -from __future__ import annotations - -import hashlib -import json -import re -import warnings -from dataclasses import dataclass, fields, is_dataclass -from enum import Enum, StrEnum -from fractions import Fraction -from typing import Any - -from pydantic import ValidationError -from pydantic_core import PydanticSerializationError - -from spatialcf.core.v2.convex_translation_partition import ( - ConvexAllowedTranslationBracketV2, - ConvexAllowedTranslationKindV2, - compile_convex_allowed_translation_v2, -) -from spatialcf.core.v2.rect_kernel import ( - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, - RectTopologyV2, -) -from spatialcf.core.v2.so2_interval import SO2AtomicBudgetV2 -from spatialcf.core.v2.strict_convex_candidate_domain import ( - _InvalidInputV2 as _LegacyInvalidInputV2, -) -from spatialcf.core.v2.strict_convex_candidate_domain import ( - _precharge_problem_structure, -) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionComplexV2, - StrictConvexIntersectionKindV2, - intersect_strict_convex_allowed_complexes_v2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - NumericPolicyV2, - UncertaintyBudgetV2, - Vec3V2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - CollisionClearanceMetricV2, - PositionRegionInterpretationV2, - RegionAggregationV2, -) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - GeometryInstanceV2_2, - SemanticProblemV2_2, - StrictConvexCandidateCompilerConfigV2_6, -) -from spatialcf.domain.v2.geometry import ( - GeometryApproximationV2, - GeometryRoleV2, - UprightBox3DV2, -) - -_ARTIFACT_HASH_DOMAIN_V2_2 = ( - b"spatialcf.multi-obstacle-strict-convex-candidate-artifact.v2.2\0" -) -_DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}") -_INTERSECTION_KERNEL_ID = "geometry-kernel:rational-strict-convex-intersection-v2" -_INTERSECTION_KERNEL_VERSION = "kernel:2.5-strict-convex-intersection" - - -class MultiObstacleStrictConvexCandidateCompilationKindV2(StrEnum): - ARTIFACT = "ARTIFACT" - UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" - NUMERIC_GAP = "NUMERIC_GAP" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - INVALID_INPUT = "INVALID_INPUT" - - -class MultiObstacleStrictConvexCandidateVerificationKindV2(StrEnum): - VERIFIED = "VERIFIED" - MISMATCH = "MISMATCH" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class MultiObstacleStrictConvexCandidateResourceUsageV2: - domain_operations: int - so2_atomic_steps: int - candidate_cells: int - - def __post_init__(self) -> None: - if type(self.domain_operations) is not int or self.domain_operations < 0: - raise ValueError("domain_operations must be a non-negative exact int") - if type(self.so2_atomic_steps) is not int or self.so2_atomic_steps <= 0: - raise ValueError("so2_atomic_steps must be a positive exact int") - if type(self.candidate_cells) is not int or self.candidate_cells < 0: - raise ValueError("candidate_cells must be a non-negative exact int") - - -@dataclass(frozen=True, slots=True) -class MultiObstacleStrictConvexAllowedBracketV2: - inner_allowed: StrictConvexIntersectionComplexV2 - outer_allowed: StrictConvexIntersectionComplexV2 - intersection_kernel_id: str - intersection_kernel_version: str - so2_atomic_steps_used: int - - def __post_init__(self) -> None: - checked_inner = _copy_intersection_complex(self.inner_allowed) - checked_outer = _copy_intersection_complex(self.outer_allowed) - if checked_inner.universe != checked_outer.universe: - raise ValueError("inner and outer intersections require one universe") - if self.intersection_kernel_id != _INTERSECTION_KERNEL_ID: - raise ValueError("unexpected intersection kernel ID") - if self.intersection_kernel_version != _INTERSECTION_KERNEL_VERSION: - raise ValueError("unexpected intersection kernel version") - if ( - type(self.so2_atomic_steps_used) is not int - or self.so2_atomic_steps_used <= 0 - ): - raise ValueError("so2_atomic_steps_used must be a positive exact int") - if not all( - checked_outer.contains_point(cell.strict_witness) - for cell in checked_inner.cells - ): - raise ValueError("inner intersection witness escaped outer intersection") - object.__setattr__(self, "inner_allowed", checked_inner) - object.__setattr__(self, "outer_allowed", checked_outer) - - -@dataclass(frozen=True, slots=True) -class MultiObstacleStrictConvexCandidateDomainArtifactV2_2: - semantic_problem_sha256: str - compiler_config_sha256: str - subject_id: str - search_universe: ExactAxisAlignedRectV2 - ordered_constraint_ids: tuple[str, ...] - ordered_obstacle_body_ids: tuple[str, ...] - allowed_domain_bracket: MultiObstacleStrictConvexAllowedBracketV2 - resource_usage: MultiObstacleStrictConvexCandidateResourceUsageV2 - remaining_constraint_ids: tuple[str, ...] - - def __post_init__(self) -> None: - for label, digest in ( - ("semantic_problem_sha256", self.semantic_problem_sha256), - ("compiler_config_sha256", self.compiler_config_sha256), - ): - if type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None: - raise ValueError(f"{label} must be a lowercase SHA-256 digest") - if type(self.subject_id) is not str or not self.subject_id.strip(): - raise ValueError("subject_id must be a non-blank exact string") - checked_universe = _copy_universe(self.search_universe) - if ( - checked_universe.topology is not RectTopologyV2.AREA - or checked_universe.coordinate_space - is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M - ): - raise ValueError("search universe must be an AREA translation-delta rect") - compiled_ids = _require_id_tuple( - self.ordered_constraint_ids, - label="ordered_constraint_ids", - nonempty=True, - sorted_required=False, - ) - obstacle_ids = _require_id_tuple( - self.ordered_obstacle_body_ids, - label="ordered_obstacle_body_ids", - nonempty=True, - sorted_required=True, - ) - remaining_ids = _require_id_tuple( - self.remaining_constraint_ids, - label="remaining_constraint_ids", - nonempty=False, - sorted_required=True, - ) - if set(compiled_ids) & set(remaining_ids): - raise ValueError("compiled and remaining constraint IDs must be disjoint") - if ( - type(self.allowed_domain_bracket) - is not MultiObstacleStrictConvexAllowedBracketV2 - ): - raise TypeError("allowed_domain_bracket has the wrong exact type") - checked_bracket = _copy_bracket(self.allowed_domain_bracket) - if ( - checked_bracket.inner_allowed.universe != checked_universe - or checked_bracket.outer_allowed.universe != checked_universe - ): - raise ValueError("allowed bracket must use the exact search universe") - if ( - type(self.resource_usage) - is not MultiObstacleStrictConvexCandidateResourceUsageV2 - ): - raise TypeError("resource_usage has the wrong exact type") - checked_usage = MultiObstacleStrictConvexCandidateResourceUsageV2( - domain_operations=self.resource_usage.domain_operations, - so2_atomic_steps=self.resource_usage.so2_atomic_steps, - candidate_cells=self.resource_usage.candidate_cells, - ) - if checked_usage.so2_atomic_steps != checked_bracket.so2_atomic_steps_used: - raise ValueError("SO(2) usage must equal the aggregate bracket usage") - if checked_usage.candidate_cells != ( - len(checked_bracket.inner_allowed.cells) - + len(checked_bracket.outer_allowed.cells) - ): - raise ValueError("candidate-cell usage must equal all published cells") - object.__setattr__(self, "search_universe", checked_universe) - object.__setattr__(self, "ordered_constraint_ids", compiled_ids) - object.__setattr__(self, "ordered_obstacle_body_ids", obstacle_ids) - object.__setattr__(self, "remaining_constraint_ids", remaining_ids) - object.__setattr__(self, "allowed_domain_bracket", checked_bracket) - object.__setattr__(self, "resource_usage", checked_usage) - - @property - def artifact_sha256(self) -> str: - return hashlib.sha256( - _ARTIFACT_HASH_DOMAIN_V2_2 + _artifact_bytes(self) - ).hexdigest() - - -@dataclass(frozen=True, slots=True) -class MultiObstacleStrictConvexCandidateCompilationOutcomeV2: - kind: MultiObstacleStrictConvexCandidateCompilationKindV2 - artifact: MultiObstacleStrictConvexCandidateDomainArtifactV2_2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not MultiObstacleStrictConvexCandidateCompilationKindV2: - raise TypeError("kind has the wrong exact type") - findings = _require_finding_codes(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - if self.kind is MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT: - if ( - type(self.artifact) - is not MultiObstacleStrictConvexCandidateDomainArtifactV2_2 - ): - raise ValueError("ARTIFACT outcome requires an exact artifact") - if findings: - raise ValueError("ARTIFACT outcome cannot carry findings") - object.__setattr__(self, "artifact", _copy_artifact(self.artifact)) - return - if self.artifact is not None: - raise ValueError("failure outcome cannot carry an artifact") - if not findings: - raise ValueError("failure outcome requires at least one finding") - - -@dataclass(frozen=True, slots=True) -class MultiObstacleStrictConvexCandidateVerificationOutcomeV2: - kind: MultiObstacleStrictConvexCandidateVerificationKindV2 - semantic_problem_sha256: str | None = None - compiler_config_sha256: str | None = None - artifact_sha256: str | None = None - verification_resource_usage: ( - MultiObstacleStrictConvexCandidateResourceUsageV2 | None - ) = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not MultiObstacleStrictConvexCandidateVerificationKindV2: - raise TypeError("verification kind has the wrong exact type") - findings = _require_finding_codes(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - refs = ( - self.semantic_problem_sha256, - self.compiler_config_sha256, - self.artifact_sha256, - ) - if self.kind is MultiObstacleStrictConvexCandidateVerificationKindV2.VERIFIED: - if any( - type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None - for digest in refs - ): - raise ValueError("VERIFIED outcome requires three SHA-256 references") - if findings: - raise ValueError("VERIFIED outcome cannot carry findings") - if ( - type(self.verification_resource_usage) - is not MultiObstacleStrictConvexCandidateResourceUsageV2 - ): - raise ValueError("VERIFIED outcome requires replay resource usage") - else: - if any(digest is not None for digest in refs): - raise ValueError("failure verification outcome cannot carry references") - if not findings: - raise ValueError("failure verification outcome requires findings") - if self.verification_resource_usage is not None: - if ( - type(self.verification_resource_usage) - is not MultiObstacleStrictConvexCandidateResourceUsageV2 - ): - raise TypeError("verification resource usage has the wrong exact type") - object.__setattr__( - self, - "verification_resource_usage", - MultiObstacleStrictConvexCandidateResourceUsageV2( - domain_operations=( - self.verification_resource_usage.domain_operations - ), - so2_atomic_steps=self.verification_resource_usage.so2_atomic_steps, - candidate_cells=self.verification_resource_usage.candidate_cells, - ), - ) - - -class MultiObstacleStrictConvexCandidateDomainCompilerV2_6: - def compile( - self, - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_6, - ) -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: - return compile_multi_obstacle_strict_convex_candidate_domain_v2_6( - problem, config - ) - - -class _InvalidInputV2(ValueError): - pass - - -class _UnsupportedModelV2(ValueError): - def __init__(self, finding_code: str) -> None: - super().__init__(finding_code) - self.finding_code = finding_code - - -def compile_multi_obstacle_strict_convex_candidate_domain_v2_6( - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_6, -) -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: - """Fresh-compile the bounded multi-obstacle collision prefix.""" - - try: - checked_config = _strict_config(config) - except _InvalidInputV2: - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.INVALID_INPUT, - "INVALID_INPUT:MULTI_OBSTACLE_STRICT_CONVEX_INPUT", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:MULTI_OBSTACLE_REVALIDATION", - ) - - budget = StrictConvexIntersectionBudgetV2( - max_domain_operations=checked_config.max_domain_operations, - max_candidate_cells=checked_config.max_candidate_cells, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _precharge_problem_structure(problem, budget) # type: ignore[arg-type] - checked_problem = _strict_problem(problem) - except StrictConvexIntersectionBudgetExhaustedV2: - return _resource_failure() - except (_InvalidInputV2, _LegacyInvalidInputV2): - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.INVALID_INPUT, - "INVALID_INPUT:MULTI_OBSTACLE_STRICT_CONVEX_INPUT", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:MULTI_OBSTACLE_REVALIDATION", - ) - - atomic_budget = SO2AtomicBudgetV2(limit=checked_config.max_so2_atomic_steps) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - from spatialcf.core.v2.candidate_domain import ( - _compile_search_universe, - _SearchUniverseFailureV2, - ) - - search = _compile_search_universe(checked_problem, budget) # type: ignore[arg-type] - if isinstance(search, _SearchUniverseFailureV2): - kind = ( - MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP - if search.uncertified_reason.value == "NUMERIC_GAP" - else MultiObstacleStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL - ) - return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( - kind=kind, - finding_codes=search.finding_codes, - ) - subject, obstacles = _extract_supported_pairs(checked_problem, budget) - inner_complexes = [] - outer_complexes = [] - for _, obstacle_transform, obstacle_shape in obstacles: - allowed = compile_convex_allowed_translation_v2( - subject[0], - subject[1], - obstacle_transform, - obstacle_shape, - search.delta_rect, - atomic_budget=atomic_budget, - ) - if allowed.kind is ConvexAllowedTranslationKindV2.RESOURCE_LIMIT: - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:max_so2_atomic_steps", - ) - if allowed.kind is ConvexAllowedTranslationKindV2.NUMERIC_GAP: - return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - finding_codes=allowed.finding_codes, - ) - if allowed.kind is ConvexAllowedTranslationKindV2.INVALID_INPUT: - raise RuntimeError("supported obstacle produced invalid T12 input") - if ( - allowed.kind is not ConvexAllowedTranslationKindV2.BRACKET - or type(allowed.bracket) is not ConvexAllowedTranslationBracketV2 - ): - raise RuntimeError("malformed T12 allowed-domain outcome") - inner_complexes.append(allowed.bracket.inner_allowed) - outer_complexes.append(allowed.bracket.outer_allowed) - - inner = intersect_strict_convex_allowed_complexes_v2( - tuple(inner_complexes), budget=budget - ) - outer = intersect_strict_convex_allowed_complexes_v2( - tuple(outer_complexes), budget=budget - ) - checked_inner = _require_intersection_success(inner) - checked_outer = _require_intersection_success(outer) - - constraints = checked_problem.constraints - remaining_ids = tuple( - sorted( - ( - *( - item.constraint_id - for item in constraints.support_constraints - ), - *( - item.constraint_id - for item in constraints.visibility_constraints - ), - constraints.target_relation.constraint_id, - ) - ) - ) - budget.consume_domain( - 12 - + len(remaining_ids) - + len(obstacles) - + sum( - len(cell.half_planes) + len(cell.closure_polygon.vertices_ccw) - for complex_ in (checked_inner, checked_outer) - for cell in complex_.cells - ) - ) - bracket = MultiObstacleStrictConvexAllowedBracketV2( - inner_allowed=checked_inner, - outer_allowed=checked_outer, - intersection_kernel_id=checked_config.intersection_kernel_id, - intersection_kernel_version=checked_config.intersection_kernel_version, - so2_atomic_steps_used=atomic_budget.used, - ) - artifact = MultiObstacleStrictConvexCandidateDomainArtifactV2_2( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - compiler_config_sha256=checked_config.config_sha256, - subject_id=constraints.allowed_edit.subject_id, - search_universe=search.delta_rect, - ordered_constraint_ids=( - constraints.position_domain.constraint_id, - constraints.collision_constraints[0].constraint_id, - ), - ordered_obstacle_body_ids=tuple(item[0] for item in obstacles), - allowed_domain_bracket=bracket, - resource_usage=MultiObstacleStrictConvexCandidateResourceUsageV2( - domain_operations=budget.domain_operations_used, - so2_atomic_steps=atomic_budget.used, - candidate_cells=budget.candidate_cells_used, - ), - remaining_constraint_ids=remaining_ids, - ) - return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT, - artifact=artifact, - ) - except _UnsupportedModelV2 as error: - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except StrictConvexIntersectionBudgetExhaustedV2: - return _resource_failure() - except (ArithmeticError, RuntimeWarning): - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:MULTI_OBSTACLE_COMPILATION", - ) - - -def verify_multi_obstacle_strict_convex_candidate_domain_v2_6( - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_6, - submitted_artifact: MultiObstacleStrictConvexCandidateDomainArtifactV2_2, -) -> MultiObstacleStrictConvexCandidateVerificationOutcomeV2: - """Fresh replay raw inputs and compare the entire submitted T14 artifact.""" - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - checked_submitted = _copy_artifact(submitted_artifact) - except (ArithmeticError, RuntimeWarning): - return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("NUMERIC_GAP:SUBMITTED_MULTI_OBSTACLE_ARTIFACT",), - ) - except (AttributeError, TypeError, ValueError, Warning): - return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("INVALID_INPUT:SUBMITTED_MULTI_OBSTACLE_ARTIFACT",), - ) - - replay = compile_multi_obstacle_strict_convex_candidate_domain_v2_6(problem, config) - if ( - replay.kind is not MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT - or type(replay.artifact) - is not MultiObstacleStrictConvexCandidateDomainArtifactV2_2 - ): - return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=replay.finding_codes, - ) - fresh = replay.artifact - usage = fresh.resource_usage - if ( - checked_submitted != fresh - or _artifact_bytes(checked_submitted) != _artifact_bytes(fresh) - or checked_submitted.artifact_sha256 != fresh.artifact_sha256 - ): - return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateVerificationKindV2.MISMATCH, - verification_resource_usage=usage, - finding_codes=("MISMATCH:MULTI_OBSTACLE_STRICT_CONVEX_ARTIFACT",), - ) - return MultiObstacleStrictConvexCandidateVerificationOutcomeV2( - kind=MultiObstacleStrictConvexCandidateVerificationKindV2.VERIFIED, - semantic_problem_sha256=fresh.semantic_problem_sha256, - compiler_config_sha256=fresh.compiler_config_sha256, - artifact_sha256=fresh.artifact_sha256, - verification_resource_usage=usage, - ) - - -def _extract_supported_pairs( - problem: SemanticProblemV2_2, - budget: StrictConvexIntersectionBudgetV2, -) -> tuple[ - tuple[DirectedYawIntervalTransformV2_2, UprightBox3DV2], - tuple[tuple[str, DirectedYawIntervalTransformV2_2, UprightBox3DV2], ...], -]: - constraints = problem.constraints - position = constraints.position_domain - if ( - position.region_interpretation - is not PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS - or position.workspace_aggregation is not RegionAggregationV2.INTERSECTION - or position.boundary_policy is not BoundaryPolicyV2.CLOSED - or position.known_free_space_fact_ids - or len(position.workspace_fact_ids) != 1 - or position.minimum_boundary_clearance_m != 0.0 - ): - raise _UnsupportedModelV2("UNSUPPORTED:POSITION_DOMAIN_SUBSET") - workspace_values = _exact_fact_values( - problem.scene.workspace_boundaries, "WORKSPACE_BOUNDARIES", budget - ) - if ( - len(workspace_values) != 1 - or workspace_values[0].fact_id != position.workspace_fact_ids[0] - or workspace_values[0].region_approximation is not GeometryApproximationV2.EXACT - or workspace_values[0].geometry_uncertainty != UncertaintyBudgetV2() - ): - raise _UnsupportedModelV2("UNSUPPORTED:POSITION_WORKSPACE_SUBSET") - if len(constraints.collision_constraints) != 1: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_CONSTRAINT_CARDINALITY") - collision = constraints.collision_constraints[0] - if len(collision.subject_body_ids) != 1 or not collision.obstacle_body_ids: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_PAIR_CARDINALITY") - if tuple(sorted(set(collision.obstacle_body_ids))) != collision.obstacle_body_ids: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_OBSTACLE_IDENTITIES") - if ( - collision.clearance_metric - is not CollisionClearanceMetricV2.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE - or collision.boundary_policy is not BoundaryPolicyV2.CLOSED - or collision.minimum_clearance_m != 0.0 - or collision.support_contact_exceptions - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_POLICY") - if problem.numeric_policy != NumericPolicyV2(): - raise _UnsupportedModelV2("UNSUPPORTED:NUMERIC_POLICY") - - bodies = _exact_fact_values( - problem.scene.collision_bodies, "COLLISION_BODIES", budget - ) - geometries = _exact_fact_values( - problem.scene.geometry_instances, "GEOMETRY_INSTANCES", budget - ) - objects = _exact_fact_values(problem.scene.objects, "OBJECTS", budget) - body_by_id = {item.body_id: item for item in bodies} - geometry_by_id = {item.geometry_id: item for item in geometries} - object_by_id = {item.object_id: item for item in objects} - budget.consume_domain(len(bodies) + len(geometries) + len(objects)) - - subject_id = constraints.allowed_edit.subject_id - try: - subject_body = body_by_id[collision.subject_body_ids[0]] - subject_object = object_by_id[subject_id] - except KeyError as error: - raise RuntimeError("semantic graph lost the collision subject") from error - if ( - subject_body.owner_object_id != subject_id - or len(subject_body.geometry_instance_ids) != 1 - or not subject_object.movable - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") - try: - subject_geometry = geometry_by_id[subject_body.geometry_instance_ids[0]] - except KeyError as error: - raise RuntimeError("semantic graph lost subject collision geometry") from error - subject_shape = _require_collision_geometry(subject_geometry, subject_id) - subject_transform = subject_object.pose.world_from_object - if type(subject_transform) is not DirectedYawIntervalTransformV2_2: - raise RuntimeError("v2.2 subject pose lost its directed-yaw transform") - - obstacles = [] - for body_id in collision.obstacle_body_ids: - try: - body = body_by_id[body_id] - owner_id = body.owner_object_id - except KeyError as error: - raise RuntimeError("semantic graph lost an obstacle reference") from error - if owner_id == subject_id or len(body.geometry_instance_ids) != 1: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") - try: - obstacle_geometry = geometry_by_id[body.geometry_instance_ids[0]] - except KeyError as error: - raise RuntimeError( - "semantic graph lost obstacle collision geometry" - ) from error - if owner_id is None: - obstacle_shape, obstacle_transform = ( - _require_environment_collision_geometry(obstacle_geometry) - ) - else: - try: - obstacle_object = object_by_id[owner_id] - except KeyError as error: - raise RuntimeError("semantic graph lost an obstacle owner") from error - if obstacle_object.movable: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") - obstacle_shape = _require_collision_geometry(obstacle_geometry, owner_id) - obstacle_transform = obstacle_object.pose.world_from_object - if type(obstacle_transform) is not DirectedYawIntervalTransformV2_2: - raise RuntimeError("v2.2 obstacle pose lost its directed-yaw transform") - # T12 publishes the exact full universe when immutable subject and - # obstacle Z interiors are disjoint (including closed-face contact). - # Keep the pair in the replay ledger rather than rejecting it or - # silently dropping its semantic identity. - budget.consume_domain(4) - obstacles.append((body_id, obstacle_transform, obstacle_shape)) - return (subject_transform, subject_shape), tuple(obstacles) - - -def _require_collision_geometry(geometry: Any, owner_id: str) -> UprightBox3DV2: - if ( - type(geometry) is not GeometryInstanceV2_2 - or geometry.owner_object_id != owner_id - or geometry.role is not GeometryRoleV2.COLLISION - or geometry.approximation is not GeometryApproximationV2.EXACT - or geometry.uncertainty != UncertaintyBudgetV2() - or type(geometry.shape) is not UprightBox3DV2 - or not _is_identity_anchor(geometry.anchor_from_geometry) - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_GEOMETRY_SUBSET") - return geometry.shape - - -def _require_environment_collision_geometry( - geometry: Any, -) -> tuple[UprightBox3DV2, DirectedYawIntervalTransformV2_2]: - """Close one ownerless world-frame collision box for the T12 kernel.""" - - if ( - type(geometry) is not GeometryInstanceV2_2 - or geometry.owner_object_id is not None - or geometry.role is not GeometryRoleV2.COLLISION - or geometry.approximation is not GeometryApproximationV2.EXACT - or geometry.uncertainty != UncertaintyBudgetV2() - or type(geometry.shape) is not UprightBox3DV2 - or type(geometry.anchor_from_geometry) is not DirectedYawIntervalTransformV2_2 - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_GEOMETRY_SUBSET") - return geometry.shape, geometry.anchor_from_geometry - - -def _exact_fact_values( - facts: Any, - label: str, - budget: StrictConvexIntersectionBudgetV2, -) -> tuple[Any, ...]: - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.uncertainty != UncertaintyBudgetV2() - or type(facts.values) is not tuple - ): - raise _UnsupportedModelV2(f"UNSUPPORTED:{label}_FACT_SET") - budget.consume_domain(len(facts.values) + 1) - return facts.values - - -def _is_identity_anchor(transform: DirectedYawIntervalTransformV2_2) -> bool: - return ( - type(transform) is DirectedYawIntervalTransformV2_2 - and transform.translation == Vec3V2(x=0.0, y=0.0, z=0.0) - and transform.yaw_radians == 0.0 - ) - - -def _require_intersection_success( - outcome: Any, -) -> StrictConvexIntersectionComplexV2: - if outcome.kind is StrictConvexIntersectionKindV2.RESOURCE_LIMIT: - raise StrictConvexIntersectionBudgetExhaustedV2 - if outcome.kind is StrictConvexIntersectionKindV2.NUMERIC_GAP: - raise ArithmeticError("strict-convex intersection numeric gap") - if outcome.kind is StrictConvexIntersectionKindV2.INVALID_INPUT: - raise RuntimeError("compiler produced invalid strict-convex operands") - if ( - outcome.kind is not StrictConvexIntersectionKindV2.COMPLEX - or type(outcome.complex) is not StrictConvexIntersectionComplexV2 - ): - raise RuntimeError("malformed strict-convex intersection outcome") - return outcome.complex - - -def _strict_config(value: object) -> StrictConvexCandidateCompilerConfigV2_6: - if type(value) is not StrictConvexCandidateCompilerConfigV2_6: - raise _InvalidInputV2 - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - payload = value.model_dump(mode="python", warnings="error") - return StrictConvexCandidateCompilerConfigV2_6.model_validate( - payload, strict=True - ) - except (ArithmeticError, RuntimeWarning): - raise - except ( - AttributeError, - PydanticSerializationError, - TypeError, - ValidationError, - ValueError, - Warning, - ) as error: - raise _InvalidInputV2 from error - - -def _strict_problem(value: object) -> SemanticProblemV2_2: - if type(value) is not SemanticProblemV2_2: - raise _InvalidInputV2 - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - payload = value.model_dump(mode="python", warnings="error") - return SemanticProblemV2_2.model_validate(payload, strict=True) - except (ArithmeticError, RuntimeWarning): - raise - except ( - AttributeError, - PydanticSerializationError, - TypeError, - ValidationError, - ValueError, - Warning, - ) as error: - raise _InvalidInputV2 from error - - -def _require_id_tuple( - value: object, - *, - label: str, - nonempty: bool, - sorted_required: bool, -) -> tuple[str, ...]: - if type(value) is not tuple or (nonempty and not value): - raise ValueError(f"{label} must be an exact tuple with valid cardinality") - if any(type(item) is not str or not item.strip() for item in value): - raise ValueError(f"{label} must contain non-blank exact strings") - if len(set(value)) != len(value): - raise ValueError(f"{label} must contain unique IDs") - if sorted_required and tuple(sorted(value)) != value: - raise ValueError(f"{label} must be canonically sorted") - return value - - -def _require_finding_codes(value: object) -> tuple[str, ...]: - if type(value) is not tuple or any( - type(item) is not str or not item.strip() for item in value - ): - raise ValueError("finding_codes must be exact non-blank strings") - return tuple(sorted(set(value))) - - -def _copy_universe(value: ExactAxisAlignedRectV2) -> ExactAxisAlignedRectV2: - if type(value) is not ExactAxisAlignedRectV2: - raise TypeError("search_universe has the wrong exact type") - bounds = value.bounds - if bounds is None: - raise ValueError("search universe cannot be empty") - return ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=bounds[0], - min_y_m=bounds[1], - max_x_m=bounds[2], - max_y_m=bounds[3], - coordinate_space=value.coordinate_space, - ) - - -def _copy_intersection_complex( - value: StrictConvexIntersectionComplexV2, -) -> StrictConvexIntersectionComplexV2: - if type(value) is not StrictConvexIntersectionComplexV2: - raise TypeError("intersection complex has the wrong exact type") - return StrictConvexIntersectionComplexV2( - cells=value.cells, - universe=value.universe, - topology=value.topology, - ) - - -def _copy_bracket( - value: MultiObstacleStrictConvexAllowedBracketV2, -) -> MultiObstacleStrictConvexAllowedBracketV2: - return MultiObstacleStrictConvexAllowedBracketV2( - inner_allowed=value.inner_allowed, - outer_allowed=value.outer_allowed, - intersection_kernel_id=value.intersection_kernel_id, - intersection_kernel_version=value.intersection_kernel_version, - so2_atomic_steps_used=value.so2_atomic_steps_used, - ) - - -def _copy_artifact( - value: MultiObstacleStrictConvexCandidateDomainArtifactV2_2, -) -> MultiObstacleStrictConvexCandidateDomainArtifactV2_2: - return MultiObstacleStrictConvexCandidateDomainArtifactV2_2( - semantic_problem_sha256=value.semantic_problem_sha256, - compiler_config_sha256=value.compiler_config_sha256, - subject_id=value.subject_id, - search_universe=value.search_universe, - ordered_constraint_ids=value.ordered_constraint_ids, - ordered_obstacle_body_ids=value.ordered_obstacle_body_ids, - allowed_domain_bracket=value.allowed_domain_bracket, - resource_usage=value.resource_usage, - remaining_constraint_ids=value.remaining_constraint_ids, - ) - - -def _artifact_bytes( - value: MultiObstacleStrictConvexCandidateDomainArtifactV2_2, -) -> bytes: - return json.dumps( - _canonical_value(value), - ensure_ascii=False, - allow_nan=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - - -def _canonical_value(value: Any) -> Any: - if isinstance(value, Fraction): - return { - "denominator": _canonical_integer(value.denominator), - "numerator": _canonical_integer(value.numerator), - } - if isinstance(value, Enum): - return value.value - if is_dataclass(value) and not isinstance(value, type): - return { - field.name: _canonical_value(getattr(value, field.name)) - for field in fields(value) - } - if isinstance(value, tuple): - return [_canonical_value(item) for item in value] - if value is None or type(value) in {str, int, float, bool}: - return value - raise TypeError(f"unsupported artifact hash value: {type(value).__name__}") - - -def _canonical_integer(value: int) -> int | dict[str, str]: - """Serialize huge exact integers without Python's decimal-digit limit. - - Ordinary values deliberately retain the historical JSON number encoding, - preserving every existing artifact byte and hash. Very large directed - rational coefficients use a frozen signed hexadecimal representation; - hexadecimal conversion is linear and is not governed by - ``sys.int_max_str_digits``. - """ - - if type(value) is not int: - raise TypeError("canonical artifact integer must have exact int type") - # Frozen v2.6/v2.7 artifacts reach 8,583 bits and must retain their exact - # historical decimal JSON bytes. Twelve thousand bits remain safely - # below CPython's default 4,300-decimal-digit conversion boundary, while - # the larger v2.9 projection coefficients use the limit-independent form. - if value.bit_length() <= 12_000: - return value - sign = "-" if value < 0 else "+" - return {"encoding": "signed-hex-v1", "value": sign + format(abs(value), "x")} - - -def _failure( - kind: MultiObstacleStrictConvexCandidateCompilationKindV2, - finding_code: str, -) -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: - return MultiObstacleStrictConvexCandidateCompilationOutcomeV2( - kind=kind, - finding_codes=(finding_code,), - ) - - -def _resource_failure() -> MultiObstacleStrictConvexCandidateCompilationOutcomeV2: - return _failure( - MultiObstacleStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:MULTI_OBSTACLE_STRICT_CONVEX_CANDIDATE", - ) - - -__all__ = ( - "MultiObstacleStrictConvexAllowedBracketV2", - "MultiObstacleStrictConvexCandidateCompilationKindV2", - "MultiObstacleStrictConvexCandidateCompilationOutcomeV2", - "MultiObstacleStrictConvexCandidateDomainArtifactV2_2", - "MultiObstacleStrictConvexCandidateDomainCompilerV2_6", - "MultiObstacleStrictConvexCandidateResourceUsageV2", - "MultiObstacleStrictConvexCandidateVerificationKindV2", - "MultiObstacleStrictConvexCandidateVerificationOutcomeV2", - "compile_multi_obstacle_strict_convex_candidate_domain_v2_6", - "verify_multi_obstacle_strict_convex_candidate_domain_v2_6", -) diff --git a/src/spatialcf/core/v2/objective_partition.py b/src/spatialcf/core/v2/objective_partition.py deleted file mode 100644 index 91c1e54..0000000 --- a/src/spatialcf/core/v2/objective_partition.py +++ /dev/null @@ -1,1058 +0,0 @@ -"""Verified, platform-neutral compilation of Canonical v2 objective bounds. - -The compiler accepts published upstream artifacts only as submissions. It -first freezes all four inputs, asks the independent replay verifier to resolve -the complete candidate/relation chain, and uses the byte-equal strict snapshots -as the data source for objective construction. No unverified resource counter, -relation vector, or domain ledger is used to make an objective claim. - -Objective cells are a one-to-one lift of verified relation cells. Each cell -contains conservative four-term loss intervals and complete safety slack -intervals. A nearest inner-domain edit may be returned as a proposal, but it -is deliberately not labelled feasible, optimal, or assigned a loss; those are -later verifier/certificate responsibilities. -""" - -from __future__ import annotations - -import math -import warnings -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction -from typing import ClassVar, TypeVar - -from pydantic import TypeAdapter, ValidationError - -from spatialcf.core.v2._internal.resources.domain_operations import ( - BaseUsageDomainOperationBudgetV2, -) -from spatialcf.core.v2.artifact_verifier import ( - CoreArtifactVerificationKindV2, - verify_core_artifacts_v2, -) -from spatialcf.core.v2.objective_numeric import ( - ConstraintSafetyInputV2, - ObjectiveIntervalOutcomeV2, - ObjectiveNumericKindV2, - TranslationCellOutcomeV2, - VisibilityMetricIntervalV2, - aggregate_relation_damage_bounds_v2, - aggregate_safety_penalty_bounds_v2, - aggregate_visibility_change_bounds_v2, - compile_translation_l2_cell_bounds_v2, -) -from spatialcf.core.v2.objective_safety_bounds import ( - ObjectiveSafetyBoundsKindV2, - ObjectiveSafetyBoundsOutcomeV2, - compile_objective_safety_bounds_v2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - ExactRectilinearRegionV2, - RectilinearAtomicBudgetExhaustedV2, - RectilinearAtomicBudgetV2, - RectilinearOutcomeKindV2, - RectilinearRegionOutcomeV2, - intersect_rectilinear_regions_v2, - lift_planar_region_v2, - normalize_rectilinear_region_v2, -) -from spatialcf.core.v2.relation_cost_partition import ( - RelationCostPartitionCompilationKindV2, - RelationCostPartitionCompilationOutcomeV2, -) -from spatialcf.domain.v2.artifacts import ( - ArtifactCoverageV2, - CandidateDomainArtifactV2, - CompilationResourceUsageV2, - ConstraintSlackV2, - NonNegativeIntervalV2, - ObjectivePartitionArtifactV2, - ObjectivePartitionCellV2, - ObjectiveTermBoundsV2, - PlanarRegionBoundV2, - RegionBoundStatusV2, - RelationCostPartitionV2, -) -from spatialcf.domain.v2.base import ( - CanonicalId, - FactAvailabilityV2, - FactCompletenessV2, - Sha256Digest, - V2Model, - Vec2V2, -) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.objective import ObjectCameraKeyV2, SafetySlackUnitV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import CoreSolverConfigV2, UncertifiedReasonV2 -from spatialcf.domain.v2.scene import BaselineObservationV2 - - -class ObjectivePartitionCompilationKindV2(StrEnum): - PARTITION = "PARTITION" - UNCERTIFIED = "UNCERTIFIED" - - -_CANONICAL_ID_ADAPTER = TypeAdapter(CanonicalId) -_SHA256_DIGEST_ADAPTER = TypeAdapter(Sha256Digest) -# Cell construction, artifact construction, explicit strict reconstruction, -# and outcome reconstruction each traverse the published vectors once. -_OBJECTIVE_PUBLICATION_VALIDATION_PASSES_V2 = 4 - - -@dataclass(frozen=True, slots=True) -class ObjectiveWitnessProposalV2: - """One unscored, unclaimed edit proposed from an exact inner-domain point.""" - - objective_cell_id: CanonicalId - parent_relation_cell_id: CanonicalId - edit: CanonicalEditV2 - - def __post_init__(self) -> None: - try: - objective_cell_id = _CANONICAL_ID_ADAPTER.validate_python( - self.objective_cell_id, - strict=True, - ) - parent_relation_cell_id = _CANONICAL_ID_ADAPTER.validate_python( - self.parent_relation_cell_id, - strict=True, - ) - except ValidationError as error: - raise TypeError("proposal cell IDs must be CanonicalId values") from error - if type(self.edit) is not CanonicalEditV2: - raise TypeError("proposal edit must be a CanonicalEditV2") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - edit = CanonicalEditV2.model_validate( - self.edit.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError("proposal edit must pass strict validation") from error - object.__setattr__(self, "objective_cell_id", objective_cell_id) - object.__setattr__( - self, - "parent_relation_cell_id", - parent_relation_cell_id, - ) - object.__setattr__(self, "edit", edit) - - -@dataclass(frozen=True, slots=True) -class ObjectivePartitionCompilationOutcomeV2: - """Closed objective-compilation result with one cumulative core ledger.""" - - kind: ObjectivePartitionCompilationKindV2 - objective_partition: ObjectivePartitionArtifactV2 | None = None - witness_proposals: tuple[ObjectiveWitnessProposalV2, ...] = () - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - cumulative_resource_usage: CompilationResourceUsageV2 | None = None - - def __post_init__(self) -> None: - if type(self.kind) is not ObjectivePartitionCompilationKindV2: - raise TypeError("kind must be an ObjectivePartitionCompilationKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be an exact tuple of non-blank strings") - if type(self.witness_proposals) is not tuple or any( - type(item) is not ObjectiveWitnessProposalV2 - for item in self.witness_proposals - ): - raise TypeError("witness_proposals must be an exact proposal tuple") - findings = tuple(sorted(set(self.finding_codes))) - proposals = tuple( - ObjectiveWitnessProposalV2( - objective_cell_id=item.objective_cell_id, - parent_relation_cell_id=item.parent_relation_cell_id, - edit=item.edit, - ) - for item in self.witness_proposals - ) - proposal_ids = tuple(item.objective_cell_id for item in proposals) - if len(proposal_ids) != len(set(proposal_ids)): - raise ValueError("witness proposals must have unique objective cell IDs") - proposals = tuple(sorted(proposals, key=lambda item: item.objective_cell_id)) - - artifact = self.objective_partition - if artifact is not None: - if type(artifact) is not ObjectivePartitionArtifactV2: - raise TypeError( - "objective_partition must be an ObjectivePartitionArtifactV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - artifact = ObjectivePartitionArtifactV2.model_validate( - artifact.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "objective_partition must pass strict validation" - ) from error - usage = self.cumulative_resource_usage - if usage is not None: - if type(usage) is not CompilationResourceUsageV2: - raise TypeError( - "cumulative_resource_usage must be a CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "cumulative_resource_usage must pass strict validation" - ) from error - - object.__setattr__(self, "finding_codes", findings) - object.__setattr__(self, "witness_proposals", proposals) - object.__setattr__(self, "objective_partition", artifact) - object.__setattr__(self, "cumulative_resource_usage", usage) - - if self.kind is ObjectivePartitionCompilationKindV2.PARTITION: - if artifact is None or usage is None: - raise ValueError("PARTITION requires an artifact and cumulative usage") - if self.uncertified_reason is not None or findings: - raise ValueError("PARTITION cannot carry failure diagnostics") - artifact_ids = {cell.cell_id for cell in artifact.cells} - relation_by_objective = { - cell.cell_id: cell.parent_relation_cell_id for cell in artifact.cells - } - if not set(proposal_ids) <= artifact_ids: - raise ValueError("proposal references an unknown objective cell") - if any( - relation_by_objective[item.objective_cell_id] - != item.parent_relation_cell_id - for item in proposals - ): - raise ValueError( - "proposal parent relation cell reference is not closed" - ) - return - - if artifact is not None or proposals: - raise ValueError("UNCERTIFIED cannot carry partial artifacts or proposals") - if type(self.uncertified_reason) is not UncertifiedReasonV2: - raise TypeError("UNCERTIFIED requires an UncertifiedReasonV2") - if not findings: - raise ValueError("UNCERTIFIED requires at least one finding") - - -ObjectivePartitionCompileOutcomeV2 = ObjectivePartitionCompilationOutcomeV2 - - -class _ResourceLimitV2(RuntimeError): - pass - - -@dataclass(slots=True) -class _DomainOperationBudgetV2(BaseUsageDomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = _ResourceLimitV2 - _exhaustion_error_message: ClassVar[str] = "RESOURCE_LIMIT:max_domain_operations" - - -class _UnsupportedV2(RuntimeError): - def __init__(self, *finding_codes: str) -> None: - self.finding_codes = tuple(sorted(set(finding_codes))) - super().__init__("|".join(self.finding_codes)) - - -class _NumericGapV2(RuntimeError): - def __init__(self, *finding_codes: str) -> None: - self.finding_codes = tuple(sorted(set(finding_codes))) - super().__init__("|".join(self.finding_codes)) - - -class _CompilationIncompleteV2(RuntimeError): - def __init__(self, *finding_codes: str) -> None: - self.finding_codes = tuple(sorted(set(finding_codes))) - super().__init__("|".join(self.finding_codes)) - - -class _InvalidInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _NumericInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def compile_objective_partition_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionCompilationOutcomeV2, -) -> ObjectivePartitionCompilationOutcomeV2: - """Compile objective bounds only after complete independent artifact replay.""" - - try: - checked_problem = _strict_model(problem, SemanticProblemV2, "SEMANTIC_PROBLEM") - checked_config = _strict_model(config, CoreSolverConfigV2, "CORE_SOLVER_CONFIG") - checked_candidate = _strict_model( - candidate, - CandidateDomainArtifactV2, - "CANDIDATE_DOMAIN", - ) - checked_relation = _strict_relation_outcome(relation) - except _NumericInputV2 as error: - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - error.finding_code, - ) - except _InvalidInputV2 as error: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - verification = verify_core_artifacts_v2( - checked_problem, - checked_config, - checked_candidate, - checked_relation, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:OBJECTIVE_ARTIFACT_VERIFICATION", - ) - - if verification.kind is not CoreArtifactVerificationKindV2.VERIFIED: - reason = ( - verification.uncertified_reason - if verification.kind is CoreArtifactVerificationKindV2.UNCERTIFIED - else UncertifiedReasonV2.COMPILATION_INCOMPLETE - ) - return _uncertified( - reason or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(verification.finding_codes or ("OBJECTIVE_UPSTREAM_NOT_VERIFIED",)), - cumulative_resource_usage=verification.verification_resource_usage, - ) - base_usage = verification.verification_resource_usage - if base_usage is None: # pragma: no cover - verifier outcome invariant - raise RuntimeError("VERIFIED upstream outcome omitted resource usage") - - # The verifier proved the strict snapshots byte-for-byte identical to its - # one candidate/relation replay. Replaying yet again here would perform - # unledgered work; the verified snapshots are therefore the closed source. - if ( - checked_relation.kind is not RelationCostPartitionCompilationKindV2.PARTITION - or checked_relation.relation_cost_partition is None - ): - raise RuntimeError("VERIFIED relation snapshot omitted its partition") - verified_relation = checked_relation.relation_cost_partition - _require_verified_replay_closure( - checked_problem, - checked_config, - checked_candidate, - verified_relation, - verification.semantic_problem_sha256, - verification.core_solver_config_sha256, - verification.candidate_domain_artifact_sha256, - verification.relation_cost_partition_sha256, - ) - - domain_budget = _DomainOperationBudgetV2( - limit=checked_config.max_domain_operations, - base_used=base_usage.domain_operations, - ) - atomic_budget = RectilinearAtomicBudgetV2( - limit=checked_config.max_partition_cells, - used=base_usage.partition_cells, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return _compile_verified_partition( - checked_problem, - checked_config, - checked_candidate, - verified_relation, - base_usage, - domain_budget, - atomic_budget, - ) - except (RectilinearAtomicBudgetExhaustedV2, _ResourceLimitV2) as error: - finding = str(error) or "RESOURCE_LIMIT:max_partition_cells" - return _uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - finding, - cumulative_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - except _NumericGapV2 as error: - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - *error.finding_codes, - cumulative_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - except _UnsupportedV2 as error: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - *error.finding_codes, - cumulative_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - except _CompilationIncompleteV2 as error: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *error.finding_codes, - cumulative_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:OBJECTIVE_PARTITION", - cumulative_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - - -class ObjectivePartitionCompilerV2: - """Stateless object wrapper for pipeline composition.""" - - def compile( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionCompilationOutcomeV2, - ) -> ObjectivePartitionCompilationOutcomeV2: - return compile_objective_partition_v2(problem, config, candidate, relation) - - -def _compile_verified_partition( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionV2, - base_usage: CompilationResourceUsageV2, - domain_budget: _DomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ObjectivePartitionCompilationOutcomeV2: - if ( - relation.cell_outer_union_coverage - is not ArtifactCoverageV2.EXACT_OUTER_COVERAGE - ): - raise _CompilationIncompleteV2( - "COMPILATION_INCOMPLETE:RELATION_CELL_OUTER_COVERAGE" - ) - if not relation.cells: - raise _CompilationIncompleteV2("COMPILATION_INCOMPLETE:NO_RELATION_CELLS") - - safety_target_count = len(problem.objective.safety_margin.aggregation.targets) - # Reserve every O(cells * (keys + targets)) publication pass before any - # nested cell/vector construction. A cap miss therefore performs no - # partial publication work and emits no partial artifact. - publication_units = _OBJECTIVE_PUBLICATION_VALIDATION_PASSES_V2 * sum( - 1 + len(cell.relation_damage_vector) + safety_target_count - for cell in relation.cells - ) - domain_budget.consume(publication_units) - - candidate_outer = _lift_bound( - candidate.hard_domain.outer_bound, - domain_budget, - atomic_budget, - ) - exact_cells: list[ - tuple[ExactRectilinearRegionV2 | None, ExactRectilinearRegionV2] - ] = [] - # This is a separate pass over cells for coverage/topology compilation. - domain_budget.consume(len(relation.cells)) - for relation_cell in relation.cells: - atomic_budget.consume(1) - inner = _lift_optional_inner_bound( - relation_cell.domain.inner_bound, - domain_budget, - atomic_budget, - ) - outer = _lift_bound( - relation_cell.domain.outer_bound, - domain_budget, - atomic_budget, - ) - if inner is not None: - domain_budget.consume() - intersection = _require_exact_region( - intersect_rectilinear_regions_v2( - inner, - outer, - atomic_budget=atomic_budget, - ) - ) - if intersection != inner: - raise _UnsupportedV2("INVALID_RELATION_CELL:INNER_NOT_SUBSET_OUTER") - exact_cells.append((inner, outer)) - - domain_budget.consume() - outer_union = _require_exact_region( - normalize_rectilinear_region_v2( - tuple( - rectangle for _, outer in exact_cells for rectangle in outer.rectangles - ), - atomic_budget=atomic_budget, - ) - ) - if outer_union != candidate_outer: - raise _CompilationIncompleteV2( - "COMPILATION_INCOMPLETE:RELATION_CELL_OUTER_UNION" - ) - - visibility_intervals = _visibility_intervals(problem, domain_budget) - domain_budget.consume(len(visibility_intervals)) - visibility_loss = _require_exact_interval( - aggregate_visibility_change_bounds_v2( - problem.objective.visibility_change, - visibility_intervals, - ), - "VISIBILITY_CHANGE", - ) - - cells: list[ObjectivePartitionCellV2] = [] - proposals: list[ObjectiveWitnessProposalV2] = [] - domain_budget.consume(len(relation.cells)) - for index, (relation_cell, exact_pair) in enumerate( - zip(relation.cells, exact_cells, strict=True) - ): - inner, outer = exact_pair - translation = _require_exact_translation( - compile_translation_l2_cell_bounds_v2( - outer, - problem.objective.translation, - atomic_budget=atomic_budget, - ) - ) - domain_budget.consume(len(relation_cell.relation_damage_vector)) - relation_loss = _require_exact_interval( - aggregate_relation_damage_bounds_v2( - problem.objective.relation_damage, - relation_cell.relation_damage_vector, - ), - "RELATION_DAMAGE", - ) - domain_budget.consume(safety_target_count) - safety = _require_exact_safety( - compile_objective_safety_bounds_v2( - problem, - outer, - atomic_budget=atomic_budget, - ) - ) - domain_budget.consume(safety_target_count) - safety_loss = _require_exact_interval( - aggregate_safety_penalty_bounds_v2( - problem.objective.safety_margin, - tuple( - ConstraintSafetyInputV2( - constraint_id=item.constraint_id, - components=item.raw_components, - ) - for item in safety.constraint_bounds - ), - ), - "SAFETY_MARGIN", - ) - - objective_cell_id = f"cell:objective:{index:06d}" - term_loss_bounds = _build_term_bounds( - NonNegativeIntervalV2( - lower_bound=translation.bounds.lower_bound, - upper_bound=translation.bounds.upper_bound, - ), - _published_nonnegative(relation_loss), - _published_nonnegative(visibility_loss), - _published_nonnegative(safety_loss), - ) - cells.append( - ObjectivePartitionCellV2( - cell_id=objective_cell_id, - parent_relation_cell_id=relation_cell.cell_id, - domain=relation_cell.domain, - relation_damage_vector=relation_cell.relation_damage_vector, - term_loss_bounds=term_loss_bounds, - constraint_slacks=tuple( - ConstraintSlackV2( - constraint_id=item.constraint_id, - lower_bound=item.normalized_slack.lower_bound, - upper_bound=item.normalized_slack.upper_bound, - unit=SafetySlackUnitV2.DIMENSIONLESS, - ) - for item in safety.constraint_bounds - ), - ) - ) - - if inner is not None: - proposal = _inner_nearest_proposal( - problem, - objective_cell_id, - relation_cell.cell_id, - inner, - atomic_budget, - ) - if proposal is not None: - proposals.append(proposal) - - artifact = ObjectivePartitionArtifactV2( - semantic_problem_sha256=problem.semantic_problem_sha256, - core_solver_config_sha256=config.core_solver_config_sha256, - candidate_domain_artifact_sha256=candidate.candidate_domain_artifact_sha256, - relation_cost_partition_sha256=relation.relation_cost_partition_sha256, - objective_spec_sha256=problem.objective.objective_spec_sha256, - cells=tuple(cells), - cell_outer_union_coverage=ArtifactCoverageV2.EXACT_OUTER_COVERAGE, - ) - checked_artifact = ObjectivePartitionArtifactV2.model_validate( - artifact.model_dump(mode="python"), - strict=True, - ) - # Reassert the one-to-one lift after schema canonicalization. - expected_mapping = tuple( - ( - f"cell:objective:{index:06d}", - cell.cell_id, - cell.domain, - cell.relation_damage_vector, - ) - for index, cell in enumerate(relation.cells) - ) - actual_mapping = tuple( - ( - cell.cell_id, - cell.parent_relation_cell_id, - cell.domain, - cell.relation_damage_vector, - ) - for cell in checked_artifact.cells - ) - if actual_mapping != expected_mapping: - raise RuntimeError("objective/relation cell mapping invariant drift") - return ObjectivePartitionCompilationOutcomeV2( - kind=ObjectivePartitionCompilationKindV2.PARTITION, - objective_partition=checked_artifact, - witness_proposals=tuple(proposals), - cumulative_resource_usage=_cumulative_usage( - base_usage, - domain_budget, - atomic_budget, - ), - ) - - -def _strict_model( - value: object, - model_type: type[ModelT], - label: str, -) -> ModelT: - if type(value) is not model_type: - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), - strict=True, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _strict_relation_outcome( - value: object, -) -> RelationCostPartitionCompilationOutcomeV2: - label = "RELATION_COST_PARTITION_OUTCOME" - if type(value) is not RelationCostPartitionCompilationOutcomeV2: - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - partition = value.relation_cost_partition - usage = value.cumulative_resource_usage - if partition is not None and type(partition) is not RelationCostPartitionV2: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") - if usage is not None and type(usage) is not CompilationResourceUsageV2: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") - if type(value.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in value.finding_codes - ): - raise _InvalidInputV2(f"INVALID_INPUT:{label}") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - if partition is not None: - partition = RelationCostPartitionV2.model_validate( - partition.model_dump(mode="python"), - strict=True, - ) - if usage is not None: - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - return RelationCostPartitionCompilationOutcomeV2( - kind=value.kind, - relation_cost_partition=partition, - uncertified_reason=value.uncertified_reason, - finding_codes=tuple(value.finding_codes), - cumulative_resource_usage=usage, - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _require_verified_replay_closure( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionV2, - problem_hash: str | None, - config_hash: str | None, - candidate_hash: str | None, - relation_hash: str | None, -) -> None: - expected = ( - problem.semantic_problem_sha256, - config.core_solver_config_sha256, - candidate.candidate_domain_artifact_sha256, - relation.relation_cost_partition_sha256, - ) - observed = (problem_hash, config_hash, candidate_hash, relation_hash) - if observed != expected: - raise RuntimeError("verified replay hash closure drift") - for value in expected: - _SHA256_DIGEST_ADAPTER.validate_python(value, strict=True) - - -def _lift_bound( - bound: PlanarRegionBoundV2, - domain_budget: _DomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ExactRectilinearRegionV2: - domain_budget.consume() - if bound.status is RegionBoundStatusV2.EMPTY: - return _require_exact_region( - normalize_rectilinear_region_v2((), atomic_budget=atomic_budget) - ) - if bound.status is not RegionBoundStatusV2.NON_EMPTY or bound.region is None: - raise _CompilationIncompleteV2("COMPILATION_INCOMPLETE:UNAVAILABLE_CELL_BOUND") - return _require_exact_region( - lift_planar_region_v2(bound.region, atomic_budget=atomic_budget) - ) - - -def _lift_optional_inner_bound( - bound: PlanarRegionBoundV2, - domain_budget: _DomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ExactRectilinearRegionV2 | None: - domain_budget.consume() - if bound.status is RegionBoundStatusV2.UNAVAILABLE: - return None - if bound.status is RegionBoundStatusV2.EMPTY: - return _require_exact_region( - normalize_rectilinear_region_v2((), atomic_budget=atomic_budget) - ) - if bound.status is not RegionBoundStatusV2.NON_EMPTY or bound.region is None: - raise _CompilationIncompleteV2("COMPILATION_INCOMPLETE:INVALID_INNER_BOUND") - return _require_exact_region( - lift_planar_region_v2(bound.region, atomic_budget=atomic_budget) - ) - - -def _require_exact_region( - outcome: RectilinearRegionOutcomeV2, -) -> ExactRectilinearRegionV2: - if outcome.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - raise _ResourceLimitV2("RESOURCE_LIMIT:max_partition_cells") - if outcome.kind is not RectilinearOutcomeKindV2.EXACT or outcome.region is None: - raise _UnsupportedV2(*(outcome.finding_codes or ("UNSUPPORTED_CELL_REGION",))) - return outcome.region - - -def _visibility_intervals( - problem: SemanticProblemV2, - domain_budget: _DomainOperationBudgetV2, -) -> tuple[VisibilityMetricIntervalV2, ...]: - facts = problem.scene.baseline_observations - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.values is None - ): - raise _CompilationIncompleteV2( - "COMPILATION_INCOMPLETE:VISIBILITY_BASELINE_NOT_EXACT" - ) - domain_budget.consume(len(facts.values)) - observations: dict[tuple[str, str, str, str], BaselineObservationV2] = {} - for observation in facts.values: - key = ( - observation.object_id, - observation.camera_id, - observation.metric_definition_id, - observation.metric_definition_version, - ) - if key in observations: - raise _UnsupportedV2("INVALID_VISIBILITY_BASELINE:DUPLICATE_KEY") - observations[key] = observation - - intervals: list[VisibilityMetricIntervalV2] = [] - domain_budget.consume( - len(problem.objective.visibility_change.object_camera_weights) - ) - for weighted in problem.objective.visibility_change.object_camera_weights: - key = weighted.key - semantic_key = ( - key.object_id, - key.camera_id, - key.metric_definition_id, - key.metric_definition_version, - ) - observation = observations.get(semantic_key) - if observation is None: - raise _CompilationIncompleteV2( - "COMPILATION_INCOMPLETE:VISIBILITY_BASELINE_KEY" - ) - intervals.append( - VisibilityMetricIntervalV2( - key=ObjectCameraKeyV2.model_validate( - key.model_dump(mode="python"), - strict=True, - ), - baseline_lower=Fraction.from_float(observation.normalized_lower_bound), - baseline_upper=Fraction.from_float(observation.normalized_upper_bound), - # Candidate visibility is deliberately not guessed from a - # platform renderer. [0, 1] is the sound semantic range. - candidate_lower=Fraction(), - candidate_upper=Fraction(1), - ) - ) - return tuple(intervals) - - -def _require_exact_translation( - outcome: TranslationCellOutcomeV2, -) -> TranslationCellOutcomeV2: - if outcome.kind is ObjectiveNumericKindV2.EXACT and outcome.bounds is not None: - return outcome - _raise_numeric_outcome( - outcome.kind, - outcome.finding_codes, - "TRANSLATION", - ) - raise AssertionError("unreachable") - - -def _require_exact_interval( - outcome: ObjectiveIntervalOutcomeV2, - label: str, -) -> ObjectiveIntervalOutcomeV2: - if outcome.kind is ObjectiveNumericKindV2.EXACT and outcome.interval is not None: - return outcome - _raise_numeric_outcome(outcome.kind, outcome.finding_codes, label) - raise AssertionError("unreachable") - - -def _raise_numeric_outcome( - kind: ObjectiveNumericKindV2, - findings: tuple[str, ...], - label: str, -) -> None: - codes = findings or (f"OBJECTIVE_{label}:{kind.value}",) - if kind is ObjectiveNumericKindV2.NUMERIC_GAP: - raise _NumericGapV2(*codes) - if kind is ObjectiveNumericKindV2.RESOURCE_LIMIT: - raise _ResourceLimitV2(*codes) - if kind is ObjectiveNumericKindV2.EMPTY: - raise _CompilationIncompleteV2(*codes) - if kind is ObjectiveNumericKindV2.INVALID_INPUT: - raise _UnsupportedV2(*codes) - raise RuntimeError(f"unknown objective numeric outcome: {kind!r}") - - -def _require_exact_safety( - outcome: ObjectiveSafetyBoundsOutcomeV2, -) -> ObjectiveSafetyBoundsOutcomeV2: - if outcome.kind is ObjectiveSafetyBoundsKindV2.EXACT: - return outcome - findings = outcome.finding_codes or (f"OBJECTIVE_SAFETY:{outcome.kind.value}",) - if outcome.kind is ObjectiveSafetyBoundsKindV2.NUMERIC_GAP: - raise _NumericGapV2(*findings) - if outcome.kind is ObjectiveSafetyBoundsKindV2.RESOURCE: - raise _ResourceLimitV2(*findings) - if outcome.kind is ObjectiveSafetyBoundsKindV2.EMPTY: - raise _CompilationIncompleteV2(*findings) - if outcome.kind is ObjectiveSafetyBoundsKindV2.UNSUPPORTED: - raise _UnsupportedV2(*findings) - raise RuntimeError(f"unknown objective safety outcome: {outcome.kind!r}") - - -def _published_nonnegative( - outcome: ObjectiveIntervalOutcomeV2, -) -> NonNegativeIntervalV2: - interval = outcome.interval - if interval is None: # pragma: no cover - exact-outcome invariant - raise RuntimeError("EXACT objective interval omitted its value") - if interval.lower_bound < 0.0: - raise RuntimeError("non-negative objective term published a negative bound") - return NonNegativeIntervalV2( - lower_bound=interval.lower_bound, - upper_bound=interval.upper_bound, - ) - - -def _build_term_bounds( - translation: NonNegativeIntervalV2, - relation: NonNegativeIntervalV2, - visibility: NonNegativeIntervalV2, - safety: NonNegativeIntervalV2, -) -> ObjectiveTermBoundsV2: - intervals = (translation, relation, visibility, safety) - if not _directed_total_is_finite( - tuple(item.lower_bound for item in intervals), - upward=False, - ) or not _directed_total_is_finite( - tuple(item.upper_bound for item in intervals), - upward=True, - ): - raise _NumericGapV2("NUMERIC_GAP:OBJECTIVE_TERM_TOTAL") - return ObjectiveTermBoundsV2( - translation_loss=translation, - relation_damage_loss=relation, - visibility_change_loss=visibility, - safety_margin_loss=safety, - ) - - -def _directed_total_is_finite( - values: tuple[float, ...], - *, - upward: bool, -) -> bool: - exact = sum((Fraction.from_float(value) for value in values), Fraction()) - try: - published = float(exact) - except OverflowError: - return False - if not math.isfinite(published): - return False - published_exact = Fraction.from_float(published) - if upward and published_exact < exact: - published = math.nextafter(published, math.inf) - elif not upward and published_exact > exact: - published = math.nextafter(published, -math.inf) - return math.isfinite(published) - - -def _inner_nearest_proposal( - problem: SemanticProblemV2, - objective_cell_id: str, - parent_relation_cell_id: str, - inner: ExactRectilinearRegionV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ObjectiveWitnessProposalV2 | None: - if not inner.rectangles: - return None - nearest = _require_exact_translation( - compile_translation_l2_cell_bounds_v2( - inner, - problem.objective.translation, - atomic_budget=atomic_budget, - ) - ) - bounds = nearest.bounds - if bounds is None: # pragma: no cover - exact-outcome invariant - raise RuntimeError("EXACT inner translation omitted its witness") - delta_x = _exact_binary64(bounds.nearest_delta_x_m) - delta_y = _exact_binary64(bounds.nearest_delta_y_m) - if delta_x is None or delta_y is None: - return None - # Membership is a new linear pass, distinct from the nearest-point pass. - atomic_budget.consume(len(inner.rectangles)) - if not inner.contains_point( - Fraction.from_float(delta_x), - Fraction.from_float(delta_y), - ): - raise RuntimeError("inner nearest proposal escaped its exact cell") - return ObjectiveWitnessProposalV2( - objective_cell_id=objective_cell_id, - parent_relation_cell_id=parent_relation_cell_id, - edit=CanonicalEditV2( - semantic_problem_sha256=problem.semantic_problem_sha256, - subject_id=problem.constraints.allowed_edit.subject_id, - translation_xy_m=Vec2V2(x=delta_x, y=delta_y), - ), - ) - - -def _exact_binary64(value: Fraction) -> float | None: - try: - published = float(value) - except OverflowError: - return None - if not math.isfinite(published) or Fraction.from_float(published) != value: - return None - return 0.0 if published == 0.0 else published - - -def _cumulative_usage( - base_usage: CompilationResourceUsageV2, - domain_budget: _DomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=base_usage.domain_operations + domain_budget.used, - partition_cells=atomic_budget.used, - refinement_steps=base_usage.refinement_steps, - ) - - -def _uncertified( - reason: UncertifiedReasonV2, - *findings: str, - cumulative_resource_usage: CompilationResourceUsageV2 | None = None, -) -> ObjectivePartitionCompilationOutcomeV2: - return ObjectivePartitionCompilationOutcomeV2( - kind=ObjectivePartitionCompilationKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=tuple(findings), - cumulative_resource_usage=cumulative_resource_usage, - ) diff --git a/src/spatialcf/core/v2/point_objective.py b/src/spatialcf/core/v2/point_objective.py deleted file mode 100644 index 2c91455..0000000 --- a/src/spatialcf/core/v2/point_objective.py +++ /dev/null @@ -1,1333 +0,0 @@ -"""Fresh-replay objective bounds for one concrete Canonical edit. - -The public entry accepts only the semantic problem, solver configuration, and -edit. Candidate, relation, and objective artifacts are regenerated inside the -pure core. A bounded point loss is published only after the independent -positive inner-domain feasibility proof succeeds; objective proposals and -submitted artifacts are never accepted as proof inputs. -""" - -from __future__ import annotations - -import math -import warnings -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction -from typing import TypeVar - -from pydantic import TypeAdapter, ValidationError - -from spatialcf.core.v2.candidate_domain import ( - CandidateDomainCompilationOutcomeV2, - CandidateDomainCompilerV2, -) -from spatialcf.core.v2.edit_feasibility import ( - CanonicalEditFeasibilityKindV2, - CanonicalEditFeasibilityVerificationOutcomeV2, - _contains_point_with_budget, - _DomainOperationBudgetExhaustedV2, - _EditFeasibilityDomainBudgetV2, - _verify_replayed_candidate_edit_membership_v2, -) -from spatialcf.core.v2.objective_numeric import ( - ConstraintSafetyInputV2, - ObjectiveIntervalOutcomeV2, - ObjectiveNumericKindV2, - TranslationCellOutcomeV2, - VisibilityMetricIntervalV2, - aggregate_relation_damage_bounds_v2, - aggregate_safety_penalty_bounds_v2, - aggregate_visibility_change_bounds_v2, - compile_translation_l2_cell_bounds_v2, -) -from spatialcf.core.v2.objective_partition import ( - ObjectivePartitionCompilationKindV2, - ObjectivePartitionCompilationOutcomeV2, - _compile_verified_partition, -) -from spatialcf.core.v2.objective_partition import ( - _CompilationIncompleteV2 as _ObjectiveCompilationIncompleteV2, -) -from spatialcf.core.v2.objective_partition import ( - _DomainOperationBudgetV2 as _ObjectiveDomainOperationBudgetV2, -) -from spatialcf.core.v2.objective_partition import ( - _NumericGapV2 as _ObjectiveNumericGapV2, -) -from spatialcf.core.v2.objective_partition import ( - _ResourceLimitV2 as _ObjectiveResourceLimitV2, -) -from spatialcf.core.v2.objective_partition import ( - _UnsupportedV2 as _ObjectiveUnsupportedV2, -) -from spatialcf.core.v2.objective_safety_bounds import ( - ObjectiveSafetyBoundsKindV2, - ObjectiveSafetyBoundsOutcomeV2, - compile_objective_safety_bounds_v2, -) -from spatialcf.core.v2.rect_kernel import ( - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - ExactRectilinearRegionV2, - RectilinearAtomicBudgetExhaustedV2, - RectilinearAtomicBudgetV2, - RectilinearOutcomeKindV2, - RectilinearRegionOutcomeV2, - lift_planar_region_v2, - normalize_rectilinear_region_v2, -) -from spatialcf.core.v2.relation_cost_partition import ( - RelationCostPartitionCompilationKindV2, - RelationCostPartitionCompilationOutcomeV2, - compile_relation_cost_partition_v2, -) -from spatialcf.domain.v2.artifacts import ( - CandidateDomainArtifactV2, - CompilationResourceUsageV2, - ConstraintSlackV2, - NonNegativeIntervalV2, - ObjectivePartitionArtifactV2, - ObjectivePartitionCellV2, - ObjectiveTermBoundsV2, - RegionBoundStatusV2, - RelationCostCellV2, - RelationCostPartitionV2, - RelationDamageBoundV2, -) -from spatialcf.domain.v2.base import ( - CanonicalId, - FactAvailabilityV2, - FactCompletenessV2, - Sha256Digest, - V2Model, -) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.objective import ObjectCameraKeyV2, SafetySlackUnitV2 -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import CoreSolverConfigV2, UncertifiedReasonV2 -from spatialcf.domain.v2.scene import BaselineObservationV2 - - -class PointObjectiveEvaluationKindV2(StrEnum): - BOUNDED_FEASIBLE = "BOUNDED_FEASIBLE" - NOT_PROVEN = "NOT_PROVEN" - UNCERTIFIED = "UNCERTIFIED" - - -_SHA256_ADAPTER = TypeAdapter(Sha256Digest) -_CANONICAL_ID_ADAPTER = TypeAdapter(CanonicalId) - - -@dataclass(frozen=True, slots=True) -class PointObjectiveEvaluationOutcomeV2: - """Closed result for one independently replayed feasible point.""" - - kind: PointObjectiveEvaluationKindV2 - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - candidate_domain_artifact_sha256: Sha256Digest | None = None - relation_cost_partition_sha256: Sha256Digest | None = None - objective_partition_artifact_sha256: Sha256Digest | None = None - canonical_edit_sha256: Sha256Digest | None = None - witness_loss_bounds: ObjectiveTermBoundsV2 | None = None - relation_damage_vector: tuple[RelationDamageBoundV2, ...] = () - constraint_slacks: tuple[ConstraintSlackV2, ...] = () - covering_objective_cell_ids: tuple[CanonicalId, ...] = () - covering_relation_cell_ids: tuple[CanonicalId, ...] = () - cumulative_generation_usage: CompilationResourceUsageV2 | None = None - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not PointObjectiveEvaluationKindV2: - raise TypeError("kind must be a PointObjectiveEvaluationKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be exact non-blank strings") - if type(self.relation_damage_vector) is not tuple or any( - type(item) is not RelationDamageBoundV2 - for item in self.relation_damage_vector - ): - raise TypeError("relation_damage_vector must be an exact bound tuple") - if type(self.constraint_slacks) is not tuple or any( - type(item) is not ConstraintSlackV2 for item in self.constraint_slacks - ): - raise TypeError("constraint_slacks must be an exact slack tuple") - - findings = tuple(sorted(set(self.finding_codes))) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - vector = tuple( - RelationDamageBoundV2.model_validate( - item.model_dump(mode="python"), - strict=True, - ) - for item in self.relation_damage_vector - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "relation_damage_vector must pass strict validation" - ) from error - vector = tuple(sorted(vector, key=lambda item: item.key.sort_key)) - if len({item.key for item in vector}) != len(vector): - raise ValueError("point relation vector keys must be unique") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - slacks = tuple( - ConstraintSlackV2.model_validate( - item.model_dump(mode="python"), - strict=True, - ) - for item in self.constraint_slacks - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError("constraint_slacks must pass strict validation") from error - slacks = tuple(sorted(slacks, key=lambda item: item.constraint_id)) - if len({item.constraint_id for item in slacks}) != len(slacks): - raise ValueError("point constraint slack IDs must be unique") - objective_ids = _strict_ids( - self.covering_objective_cell_ids, - "covering_objective_cell_ids", - ) - relation_ids = _strict_ids( - self.covering_relation_cell_ids, - "covering_relation_cell_ids", - ) - loss = self.witness_loss_bounds - if loss is not None: - if type(loss) is not ObjectiveTermBoundsV2: - raise TypeError("witness_loss_bounds must be ObjectiveTermBoundsV2") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - loss = ObjectiveTermBoundsV2.model_validate( - loss.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "witness_loss_bounds must pass strict validation" - ) from error - usage = self.cumulative_generation_usage - if usage is not None: - if type(usage) is not CompilationResourceUsageV2: - raise TypeError( - "cumulative_generation_usage must be CompilationResourceUsageV2" - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "cumulative_generation_usage must pass strict validation" - ) from error - references: list[str | None] = [] - for field_name in ( - "semantic_problem_sha256", - "core_solver_config_sha256", - "candidate_domain_artifact_sha256", - "relation_cost_partition_sha256", - "objective_partition_artifact_sha256", - "canonical_edit_sha256", - ): - value = getattr(self, field_name) - if value is not None: - value = _SHA256_ADAPTER.validate_python(value, strict=True) - object.__setattr__(self, field_name, value) - references.append(value) - - object.__setattr__(self, "finding_codes", findings) - object.__setattr__(self, "relation_damage_vector", vector) - object.__setattr__(self, "constraint_slacks", slacks) - object.__setattr__(self, "covering_objective_cell_ids", objective_ids) - object.__setattr__(self, "covering_relation_cell_ids", relation_ids) - object.__setattr__(self, "witness_loss_bounds", loss) - object.__setattr__(self, "cumulative_generation_usage", usage) - - if self.kind is PointObjectiveEvaluationKindV2.BOUNDED_FEASIBLE: - if any(value is None for value in references): - raise ValueError("BOUNDED_FEASIBLE requires all replay hashes") - if ( - loss is None - or not vector - or not slacks - or not objective_ids - or not relation_ids - or usage is None - ): - raise ValueError("BOUNDED_FEASIBLE requires complete point bounds") - if self.uncertified_reason is not None or findings: - raise ValueError("BOUNDED_FEASIBLE cannot carry failure diagnostics") - return - - if any(value is not None for value in references): - raise ValueError("non-success outcomes cannot carry replay hashes") - if loss is not None or vector or slacks or objective_ids or relation_ids: - raise ValueError("non-success outcomes cannot carry partial point bounds") - if not findings: - raise ValueError(f"{self.kind.value} requires at least one finding") - if self.kind is PointObjectiveEvaluationKindV2.NOT_PROVEN: - if self.uncertified_reason is not None: - raise ValueError("NOT_PROVEN cannot carry an uncertified reason") - return - if type(self.uncertified_reason) is not UncertifiedReasonV2: - raise TypeError("UNCERTIFIED requires an UncertifiedReasonV2") - - -PointObjectiveOutcomeV2 = PointObjectiveEvaluationOutcomeV2 - - -@dataclass(frozen=True, slots=True) -class _PointObjectiveReplayContextV2: - problem: SemanticProblemV2 - config: CoreSolverConfigV2 - candidate: CandidateDomainArtifactV2 - relation: RelationCostPartitionV2 - objective: ObjectivePartitionArtifactV2 - domain_budget: _EditFeasibilityDomainBudgetV2 - atomic_budget: RectilinearAtomicBudgetV2 - replay_usage: CompilationResourceUsageV2 - - -class _PointResourceLimitV2(RuntimeError): - pass - - -class _PointNumericGapV2(RuntimeError): - def __init__(self, *finding_codes: str) -> None: - self.finding_codes = tuple(sorted(set(finding_codes))) - super().__init__("|".join(self.finding_codes)) - - -class _PointUnsupportedV2(RuntimeError): - def __init__(self, *finding_codes: str) -> None: - self.finding_codes = tuple(sorted(set(finding_codes))) - super().__init__("|".join(self.finding_codes)) - - -class _PointIncompleteV2(RuntimeError): - def __init__(self, *finding_codes: str) -> None: - self.finding_codes = tuple(sorted(set(finding_codes))) - super().__init__("|".join(self.finding_codes)) - - -class _InvalidInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _NumericInputV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -ModelT = TypeVar("ModelT", bound=V2Model) - - -def evaluate_point_objective_v2( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - edit: CanonicalEditV2, -) -> PointObjectiveEvaluationOutcomeV2: - """Freshly compile and bound the objective at one positively proven edit.""" - - try: - checked_problem = _strict_model(problem, SemanticProblemV2, "SEMANTIC_PROBLEM") - checked_config = _strict_model(config, CoreSolverConfigV2, "CORE_SOLVER_CONFIG") - checked_edit = _strict_model(edit, CanonicalEditV2, "CANONICAL_EDIT") - except _NumericInputV2 as error: - return _uncertified(UncertifiedReasonV2.NUMERIC_GAP, error.finding_code) - except _InvalidInputV2 as error: - return _uncertified(UncertifiedReasonV2.UNSUPPORTED_MODEL, error.finding_code) - - reference_findings = _edit_reference_findings(checked_problem, checked_edit) - if reference_findings: - return _not_proven(*reference_findings) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - candidate_outcome = CandidateDomainCompilerV2().compile( - checked_problem, - checked_config, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:POINT_CANDIDATE_REPLAY", - ) - if type(candidate_outcome) is not CandidateDomainCompilationOutcomeV2: - raise TypeError("candidate compiler returned an invalid internal outcome") - candidate = candidate_outcome.candidate_domain - if candidate is None: - return _uncertified( - candidate_outcome.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(candidate_outcome.finding_codes or ("POINT_REPLAY_NO_CANDIDATE",)), - ) - _require_candidate_closure(checked_problem, checked_config, candidate) - if candidate_outcome.uncertified_reason is not None: - return _uncertified( - candidate_outcome.uncertified_reason, - *(candidate_outcome.finding_codes or ("POINT_CANDIDATE_UNCERTIFIED",)), - cumulative_generation_usage=candidate.resource_usage, - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - relation_outcome = compile_relation_cost_partition_v2( - checked_problem, - checked_config, - candidate, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:POINT_RELATION_REPLAY", - cumulative_generation_usage=candidate.resource_usage, - ) - if type(relation_outcome) is not RelationCostPartitionCompilationOutcomeV2: - raise TypeError("relation compiler returned an invalid internal outcome") - relation_usage = relation_outcome.cumulative_resource_usage - if relation_outcome.kind is not RelationCostPartitionCompilationKindV2.PARTITION: - return _uncertified( - relation_outcome.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(relation_outcome.finding_codes or ("POINT_REPLAY_NO_RELATION",)), - cumulative_generation_usage=relation_usage or candidate.resource_usage, - ) - relation = relation_outcome.relation_cost_partition - if relation is None or relation_usage is None: - raise RuntimeError("PARTITION relation replay omitted artifact or usage") - _require_relation_usage(checked_config, candidate, relation_usage) - _require_relation_closure( - checked_problem, - checked_config, - candidate, - relation, - ) - - objective_domain_budget = _ObjectiveDomainOperationBudgetV2( - limit=checked_config.max_domain_operations, - base_used=relation_usage.domain_operations, - ) - atomic_budget = RectilinearAtomicBudgetV2( - limit=checked_config.max_partition_cells, - used=relation_usage.partition_cells, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - objective_outcome = _compile_verified_partition( - checked_problem, - checked_config, - candidate, - relation, - relation_usage, - objective_domain_budget, - atomic_budget, - ) - except (RectilinearAtomicBudgetExhaustedV2, _ObjectiveResourceLimitV2) as error: - return _uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - str(error) or "RESOURCE_LIMIT:max_partition_cells", - cumulative_generation_usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveNumericGapV2 as error: - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - *error.finding_codes, - cumulative_generation_usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveUnsupportedV2 as error: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - *error.finding_codes, - cumulative_generation_usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except _ObjectiveCompilationIncompleteV2 as error: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *error.finding_codes, - cumulative_generation_usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:POINT_OBJECTIVE_REPLAY", - cumulative_generation_usage=_objective_usage( - relation_usage, - objective_domain_budget, - atomic_budget, - ), - ) - - if type(objective_outcome) is not ObjectivePartitionCompilationOutcomeV2: - raise TypeError("objective compiler returned an invalid internal outcome") - if objective_outcome.kind is not ObjectivePartitionCompilationKindV2.PARTITION: - raise RuntimeError("private objective compiler returned a non-partition") - objective = objective_outcome.objective_partition - objective_usage = objective_outcome.cumulative_resource_usage - if objective is None or objective_usage is None: - raise RuntimeError("PARTITION objective replay omitted artifact or usage") - if ( - relation_usage.domain_operations + objective_domain_budget.used - != objective_usage.domain_operations - ): - raise RuntimeError("objective domain ledger drift") - if atomic_budget.used != objective_usage.partition_cells: - raise RuntimeError("objective atomic ledger drift") - if objective_usage.refinement_steps != relation_usage.refinement_steps: - raise RuntimeError("objective refinement ledger drift") - _require_objective_closure( - checked_problem, - checked_config, - candidate, - relation, - objective, - ) - domain_budget = _EditFeasibilityDomainBudgetV2( - limit=checked_config.max_domain_operations, - used=objective_usage.domain_operations, - ) - context = _PointObjectiveReplayContextV2( - problem=checked_problem, - config=checked_config, - candidate=candidate, - relation=relation, - objective=objective, - domain_budget=domain_budget, - atomic_budget=atomic_budget, - replay_usage=objective_usage, - ) - return _evaluate_point_objective_from_replay_v2(context, checked_edit) - - -class PointObjectiveEvaluatorV2: - """Stateless wrapper for the public fresh-replay evaluation.""" - - def evaluate( - self, - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - edit: CanonicalEditV2, - ) -> PointObjectiveEvaluationOutcomeV2: - return evaluate_point_objective_v2(problem, config, edit) - - -def _evaluate_point_objective_from_replay_v2( - context: _PointObjectiveReplayContextV2, - edit: CanonicalEditV2, -) -> PointObjectiveEvaluationOutcomeV2: - """Evaluate one edit while continuing caller-owned replay ledgers. - - The context is intentionally reusable for a sequence of different edits. - Its artifact prefix and refinement count stay frozen while the two mutable - budget objects advance. Every membership proof receives the usage derived - from those current ledgers, so a later point can neither reset nor replay an - earlier budget prefix. - """ - - try: - checked_edit = _strict_replay_edit(edit) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:POINT_EDIT_REVALIDATION", - cumulative_generation_usage=_context_usage(context), - ) - _validate_replay_context(context, checked_edit) - current_usage = _context_usage(context) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - feasibility = _verify_replayed_candidate_edit_membership_v2( - context.problem, - checked_edit, - context.candidate, - context.domain_budget, - context.atomic_budget, - cumulative_resource_usage=current_usage, - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:POINT_FEASIBILITY", - cumulative_generation_usage=_context_usage(context), - ) - if type(feasibility) is not CanonicalEditFeasibilityVerificationOutcomeV2: - raise TypeError("feasibility helper returned an invalid internal outcome") - feasibility_usage = feasibility.verification_resource_usage - _require_feasibility_usage(context, feasibility_usage) - if feasibility.kind is CanonicalEditFeasibilityKindV2.NOT_PROVEN: - return _not_proven( - *(feasibility.finding_codes or ("POINT_FEASIBILITY_NOT_PROVEN",)), - cumulative_generation_usage=feasibility_usage, - ) - if feasibility.kind is CanonicalEditFeasibilityKindV2.UNCERTIFIED: - return _uncertified( - feasibility.uncertified_reason - or UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *(feasibility.finding_codes or ("POINT_FEASIBILITY_UNCERTIFIED",)), - cumulative_generation_usage=feasibility_usage, - ) - if feasibility_usage is None: - raise RuntimeError("positive feasibility proof omitted cumulative usage") - _require_feasibility_closure(context, checked_edit, feasibility) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return _compile_point_bounds(context, checked_edit) - except ( - RectilinearAtomicBudgetExhaustedV2, - _DomainOperationBudgetExhaustedV2, - _PointResourceLimitV2, - ) as error: - return _uncertified( - UncertifiedReasonV2.BOUNDED_SEARCH_EXHAUSTED, - str(error) or "RESOURCE_LIMIT:POINT_OBJECTIVE", - cumulative_generation_usage=_context_usage(context), - ) - except _PointNumericGapV2 as error: - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - *error.finding_codes, - cumulative_generation_usage=_context_usage(context), - ) - except _PointUnsupportedV2 as error: - return _uncertified( - UncertifiedReasonV2.UNSUPPORTED_MODEL, - *error.finding_codes, - cumulative_generation_usage=_context_usage(context), - ) - except _PointIncompleteV2 as error: - return _uncertified( - UncertifiedReasonV2.COMPILATION_INCOMPLETE, - *error.finding_codes, - cumulative_generation_usage=_context_usage(context), - ) - except (ArithmeticError, RuntimeWarning): - return _uncertified( - UncertifiedReasonV2.NUMERIC_GAP, - "NUMERIC_GAP:POINT_OBJECTIVE_EVALUATION", - cumulative_generation_usage=_context_usage(context), - ) - - -def _compile_point_bounds( - context: _PointObjectiveReplayContextV2, - edit: CanonicalEditV2, -) -> PointObjectiveEvaluationOutcomeV2: - _reserve_point_domain_work(context) - point = _point_region(edit, context.atomic_budget) - x = Fraction.from_float(edit.translation_xy_m.x) - y = Fraction.from_float(edit.translation_xy_m.y) - - relation_cells = _covering_cells( - context.relation.cells, - x, - y, - context.atomic_budget, - ) - objective_cells = _covering_cells( - context.objective.cells, - x, - y, - context.atomic_budget, - ) - if not relation_cells or not objective_cells: - raise _PointIncompleteV2("COMPILATION_INCOMPLETE:POINT_CELL_COVERAGE") - relation_ids = tuple(sorted(cell.cell_id for cell in relation_cells)) - objective_ids = tuple(sorted(cell.cell_id for cell in objective_cells)) - objective_parent_ids = tuple( - sorted({cell.parent_relation_cell_id for cell in objective_cells}) - ) - if objective_parent_ids != relation_ids: - raise RuntimeError("point relation/objective covering-cell mapping drift") - - relation_vector = _damage_vector_hull( - context.problem, - relation_cells, - ) - objective_vector = _damage_vector_hull( - context.problem, - objective_cells, - ) - if relation_vector != objective_vector: - raise RuntimeError("point relation vector differs across fresh artifacts") - - translation = _require_translation( - compile_translation_l2_cell_bounds_v2( - point, - context.problem.objective.translation, - atomic_budget=context.atomic_budget, - ) - ) - relation_loss = _require_interval( - aggregate_relation_damage_bounds_v2( - context.problem.objective.relation_damage, - relation_vector, - ), - "POINT_RELATION_DAMAGE", - ) - - visibility_intervals = _visibility_intervals( - context.problem, - ) - visibility_loss = _require_interval( - aggregate_visibility_change_bounds_v2( - context.problem.objective.visibility_change, - visibility_intervals, - ), - "POINT_VISIBILITY_CHANGE", - ) - - safety = _require_safety( - compile_objective_safety_bounds_v2( - context.problem, - point, - atomic_budget=context.atomic_budget, - ) - ) - safety_inputs = tuple( - ConstraintSafetyInputV2( - constraint_id=item.constraint_id, - components=item.raw_components, - ) - for item in safety.constraint_bounds - ) - safety_loss = _require_interval( - aggregate_safety_penalty_bounds_v2( - context.problem.objective.safety_margin, - safety_inputs, - ), - "POINT_SAFETY_MARGIN", - ) - slacks = tuple( - ConstraintSlackV2( - constraint_id=item.constraint_id, - lower_bound=item.normalized_slack.lower_bound, - upper_bound=item.normalized_slack.upper_bound, - unit=SafetySlackUnitV2.DIMENSIONLESS, - ) - for item in safety.constraint_bounds - ) - _require_point_slacks_enclosed( - objective_cells, - slacks, - ) - loss = _build_point_term_bounds( - NonNegativeIntervalV2( - lower_bound=translation.bounds.lower_bound, - upper_bound=translation.bounds.upper_bound, - ), - _nonnegative_interval(relation_loss), - _nonnegative_interval(visibility_loss), - _nonnegative_interval(safety_loss), - ) - usage = _context_usage(context) - return PointObjectiveEvaluationOutcomeV2( - kind=PointObjectiveEvaluationKindV2.BOUNDED_FEASIBLE, - semantic_problem_sha256=context.problem.semantic_problem_sha256, - core_solver_config_sha256=context.config.core_solver_config_sha256, - candidate_domain_artifact_sha256=( - context.candidate.candidate_domain_artifact_sha256 - ), - relation_cost_partition_sha256=( - context.relation.relation_cost_partition_sha256 - ), - objective_partition_artifact_sha256=( - context.objective.objective_partition_artifact_sha256 - ), - canonical_edit_sha256=edit.edit_sha256, - witness_loss_bounds=loss, - relation_damage_vector=relation_vector, - constraint_slacks=slacks, - covering_objective_cell_ids=objective_ids, - covering_relation_cell_ids=relation_ids, - cumulative_generation_usage=usage, - ) - - -def _strict_model( - value: object, - model_type: type[ModelT], - label: str, -) -> ModelT: - if type(value) is not model_type: - raise _InvalidInputV2(f"INVALID_INPUT:{label}:TYPE") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return model_type.model_validate( - value.model_dump(mode="python"), strict=True - ) - except (ArithmeticError, RuntimeWarning) as error: - raise _NumericInputV2(f"NUMERIC_GAP:{label}_REVALIDATION") from error - except (ValidationError, TypeError, ValueError, Warning) as error: - raise _InvalidInputV2(f"INVALID_INPUT:{label}") from error - - -def _strict_ids(values: object, label: str) -> tuple[str, ...]: - if type(values) is not tuple: - raise TypeError(f"{label} must be an exact tuple") - try: - checked = tuple( - _CANONICAL_ID_ADAPTER.validate_python(value, strict=True) - for value in values - ) - except ValidationError as error: - raise TypeError(f"{label} contains an invalid CanonicalId") from error - if len(checked) != len(set(checked)): - raise ValueError(f"{label} must be unique") - return tuple(sorted(checked)) - - -def _edit_reference_findings( - problem: SemanticProblemV2, - edit: CanonicalEditV2, -) -> tuple[str, ...]: - findings: list[str] = [] - if edit.semantic_problem_sha256 != problem.semantic_problem_sha256: - findings.append("EDIT_REFERENCE_MISMATCH:SEMANTIC_PROBLEM_HASH") - if edit.subject_id != problem.constraints.allowed_edit.subject_id: - findings.append("EDIT_REFERENCE_MISMATCH:SUBJECT_ID") - return tuple(findings) - - -def _require_candidate_closure( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, -) -> None: - if candidate.semantic_problem_sha256 != problem.semantic_problem_sha256: - raise RuntimeError("fresh candidate problem hash is not closed") - if candidate.core_solver_config_sha256 != config.core_solver_config_sha256: - raise RuntimeError("fresh candidate config hash is not closed") - if candidate.candidate_variable.subject_id != ( - problem.constraints.allowed_edit.subject_id - ): - raise RuntimeError("fresh candidate subject is not closed") - - -def _require_relation_closure( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionV2, -) -> None: - expected = ( - problem.semantic_problem_sha256, - config.core_solver_config_sha256, - candidate.candidate_domain_artifact_sha256, - problem.objective.objective_spec_sha256, - ) - actual = ( - relation.semantic_problem_sha256, - relation.core_solver_config_sha256, - relation.candidate_domain_artifact_sha256, - relation.objective_spec_sha256, - ) - if actual != expected: - raise RuntimeError("fresh relation replay reference closure drift") - - -def _require_relation_usage( - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - usage: CompilationResourceUsageV2, -) -> None: - candidate_usage = candidate.resource_usage - if ( - usage.domain_operations < candidate_usage.domain_operations - or usage.partition_cells < candidate_usage.partition_cells - ): - raise RuntimeError("fresh relation replay rolled back a resource ledger") - if usage.refinement_steps != candidate_usage.refinement_steps: - raise RuntimeError("fresh relation replay changed the refinement ledger") - if ( - usage.domain_operations > config.max_domain_operations - or usage.partition_cells > config.max_partition_cells - ): - raise RuntimeError("fresh relation replay exceeded a configured ledger") - - -def _require_objective_closure( - problem: SemanticProblemV2, - config: CoreSolverConfigV2, - candidate: CandidateDomainArtifactV2, - relation: RelationCostPartitionV2, - objective: ObjectivePartitionArtifactV2, -) -> None: - expected = ( - problem.semantic_problem_sha256, - config.core_solver_config_sha256, - candidate.candidate_domain_artifact_sha256, - relation.relation_cost_partition_sha256, - problem.objective.objective_spec_sha256, - ) - actual = ( - objective.semantic_problem_sha256, - objective.core_solver_config_sha256, - objective.candidate_domain_artifact_sha256, - objective.relation_cost_partition_sha256, - objective.objective_spec_sha256, - ) - if actual != expected: - raise RuntimeError("fresh objective replay reference closure drift") - - -def _strict_replay_edit(edit: object) -> CanonicalEditV2: - if type(edit) is not CanonicalEditV2: - raise TypeError("edit must be an exact CanonicalEditV2") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return CanonicalEditV2.model_validate( - edit.model_dump(mode="python"), - strict=True, - ) - except (ValidationError, TypeError, ValueError) as error: - raise TypeError("edit must pass strict validation") from error - - -def _validate_replay_context( - context: _PointObjectiveReplayContextV2, - edit: CanonicalEditV2, -) -> None: - if type(context) is not _PointObjectiveReplayContextV2: - raise TypeError("context must be a _PointObjectiveReplayContextV2") - if type(context.domain_budget) is not _EditFeasibilityDomainBudgetV2: - raise TypeError("context domain budget has the wrong type") - if type(context.atomic_budget) is not RectilinearAtomicBudgetV2: - raise TypeError("context atomic budget has the wrong type") - context.domain_budget.validate() - context.atomic_budget.validate() - if context.domain_budget.limit != context.config.max_domain_operations: - raise ValueError("context domain budget/config limit mismatch") - if context.atomic_budget.limit != context.config.max_partition_cells: - raise ValueError("context atomic budget/config limit mismatch") - if type(context.replay_usage) is not CompilationResourceUsageV2: - raise TypeError("context replay usage has the wrong type") - if context.replay_usage.domain_operations > context.domain_budget.used: - raise ValueError("context domain ledger rolled back below replay") - if context.replay_usage.partition_cells > context.atomic_budget.used: - raise ValueError("context atomic ledger rolled back below replay") - if ( - context.replay_usage.refinement_steps - != context.candidate.resource_usage.refinement_steps - ): - raise ValueError("context refinement ledger drift") - _require_candidate_closure(context.problem, context.config, context.candidate) - _require_relation_closure( - context.problem, - context.config, - context.candidate, - context.relation, - ) - _require_objective_closure( - context.problem, - context.config, - context.candidate, - context.relation, - context.objective, - ) - if _edit_reference_findings(context.problem, edit): - raise ValueError("context edit references are not closed") - - -def _require_feasibility_closure( - context: _PointObjectiveReplayContextV2, - edit: CanonicalEditV2, - outcome: object, -) -> None: - expected = ( - context.problem.semantic_problem_sha256, - context.config.core_solver_config_sha256, - context.candidate.candidate_domain_artifact_sha256, - edit.edit_sha256, - ) - actual = ( - outcome.semantic_problem_sha256, - outcome.core_solver_config_sha256, - outcome.candidate_domain_artifact_sha256, - outcome.canonical_edit_sha256, - ) - if actual != expected: - raise RuntimeError("positive feasibility proof reference closure drift") - - -def _require_feasibility_usage( - context: _PointObjectiveReplayContextV2, - usage: CompilationResourceUsageV2 | None, -) -> None: - if usage is None: - raise RuntimeError("feasibility helper omitted cumulative usage") - expected = _context_usage(context) - if usage != expected: - raise RuntimeError("feasibility helper cumulative ledger drift") - - -def _point_region( - edit: CanonicalEditV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ExactRectilinearRegionV2: - x = Fraction.from_float(edit.translation_xy_m.x) - y = Fraction.from_float(edit.translation_xy_m.y) - rectangle = ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=x, - min_y_m=y, - max_x_m=x, - max_y_m=y, - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ) - return _require_region( - normalize_rectilinear_region_v2( - (rectangle,), - atomic_budget=atomic_budget, - ) - ) - - -def _reserve_point_domain_work(context: _PointObjectiveReplayContextV2) -> None: - """Atomically reserve every logical deep pass for one point evaluation.""" - - relation_cells = len(context.relation.cells) - objective_cells = len(context.objective.cells) - relation_keys = len(context.problem.objective.relation_damage.pair_axis_weights) - safety_targets = len(context.problem.objective.safety_margin.aggregation.targets) - visibility_values = context.problem.scene.baseline_observations.values - visibility_facts = len(visibility_values) if visibility_values is not None else 0 - visibility_weights = len( - context.problem.objective.visibility_change.object_camera_weights - ) - units = ( - 1 # exact singleton-region construction - + relation_cells - + objective_cells # closed-cell coverage passes - + objective_cells # objective-to-relation parent mapping - + 2 - * (relation_cells + objective_cells) - * relation_keys # vector maps plus per-key interval hulls - + relation_keys # relation weighted aggregation - + visibility_facts - + 2 * visibility_weights # interval construction plus aggregation - + 2 * safety_targets # singleton safety compilation plus aggregation - + objective_cells * safety_targets # all covering-cell enclosure checks - + relation_keys - + safety_targets - + objective_cells - + relation_cells # canonical success publication - ) - context.domain_budget.consume(units) - - -CellV2 = RelationCostCellV2 | ObjectivePartitionCellV2 - - -def _covering_cells( - cells: tuple[CellV2, ...], - x: Fraction, - y: Fraction, - atomic_budget: RectilinearAtomicBudgetV2, -) -> tuple[CellV2, ...]: - covering: list[CellV2] = [] - for cell in cells: - outer = cell.domain.outer_bound - if outer.status is RegionBoundStatusV2.EMPTY: - continue - if outer.status is not RegionBoundStatusV2.NON_EMPTY or outer.region is None: - raise _PointIncompleteV2("COMPILATION_INCOMPLETE:POINT_CELL_OUTER_BOUND") - lifted = _require_region( - lift_planar_region_v2( - outer.region, - atomic_budget=atomic_budget, - ) - ) - if _contains_point_with_budget(lifted, x, y, atomic_budget): - covering.append(cell) - return tuple(covering) - - -def _damage_vector_hull( - problem: SemanticProblemV2, - cells: tuple[CellV2, ...], -) -> tuple[RelationDamageBoundV2, ...]: - keys = tuple( - item.key for item in problem.objective.relation_damage.pair_axis_weights - ) - by_cell: list[dict[object, RelationDamageBoundV2]] = [] - for cell in cells: - mapping = {item.key: item for item in cell.relation_damage_vector} - if set(mapping) != set(keys): - raise RuntimeError("fresh point relation vector key closure drift") - by_cell.append(mapping) - return tuple( - RelationDamageBoundV2( - key=key, - lower_bound=min(mapping[key].lower_bound for mapping in by_cell), - upper_bound=max(mapping[key].upper_bound for mapping in by_cell), - ) - for key in keys - ) - - -def _visibility_intervals( - problem: SemanticProblemV2, -) -> tuple[VisibilityMetricIntervalV2, ...]: - facts = problem.scene.baseline_observations - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.values is None - ): - raise _PointIncompleteV2("COMPILATION_INCOMPLETE:POINT_VISIBILITY_BASELINE") - observations: dict[tuple[str, str, str, str], BaselineObservationV2] = {} - for observation in facts.values: - key = ( - observation.object_id, - observation.camera_id, - observation.metric_definition_id, - observation.metric_definition_version, - ) - if key in observations: - raise _PointUnsupportedV2("INVALID_POINT_VISIBILITY:DUPLICATE_BASELINE") - observations[key] = observation - weights = problem.objective.visibility_change.object_camera_weights - intervals: list[VisibilityMetricIntervalV2] = [] - for weighted in weights: - key = weighted.key - semantic_key = ( - key.object_id, - key.camera_id, - key.metric_definition_id, - key.metric_definition_version, - ) - observation = observations.get(semantic_key) - if observation is None: - raise _PointIncompleteV2( - "COMPILATION_INCOMPLETE:POINT_VISIBILITY_BASELINE_KEY" - ) - intervals.append( - VisibilityMetricIntervalV2( - key=ObjectCameraKeyV2.model_validate( - key.model_dump(mode="python"), - strict=True, - ), - baseline_lower=Fraction.from_float(observation.normalized_lower_bound), - baseline_upper=Fraction.from_float(observation.normalized_upper_bound), - candidate_lower=Fraction(), - candidate_upper=Fraction(1), - ) - ) - return tuple(intervals) - - -def _require_point_slacks_enclosed( - cells: tuple[ObjectivePartitionCellV2, ...], - slacks: tuple[ConstraintSlackV2, ...], -) -> None: - point_by_id = {item.constraint_id: item for item in slacks} - for cell in cells: - cell_by_id = {item.constraint_id: item for item in cell.constraint_slacks} - if set(cell_by_id) != set(point_by_id): - raise RuntimeError("fresh objective slack key closure drift") - if any( - cell_by_id[key].lower_bound > point.lower_bound - or cell_by_id[key].upper_bound < point.upper_bound - for key, point in point_by_id.items() - ): - raise RuntimeError("point slack escaped a covering objective cell bound") - - -def _require_region(outcome: RectilinearRegionOutcomeV2) -> ExactRectilinearRegionV2: - if outcome.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - raise _PointResourceLimitV2("RESOURCE_LIMIT:max_partition_cells") - if outcome.kind is not RectilinearOutcomeKindV2.EXACT or outcome.region is None: - raise _PointUnsupportedV2( - *(outcome.finding_codes or ("UNSUPPORTED_POINT_RECTILINEAR_REGION",)) - ) - return outcome.region - - -def _require_translation(outcome: TranslationCellOutcomeV2) -> TranslationCellOutcomeV2: - if outcome.kind is ObjectiveNumericKindV2.EXACT and outcome.bounds is not None: - return outcome - _raise_numeric(outcome.kind, outcome.finding_codes, "POINT_TRANSLATION") - raise AssertionError("unreachable") - - -def _require_interval( - outcome: ObjectiveIntervalOutcomeV2, - label: str, -) -> ObjectiveIntervalOutcomeV2: - if outcome.kind is ObjectiveNumericKindV2.EXACT and outcome.interval is not None: - return outcome - _raise_numeric(outcome.kind, outcome.finding_codes, label) - raise AssertionError("unreachable") - - -def _raise_numeric( - kind: ObjectiveNumericKindV2, - findings: tuple[str, ...], - label: str, -) -> None: - codes = findings or (f"{label}:{kind.value}",) - if kind is ObjectiveNumericKindV2.NUMERIC_GAP: - raise _PointNumericGapV2(*codes) - if kind is ObjectiveNumericKindV2.RESOURCE_LIMIT: - raise _PointResourceLimitV2(*codes) - if kind is ObjectiveNumericKindV2.EMPTY: - raise _PointIncompleteV2(*codes) - if kind is ObjectiveNumericKindV2.INVALID_INPUT: - raise _PointUnsupportedV2(*codes) - raise RuntimeError(f"unknown point numeric outcome: {kind!r}") - - -def _require_safety( - outcome: ObjectiveSafetyBoundsOutcomeV2, -) -> ObjectiveSafetyBoundsOutcomeV2: - if outcome.kind is ObjectiveSafetyBoundsKindV2.EXACT: - return outcome - findings = outcome.finding_codes or (f"POINT_SAFETY:{outcome.kind.value}",) - if outcome.kind is ObjectiveSafetyBoundsKindV2.NUMERIC_GAP: - raise _PointNumericGapV2(*findings) - if outcome.kind is ObjectiveSafetyBoundsKindV2.RESOURCE: - raise _PointResourceLimitV2(*findings) - if outcome.kind is ObjectiveSafetyBoundsKindV2.EMPTY: - raise _PointIncompleteV2(*findings) - if outcome.kind is ObjectiveSafetyBoundsKindV2.UNSUPPORTED: - raise _PointUnsupportedV2(*findings) - raise RuntimeError(f"unknown point safety outcome: {outcome.kind!r}") - - -def _nonnegative_interval( - outcome: ObjectiveIntervalOutcomeV2, -) -> NonNegativeIntervalV2: - if outcome.interval is None: - raise RuntimeError("EXACT point interval omitted its value") - if outcome.interval.lower_bound < 0.0: - raise RuntimeError("non-negative point loss published a negative bound") - return NonNegativeIntervalV2( - lower_bound=outcome.interval.lower_bound, - upper_bound=outcome.interval.upper_bound, - ) - - -def _build_point_term_bounds( - translation: NonNegativeIntervalV2, - relation: NonNegativeIntervalV2, - visibility: NonNegativeIntervalV2, - safety: NonNegativeIntervalV2, -) -> ObjectiveTermBoundsV2: - intervals = (translation, relation, visibility, safety) - if not _point_directed_total_is_finite( - tuple(item.lower_bound for item in intervals), - upward=False, - ) or not _point_directed_total_is_finite( - tuple(item.upper_bound for item in intervals), - upward=True, - ): - raise _PointNumericGapV2("NUMERIC_GAP:POINT_OBJECTIVE_TERM_TOTAL") - return ObjectiveTermBoundsV2( - translation_loss=translation, - relation_damage_loss=relation, - visibility_change_loss=visibility, - safety_margin_loss=safety, - ) - - -def _point_directed_total_is_finite( - values: tuple[float, ...], - *, - upward: bool, -) -> bool: - exact = sum((Fraction.from_float(value) for value in values), Fraction()) - try: - published = float(exact) - except OverflowError: - return False - if not math.isfinite(published): - return False - published_exact = Fraction.from_float(published) - if upward and published_exact < exact: - published = math.nextafter(published, math.inf) - elif not upward and published_exact > exact: - published = math.nextafter(published, -math.inf) - return math.isfinite(published) - - -def _objective_usage( - base: CompilationResourceUsageV2, - domain_budget: _ObjectiveDomainOperationBudgetV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=base.domain_operations + domain_budget.used, - partition_cells=atomic_budget.used, - refinement_steps=base.refinement_steps, - ) - - -def _context_usage( - context: _PointObjectiveReplayContextV2, -) -> CompilationResourceUsageV2: - return CompilationResourceUsageV2( - domain_operations=context.domain_budget.used, - partition_cells=context.atomic_budget.used, - refinement_steps=context.replay_usage.refinement_steps, - ) - - -def _uncertified( - reason: UncertifiedReasonV2, - *findings: str, - cumulative_generation_usage: CompilationResourceUsageV2 | None = None, -) -> PointObjectiveEvaluationOutcomeV2: - return PointObjectiveEvaluationOutcomeV2( - kind=PointObjectiveEvaluationKindV2.UNCERTIFIED, - uncertified_reason=reason, - finding_codes=tuple(findings), - cumulative_generation_usage=cumulative_generation_usage, - ) - - -def _not_proven( - *findings: str, - cumulative_generation_usage: CompilationResourceUsageV2 | None = None, -) -> PointObjectiveEvaluationOutcomeV2: - return PointObjectiveEvaluationOutcomeV2( - kind=PointObjectiveEvaluationKindV2.NOT_PROVEN, - finding_codes=tuple(findings), - cumulative_generation_usage=cumulative_generation_usage, - ) diff --git a/src/spatialcf/core/v2/solve_verifier.py b/src/spatialcf/core/v2/solve_verifier.py deleted file mode 100644 index 49cc4cd..0000000 --- a/src/spatialcf/core/v2/solve_verifier.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Independent pure-core replay verification for Canonical v2 solve results.""" - -from __future__ import annotations - -import warnings -from dataclasses import dataclass -from enum import StrEnum - -from pydantic import TypeAdapter, ValidationError - -from spatialcf.core.v2.minimum_cost_solver import ( - CanonicalMinimumCostSolveOutcomeV2 as CanonicalMinimumCostSolveOutcomeV2, # noqa: PLC0414 -) -from spatialcf.core.v2.minimum_cost_solver import solve_canonical_minimum_cost_v2 -from spatialcf.domain.v2.artifacts import CompilationResourceUsageV2 -from spatialcf.domain.v2.base import Sha256Digest -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - SolveStatusV2, - UncertifiedReasonV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - -_SHA256_ADAPTER = TypeAdapter(Sha256Digest) - - -class CanonicalSolveVerificationKindV2(StrEnum): - """Closed states of independent deterministic replay verification.""" - - VERIFIED = "VERIFIED" - MISMATCH = "MISMATCH" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class CanonicalSolveVerificationOutcomeV2: - """Closed verifier outcome. - - Verified references exist only after a fresh solve matches the submitted - result byte-for-byte. ``replay_generation_usage`` is telemetry from this - call, not a transferable proof token; a consumer must replay raw inputs. - """ - - kind: CanonicalSolveVerificationKindV2 - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - submitted_solve_result_sha256: Sha256Digest | None = None - verified_status: SolveStatusV2 | None = None - replay_generation_usage: CompilationResourceUsageV2 | None = None - proposal_count: int = 0 - evaluated_proposal_count: int = 0 - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not CanonicalSolveVerificationKindV2: - raise TypeError("kind must be CanonicalSolveVerificationKindV2") - if type(self.finding_codes) is not tuple or any( - type(code) is not str or not code.strip() for code in self.finding_codes - ): - raise TypeError("finding_codes must be exact non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - object.__setattr__(self, "finding_codes", findings) - - for name in ("proposal_count", "evaluated_proposal_count"): - value = getattr(self, name) - if type(value) is not int or value < 0: - raise TypeError(f"{name} must be a non-negative exact int") - if self.evaluated_proposal_count > self.proposal_count: - raise ValueError("evaluated proposal count cannot exceed proposal count") - - usage = self.replay_generation_usage - if usage is not None: - if type(usage) is not CompilationResourceUsageV2: - raise TypeError("replay_generation_usage has the wrong exact type") - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - usage = CompilationResourceUsageV2.model_validate( - usage.model_dump(mode="python"), strict=True - ) - except (ValidationError, TypeError, ValueError, Warning) as error: - raise TypeError( - "replay_generation_usage must validate strictly" - ) from error - object.__setattr__(self, "replay_generation_usage", usage) - if usage is None and (self.proposal_count or self.evaluated_proposal_count): - raise ValueError("proposal telemetry requires replay generation usage") - - reference_names = ( - "semantic_problem_sha256", - "core_solver_config_sha256", - "submitted_solve_result_sha256", - ) - for name in reference_names: - value = getattr(self, name) - if value is None: - continue - try: - value = _SHA256_ADAPTER.validate_python(value, strict=True) - except (ValidationError, TypeError, ValueError) as error: - raise ValueError(f"{name} must be a Sha256Digest") from error - object.__setattr__(self, name, value) - references = tuple(getattr(self, name) for name in reference_names) - - if self.kind is CanonicalSolveVerificationKindV2.VERIFIED: - if any(value is None for value in references): - raise ValueError("VERIFIED requires all verified references") - if type(self.verified_status) is not SolveStatusV2: - raise TypeError("VERIFIED requires an exact SolveStatusV2") - if self.uncertified_reason is not None or findings: - raise ValueError("VERIFIED cannot carry failure diagnostics") - if self.verified_status is SolveStatusV2.CERTIFIED_SUCCESS and ( - usage is None - or self.proposal_count < 1 - or self.evaluated_proposal_count != self.proposal_count - ): - raise ValueError( - "verified success requires complete fresh proposal telemetry" - ) - if self.verified_status is SolveStatusV2.PROVEN_UNSAT and ( - usage is None - or self.proposal_count != 0 - or self.evaluated_proposal_count != 0 - ): - raise ValueError( - "verified unsat requires zero-proposal fresh telemetry" - ) - return - - if ( - any(value is not None for value in references) - or self.verified_status is not None - ): - raise ValueError("failure outcomes cannot carry verified references") - if not findings: - raise ValueError(f"{self.kind.value} requires at least one finding") - if self.kind is CanonicalSolveVerificationKindV2.MISMATCH: - if self.uncertified_reason is not None: - raise ValueError("MISMATCH cannot carry an uncertified reason") - return - if type(self.uncertified_reason) is not UncertifiedReasonV2: - raise TypeError("UNCERTIFIED requires an UncertifiedReasonV2") - - -def verify_canonical_solve_result_v2( - problem: SemanticProblemV2, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, -) -> CanonicalSolveVerificationOutcomeV2: - """Replay with trusted policy and compare the complete canonical result.""" - - from spatialcf.core.v2._internal.certification.solve_replay import ( - SolveReplayBindingsV2, - verify_solve_replay_v2, - ) - - return verify_solve_replay_v2( - problem, - expected_config, - submitted_result, - bindings=SolveReplayBindingsV2( - problem_type=SemanticProblemV2, - solve=solve_canonical_minimum_cost_v2, - canonical_json_bytes=canonical_json_bytes_v2, - ), - ) - - -class CanonicalSolveResultVerifierV2: - """Stateless object wrapper for independent result replay.""" - - def verify( - self, - problem: SemanticProblemV2, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, - ) -> CanonicalSolveVerificationOutcomeV2: - return verify_canonical_solve_result_v2( - problem, expected_config, submitted_result - ) diff --git a/src/spatialcf/core/v2/solve_verifier_v2_1.py b/src/spatialcf/core/v2/solve_verifier_v2_1.py deleted file mode 100644 index e5a56cf..0000000 --- a/src/spatialcf/core/v2/solve_verifier_v2_1.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Independent replay verification for Exact Cardinal Canonical 2.1 results.""" - -from __future__ import annotations - -from spatialcf.core.v2._internal.certification.solve_replay import ( - SolveReplayBindingsV2, - verify_solve_replay_v2, -) -from spatialcf.core.v2.minimum_cost_solver_v2_1 import ( - solve_canonical_minimum_cost_v2_1, -) -from spatialcf.core.v2.solve_verifier import CanonicalSolveVerificationOutcomeV2 -from spatialcf.domain.v2.cardinal import SchemaIdentityV2_1, SemanticProblemV2_1 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - - -def verify_canonical_solve_result_v2_1( - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, -) -> CanonicalSolveVerificationOutcomeV2: - """Replay with trusted 2.1 inputs and compare complete canonical bytes.""" - - return verify_solve_replay_v2( - problem, - expected_config, - submitted_result, - bindings=SolveReplayBindingsV2( - problem_type=SemanticProblemV2_1, - solve=solve_canonical_minimum_cost_v2_1, - canonical_json_bytes=canonical_json_bytes_v2, - exact_schema_identity_type=SchemaIdentityV2_1, - exact_schema_version="2.1", - schema_mismatch_finding=("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION"), - ), - ) - - -class CanonicalSolveResultVerifierV2_1: - """Stateless object wrapper for independent Cardinal 2.1 replay.""" - - def verify( - self, - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, - ) -> CanonicalSolveVerificationOutcomeV2: - return verify_canonical_solve_result_v2_1( - problem, - expected_config, - submitted_result, - ) - - -__all__ = ( - "CanonicalSolveResultVerifierV2_1", - "verify_canonical_solve_result_v2_1", -) diff --git a/src/spatialcf/core/v2/solve_verifier_v2_2.py b/src/spatialcf/core/v2/solve_verifier_v2_2.py deleted file mode 100644 index 6e756e2..0000000 --- a/src/spatialcf/core/v2/solve_verifier_v2_2.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Independent replay verification for zero-distortion 2.2 results.""" - -from __future__ import annotations - -from spatialcf.core.v2._internal.certification.solve_replay import ( - SolveReplayBindingsV2, - verify_solve_replay_v2, -) -from spatialcf.core.v2.minimum_cost_solver_v2_2 import ( - solve_canonical_minimum_cost_v2_2, -) -from spatialcf.core.v2.solve_verifier import CanonicalSolveVerificationOutcomeV2 -from spatialcf.domain.v2.cardinal import SchemaIdentityV2_1, SemanticProblemV2_1 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - - -def verify_canonical_solve_result_v2_2( - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, -) -> CanonicalSolveVerificationOutcomeV2: - """Replay with trusted 2.2 inputs and compare complete canonical bytes.""" - - return verify_solve_replay_v2( - problem, - expected_config, - submitted_result, - bindings=SolveReplayBindingsV2( - problem_type=SemanticProblemV2_1, - solve=solve_canonical_minimum_cost_v2_2, - canonical_json_bytes=canonical_json_bytes_v2, - exact_schema_identity_type=SchemaIdentityV2_1, - exact_schema_version="2.1", - schema_mismatch_finding=("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION"), - ), - ) - - -class CanonicalSolveResultVerifierV2_2: - """Stateless object wrapper for independent zero-distortion replay.""" - - def verify( - self, - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, - ) -> CanonicalSolveVerificationOutcomeV2: - return verify_canonical_solve_result_v2_2( - problem, - expected_config, - submitted_result, - ) - - -__all__ = ( - "CanonicalSolveResultVerifierV2_2", - "verify_canonical_solve_result_v2_2", -) diff --git a/src/spatialcf/core/v2/solve_verifier_v2_3.py b/src/spatialcf/core/v2/solve_verifier_v2_3.py deleted file mode 100644 index cc71549..0000000 --- a/src/spatialcf/core/v2/solve_verifier_v2_3.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Independent replay verification for exact camera-translation 2.3 results.""" - -from __future__ import annotations - -from spatialcf.core.v2._internal.certification.solve_replay import ( - SolveReplayBindingsV2, - verify_solve_replay_v2, -) -from spatialcf.core.v2.minimum_cost_solver_v2_3 import ( - solve_canonical_minimum_cost_v2_3, -) -from spatialcf.core.v2.solve_verifier import CanonicalSolveVerificationOutcomeV2 -from spatialcf.domain.v2.cardinal import SchemaIdentityV2_1, SemanticProblemV2_1 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - - -def verify_canonical_solve_result_v2_3( - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, -) -> CanonicalSolveVerificationOutcomeV2: - """Replay with trusted 2.3 inputs and compare complete canonical bytes.""" - - return verify_solve_replay_v2( - problem, - expected_config, - submitted_result, - bindings=SolveReplayBindingsV2( - problem_type=SemanticProblemV2_1, - solve=solve_canonical_minimum_cost_v2_3, - canonical_json_bytes=canonical_json_bytes_v2, - exact_schema_identity_type=SchemaIdentityV2_1, - exact_schema_version="2.1", - schema_mismatch_finding=("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION"), - ), - ) - - -class CanonicalSolveResultVerifierV2_3: - """Stateless wrapper for independent camera-translation replay.""" - - def verify( - self, - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, - ) -> CanonicalSolveVerificationOutcomeV2: - return verify_canonical_solve_result_v2_3( - problem, - expected_config, - submitted_result, - ) - - -__all__ = ( - "CanonicalSolveResultVerifierV2_3", - "verify_canonical_solve_result_v2_3", -) diff --git a/src/spatialcf/core/v2/solve_verifier_v2_4.py b/src/spatialcf/core/v2/solve_verifier_v2_4.py deleted file mode 100644 index 8c6c5f1..0000000 --- a/src/spatialcf/core/v2/solve_verifier_v2_4.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Independent replay verification for exact cardinal-camera 2.4 results.""" - -from __future__ import annotations - -from spatialcf.core.v2._internal.certification.solve_replay import ( - SolveReplayBindingsV2, - verify_solve_replay_v2, -) -from spatialcf.core.v2.minimum_cost_solver_v2_4 import ( - solve_canonical_minimum_cost_v2_4, -) -from spatialcf.core.v2.solve_verifier import CanonicalSolveVerificationOutcomeV2 -from spatialcf.domain.v2.cardinal import SchemaIdentityV2_1, SemanticProblemV2_1 -from spatialcf.domain.v2.result import ( - CertifiedSuccessResultV2, - CoreSolverConfigV2, - ProvenUnsatResultV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 - - -def verify_canonical_solve_result_v2_4( - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, -) -> CanonicalSolveVerificationOutcomeV2: - """Replay with trusted 2.4 inputs and compare complete canonical bytes.""" - - return verify_solve_replay_v2( - problem, - expected_config, - submitted_result, - bindings=SolveReplayBindingsV2( - problem_type=SemanticProblemV2_1, - solve=solve_canonical_minimum_cost_v2_4, - canonical_json_bytes=canonical_json_bytes_v2, - exact_schema_identity_type=SchemaIdentityV2_1, - exact_schema_version="2.1", - schema_mismatch_finding=("INVALID_INPUT:SEMANTIC_PROBLEM_SCHEMA_VERSION"), - ), - ) - - -class CanonicalSolveResultVerifierV2_4: - """Stateless wrapper for independent exact cardinal-camera replay.""" - - def verify( - self, - problem: SemanticProblemV2_1, - expected_config: CoreSolverConfigV2, - submitted_result: CertifiedSuccessResultV2 - | ProvenUnsatResultV2 - | UncertifiedResultV2, - ) -> CanonicalSolveVerificationOutcomeV2: - return verify_canonical_solve_result_v2_4( - problem, - expected_config, - submitted_result, - ) - - -__all__ = ( - "CanonicalSolveResultVerifierV2_4", - "verify_canonical_solve_result_v2_4", -) diff --git a/src/spatialcf/core/v2/strict_convex_candidate_domain.py b/src/spatialcf/core/v2/strict_convex_candidate_domain.py deleted file mode 100644 index 6fa1ea7..0000000 --- a/src/spatialcf/core/v2/strict_convex_candidate_domain.py +++ /dev/null @@ -1,858 +0,0 @@ -"""Frozen candidate-stage values for strict convex continuous-yaw domains. - -These values are immutable proposals, not proof capabilities. A semantic -consumer must fresh replay the raw problem and compiler config. -""" - -from __future__ import annotations - -import hashlib -import json -import re -import warnings -from dataclasses import dataclass, fields, is_dataclass -from enum import Enum, StrEnum -from fractions import Fraction -from typing import Any, ClassVar - -from pydantic import ValidationError -from pydantic_core import PydanticSerializationError - -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.convex_translation_partition import ( - ConvexAllowedTranslationBracketV2, - ConvexAllowedTranslationKindV2, - compile_convex_allowed_translation_v2, -) -from spatialcf.core.v2.rect_kernel import ( - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, - RectTopologyV2, -) -from spatialcf.core.v2.so2_interval import SO2AtomicBudgetV2 -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - NumericPolicyV2, - UncertaintyBudgetV2, - Vec3V2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - CollisionClearanceMetricV2, - PositionRegionInterpretationV2, - RegionAggregationV2, -) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - GeometryInstanceV2_2, - SemanticProblemV2_2, - StrictConvexCandidateCompilerConfigV2_5, -) -from spatialcf.domain.v2.geometry import ( - GeometryApproximationV2, - GeometryRoleV2, - UprightBox3DV2, -) - -_ARTIFACT_HASH_DOMAIN_V2_2 = b"spatialcf.strict-convex-candidate-artifact.v2.2\0" -_DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}") - - -class StrictConvexCandidateCompilationKindV2(StrEnum): - ARTIFACT = "ARTIFACT" - UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" - NUMERIC_GAP = "NUMERIC_GAP" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - INVALID_INPUT = "INVALID_INPUT" - - -class StrictConvexCandidateVerificationKindV2(StrEnum): - VERIFIED = "VERIFIED" - MISMATCH = "MISMATCH" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class StrictConvexCandidateResourceUsageV2: - domain_operations: int - so2_atomic_steps: int - - def __post_init__(self) -> None: - if type(self.domain_operations) is not int or self.domain_operations < 0: - raise ValueError("domain_operations must be a non-negative exact int") - if type(self.so2_atomic_steps) is not int or self.so2_atomic_steps <= 0: - raise ValueError("so2_atomic_steps must be a positive exact int") - - -@dataclass(frozen=True, slots=True) -class StrictConvexCandidateDomainArtifactV2_2: - semantic_problem_sha256: str - compiler_config_sha256: str - subject_id: str - search_universe: ExactAxisAlignedRectV2 - ordered_constraint_ids: tuple[str, ...] - allowed_domain_bracket: ConvexAllowedTranslationBracketV2 - resource_usage: StrictConvexCandidateResourceUsageV2 - remaining_constraint_ids: tuple[str, ...] - - def __post_init__(self) -> None: - for name, digest in ( - ("semantic_problem_sha256", self.semantic_problem_sha256), - ("compiler_config_sha256", self.compiler_config_sha256), - ): - if type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None: - raise ValueError(f"{name} must be a lowercase SHA-256 digest") - if type(self.subject_id) is not str or not self.subject_id.strip(): - raise ValueError("subject_id must be a non-blank exact string") - checked_universe = _copy_universe(self.search_universe) - if ( - checked_universe.topology is not RectTopologyV2.AREA - or checked_universe.coordinate_space - is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M - ): - raise ValueError("search universe must be an AREA translation-delta rect") - if type(self.ordered_constraint_ids) is not tuple or not ( - self.ordered_constraint_ids - ): - raise ValueError("ordered_constraint_ids must be a non-empty exact tuple") - if any( - type(item) is not str or not item.strip() - for item in self.ordered_constraint_ids - ): - raise ValueError("constraint IDs must be non-blank exact strings") - if len(set(self.ordered_constraint_ids)) != len(self.ordered_constraint_ids): - raise ValueError("constraint IDs must be unique") - if type(self.remaining_constraint_ids) is not tuple or any( - type(item) is not str or not item.strip() - for item in self.remaining_constraint_ids - ): - raise ValueError( - "remaining_constraint_ids must be an exact tuple of non-blank strings" - ) - if len(set(self.remaining_constraint_ids)) != len( - self.remaining_constraint_ids - ): - raise ValueError("remaining constraint IDs must be unique") - if ( - tuple(sorted(self.remaining_constraint_ids)) - != self.remaining_constraint_ids - ): - raise ValueError("remaining constraint IDs must be canonically ordered") - if set(self.ordered_constraint_ids) & set(self.remaining_constraint_ids): - raise ValueError("compiled and remaining constraint IDs must be disjoint") - checked_bracket = _copy_bracket(self.allowed_domain_bracket) - if ( - checked_bracket.inner_allowed.universe != checked_universe - or checked_bracket.outer_allowed.universe != checked_universe - ): - raise ValueError("allowed bracket must use the exact search universe") - if type(self.resource_usage) is not StrictConvexCandidateResourceUsageV2: - raise TypeError("resource_usage has the wrong exact type") - checked_usage = StrictConvexCandidateResourceUsageV2( - self.resource_usage.domain_operations, - self.resource_usage.so2_atomic_steps, - ) - if checked_usage.so2_atomic_steps != checked_bracket.atomic_steps_used: - raise ValueError("SO(2) resource usage must equal the bracket replay usage") - object.__setattr__(self, "search_universe", checked_universe) - object.__setattr__(self, "allowed_domain_bracket", checked_bracket) - object.__setattr__(self, "resource_usage", checked_usage) - - @property - def artifact_sha256(self) -> str: - return hashlib.sha256( - _ARTIFACT_HASH_DOMAIN_V2_2 + _artifact_bytes(self) - ).hexdigest() - - -@dataclass(frozen=True, slots=True) -class StrictConvexCandidateCompilationOutcomeV2: - kind: StrictConvexCandidateCompilationKindV2 - artifact: StrictConvexCandidateDomainArtifactV2_2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not StrictConvexCandidateCompilationKindV2: - raise TypeError("kind has the wrong exact type") - if type(self.finding_codes) is not tuple or any( - type(item) is not str or not item.strip() for item in self.finding_codes - ): - raise ValueError("finding_codes must be exact non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - object.__setattr__(self, "finding_codes", findings) - if self.kind is StrictConvexCandidateCompilationKindV2.ARTIFACT: - if type(self.artifact) is not StrictConvexCandidateDomainArtifactV2_2: - raise ValueError("ARTIFACT outcome requires an exact artifact") - if findings: - raise ValueError("ARTIFACT outcome cannot carry findings") - object.__setattr__(self, "artifact", _copy_artifact(self.artifact)) - return - if self.artifact is not None: - raise ValueError("failure outcome cannot carry an artifact") - if not findings: - raise ValueError("failure outcome requires at least one finding") - - -@dataclass(frozen=True, slots=True) -class StrictConvexCandidateVerificationOutcomeV2: - kind: StrictConvexCandidateVerificationKindV2 - semantic_problem_sha256: str | None = None - compiler_config_sha256: str | None = None - artifact_sha256: str | None = None - verification_resource_usage: StrictConvexCandidateResourceUsageV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not StrictConvexCandidateVerificationKindV2: - raise TypeError("verification kind has the wrong exact type") - if type(self.finding_codes) is not tuple or any( - type(item) is not str or not item.strip() for item in self.finding_codes - ): - raise ValueError("finding_codes must be exact non-blank strings") - findings = tuple(sorted(set(self.finding_codes))) - object.__setattr__(self, "finding_codes", findings) - refs = ( - self.semantic_problem_sha256, - self.compiler_config_sha256, - self.artifact_sha256, - ) - if self.kind is StrictConvexCandidateVerificationKindV2.VERIFIED: - if any( - type(value) is not str or _DIGEST_PATTERN.fullmatch(value) is None - for value in refs - ): - raise ValueError("VERIFIED requires three exact SHA-256 references") - if ( - type(self.verification_resource_usage) - is not StrictConvexCandidateResourceUsageV2 - ): - raise ValueError("VERIFIED requires exact replay resource usage") - object.__setattr__( - self, - "verification_resource_usage", - StrictConvexCandidateResourceUsageV2( - self.verification_resource_usage.domain_operations, - self.verification_resource_usage.so2_atomic_steps, - ), - ) - if findings: - raise ValueError("VERIFIED cannot carry findings") - return - if any(value is not None for value in refs): - raise ValueError("non-VERIFIED outcomes cannot carry verified references") - if not findings: - raise ValueError("non-VERIFIED outcomes require findings") - if self.verification_resource_usage is not None: - if ( - type(self.verification_resource_usage) - is not StrictConvexCandidateResourceUsageV2 - ): - raise TypeError("verification_resource_usage has the wrong exact type") - object.__setattr__( - self, - "verification_resource_usage", - StrictConvexCandidateResourceUsageV2( - self.verification_resource_usage.domain_operations, - self.verification_resource_usage.so2_atomic_steps, - ), - ) - - -class StrictConvexCandidateDomainCompilerV2_5: - """Compile the bounded one-pair continuous-yaw collision prefix.""" - - def compile( - self, - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_5, - ) -> StrictConvexCandidateCompilationOutcomeV2: - return compile_strict_convex_candidate_domain_v2_5(problem, config) - - -class _InvalidInputV2(ValueError): - pass - - -class _UnsupportedModelV2(ValueError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -class _ResourceLimitErrorV2(RuntimeError): - pass - - -@dataclass(slots=True) -class _DomainOperationBudgetV2(DomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = _ResourceLimitErrorV2 - - -def compile_strict_convex_candidate_domain_v2_5( - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_5, -) -> StrictConvexCandidateCompilationOutcomeV2: - """Fresh-compile a collision prefix; remaining hard constraints stay explicit.""" - - try: - checked_config = _strict_config(config) - except _InvalidInputV2: - return _failure( - StrictConvexCandidateCompilationKindV2.INVALID_INPUT, - "INVALID_INPUT:STRICT_CONVEX_CANDIDATE_INPUT", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:STRICT_CONVEX_CANDIDATE_REVALIDATION", - ) - - budget = _DomainOperationBudgetV2(checked_config.max_domain_operations) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _precharge_problem_structure(problem, budget) - checked_problem = _strict_problem(problem) - except _ResourceLimitErrorV2: - return _failure( - StrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:max_domain_operations", - ) - except _InvalidInputV2: - return _failure( - StrictConvexCandidateCompilationKindV2.INVALID_INPUT, - "INVALID_INPUT:STRICT_CONVEX_CANDIDATE_INPUT", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:STRICT_CONVEX_CANDIDATE_REVALIDATION", - ) - atomic_budget = SO2AtomicBudgetV2(limit=checked_config.max_so2_atomic_steps) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - from spatialcf.core.v2.candidate_domain import ( - _compile_search_universe, - _SearchUniverseFailureV2, - ) - - search = _compile_search_universe(checked_problem, budget) - if isinstance(search, _SearchUniverseFailureV2): - kind = ( - StrictConvexCandidateCompilationKindV2.NUMERIC_GAP - if search.uncertified_reason.value == "NUMERIC_GAP" - else StrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL - ) - return StrictConvexCandidateCompilationOutcomeV2( - kind=kind, - finding_codes=search.finding_codes, - ) - subject_transform, subject_shape, obstacle_transform, obstacle_shape = ( - _extract_supported_pair(checked_problem, budget) - ) - allowed = compile_convex_allowed_translation_v2( - subject_transform, - subject_shape, - obstacle_transform, - obstacle_shape, - search.delta_rect, - atomic_budget=atomic_budget, - ) - if allowed.kind is ConvexAllowedTranslationKindV2.RESOURCE_LIMIT: - return _failure( - StrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:max_so2_atomic_steps", - ) - if allowed.kind is ConvexAllowedTranslationKindV2.NUMERIC_GAP: - return StrictConvexCandidateCompilationOutcomeV2( - kind=StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - finding_codes=allowed.finding_codes, - ) - if allowed.kind is ConvexAllowedTranslationKindV2.INVALID_INPUT: - raise RuntimeError("strict supported pair produced invalid T12 input") - if ( - allowed.kind is not ConvexAllowedTranslationKindV2.BRACKET - or type(allowed.bracket) is not ConvexAllowedTranslationBracketV2 - ): - raise RuntimeError("malformed T12 allowed-domain outcome") - constraints = checked_problem.constraints - budget.consume( - len(constraints.support_constraints) - + len(constraints.visibility_constraints) - + 1 - ) - remaining_ids = tuple( - sorted( - ( - *( - item.constraint_id - for item in constraints.support_constraints - ), - *( - item.constraint_id - for item in constraints.visibility_constraints - ), - constraints.target_relation.constraint_id, - ) - ) - ) - artifact = StrictConvexCandidateDomainArtifactV2_2( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - compiler_config_sha256=checked_config.config_sha256, - subject_id=constraints.allowed_edit.subject_id, - search_universe=search.delta_rect, - ordered_constraint_ids=( - constraints.position_domain.constraint_id, - constraints.collision_constraints[0].constraint_id, - ), - allowed_domain_bracket=allowed.bracket, - resource_usage=StrictConvexCandidateResourceUsageV2( - domain_operations=budget.used, - so2_atomic_steps=atomic_budget.used, - ), - remaining_constraint_ids=remaining_ids, - ) - return StrictConvexCandidateCompilationOutcomeV2( - kind=StrictConvexCandidateCompilationKindV2.ARTIFACT, - artifact=artifact, - ) - except _UnsupportedModelV2 as error: - return _failure( - StrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL, - error.finding_code, - ) - except _ResourceLimitErrorV2: - return _failure( - StrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:max_domain_operations", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - StrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:STRICT_CONVEX_CANDIDATE_COMPILATION", - ) - - -def verify_strict_convex_candidate_domain_v2_5( - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_5, - submitted_artifact: StrictConvexCandidateDomainArtifactV2_2, -) -> StrictConvexCandidateVerificationOutcomeV2: - """Fresh replay raw inputs and compare the entire submitted prefix artifact.""" - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - checked_submitted = _copy_artifact(submitted_artifact) - except (ArithmeticError, RuntimeWarning): - return StrictConvexCandidateVerificationOutcomeV2( - kind=StrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("NUMERIC_GAP:SUBMITTED_CANDIDATE_ARTIFACT",), - ) - except (AttributeError, TypeError, ValueError, Warning): - return StrictConvexCandidateVerificationOutcomeV2( - kind=StrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("INVALID_INPUT:SUBMITTED_CANDIDATE_ARTIFACT",), - ) - - replay = compile_strict_convex_candidate_domain_v2_5(problem, config) - if ( - replay.kind is not StrictConvexCandidateCompilationKindV2.ARTIFACT - or type(replay.artifact) is not StrictConvexCandidateDomainArtifactV2_2 - ): - return StrictConvexCandidateVerificationOutcomeV2( - kind=StrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=replay.finding_codes, - ) - fresh = replay.artifact - usage = fresh.resource_usage - if ( - checked_submitted != fresh - or _artifact_bytes(checked_submitted) != _artifact_bytes(fresh) - or checked_submitted.artifact_sha256 != fresh.artifact_sha256 - ): - return StrictConvexCandidateVerificationOutcomeV2( - kind=StrictConvexCandidateVerificationKindV2.MISMATCH, - verification_resource_usage=usage, - finding_codes=("MISMATCH:STRICT_CONVEX_CANDIDATE_ARTIFACT",), - ) - return StrictConvexCandidateVerificationOutcomeV2( - kind=StrictConvexCandidateVerificationKindV2.VERIFIED, - semantic_problem_sha256=fresh.semantic_problem_sha256, - compiler_config_sha256=fresh.compiler_config_sha256, - artifact_sha256=fresh.artifact_sha256, - verification_resource_usage=usage, - ) - - -def _strict_problem(value: object) -> SemanticProblemV2_2: - if type(value) is not SemanticProblemV2_2: - raise _InvalidInputV2 - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - payload = value.model_dump(mode="python", warnings="error") - return SemanticProblemV2_2.model_validate(payload, strict=True) - except (ArithmeticError, RuntimeWarning): - raise - except ( - AttributeError, - PydanticSerializationError, - TypeError, - ValidationError, - ValueError, - Warning, - ) as error: - raise _InvalidInputV2 from error - - -def _precharge_problem_structure( - value: object, - budget: _DomainOperationBudgetV2, -) -> None: - if type(value) is not SemanticProblemV2_2: - raise _InvalidInputV2 - try: - scene = value.scene - constraints = value.constraints - objective = value.objective - relation_semantics = value.relation_semantics - visibility_semantics = value.visibility_semantics - families = ( - scene.objects, - scene.geometry_instances, - scene.collision_bodies, - scene.workspace_boundaries, - scene.known_free_spaces, - scene.support_surfaces, - scene.cameras, - scene.baseline_observations, - ) - family_values = tuple( - values - for facts in families - for values in (facts.values, facts.inner_values, facts.outer_values) - if values is not None - ) - if any(type(values) is not tuple for values in family_values): - raise _InvalidInputV2 - budget.consume( - 1 - + sum(len(values) for values in family_values) - + len(constraints.collision_constraints) - + len(constraints.support_constraints) - + len(constraints.visibility_constraints) - + len(relation_semantics.definitions) - + len(visibility_semantics.definitions) - + len(objective.relation_damage.pair_axis_weights) - + len(objective.visibility_change.object_camera_weights) - + len(objective.safety_margin.aggregation.targets) - ) - for facts in (scene.workspace_boundaries, scene.known_free_spaces): - for item in facts.values or (): - _precharge_region(item.region_world_xy, budget) - for item in scene.support_surfaces.values or (): - _precharge_region(item.region_uv, budget) - for item in scene.geometry_instances.values or (): - shape = item.shape - if hasattr(shape, "footprint"): - budget.consume() - _precharge_component(shape.footprint, budget) - except _ResourceLimitErrorV2: - raise - except (AttributeError, TypeError, ValueError) as error: - raise _InvalidInputV2 from error - - -def _precharge_region(value: Any, budget: _DomainOperationBudgetV2) -> None: - components = value.components - if type(components) is not tuple: - raise _InvalidInputV2 - budget.consume(len(components) + 1) - for component in components: - _precharge_component(component, budget) - - -def _precharge_component(value: Any, budget: _DomainOperationBudgetV2) -> None: - holes = value.holes - if type(holes) is not tuple: - raise _InvalidInputV2 - budget.consume(len(value.exterior.vertices) + len(holes) + 1) - for hole in holes: - budget.consume(len(hole.vertices) + 1) - - -def _strict_config(value: object) -> StrictConvexCandidateCompilerConfigV2_5: - if type(value) is not StrictConvexCandidateCompilerConfigV2_5: - raise _InvalidInputV2 - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - payload = value.model_dump(mode="python", warnings="error") - return StrictConvexCandidateCompilerConfigV2_5.model_validate( - payload, strict=True - ) - except (ArithmeticError, RuntimeWarning): - raise - except ( - AttributeError, - PydanticSerializationError, - TypeError, - ValidationError, - ValueError, - Warning, - ) as error: - raise _InvalidInputV2 from error - - -def _extract_supported_pair( - problem: SemanticProblemV2_2, - budget: _DomainOperationBudgetV2, -) -> tuple[ - DirectedYawIntervalTransformV2_2, - UprightBox3DV2, - DirectedYawIntervalTransformV2_2, - UprightBox3DV2, -]: - constraints = problem.constraints - position = constraints.position_domain - if ( - position.region_interpretation - is not PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS - or position.workspace_aggregation is not RegionAggregationV2.INTERSECTION - or position.boundary_policy is not BoundaryPolicyV2.CLOSED - or position.known_free_space_fact_ids - or len(position.workspace_fact_ids) != 1 - or position.minimum_boundary_clearance_m != 0.0 - ): - raise _UnsupportedModelV2("UNSUPPORTED:POSITION_DOMAIN_SUBSET") - workspace_values = _exact_fact_values( - problem.scene.workspace_boundaries, "WORKSPACE_BOUNDARIES", budget - ) - if ( - len(workspace_values) != 1 - or workspace_values[0].fact_id != position.workspace_fact_ids[0] - or workspace_values[0].region_approximation is not GeometryApproximationV2.EXACT - or workspace_values[0].geometry_uncertainty != UncertaintyBudgetV2() - ): - raise _UnsupportedModelV2("UNSUPPORTED:POSITION_WORKSPACE_SUBSET") - if len(constraints.collision_constraints) != 1: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_CONSTRAINT_CARDINALITY") - collision = constraints.collision_constraints[0] - if len(collision.subject_body_ids) != 1 or len(collision.obstacle_body_ids) != 1: - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_PAIR_CARDINALITY") - if ( - collision.clearance_metric - is not CollisionClearanceMetricV2.SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE - or collision.boundary_policy is not BoundaryPolicyV2.CLOSED - or collision.minimum_clearance_m != 0.0 - or collision.support_contact_exceptions - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_POLICY") - if problem.numeric_policy != NumericPolicyV2(): - raise _UnsupportedModelV2("UNSUPPORTED:NUMERIC_POLICY") - - bodies = _exact_fact_values( - problem.scene.collision_bodies, "COLLISION_BODIES", budget - ) - geometries = _exact_fact_values( - problem.scene.geometry_instances, "GEOMETRY_INSTANCES", budget - ) - objects = _exact_fact_values(problem.scene.objects, "OBJECTS", budget) - body_by_id = {item.body_id: item for item in bodies} - geometry_by_id = {item.geometry_id: item for item in geometries} - object_by_id = {item.object_id: item for item in objects} - budget.consume(len(bodies) + len(geometries) + len(objects)) - - subject_id = constraints.allowed_edit.subject_id - subject_body = body_by_id[collision.subject_body_ids[0]] - obstacle_body = body_by_id[collision.obstacle_body_ids[0]] - if ( - subject_body.owner_object_id != subject_id - or obstacle_body.owner_object_id is None - or obstacle_body.owner_object_id == subject_id - or len(subject_body.geometry_instance_ids) != 1 - or len(obstacle_body.geometry_instance_ids) != 1 - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_BODY_SUBSET") - try: - subject_object = object_by_id[subject_id] - obstacle_object = object_by_id[obstacle_body.owner_object_id] - subject_geometry = geometry_by_id[subject_body.geometry_instance_ids[0]] - obstacle_geometry = geometry_by_id[obstacle_body.geometry_instance_ids[0]] - except KeyError as error: - raise RuntimeError( - "strict semantic graph lost a collision reference" - ) from error - budget.consume(4) - if not subject_object.movable or obstacle_object.movable: - raise _UnsupportedModelV2("UNSUPPORTED:FIXED_OBSTACLE_SUBSET") - for geometry, owner_id in ( - (subject_geometry, subject_id), - (obstacle_geometry, obstacle_body.owner_object_id), - ): - if ( - type(geometry) is not GeometryInstanceV2_2 - or geometry.owner_object_id != owner_id - or geometry.role is not GeometryRoleV2.COLLISION - or geometry.approximation is not GeometryApproximationV2.EXACT - or geometry.uncertainty != UncertaintyBudgetV2() - or type(geometry.shape) is not UprightBox3DV2 - or not _is_identity_anchor(geometry.anchor_from_geometry) - ): - raise _UnsupportedModelV2("UNSUPPORTED:COLLISION_GEOMETRY_SUBSET") - subject_transform = subject_object.pose.world_from_object - obstacle_transform = obstacle_object.pose.world_from_object - if ( - type(subject_transform) is not DirectedYawIntervalTransformV2_2 - or type(obstacle_transform) is not DirectedYawIntervalTransformV2_2 - ): - raise RuntimeError("v2.2 object pose lost its directed-yaw transform") - subject_shape = subject_geometry.shape - obstacle_shape = obstacle_geometry.shape - assert type(subject_shape) is UprightBox3DV2 - assert type(obstacle_shape) is UprightBox3DV2 - if not _z_interiors_overlap( - subject_transform, subject_shape, obstacle_transform, obstacle_shape - ): - raise _UnsupportedModelV2("UNSUPPORTED:Z_SEPARATED_COLLISION_PAIR") - return subject_transform, subject_shape, obstacle_transform, obstacle_shape - - -def _exact_fact_values( - facts: Any, - label: str, - budget: _DomainOperationBudgetV2, -) -> tuple[Any, ...]: - if ( - facts.availability is not FactAvailabilityV2.KNOWN - or facts.completeness is not FactCompletenessV2.EXACT - or facts.uncertainty != UncertaintyBudgetV2() - or type(facts.values) is not tuple - ): - raise _UnsupportedModelV2(f"UNSUPPORTED:{label}_FACT_SET") - budget.consume(len(facts.values) + 1) - return facts.values - - -def _is_identity_anchor(transform: DirectedYawIntervalTransformV2_2) -> bool: - return ( - type(transform) is DirectedYawIntervalTransformV2_2 - and transform.translation == Vec3V2(x=0.0, y=0.0, z=0.0) - and transform.yaw_radians == 0.0 - ) - - -def _z_interiors_overlap( - subject_transform: DirectedYawIntervalTransformV2_2, - subject_shape: UprightBox3DV2, - obstacle_transform: DirectedYawIntervalTransformV2_2, - obstacle_shape: UprightBox3DV2, -) -> bool: - subject_z = Fraction.from_float(subject_transform.translation.z) - obstacle_z = Fraction.from_float(obstacle_transform.translation.z) - subject_half = Fraction.from_float(subject_shape.size_m.z) / 2 - obstacle_half = Fraction.from_float(obstacle_shape.size_m.z) / 2 - return max(subject_z - subject_half, obstacle_z - obstacle_half) < min( - subject_z + subject_half, obstacle_z + obstacle_half - ) - - -def _failure( - kind: StrictConvexCandidateCompilationKindV2, - finding_code: str, -) -> StrictConvexCandidateCompilationOutcomeV2: - return StrictConvexCandidateCompilationOutcomeV2( - kind=kind, - finding_codes=(finding_code,), - ) - - -def _copy_universe(value: ExactAxisAlignedRectV2) -> ExactAxisAlignedRectV2: - if type(value) is not ExactAxisAlignedRectV2: - raise TypeError("search_universe has the wrong exact type") - bounds = value.bounds - if bounds is None: - raise ValueError("search universe cannot be empty") - return ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=bounds[0], - min_y_m=bounds[1], - max_x_m=bounds[2], - max_y_m=bounds[3], - coordinate_space=value.coordinate_space, - ) - - -def _copy_bracket( - value: ConvexAllowedTranslationBracketV2, -) -> ConvexAllowedTranslationBracketV2: - if type(value) is not ConvexAllowedTranslationBracketV2: - raise TypeError("allowed_domain_bracket has the wrong exact type") - return ConvexAllowedTranslationBracketV2( - inner_allowed=value.inner_allowed, - outer_allowed=value.outer_allowed, - obstacle_kernel_id=value.obstacle_kernel_id, - obstacle_kernel_version=value.obstacle_kernel_version, - partition_kernel_id=value.partition_kernel_id, - partition_kernel_version=value.partition_kernel_version, - atomic_steps_used=value.atomic_steps_used, - ) - - -def _copy_artifact( - value: StrictConvexCandidateDomainArtifactV2_2, -) -> StrictConvexCandidateDomainArtifactV2_2: - return StrictConvexCandidateDomainArtifactV2_2( - semantic_problem_sha256=value.semantic_problem_sha256, - compiler_config_sha256=value.compiler_config_sha256, - subject_id=value.subject_id, - search_universe=value.search_universe, - ordered_constraint_ids=value.ordered_constraint_ids, - allowed_domain_bracket=value.allowed_domain_bracket, - resource_usage=value.resource_usage, - remaining_constraint_ids=value.remaining_constraint_ids, - ) - - -def _artifact_bytes(value: StrictConvexCandidateDomainArtifactV2_2) -> bytes: - return json.dumps( - _canonical_value(value), - ensure_ascii=False, - allow_nan=False, - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - - -def _canonical_value(value: Any) -> Any: - if isinstance(value, Fraction): - return {"denominator": value.denominator, "numerator": value.numerator} - if isinstance(value, Enum): - return value.value - if is_dataclass(value) and not isinstance(value, type): - return { - field.name: _canonical_value(getattr(value, field.name)) - for field in fields(value) - } - if isinstance(value, tuple): - return [_canonical_value(item) for item in value] - if value is None or type(value) in {str, int, float, bool}: - return value - raise TypeError(f"unsupported artifact hash value: {type(value).__name__}") - - -__all__ = ( - "StrictConvexCandidateCompilationKindV2", - "StrictConvexCandidateCompilationOutcomeV2", - "StrictConvexCandidateDomainArtifactV2_2", - "StrictConvexCandidateDomainCompilerV2_5", - "StrictConvexCandidateResourceUsageV2", - "StrictConvexCandidateVerificationKindV2", - "StrictConvexCandidateVerificationOutcomeV2", - "compile_strict_convex_candidate_domain_v2_5", - "verify_strict_convex_candidate_domain_v2_5", -) diff --git a/src/spatialcf/core/v2/support_domain.py b/src/spatialcf/core/v2/support_domain.py deleted file mode 100644 index cc69a34..0000000 --- a/src/spatialcf/core/v2/support_domain.py +++ /dev/null @@ -1,526 +0,0 @@ -"""Sound SUPPORT-domain compilation for the exact rectangular Canonical v2 subset. - -The compiler works entirely in Canonical world coordinates. It derives a -domain over the sole allowed edit variable, world-XY translation delta, from -the relative motion of the supported object and the support-surface owner. -Anything outside the deliberately small exact subset is ``UNKNOWN`` rather -than being approximated into a false ``EMPTY`` or certified restriction. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction - -from spatialcf.core.v2.rect_kernel import ( - AxisMarginXYV2, - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, - RectTopologyV2, - TranslationDeltaXYV2, - UnsupportedRectRegionErrorV2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - FactSetV2, - NumericPolicyV2, - QuaternionV2, - RigidTransformV2, - UncertaintyBudgetV2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - SupportConstraintV2, -) -from spatialcf.domain.v2.geometry import ( - ExtrudedPlanarPolygonV2, - GeometryApproximationV2, - GeometryInstanceV2, - PlanarRegionV2, - UprightBox3DV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import ( - CanonicalObjectV2, - RegionBoundaryPolicyV2, - SupportSurfaceFactV2, -) - - -class SupportDomainKindV2(StrEnum): - """Mathematical effect of one SUPPORT predicate on edit deltas.""" - - RECT_DELTA_LOCUS = "RECT_DELTA_LOCUS" - IDENTITY = "IDENTITY" - EMPTY = "EMPTY" - UNKNOWN = "UNKNOWN" - - -@dataclass(frozen=True, slots=True) -class SupportDomainCompilationOutcomeV2: - """Closed, deterministic result of compiling one support constraint. - - ``IDENTITY`` means the exact predicate is constant true over every XY edit - delta; it does not mean that only the zero delta is allowed. - """ - - kind: SupportDomainKindV2 - delta_locus: ExactAxisAlignedRectV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.kind, SupportDomainKindV2): - raise TypeError("kind must be a SupportDomainKindV2") - object.__setattr__( - self, - "finding_codes", - tuple(sorted(set(self.finding_codes))), - ) - if self.kind is SupportDomainKindV2.RECT_DELTA_LOCUS: - if self.delta_locus is None: - raise ValueError("RECT_DELTA_LOCUS requires a rectangle") - if ( - self.delta_locus.coordinate_space - is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M - or self.delta_locus.topology is not RectTopologyV2.AREA - ): - raise ValueError( - "support delta locus must be a positive-area translation rectangle" - ) - if self.finding_codes: - raise ValueError("a compiled rectangular locus cannot carry findings") - return - if self.delta_locus is not None: - raise ValueError(f"{self.kind.value} must not carry a delta rectangle") - if self.kind is SupportDomainKindV2.UNKNOWN and not self.finding_codes: - raise ValueError("UNKNOWN support outcome requires a finding") - - -SupportDomainOutcomeV2 = SupportDomainCompilationOutcomeV2 - - -@dataclass(frozen=True, slots=True) -class _WorldContactV2: - rectangle: ExactAxisAlignedRectV2 - plane_z_m: Fraction - - -def compile_support_domain_v2( - problem: SemanticProblemV2, - constraint: SupportConstraintV2 | str, -) -> SupportDomainCompilationOutcomeV2: - """Compile one exact rectangular SUPPORT predicate into delta coordinates. - - The root and optional constraint instance are reconstructed with strict - validation before any certified geometric conclusion. - """ - - if not isinstance(problem, SemanticProblemV2): - raise TypeError("problem must be a SemanticProblemV2") - - checked_problem = SemanticProblemV2.model_validate( - problem.model_dump(mode="python"), - strict=True, - ) - selected = _resolve_constraint(checked_problem, constraint) - if isinstance(selected, SupportDomainCompilationOutcomeV2): - return selected - - findings = list(_supported_subset_findings(checked_problem, selected)) - if findings: - return _unknown(*findings) - - objects = { - item.object_id: item for item in checked_problem.scene.objects.values or () - } - geometries = { - item.geometry_id: item - for item in checked_problem.scene.geometry_instances.values or () - } - surfaces = { - item.surface_id: item - for item in checked_problem.scene.support_surfaces.values or () - } - supported_object = objects[selected.supported_object_id] - geometry = geometries[selected.subject_contact_geometry_ids[0]] - surface = surfaces[selected.surface_id] - - transform_findings = _transform_findings( - supported_object, - geometry, - surface, - objects, - ) - if transform_findings: - return _unknown(*transform_findings) - - try: - contact = _contact_in_world(supported_object, geometry) - support = _surface_in_world(surface, objects) - except UnsupportedRectRegionErrorV2: - return _unknown( - f"UNSUPPORTED_SUPPORT_DOMAIN:NON_RECTANGULAR_GEOMETRY:" - f"{selected.constraint_id}" - ) - - gap_m = contact.plane_z_m - support.plane_z_m - gap_min_m = Fraction.from_float(selected.contact_gap_min_m) - gap_max_m = Fraction.from_float(selected.contact_gap_max_m) - if gap_m < gap_min_m or gap_m > gap_max_m: - return _empty(f"EXACT_EMPTY:CONTACT_GAP:{selected.constraint_id}") - - contact_bounds = contact.rectangle.bounds - assert contact_bounds is not None - contact_area_m2 = (contact_bounds[2] - contact_bounds[0]) * ( - contact_bounds[3] - contact_bounds[1] - ) - if contact_area_m2 < Fraction.from_float(selected.minimum_overlap_area_m2): - return _empty(f"EXACT_EMPTY:OVERLAP_AREA:{selected.constraint_id}") - - inset = support.rectangle.erode_axis( - AxisMarginXYV2.isotropic_from_binary64(selected.stability_margin_m) - ) - if inset.topology is RectTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:STABILITY_INSET:{selected.constraint_id}") - if inset.topology is RectTopologyV2.DEGENERATE: - return _unknown( - f"UNSUPPORTED_SUPPORT_DOMAIN:DEGENERATE_SURFACE_INSET:" - f"{selected.constraint_id}" - ) - - relative_edit_coefficient = _relative_edit_coefficient( - checked_problem, - supported_object, - surface, - ) - if relative_edit_coefficient == 0: - if inset.contains(contact.rectangle): - return SupportDomainCompilationOutcomeV2(kind=SupportDomainKindV2.IDENTITY) - return _empty(f"EXACT_EMPTY:STABILITY_CONTAINMENT:{selected.constraint_id}") - - relative_locus = _containment_translation_locus(contact.rectangle, inset) - if relative_locus.topology is RectTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:STABILITY_CONTAINMENT:{selected.constraint_id}") - if relative_locus.topology is RectTopologyV2.DEGENERATE: - return _unknown( - f"UNSUPPORTED_SUPPORT_DOMAIN:DEGENERATE_DELTA_LOCUS:" - f"{selected.constraint_id}" - ) - - delta_locus = ( - relative_locus - if relative_edit_coefficient == 1 - else _negate_delta_rectangle(relative_locus) - ) - return SupportDomainCompilationOutcomeV2( - kind=SupportDomainKindV2.RECT_DELTA_LOCUS, - delta_locus=delta_locus, - ) - - -def _resolve_constraint( - problem: SemanticProblemV2, - requested: SupportConstraintV2 | str, -) -> SupportConstraintV2 | SupportDomainCompilationOutcomeV2: - if isinstance(requested, SupportConstraintV2): - checked = SupportConstraintV2.model_validate( - requested.model_dump(mode="python"), - strict=True, - ) - constraint_id = checked.constraint_id - elif type(requested) is str: - checked = None - constraint_id = requested - else: - raise TypeError("constraint must be a SupportConstraintV2 or exact str ID") - - registered = next( - ( - item - for item in problem.constraints.support_constraints - if item.constraint_id == constraint_id - ), - None, - ) - if registered is None: - return _unknown(f"UNKNOWN_SUPPORT_CONSTRAINT:{constraint_id}") - if checked is not None and checked != registered: - return _unknown(f"SUPPORT_CONSTRAINT_MISMATCH:{constraint_id}") - return registered - - -def _supported_subset_findings( - problem: SemanticProblemV2, - constraint: SupportConstraintV2, -) -> tuple[str, ...]: - findings: list[str] = [] - if constraint.boundary_policy is not BoundaryPolicyV2.CLOSED: - findings.append( - f"UNSUPPORTED_SUPPORT_DOMAIN:BOUNDARY_POLICY:{constraint.constraint_id}" - ) - if not _numeric_policy_is_zero(problem.numeric_policy): - findings.append("UNSUPPORTED_SUPPORT_DOMAIN:NUMERIC_POLICY") - - for label, facts in ( - ("OBJECTS", problem.scene.objects), - ("GEOMETRY_INSTANCES", problem.scene.geometry_instances), - ("SUPPORT_SURFACES", problem.scene.support_surfaces), - ): - findings.extend(_fact_family_findings(label, facts)) - - if len(constraint.subject_contact_geometry_ids) != 1: - findings.append( - f"UNSUPPORTED_SUPPORT_DOMAIN:CONTACT_UNION_CARDINALITY:" - f"{constraint.constraint_id}:{len(constraint.subject_contact_geometry_ids)}" - ) - - geometry_facts = problem.scene.geometry_instances - if ( - geometry_facts.availability is FactAvailabilityV2.KNOWN - and geometry_facts.completeness is FactCompletenessV2.EXACT - ): - geometries = {item.geometry_id: item for item in geometry_facts.values or ()} - for geometry_id in constraint.subject_contact_geometry_ids: - geometry = geometries.get(geometry_id) - if geometry is None: - findings.append(f"MISSING_FACT:SUPPORT_GEOMETRY:{geometry_id}") - continue - if geometry.approximation is not GeometryApproximationV2.EXACT: - findings.append( - "UNSUPPORTED_SUPPORT_DOMAIN:CONTACT_APPROXIMATION:" - f"{geometry_id}:{geometry.approximation.value}" - ) - if not _uncertainty_is_zero(geometry.uncertainty): - findings.append( - f"UNSUPPORTED_SUPPORT_DOMAIN:CONTACT_ITEM_UNCERTAINTY:{geometry_id}" - ) - - surface_facts = problem.scene.support_surfaces - if ( - surface_facts.availability is FactAvailabilityV2.KNOWN - and surface_facts.completeness is FactCompletenessV2.EXACT - ): - surfaces = {item.surface_id: item for item in surface_facts.values or ()} - surface = surfaces.get(constraint.surface_id) - if surface is None: - findings.append(f"MISSING_FACT:SUPPORT_SURFACE:{constraint.surface_id}") - return tuple(sorted(set(findings))) - if surface.region_approximation is not GeometryApproximationV2.EXACT: - findings.append( - "UNSUPPORTED_SUPPORT_DOMAIN:SURFACE_APPROXIMATION:" - f"{surface.surface_id}:{surface.region_approximation.value}" - ) - if surface.boundary_policy is not RegionBoundaryPolicyV2.CLOSED: - findings.append( - f"UNSUPPORTED_SUPPORT_DOMAIN:SURFACE_BOUNDARY:{surface.surface_id}" - ) - if not _uncertainty_is_zero(surface.geometry_uncertainty): - findings.append( - f"UNSUPPORTED_SUPPORT_DOMAIN:SURFACE_ITEM_UNCERTAINTY:" - f"{surface.surface_id}" - ) - if ( - surface.normal_in_anchor.x, - surface.normal_in_anchor.y, - surface.normal_in_anchor.z, - ) != ( - 0.0, - 0.0, - 1.0, - ): - findings.append( - f"UNSUPPORTED_SUPPORT_DOMAIN:NON_HORIZONTAL_SURFACE:" - f"{surface.surface_id}" - ) - return tuple(sorted(set(findings))) - - -def _fact_family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: - if facts.availability is FactAvailabilityV2.MISSING: - return (f"MISSING_FACT:{label}",) - if facts.availability is not FactAvailabilityV2.KNOWN: - return (f"UNSUPPORTED_SUPPORT_DOMAIN:{label}_AVAILABILITY",) - findings: list[str] = [] - if facts.completeness is not FactCompletenessV2.EXACT: - value = facts.completeness.value if facts.completeness is not None else "NONE" - findings.append(f"UNSUPPORTED_SUPPORT_DOMAIN:{label}_COMPLETENESS:{value}") - if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): - findings.append(f"UNSUPPORTED_SUPPORT_DOMAIN:{label}_FACT_UNCERTAINTY") - return tuple(findings) - - -def _transform_findings( - supported_object: CanonicalObjectV2, - geometry: GeometryInstanceV2, - surface: SupportSurfaceFactV2, - objects: dict[str, CanonicalObjectV2], -) -> tuple[str, ...]: - findings: list[str] = [] - transforms = ( - ( - f"OBJECT_POSE:{supported_object.object_id}", - supported_object.pose.world_from_object, - ), - (geometry.geometry_id, geometry.anchor_from_geometry), - (f"SURFACE_FRAME:{surface.surface_id}", surface.anchor_from_surface), - ) - for label, transform in transforms: - if not _has_exact_identity_rotation(transform): - findings.append(f"UNSUPPORTED_SUPPORT_DOMAIN:NON_IDENTITY_ROTATION:{label}") - if surface.owner_object_id is not None: - owner = objects[surface.owner_object_id] - if not _has_exact_identity_rotation(owner.pose.world_from_object): - findings.append( - "UNSUPPORTED_SUPPORT_DOMAIN:NON_IDENTITY_ROTATION:" - f"SURFACE_OWNER_POSE:{owner.object_id}" - ) - return tuple(sorted(set(findings))) - - -def _has_exact_identity_rotation(transform: RigidTransformV2) -> bool: - rotation: QuaternionV2 = transform.rotation - return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) - - -def _contact_in_world( - supported_object: CanonicalObjectV2, - geometry: GeometryInstanceV2, -) -> _WorldContactV2: - if isinstance(geometry.shape, UprightBox3DV2): - half_x = Fraction.from_float(geometry.shape.size_m.x) / 2 - half_y = Fraction.from_float(geometry.shape.size_m.y) / 2 - local = ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=-half_x, - min_y_m=-half_y, - max_x_m=half_x, - max_y_m=half_y, - coordinate_space=RectCoordinateSpaceV2.WORLD_XY_M, - ) - local_z = -Fraction.from_float(geometry.shape.size_m.z) / 2 - elif isinstance(geometry.shape, ExtrudedPlanarPolygonV2): - local = ExactAxisAlignedRectV2.from_planar_region( - # A component is wrapped back into the canonical region expected by - # the exact parser; no floating-point geometric operation is used. - PlanarRegionV2(components=(geometry.shape.footprint,)) - ) - local_z = Fraction.from_float(geometry.shape.lower_z_m) - else: # pragma: no cover - closed discriminated union, retained defensively - raise UnsupportedRectRegionErrorV2("unsupported support geometry shape") - - object_transform = supported_object.pose.world_from_object - geometry_transform = geometry.anchor_from_geometry - dx = Fraction.from_float(object_transform.translation.x) + Fraction.from_float( - geometry_transform.translation.x - ) - dy = Fraction.from_float(object_transform.translation.y) + Fraction.from_float( - geometry_transform.translation.y - ) - plane_z = ( - Fraction.from_float(object_transform.translation.z) - + Fraction.from_float(geometry_transform.translation.z) - + local_z - ) - return _WorldContactV2( - rectangle=local.translate(TranslationDeltaXYV2(dx_m=dx, dy_m=dy)), - plane_z_m=plane_z, - ) - - -def _surface_in_world( - surface: SupportSurfaceFactV2, - objects: dict[str, CanonicalObjectV2], -) -> _WorldContactV2: - local = ExactAxisAlignedRectV2.from_planar_region(surface.region_uv) - owner_translation = (Fraction(), Fraction(), Fraction()) - if surface.owner_object_id is not None: - translation = objects[ - surface.owner_object_id - ].pose.world_from_object.translation - owner_translation = tuple( - Fraction.from_float(value) - for value in (translation.x, translation.y, translation.z) - ) - surface_translation = surface.anchor_from_surface.translation - dx = owner_translation[0] + Fraction.from_float(surface_translation.x) - dy = owner_translation[1] + Fraction.from_float(surface_translation.y) - plane_z = owner_translation[2] + Fraction.from_float(surface_translation.z) - return _WorldContactV2( - rectangle=local.translate(TranslationDeltaXYV2(dx_m=dx, dy_m=dy)), - plane_z_m=plane_z, - ) - - -def _relative_edit_coefficient( - problem: SemanticProblemV2, - supported_object: CanonicalObjectV2, - surface: SupportSurfaceFactV2, -) -> int: - subject_id = problem.constraints.allowed_edit.subject_id - object_coefficient = int(supported_object.object_id == subject_id) - surface_coefficient = int(surface.owner_object_id == subject_id) - return object_coefficient - surface_coefficient - - -def _containment_translation_locus( - contact: ExactAxisAlignedRectV2, - surface_inset: ExactAxisAlignedRectV2, -) -> ExactAxisAlignedRectV2: - contact_bounds = contact.bounds - surface_bounds = surface_inset.bounds - assert contact_bounds is not None and surface_bounds is not None - return ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=surface_bounds[0] - contact_bounds[0], - min_y_m=surface_bounds[1] - contact_bounds[1], - max_x_m=surface_bounds[2] - contact_bounds[2], - max_y_m=surface_bounds[3] - contact_bounds[3], - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ) - - -def _negate_delta_rectangle( - rectangle: ExactAxisAlignedRectV2, -) -> ExactAxisAlignedRectV2: - bounds = rectangle.bounds - assert bounds is not None - return ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=-bounds[2], - min_y_m=-bounds[3], - max_x_m=-bounds[0], - max_y_m=-bounds[1], - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ) - - -def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: - return all( - value == 0.0 - for value in ( - policy.linear_tolerance_m, - policy.area_tolerance_m2, - policy.angular_tolerance_rad, - policy.pixel_tolerance_px, - policy.fraction_tolerance, - ) - ) - - -def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: - return _numeric_policy_is_zero( - uncertainty.source_error - ) and _numeric_policy_is_zero(uncertainty.shape_approximation) - - -def _unknown(*findings: str) -> SupportDomainCompilationOutcomeV2: - return SupportDomainCompilationOutcomeV2( - kind=SupportDomainKindV2.UNKNOWN, - finding_codes=tuple(findings), - ) - - -def _empty(finding: str) -> SupportDomainCompilationOutcomeV2: - return SupportDomainCompilationOutcomeV2( - kind=SupportDomainKindV2.EMPTY, - finding_codes=(finding,), - ) diff --git a/src/spatialcf/core/v2/support_strict_convex_candidate_domain.py b/src/spatialcf/core/v2/support_strict_convex_candidate_domain.py deleted file mode 100644 index ca0a5fe..0000000 --- a/src/spatialcf/core/v2/support_strict_convex_candidate_domain.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Raw support-aware strict-convex candidate compilation for Canonical v2.2.""" - -from __future__ import annotations - -import hashlib -import re -import warnings -from dataclasses import dataclass -from enum import StrEnum - -from pydantic import ValidationError -from pydantic_core import PydanticSerializationError - -from spatialcf.core.v2.continuous_yaw_support_projection import ( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2, - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2, - ContinuousYawSupportProjectionBracketV2, - ContinuousYawSupportProjectionKindV2, - compile_exact_horizontal_support_projection_v2, -) -from spatialcf.core.v2.multi_obstacle_strict_convex_candidate_domain import ( - MultiObstacleStrictConvexCandidateCompilationKindV2, - MultiObstacleStrictConvexCandidateDomainArtifactV2_2, - MultiObstacleStrictConvexCandidateResourceUsageV2, - _artifact_bytes, - _copy_intersection_complex, - _copy_universe, - _precharge_problem_structure, - _require_finding_codes, - _require_id_tuple, - _strict_problem, - compile_multi_obstacle_strict_convex_candidate_domain_v2_6, -) -from spatialcf.core.v2.rect_kernel import ( - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, - RectTopologyV2, -) -from spatialcf.core.v2.so2_interval import SO2AtomicBudgetV2 -from spatialcf.core.v2.strict_convex_candidate_domain import ( - _InvalidInputV2 as _LegacyInvalidInputV2, -) -from spatialcf.core.v2.strict_convex_intersection import ( - StrictConvexIntersectionBudgetExhaustedV2, - StrictConvexIntersectionBudgetV2, - StrictConvexIntersectionComplexV2, - StrictConvexIntersectionKindV2, - StrictConvexIntersectionOutcomeV2, - intersect_strict_convex_allowed_complexes_v2, -) -from spatialcf.domain.v2.continuous_yaw_candidate import ( - SemanticProblemV2_2, - StrictConvexCandidateCompilerConfigV2_6, - StrictConvexCandidateCompilerConfigV2_7, -) - -_ARTIFACT_HASH_DOMAIN_V2_2 = ( - b"spatialcf.support-strict-convex-candidate-artifact.v2.2\0" -) -_DIGEST_PATTERN = re.compile(r"[0-9a-f]{64}") -_INTERSECTION_KERNEL_ID = "geometry-kernel:rational-strict-convex-intersection-v2" -_INTERSECTION_KERNEL_VERSION = "kernel:2.5-strict-convex-intersection" - - -class SupportStrictConvexCandidateCompilationKindV2(StrEnum): - ARTIFACT = "ARTIFACT" - UNSUPPORTED_MODEL = "UNSUPPORTED_MODEL" - NUMERIC_GAP = "NUMERIC_GAP" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - INVALID_INPUT = "INVALID_INPUT" - - -class SupportStrictConvexCandidateVerificationKindV2(StrEnum): - VERIFIED = "VERIFIED" - MISMATCH = "MISMATCH" - UNCERTIFIED = "UNCERTIFIED" - - -@dataclass(frozen=True, slots=True) -class SupportStrictConvexAllowedBracketV2: - inner_allowed: StrictConvexIntersectionComplexV2 - outer_allowed: StrictConvexIntersectionComplexV2 - intersection_kernel_id: str - intersection_kernel_version: str - support_projection_kernel_id: str - support_projection_kernel_version: str - so2_atomic_steps_used: int - - def __post_init__(self) -> None: - checked_inner = _copy_intersection_complex(self.inner_allowed) - checked_outer = _copy_intersection_complex(self.outer_allowed) - if checked_inner.universe != checked_outer.universe: - raise ValueError("support-aware bracket requires one exact universe") - if self.intersection_kernel_id != _INTERSECTION_KERNEL_ID: - raise ValueError("unexpected intersection kernel ID") - if self.intersection_kernel_version != _INTERSECTION_KERNEL_VERSION: - raise ValueError("unexpected intersection kernel version") - if self.support_projection_kernel_id != ( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_ID_V2 - ): - raise ValueError("unexpected support projection kernel ID") - if self.support_projection_kernel_version != ( - CONTINUOUS_YAW_SUPPORT_PROJECTION_KERNEL_VERSION_V2 - ): - raise ValueError("unexpected support projection kernel version") - if ( - type(self.so2_atomic_steps_used) is not int - or self.so2_atomic_steps_used <= 0 - ): - raise ValueError("so2_atomic_steps_used must be a positive exact int") - if not all( - checked_outer.contains_point(cell.strict_witness) - for cell in checked_inner.cells - ): - raise ValueError("support-aware inner witness escaped the outer domain") - object.__setattr__(self, "inner_allowed", checked_inner) - object.__setattr__(self, "outer_allowed", checked_outer) - - -@dataclass(frozen=True, slots=True) -class SupportStrictConvexCandidateDomainArtifactV2_2: - semantic_problem_sha256: str - compiler_config_sha256: str - upstream_t14_artifact_sha256: str - subject_id: str - search_universe: ExactAxisAlignedRectV2 - ordered_constraint_ids: tuple[str, ...] - ordered_obstacle_body_ids: tuple[str, ...] - support_constraint_id: str - surface_id: str - contact_geometry_id: str - allowed_domain_bracket: SupportStrictConvexAllowedBracketV2 - resource_usage: MultiObstacleStrictConvexCandidateResourceUsageV2 - remaining_constraint_ids: tuple[str, ...] - - def __post_init__(self) -> None: - for label, digest in ( - ("semantic_problem_sha256", self.semantic_problem_sha256), - ("compiler_config_sha256", self.compiler_config_sha256), - ("upstream_t14_artifact_sha256", self.upstream_t14_artifact_sha256), - ): - if type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None: - raise ValueError(f"{label} must be a lowercase SHA-256 digest") - for label, value in ( - ("subject_id", self.subject_id), - ("support_constraint_id", self.support_constraint_id), - ("surface_id", self.surface_id), - ("contact_geometry_id", self.contact_geometry_id), - ): - if type(value) is not str or not value.strip(): - raise ValueError(f"{label} must be a non-blank exact string") - checked_universe = _copy_universe(self.search_universe) - if ( - checked_universe.topology is not RectTopologyV2.AREA - or checked_universe.coordinate_space - is not RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M - ): - raise ValueError("search universe must be an AREA translation-delta rect") - compiled_ids = _require_id_tuple( - self.ordered_constraint_ids, - label="ordered_constraint_ids", - nonempty=True, - sorted_required=False, - ) - obstacle_ids = _require_id_tuple( - self.ordered_obstacle_body_ids, - label="ordered_obstacle_body_ids", - nonempty=True, - sorted_required=True, - ) - remaining_ids = _require_id_tuple( - self.remaining_constraint_ids, - label="remaining_constraint_ids", - nonempty=False, - sorted_required=True, - ) - if self.support_constraint_id not in compiled_ids: - raise ValueError("support constraint must be included in compiled IDs") - if set(compiled_ids) & set(remaining_ids): - raise ValueError("compiled and remaining constraint IDs must be disjoint") - if type(self.allowed_domain_bracket) is not SupportStrictConvexAllowedBracketV2: - raise TypeError("allowed_domain_bracket has the wrong exact type") - checked_bracket = _copy_bracket(self.allowed_domain_bracket) - if ( - checked_bracket.inner_allowed.universe != checked_universe - or checked_bracket.outer_allowed.universe != checked_universe - ): - raise ValueError("allowed bracket must use the search universe") - if ( - type(self.resource_usage) - is not MultiObstacleStrictConvexCandidateResourceUsageV2 - ): - raise TypeError("resource_usage has the wrong exact type") - usage = MultiObstacleStrictConvexCandidateResourceUsageV2( - domain_operations=self.resource_usage.domain_operations, - so2_atomic_steps=self.resource_usage.so2_atomic_steps, - candidate_cells=self.resource_usage.candidate_cells, - ) - if usage.so2_atomic_steps != checked_bracket.so2_atomic_steps_used: - raise ValueError("SO(2) usage must equal bracket cumulative usage") - published_cells = len(checked_bracket.inner_allowed.cells) + len( - checked_bracket.outer_allowed.cells - ) - if usage.candidate_cells < published_cells: - raise ValueError("cumulative candidate usage cannot undercount final cells") - object.__setattr__(self, "search_universe", checked_universe) - object.__setattr__(self, "ordered_constraint_ids", compiled_ids) - object.__setattr__(self, "ordered_obstacle_body_ids", obstacle_ids) - object.__setattr__(self, "remaining_constraint_ids", remaining_ids) - object.__setattr__(self, "allowed_domain_bracket", checked_bracket) - object.__setattr__(self, "resource_usage", usage) - - @property - def artifact_sha256(self) -> str: - return hashlib.sha256( - _ARTIFACT_HASH_DOMAIN_V2_2 + _artifact_bytes(self) # type: ignore[arg-type] - ).hexdigest() - - -@dataclass(frozen=True, slots=True) -class SupportStrictConvexCandidateCompilationOutcomeV2: - kind: SupportStrictConvexCandidateCompilationKindV2 - artifact: SupportStrictConvexCandidateDomainArtifactV2_2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not SupportStrictConvexCandidateCompilationKindV2: - raise TypeError("kind has the wrong exact type") - findings = _require_finding_codes(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - if self.kind is SupportStrictConvexCandidateCompilationKindV2.ARTIFACT: - if ( - type(self.artifact) - is not SupportStrictConvexCandidateDomainArtifactV2_2 - ): - raise ValueError("ARTIFACT outcome requires an exact artifact") - if findings: - raise ValueError("ARTIFACT outcome cannot carry findings") - object.__setattr__(self, "artifact", _copy_artifact(self.artifact)) - return - if self.artifact is not None or not findings: - raise ValueError("failure requires findings and no artifact") - - -@dataclass(frozen=True, slots=True) -class SupportStrictConvexCandidateVerificationOutcomeV2: - kind: SupportStrictConvexCandidateVerificationKindV2 - semantic_problem_sha256: str | None = None - compiler_config_sha256: str | None = None - artifact_sha256: str | None = None - verification_resource_usage: ( - MultiObstacleStrictConvexCandidateResourceUsageV2 | None - ) = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if type(self.kind) is not SupportStrictConvexCandidateVerificationKindV2: - raise TypeError("verification kind has the wrong exact type") - findings = _require_finding_codes(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - refs = ( - self.semantic_problem_sha256, - self.compiler_config_sha256, - self.artifact_sha256, - ) - if self.kind is SupportStrictConvexCandidateVerificationKindV2.VERIFIED: - if any( - type(digest) is not str or _DIGEST_PATTERN.fullmatch(digest) is None - for digest in refs - ): - raise ValueError("VERIFIED outcome requires three SHA-256 references") - if findings: - raise ValueError("VERIFIED outcome cannot carry findings") - if ( - type(self.verification_resource_usage) - is not MultiObstacleStrictConvexCandidateResourceUsageV2 - ): - raise ValueError("VERIFIED outcome requires replay resource usage") - else: - if any(digest is not None for digest in refs): - raise ValueError("failure verification outcome cannot carry references") - if not findings: - raise ValueError("failure verification outcome requires findings") - if self.verification_resource_usage is not None: - if ( - type(self.verification_resource_usage) - is not MultiObstacleStrictConvexCandidateResourceUsageV2 - ): - raise TypeError("verification resource usage has the wrong exact type") - usage = self.verification_resource_usage - object.__setattr__( - self, - "verification_resource_usage", - MultiObstacleStrictConvexCandidateResourceUsageV2( - domain_operations=usage.domain_operations, - so2_atomic_steps=usage.so2_atomic_steps, - candidate_cells=usage.candidate_cells, - ), - ) - - -class SupportStrictConvexCandidateDomainCompilerV2_7: - def compile( - self, - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_7, - ) -> SupportStrictConvexCandidateCompilationOutcomeV2: - return compile_support_strict_convex_candidate_domain_v2_7(problem, config) - - -class _InvalidInputV2(ValueError): - pass - - -def compile_support_strict_convex_candidate_domain_v2_7( - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_7, -) -> SupportStrictConvexCandidateCompilationOutcomeV2: - """Fresh-compile T14 plus one exact horizontal SUPPORT predicate.""" - - try: - checked_config = _strict_config(config) - except _InvalidInputV2: - return _failure( - SupportStrictConvexCandidateCompilationKindV2.INVALID_INPUT, - "INVALID_INPUT:SUPPORT_STRICT_CONVEX_INPUT", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:SUPPORT_CONFIG_REVALIDATION", - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - upstream = compile_multi_obstacle_strict_convex_candidate_domain_v2_6( - problem, _t14_config(checked_config) - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:UPSTREAM_MULTI_OBSTACLE_REPLAY", - ) - if ( - upstream.kind - is not MultiObstacleStrictConvexCandidateCompilationKindV2.ARTIFACT - or type(upstream.artifact) - is not MultiObstacleStrictConvexCandidateDomainArtifactV2_2 - ): - return _from_upstream_failure(upstream.kind, upstream.finding_codes) - upstream_artifact = upstream.artifact - budget = StrictConvexIntersectionBudgetV2( - max_domain_operations=checked_config.max_domain_operations, - max_candidate_cells=checked_config.max_candidate_cells, - domain_operations_used=upstream_artifact.resource_usage.domain_operations, - candidate_cells_used=upstream_artifact.resource_usage.candidate_cells, - ) - atomic_budget = SO2AtomicBudgetV2( - limit=checked_config.max_so2_atomic_steps, - used=upstream_artifact.resource_usage.so2_atomic_steps, - ) - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - _precharge_problem_structure(problem, budget) # type: ignore[arg-type] - checked_problem = _strict_problem(problem) - if ( - checked_problem.semantic_problem_sha256 - != upstream_artifact.semantic_problem_sha256 - ): - raise _InvalidInputV2 - support = compile_exact_horizontal_support_projection_v2( - checked_problem, - "constraint:support", - upstream_artifact.search_universe, - atomic_budget=atomic_budget, - intersection_budget=budget, - ) - if support.kind is ContinuousYawSupportProjectionKindV2.RESOURCE_LIMIT: - return _resource_failure() - if support.kind is ContinuousYawSupportProjectionKindV2.NUMERIC_GAP: - return SupportStrictConvexCandidateCompilationOutcomeV2( - kind=SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - finding_codes=support.finding_codes, - ) - if support.kind is ContinuousYawSupportProjectionKindV2.INVALID_INPUT: - raise RuntimeError("strictly checked support input became invalid") - if support.kind is ContinuousYawSupportProjectionKindV2.UNSUPPORTED_MODEL: - return SupportStrictConvexCandidateCompilationOutcomeV2( - kind=SupportStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL, - finding_codes=support.finding_codes, - ) - if ( - support.kind is not ContinuousYawSupportProjectionKindV2.BRACKET - or type(support.bracket) is not ContinuousYawSupportProjectionBracketV2 - ): - raise RuntimeError("malformed support projection outcome") - support_bracket = support.bracket - inner = _require_intersection( - intersect_strict_convex_allowed_complexes_v2( - ( - upstream_artifact.allowed_domain_bracket.inner_allowed, - support_bracket.inner_allowed, - ), - budget=budget, - ) - ) - outer = _require_intersection( - intersect_strict_convex_allowed_complexes_v2( - ( - upstream_artifact.allowed_domain_bracket.outer_allowed, - support_bracket.outer_allowed, - ), - budget=budget, - ) - ) - remaining = tuple( - item - for item in upstream_artifact.remaining_constraint_ids - if item != support_bracket.support_constraint_id - ) - if len(remaining) + 1 != len(upstream_artifact.remaining_constraint_ids): - raise RuntimeError("T14 remaining IDs lost the support constraint") - budget.consume_domain( - 24 - + len(remaining) - + len(upstream_artifact.ordered_obstacle_body_ids) - + sum( - len(cell.half_planes) + len(cell.closure_polygon.vertices_ccw) - for complex_ in (inner, outer) - for cell in complex_.cells - ) - ) - bracket = SupportStrictConvexAllowedBracketV2( - inner_allowed=inner, - outer_allowed=outer, - intersection_kernel_id=checked_config.intersection_kernel_id, - intersection_kernel_version=checked_config.intersection_kernel_version, - support_projection_kernel_id=( - checked_config.support_projection_kernel_id - ), - support_projection_kernel_version=( - checked_config.support_projection_kernel_version - ), - so2_atomic_steps_used=atomic_budget.used, - ) - artifact = SupportStrictConvexCandidateDomainArtifactV2_2( - semantic_problem_sha256=checked_problem.semantic_problem_sha256, - compiler_config_sha256=checked_config.config_sha256, - upstream_t14_artifact_sha256=upstream_artifact.artifact_sha256, - subject_id=upstream_artifact.subject_id, - search_universe=upstream_artifact.search_universe, - ordered_constraint_ids=( - *upstream_artifact.ordered_constraint_ids, - support_bracket.support_constraint_id, - ), - ordered_obstacle_body_ids=(upstream_artifact.ordered_obstacle_body_ids), - support_constraint_id=support_bracket.support_constraint_id, - surface_id=support_bracket.surface_id, - contact_geometry_id=support_bracket.contact_geometry_id, - allowed_domain_bracket=bracket, - resource_usage=MultiObstacleStrictConvexCandidateResourceUsageV2( - domain_operations=budget.domain_operations_used, - so2_atomic_steps=atomic_budget.used, - candidate_cells=budget.candidate_cells_used, - ), - remaining_constraint_ids=remaining, - ) - return SupportStrictConvexCandidateCompilationOutcomeV2( - kind=SupportStrictConvexCandidateCompilationKindV2.ARTIFACT, - artifact=artifact, - ) - except StrictConvexIntersectionBudgetExhaustedV2: - return _resource_failure() - except (_InvalidInputV2, _LegacyInvalidInputV2): - return _failure( - SupportStrictConvexCandidateCompilationKindV2.INVALID_INPUT, - "INVALID_INPUT:SUPPORT_STRICT_CONVEX_INPUT", - ) - except (ArithmeticError, RuntimeWarning): - return _failure( - SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP, - "NUMERIC_GAP:SUPPORT_STRICT_CONVEX_COMPILATION", - ) - - -def verify_support_strict_convex_candidate_domain_v2_7( - problem: SemanticProblemV2_2, - config: StrictConvexCandidateCompilerConfigV2_7, - submitted_artifact: SupportStrictConvexCandidateDomainArtifactV2_2, -) -> SupportStrictConvexCandidateVerificationOutcomeV2: - """Fresh replay raw inputs and compare the entire submitted T15 artifact.""" - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - checked_submitted = _copy_artifact(submitted_artifact) - except (ArithmeticError, RuntimeWarning): - return SupportStrictConvexCandidateVerificationOutcomeV2( - kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("NUMERIC_GAP:SUBMITTED_SUPPORT_STRICT_CONVEX_ARTIFACT",), - ) - except (AttributeError, TypeError, ValueError, Warning): - return SupportStrictConvexCandidateVerificationOutcomeV2( - kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("INVALID_INPUT:SUBMITTED_SUPPORT_STRICT_CONVEX_ARTIFACT",), - ) - - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - replay = compile_support_strict_convex_candidate_domain_v2_7( - problem, config - ) - except (ArithmeticError, RuntimeWarning): - return SupportStrictConvexCandidateVerificationOutcomeV2( - kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=("NUMERIC_GAP:SUPPORT_STRICT_CONVEX_REPLAY",), - ) - if ( - replay.kind is not SupportStrictConvexCandidateCompilationKindV2.ARTIFACT - or type(replay.artifact) is not SupportStrictConvexCandidateDomainArtifactV2_2 - ): - return SupportStrictConvexCandidateVerificationOutcomeV2( - kind=SupportStrictConvexCandidateVerificationKindV2.UNCERTIFIED, - finding_codes=replay.finding_codes, - ) - fresh = replay.artifact - usage = fresh.resource_usage - if ( - checked_submitted != fresh - or _artifact_bytes(checked_submitted) != _artifact_bytes(fresh) # type: ignore[arg-type] - or checked_submitted.artifact_sha256 != fresh.artifact_sha256 - ): - return SupportStrictConvexCandidateVerificationOutcomeV2( - kind=SupportStrictConvexCandidateVerificationKindV2.MISMATCH, - verification_resource_usage=usage, - finding_codes=("MISMATCH:SUPPORT_STRICT_CONVEX_ARTIFACT",), - ) - return SupportStrictConvexCandidateVerificationOutcomeV2( - kind=SupportStrictConvexCandidateVerificationKindV2.VERIFIED, - semantic_problem_sha256=fresh.semantic_problem_sha256, - compiler_config_sha256=fresh.compiler_config_sha256, - artifact_sha256=fresh.artifact_sha256, - verification_resource_usage=usage, - ) - - -def _t14_config( - config: StrictConvexCandidateCompilerConfigV2_7, -) -> StrictConvexCandidateCompilerConfigV2_6: - return StrictConvexCandidateCompilerConfigV2_6( - max_domain_operations=config.max_domain_operations, - max_so2_atomic_steps=config.max_so2_atomic_steps, - max_candidate_cells=config.max_candidate_cells, - ) - - -def _require_intersection( - outcome: StrictConvexIntersectionOutcomeV2, -) -> StrictConvexIntersectionComplexV2: - if outcome.kind is StrictConvexIntersectionKindV2.RESOURCE_LIMIT: - raise StrictConvexIntersectionBudgetExhaustedV2 - if outcome.kind is StrictConvexIntersectionKindV2.NUMERIC_GAP: - raise ArithmeticError("strict-convex support intersection numeric gap") - if outcome.kind is StrictConvexIntersectionKindV2.INVALID_INPUT: - raise RuntimeError("compiler produced invalid support intersection operands") - if ( - outcome.kind is not StrictConvexIntersectionKindV2.COMPLEX - or type(outcome.complex) is not StrictConvexIntersectionComplexV2 - ): - raise RuntimeError("malformed support intersection outcome") - return outcome.complex - - -def _strict_config(value: object) -> StrictConvexCandidateCompilerConfigV2_7: - if type(value) is not StrictConvexCandidateCompilerConfigV2_7: - raise _InvalidInputV2 - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - return StrictConvexCandidateCompilerConfigV2_7.model_validate( - value.model_dump(mode="python", warnings="error"), strict=True - ) - except (ArithmeticError, RuntimeWarning): - raise - except ( - AttributeError, - PydanticSerializationError, - TypeError, - ValidationError, - ValueError, - Warning, - ) as error: - raise _InvalidInputV2 from error - - -def _from_upstream_failure( - kind: MultiObstacleStrictConvexCandidateCompilationKindV2, - finding_codes: tuple[str, ...], -) -> SupportStrictConvexCandidateCompilationOutcomeV2: - mapped = { - MultiObstacleStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL: ( - SupportStrictConvexCandidateCompilationKindV2.UNSUPPORTED_MODEL - ), - MultiObstacleStrictConvexCandidateCompilationKindV2.NUMERIC_GAP: ( - SupportStrictConvexCandidateCompilationKindV2.NUMERIC_GAP - ), - MultiObstacleStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT: ( - SupportStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT - ), - MultiObstacleStrictConvexCandidateCompilationKindV2.INVALID_INPUT: ( - SupportStrictConvexCandidateCompilationKindV2.INVALID_INPUT - ), - }.get(kind) - if mapped is None: - raise RuntimeError("malformed T14 compiler outcome") - return SupportStrictConvexCandidateCompilationOutcomeV2( - kind=mapped, - finding_codes=finding_codes, - ) - - -def _copy_bracket( - value: SupportStrictConvexAllowedBracketV2, -) -> SupportStrictConvexAllowedBracketV2: - return SupportStrictConvexAllowedBracketV2( - inner_allowed=value.inner_allowed, - outer_allowed=value.outer_allowed, - intersection_kernel_id=value.intersection_kernel_id, - intersection_kernel_version=value.intersection_kernel_version, - support_projection_kernel_id=value.support_projection_kernel_id, - support_projection_kernel_version=value.support_projection_kernel_version, - so2_atomic_steps_used=value.so2_atomic_steps_used, - ) - - -def _copy_artifact( - value: SupportStrictConvexCandidateDomainArtifactV2_2, -) -> SupportStrictConvexCandidateDomainArtifactV2_2: - return SupportStrictConvexCandidateDomainArtifactV2_2( - semantic_problem_sha256=value.semantic_problem_sha256, - compiler_config_sha256=value.compiler_config_sha256, - upstream_t14_artifact_sha256=value.upstream_t14_artifact_sha256, - subject_id=value.subject_id, - search_universe=value.search_universe, - ordered_constraint_ids=value.ordered_constraint_ids, - ordered_obstacle_body_ids=value.ordered_obstacle_body_ids, - support_constraint_id=value.support_constraint_id, - surface_id=value.surface_id, - contact_geometry_id=value.contact_geometry_id, - allowed_domain_bracket=value.allowed_domain_bracket, - resource_usage=value.resource_usage, - remaining_constraint_ids=value.remaining_constraint_ids, - ) - - -def _failure( - kind: SupportStrictConvexCandidateCompilationKindV2, - finding_code: str, -) -> SupportStrictConvexCandidateCompilationOutcomeV2: - return SupportStrictConvexCandidateCompilationOutcomeV2( - kind=kind, - finding_codes=(finding_code,), - ) - - -def _resource_failure() -> SupportStrictConvexCandidateCompilationOutcomeV2: - return _failure( - SupportStrictConvexCandidateCompilationKindV2.RESOURCE_LIMIT, - "RESOURCE_LIMIT:SUPPORT_STRICT_CONVEX_CANDIDATE", - ) - - -__all__ = ( - "SupportStrictConvexAllowedBracketV2", - "SupportStrictConvexCandidateCompilationKindV2", - "SupportStrictConvexCandidateCompilationOutcomeV2", - "SupportStrictConvexCandidateDomainArtifactV2_2", - "SupportStrictConvexCandidateDomainCompilerV2_7", - "SupportStrictConvexCandidateVerificationKindV2", - "SupportStrictConvexCandidateVerificationOutcomeV2", - "compile_support_strict_convex_candidate_domain_v2_7", - "verify_support_strict_convex_candidate_domain_v2_7", -) diff --git a/src/spatialcf/core/v2/target_relation_domain.py b/src/spatialcf/core/v2/target_relation_domain.py deleted file mode 100644 index 0d10e35..0000000 --- a/src/spatialcf/core/v2/target_relation_domain.py +++ /dev/null @@ -1,530 +0,0 @@ -"""Sound target-relation domain compilation for exact rectangular NEAR/FAR. - -The exact Euclidean rounded-rectangle locus is bracketed by rational -axis-aligned regions. No camera callback or source-specific predicate is -consulted: unsupported semantics remain explicit ``UNKNOWN`` outcomes. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction - -from spatialcf.core.v2.rect_kernel import ( - AxisMarginXYV2, - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - ExactRectilinearRegionV2, - RectilinearAtomicBudgetV2, - RectilinearOutcomeKindV2, - RectilinearRegionOutcomeV2, - RectilinearTopologyV2, - difference_rectilinear_region_v2, - intersect_rectilinear_regions_v2, - normalize_rectilinear_region_v2, - union_rectilinear_regions_v2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - FactSetV2, - NumericPolicyV2, - QuaternionV2, - UncertaintyBudgetV2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - RelationMeasurementV2, - RelationV2, -) -from spatialcf.domain.v2.geometry import ( - GeometryApproximationV2, - GeometryInstanceV2, - GeometryRoleV2, - UprightBox3DV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import CanonicalObjectV2 - - -class TargetRelationDomainKindV2(StrEnum): - BRACKET = "BRACKET" - IDENTITY = "IDENTITY" - EMPTY = "EMPTY" - UNKNOWN = "UNKNOWN" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - - -@dataclass(frozen=True, slots=True) -class TargetRelationDomainOutcomeV2: - """Inner/outer allowed-delta bracket or one closed terminal disposition.""" - - kind: TargetRelationDomainKindV2 - inner_allowed_delta: ExactRectilinearRegionV2 | None = None - outer_allowed_delta: ExactRectilinearRegionV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.kind, TargetRelationDomainKindV2): - raise TypeError("kind must be a TargetRelationDomainKindV2") - object.__setattr__( - self, - "finding_codes", - tuple(sorted(set(self.finding_codes))), - ) - if self.kind is TargetRelationDomainKindV2.BRACKET: - if self.inner_allowed_delta is None or self.outer_allowed_delta is None: - raise ValueError( - "BRACKET requires both inner and outer allowed domains" - ) - if self.finding_codes: - raise ValueError("BRACKET cannot carry findings") - return - if self.inner_allowed_delta is not None or self.outer_allowed_delta is not None: - raise ValueError(f"{self.kind.value} must not carry partial domains") - if ( - self.kind - in { - TargetRelationDomainKindV2.UNKNOWN, - TargetRelationDomainKindV2.RESOURCE_LIMIT, - } - and not self.finding_codes - ): - raise ValueError(f"{self.kind.value} requires a finding") - - -TargetRelationDomainCompilationOutcomeV2 = TargetRelationDomainOutcomeV2 - - -def compile_target_relation_domain_v2( - problem: SemanticProblemV2, - universe: ExactRectilinearRegionV2, - *, - max_atomic_cells: int | None = None, - atomic_budget: RectilinearAtomicBudgetV2 | None = None, -) -> TargetRelationDomainOutcomeV2: - """Compile the target's after-relation over one finite delta universe.""" - - budget = _resolve_atomic_budget(max_atomic_cells, atomic_budget) - if not isinstance(problem, SemanticProblemV2): - raise TypeError("problem must be a SemanticProblemV2") - if not isinstance(universe, ExactRectilinearRegionV2): - raise TypeError("universe must be an ExactRectilinearRegionV2") - - checked_problem = SemanticProblemV2.model_validate( - problem.model_dump(mode="python"), - strict=True, - ) - - validation = union_rectilinear_regions_v2( - universe, - universe, - atomic_budget=budget, - ) - failure = _maybe_nested_failure(validation) - if failure is not None: - return failure - checked_universe = _exact_region(validation) - - findings = _supported_subset_findings(checked_problem) - if findings: - return _unknown(*findings) - - target = checked_problem.constraints.target_relation - definition = next( - item - for item in checked_problem.relation_semantics.definitions - if item.relation is target.relation_after - ) - threshold = Fraction.from_float(definition.threshold) - - if target.relation_after is RelationV2.NEAR and threshold < 0: - return _empty(f"EXACT_EMPTY:NEGATIVE_NEAR_THRESHOLD:{target.constraint_id}") - if target.relation_after is RelationV2.FAR and threshold <= 0: - return TargetRelationDomainOutcomeV2(kind=TargetRelationDomainKindV2.IDENTITY) - if checked_universe.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{target.constraint_id}") - - q0_rectangle = _overlap_delta_rectangle(checked_problem) - q0_outcome = normalize_rectilinear_region_v2( - (q0_rectangle,), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(q0_outcome) - if failure is not None: - return failure - q0 = _exact_region(q0_outcome) - - if target.relation_after is RelationV2.NEAR: - return _compile_near( - target.constraint_id, - checked_universe, - q0_rectangle, - q0, - threshold, - budget, - ) - return _compile_far( - target.constraint_id, - checked_universe, - q0_rectangle, - q0, - threshold, - budget, - ) - - -def _compile_near( - constraint_id: str, - universe: ExactRectilinearRegionV2, - q0_rectangle: ExactAxisAlignedRectV2, - q0: ExactRectilinearRegionV2, - threshold: Fraction, - budget: RectilinearAtomicBudgetV2, -) -> TargetRelationDomainOutcomeV2: - if threshold == 0: - clipped = intersect_rectilinear_regions_v2( - universe, - q0, - atomic_budget=budget, - ) - failure = _maybe_nested_failure(clipped) - if failure is not None: - return failure - exact = _exact_region(clipped) - if exact.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{constraint_id}") - return _bracket(exact, exact) - - inner_rectangle = q0_rectangle.dilate_axis( - AxisMarginXYV2(x_m=threshold / 2, y_m=threshold / 2) - ) - outer_rectangle = q0_rectangle.dilate_axis( - AxisMarginXYV2(x_m=threshold, y_m=threshold) - ) - inner_shape_outcome = normalize_rectilinear_region_v2( - (inner_rectangle,), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(inner_shape_outcome) - if failure is not None: - return failure - outer_shape_outcome = normalize_rectilinear_region_v2( - (outer_rectangle,), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(outer_shape_outcome) - if failure is not None: - return failure - - inner_outcome = intersect_rectilinear_regions_v2( - universe, - _exact_region(inner_shape_outcome), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(inner_outcome) - if failure is not None: - return failure - outer_outcome = intersect_rectilinear_regions_v2( - universe, - _exact_region(outer_shape_outcome), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(outer_outcome) - if failure is not None: - return failure - inner = _exact_region(inner_outcome) - outer = _exact_region(outer_outcome) - if outer.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{constraint_id}") - return _bracket(inner, outer) - - -def _compile_far( - constraint_id: str, - universe: ExactRectilinearRegionV2, - q0_rectangle: ExactAxisAlignedRectV2, - q0: ExactRectilinearRegionV2, - threshold: Fraction, - budget: RectilinearAtomicBudgetV2, -) -> TargetRelationDomainOutcomeV2: - dilated_rectangle = q0_rectangle.dilate_axis( - AxisMarginXYV2(x_m=threshold, y_m=threshold) - ) - dilated_outcome = normalize_rectilinear_region_v2( - (dilated_rectangle,), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(dilated_outcome) - if failure is not None: - return failure - inner_outcome = difference_rectilinear_region_v2( - universe, - _exact_region(dilated_outcome), - atomic_budget=budget, - ) - failure = _maybe_nested_failure(inner_outcome) - if failure is not None: - return failure - outer_outcome = difference_rectilinear_region_v2( - universe, - q0, - atomic_budget=budget, - ) - failure = _maybe_nested_failure(outer_outcome) - if failure is not None: - return failure - inner = _exact_region(inner_outcome) - outer = _exact_region(outer_outcome) - if outer.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:TARGET_RELATION_DOMAIN:{constraint_id}") - return _bracket(inner, outer) - - -def _supported_subset_findings(problem: SemanticProblemV2) -> tuple[str, ...]: - target = problem.constraints.target_relation - definition = next( - item - for item in problem.relation_semantics.definitions - if item.relation is target.relation_after - ) - findings: list[str] = [] - if target.relation_after not in {RelationV2.NEAR, RelationV2.FAR}: - findings.append( - f"UNSUPPORTED_TARGET_RELATION:AFTER_RELATION:{target.relation_after.value}" - ) - if definition.measurement is not RelationMeasurementV2.SHAPE_GAP_XY: - findings.append( - f"UNSUPPORTED_TARGET_RELATION:MEASUREMENT:{definition.measurement.value}" - ) - if definition.tolerance != 0.0: - findings.append("UNSUPPORTED_TARGET_RELATION:TOLERANCE") - if definition.boundary_policy is not BoundaryPolicyV2.CLOSED: - findings.append( - "UNSUPPORTED_TARGET_RELATION:BOUNDARY_POLICY:" - f"{definition.boundary_policy.value}" - ) - if not _numeric_policy_is_zero(problem.numeric_policy): - findings.append("UNSUPPORTED_TARGET_RELATION:NUMERIC_POLICY") - - object_facts = problem.scene.objects - geometry_facts = problem.scene.geometry_instances - findings.extend(_family_findings("OBJECT", object_facts)) - findings.extend(_family_findings("GEOMETRY", geometry_facts)) - - objects = {item.object_id: item for item in object_facts.values or ()} - for object_id in (target.subject_id, target.reference_id): - object_ = objects.get(object_id) - if object_ is not None and not _is_identity_rotation( - object_.pose.world_from_object.rotation - ): - findings.append( - f"UNSUPPORTED_TARGET_RELATION:NON_IDENTITY_ROTATION:" - f"OBJECT_POSE:{object_id}" - ) - - if ( - geometry_facts.availability is FactAvailabilityV2.KNOWN - and geometry_facts.completeness is FactCompletenessV2.EXACT - ): - geometries = geometry_facts.values or () - for object_id in (target.subject_id, target.reference_id): - selected = tuple( - item - for item in geometries - if item.owner_object_id == object_id - and item.role is GeometryRoleV2.RELATION - ) - if len(selected) != 1: - findings.append( - f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_CARDINALITY:" - f"{object_id}:{len(selected)}" - ) - continue - geometry = selected[0] - if geometry.approximation is not GeometryApproximationV2.EXACT: - findings.append( - f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_APPROXIMATION:" - f"{geometry.geometry_id}:{geometry.approximation.value}" - ) - if not _uncertainty_is_zero(geometry.uncertainty): - findings.append( - f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_ITEM_UNCERTAINTY:" - f"{geometry.geometry_id}" - ) - if not isinstance(geometry.shape, UprightBox3DV2): - findings.append( - f"UNSUPPORTED_TARGET_RELATION:GEOMETRY_SHAPE:" - f"{geometry.geometry_id}:{geometry.shape.shape_type}" - ) - if not _is_identity_rotation(geometry.anchor_from_geometry.rotation): - findings.append( - f"UNSUPPORTED_TARGET_RELATION:NON_IDENTITY_ROTATION:" - f"{geometry.geometry_id}" - ) - return tuple(sorted(set(findings))) - - -def _family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: - if facts.availability is FactAvailabilityV2.MISSING: - return (f"MISSING_FACT:{label}",) - if facts.availability is not FactAvailabilityV2.KNOWN: - return ( - f"UNSUPPORTED_TARGET_RELATION:{label}_AVAILABILITY:{facts.availability.value}", - ) - findings: list[str] = [] - if facts.completeness is not FactCompletenessV2.EXACT: - value = facts.completeness.value if facts.completeness is not None else "NONE" - findings.append(f"UNSUPPORTED_TARGET_RELATION:{label}_COMPLETENESS:{value}") - if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): - findings.append(f"UNSUPPORTED_TARGET_RELATION:{label}_FACT_UNCERTAINTY") - return tuple(findings) - - -def _overlap_delta_rectangle( - problem: SemanticProblemV2, -) -> ExactAxisAlignedRectV2: - target = problem.constraints.target_relation - objects = {item.object_id: item for item in problem.scene.objects.values or ()} - geometries = problem.scene.geometry_instances.values or () - subject_geometry = next( - item - for item in geometries - if item.owner_object_id == target.subject_id - and item.role is GeometryRoleV2.RELATION - ) - reference_geometry = next( - item - for item in geometries - if item.owner_object_id == target.reference_id - and item.role is GeometryRoleV2.RELATION - ) - subject_bounds = _world_box_bounds(objects[target.subject_id], subject_geometry) - reference_bounds = _world_box_bounds( - objects[target.reference_id], reference_geometry - ) - return ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=reference_bounds[0] - subject_bounds[2], - min_y_m=reference_bounds[1] - subject_bounds[3], - max_x_m=reference_bounds[2] - subject_bounds[0], - max_y_m=reference_bounds[3] - subject_bounds[1], - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ) - - -def _world_box_bounds( - object_: CanonicalObjectV2, - geometry: GeometryInstanceV2, -) -> tuple[Fraction, Fraction, Fraction, Fraction]: - if not isinstance(geometry.shape, UprightBox3DV2): - raise TypeError("certified relation geometry must be an UprightBox3DV2") - center_x = Fraction.from_float( - object_.pose.world_from_object.translation.x - ) + Fraction.from_float(geometry.anchor_from_geometry.translation.x) - center_y = Fraction.from_float( - object_.pose.world_from_object.translation.y - ) + Fraction.from_float(geometry.anchor_from_geometry.translation.y) - half_x = Fraction.from_float(geometry.shape.size_m.x) / 2 - half_y = Fraction.from_float(geometry.shape.size_m.y) / 2 - return ( - center_x - half_x, - center_y - half_y, - center_x + half_x, - center_y + half_y, - ) - - -def _is_identity_rotation(rotation: QuaternionV2) -> bool: - return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) - - -def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: - return all( - value == 0.0 - for value in ( - policy.linear_tolerance_m, - policy.area_tolerance_m2, - policy.angular_tolerance_rad, - policy.pixel_tolerance_px, - policy.fraction_tolerance, - ) - ) - - -def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: - return _numeric_policy_is_zero( - uncertainty.source_error - ) and _numeric_policy_is_zero(uncertainty.shape_approximation) - - -def _resolve_atomic_budget( - max_atomic_cells: int | None, - atomic_budget: RectilinearAtomicBudgetV2 | None, -) -> RectilinearAtomicBudgetV2: - if (max_atomic_cells is None) == (atomic_budget is None): - raise ValueError("provide exactly one of max_atomic_cells or atomic_budget") - if atomic_budget is not None: - if type(atomic_budget) is not RectilinearAtomicBudgetV2: - raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") - atomic_budget.validate() - return atomic_budget - if type(max_atomic_cells) is not int: - raise TypeError("max_atomic_cells must be an exact int") - return RectilinearAtomicBudgetV2(limit=max_atomic_cells) - - -def _exact_region(outcome: RectilinearRegionOutcomeV2) -> ExactRectilinearRegionV2: - if outcome.kind is not RectilinearOutcomeKindV2.EXACT or outcome.region is None: - raise RuntimeError("nested rectilinear outcome is not exact") - return outcome.region - - -def _maybe_nested_failure( - outcome: RectilinearRegionOutcomeV2, -) -> TargetRelationDomainOutcomeV2 | None: - if outcome.kind is RectilinearOutcomeKindV2.EXACT: - return None - return _nested_failure(outcome) - - -def _nested_failure( - outcome: RectilinearRegionOutcomeV2, -) -> TargetRelationDomainOutcomeV2: - if outcome.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource() - return _unknown(*outcome.finding_codes) - - -def _bracket( - inner: ExactRectilinearRegionV2, - outer: ExactRectilinearRegionV2, -) -> TargetRelationDomainOutcomeV2: - return TargetRelationDomainOutcomeV2( - kind=TargetRelationDomainKindV2.BRACKET, - inner_allowed_delta=inner, - outer_allowed_delta=outer, - ) - - -def _empty(finding: str) -> TargetRelationDomainOutcomeV2: - return TargetRelationDomainOutcomeV2( - kind=TargetRelationDomainKindV2.EMPTY, - finding_codes=(finding,), - ) - - -def _unknown(*findings: str) -> TargetRelationDomainOutcomeV2: - return TargetRelationDomainOutcomeV2( - kind=TargetRelationDomainKindV2.UNKNOWN, - finding_codes=tuple(findings), - ) - - -def _resource() -> TargetRelationDomainOutcomeV2: - return TargetRelationDomainOutcomeV2( - kind=TargetRelationDomainKindV2.RESOURCE_LIMIT, - finding_codes=("RESOURCE_LIMIT:ATOMIC_CELLS",), - ) diff --git a/src/spatialcf/core/v2/visibility_domain.py b/src/spatialcf/core/v2/visibility_domain.py deleted file mode 100644 index 4716bdc..0000000 --- a/src/spatialcf/core/v2/visibility_domain.py +++ /dev/null @@ -1,816 +0,0 @@ -"""Sound visibility-domain compilation for a small analytic Canonical v2 subset.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from fractions import Fraction - -from spatialcf.core.v2.rect_kernel import ( - ExactAxisAlignedRectV2, - RectCoordinateSpaceV2, -) -from spatialcf.core.v2.rectilinear_kernel import ( - ExactRectilinearRegionV2, - RectilinearAtomicBudgetV2, - RectilinearOutcomeKindV2, - RectilinearRegionOutcomeV2, - RectilinearTopologyV2, - intersect_rectilinear_regions_v2, - normalize_rectilinear_region_v2, - union_rectilinear_regions_v2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactCompletenessV2, - FactSetV2, - NumericPolicyV2, - QuaternionV2, - RigidTransformV2, - UncertaintyBudgetV2, -) -from spatialcf.domain.v2.constraints import ( - BoundaryPolicyV2, - OccluderSoundnessPolicyV2, - VisibilityAreaMeasureV2, - VisibilityConstraintV2, - VisibilityDepthPolicyV2, - VisibilityMaskPolicyV2, - VisibilityMetricFormulaV2, - VisibilityMetricKindV2, -) -from spatialcf.domain.v2.geometry import ( - GeometryApproximationV2, - GeometryInstanceV2, - GeometryRoleV2, - UprightBox3DV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import ( - CameraAxesV2, - CameraDepthConventionV2, - CameraDistortionModelV2, - CameraMatrixLayoutV2, - CameraPixelConventionV2, - CanonicalObjectV2, - PinholeCameraV2, -) - - -class VisibilityDomainKindV2(StrEnum): - BRACKET = "BRACKET" - IDENTITY = "IDENTITY" - EMPTY = "EMPTY" - UNKNOWN = "UNKNOWN" - RESOURCE_LIMIT = "RESOURCE_LIMIT" - - -@dataclass(frozen=True, slots=True) -class VisibilityDomainOutcomeV2: - """One exact constant result or an inner/outer allowed-delta bracket.""" - - kind: VisibilityDomainKindV2 - inner_allowed_delta: ExactRectilinearRegionV2 | None = None - outer_allowed_delta: ExactRectilinearRegionV2 | None = None - finding_codes: tuple[str, ...] = () - - def __post_init__(self) -> None: - if not isinstance(self.kind, VisibilityDomainKindV2): - raise TypeError("kind must be a VisibilityDomainKindV2") - object.__setattr__( - self, - "finding_codes", - tuple(sorted(set(self.finding_codes))), - ) - if self.kind is VisibilityDomainKindV2.BRACKET: - if not isinstance( - self.inner_allowed_delta, ExactRectilinearRegionV2 - ) or not isinstance(self.outer_allowed_delta, ExactRectilinearRegionV2): - raise ValueError("BRACKET requires exact inner and outer regions") - if self.finding_codes: - raise ValueError("BRACKET cannot carry findings") - return - if self.inner_allowed_delta is not None or self.outer_allowed_delta is not None: - raise ValueError(f"{self.kind.value} must not carry partial regions") - if self.kind is VisibilityDomainKindV2.IDENTITY: - if self.finding_codes: - raise ValueError("IDENTITY cannot carry findings") - return - if not self.finding_codes: - raise ValueError(f"{self.kind.value} requires a finding") - - -VisibilityDomainCompilationOutcomeV2 = VisibilityDomainOutcomeV2 - - -class _BaselineStateV2(StrEnum): - PASS = "PASS" - FAIL = "FAIL" - AMBIGUOUS = "AMBIGUOUS" - - -@dataclass(frozen=True, slots=True) -class _BaselineClassificationV2: - state: _BaselineStateV2 - failure_codes: tuple[str, ...] = () - - -@dataclass(frozen=True, slots=True) -class _ProjectionCertificateV2: - min_dx_m: Fraction - min_dy_m: Fraction - max_dx_m: Fraction - max_dy_m: Fraction - near_face_image_area_fraction: Fraction - - @property - def has_full_containment_delta(self) -> bool: - return self.min_dx_m <= self.max_dx_m and self.min_dy_m <= self.max_dy_m - - -def compile_visibility_domain_v2( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2 | str, - universe: ExactRectilinearRegionV2, - *, - max_atomic_cells: int | None = None, - atomic_budget: RectilinearAtomicBudgetV2 | None = None, -) -> VisibilityDomainOutcomeV2: - """Compile one hard visibility predicate over a finite XY-delta universe.""" - - budget = _resolve_atomic_budget(max_atomic_cells, atomic_budget) - if not isinstance(problem, SemanticProblemV2): - raise TypeError("problem must be a SemanticProblemV2") - if not isinstance(universe, ExactRectilinearRegionV2): - raise TypeError("universe must be an ExactRectilinearRegionV2") - - checked_problem = SemanticProblemV2.model_validate( - problem.model_dump(mode="python"), - strict=True, - ) - selected = _resolve_constraint(checked_problem, constraint) - checked_universe = _revalidate_universe(universe, budget) - if isinstance(checked_universe, VisibilityDomainOutcomeV2): - return checked_universe - if isinstance(selected, VisibilityDomainOutcomeV2): - return selected - - common_findings = _common_findings(checked_problem, selected) - subject_id = checked_problem.constraints.allowed_edit.subject_id - moving_subject = subject_id in selected.query_object_ids - fixed_query_ids = tuple( - object_id for object_id in selected.query_object_ids if object_id != subject_id - ) - - if moving_subject: - findings = [*common_findings] - findings.extend(_moving_subset_findings(checked_problem, selected)) - if fixed_query_ids: - findings.extend(_baseline_family_findings(checked_problem, selected)) - if findings: - return _unknown(*findings) - return _compile_moving_subject( - checked_problem, - selected, - fixed_query_ids, - checked_universe, - atomic_budget=budget, - ) - - findings = [*common_findings] - findings.extend(_fixed_subset_findings(checked_problem, selected)) - findings.extend(_baseline_family_findings(checked_problem, selected)) - if findings: - return _unknown(*findings) - return _compile_fixed_queries( - checked_problem, - selected, - checked_universe, - atomic_budget=budget, - ) - - -def _compile_fixed_queries( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, - universe: ExactRectilinearRegionV2, - *, - atomic_budget: RectilinearAtomicBudgetV2, -) -> VisibilityDomainOutcomeV2: - classification = _classify_baselines( - problem, - constraint, - constraint.query_object_ids, - ) - if classification.state is _BaselineStateV2.FAIL: - return _empty(*classification.failure_codes) - if classification.state is _BaselineStateV2.PASS: - return VisibilityDomainOutcomeV2(kind=VisibilityDomainKindV2.IDENTITY) - if universe.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:VISIBILITY_DOMAIN:{constraint.constraint_id}") - empty = _make_empty_region(atomic_budget) - if isinstance(empty, VisibilityDomainOutcomeV2): - return empty - return _bracket(empty, universe) - - -def _compile_moving_subject( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, - fixed_query_ids: tuple[str, ...], - universe: ExactRectilinearRegionV2, - *, - atomic_budget: RectilinearAtomicBudgetV2, -) -> VisibilityDomainOutcomeV2: - fixed_classification = _classify_baselines( - problem, - constraint, - fixed_query_ids, - ) - if fixed_classification.state is _BaselineStateV2.FAIL: - return _empty(*fixed_classification.failure_codes) - - certificate = _projection_certificate(problem, constraint) - area_threshold = Fraction.from_float(constraint.minimum_image_area_fraction) - subject_is_guaranteed = certificate.near_face_image_area_fraction >= area_threshold - clear_inner = ( - fixed_classification.state is _BaselineStateV2.AMBIGUOUS - or not subject_is_guaranteed - or not certificate.has_full_containment_delta - ) - if universe.topology is RectilinearTopologyV2.EMPTY: - return _empty(f"EXACT_EMPTY:VISIBILITY_DOMAIN:{constraint.constraint_id}") - if clear_inner: - empty = _make_empty_region(atomic_budget) - if isinstance(empty, VisibilityDomainOutcomeV2): - return empty - return _bracket(empty, universe) - - containment_rectangle = ExactAxisAlignedRectV2.from_fraction_bounds( - min_x_m=certificate.min_dx_m, - min_y_m=certificate.min_dy_m, - max_x_m=certificate.max_dx_m, - max_y_m=certificate.max_dy_m, - coordinate_space=RectCoordinateSpaceV2.TRANSLATION_DELTA_XY_M, - ) - containment_outcome = normalize_rectilinear_region_v2( - (containment_rectangle,), - atomic_budget=atomic_budget, - ) - failure = _maybe_nested_failure(containment_outcome) - if failure is not None: - return failure - containment = _exact_region(containment_outcome) - inner_outcome = intersect_rectilinear_regions_v2( - universe, - containment, - atomic_budget=atomic_budget, - ) - failure = _maybe_nested_failure(inner_outcome) - if failure is not None: - return failure - return _bracket(_exact_region(inner_outcome), universe) - - -def _resolve_constraint( - problem: SemanticProblemV2, - requested: VisibilityConstraintV2 | str, -) -> VisibilityConstraintV2 | VisibilityDomainOutcomeV2: - if isinstance(requested, VisibilityConstraintV2): - checked = VisibilityConstraintV2.model_validate( - requested.model_dump(mode="python"), - strict=True, - ) - constraint_id = checked.constraint_id - elif type(requested) is str: - checked = None - constraint_id = requested - else: - raise TypeError("constraint must be a VisibilityConstraintV2 or exact str ID") - registered = next( - ( - item - for item in problem.constraints.visibility_constraints - if item.constraint_id == constraint_id - ), - None, - ) - if registered is None: - return _unknown(f"UNKNOWN_VISIBILITY_CONSTRAINT:{constraint_id}") - if checked is not None and checked != registered: - return _unknown(f"VISIBILITY_CONSTRAINT_MISMATCH:{constraint_id}") - return registered - - -def _common_findings( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, -) -> tuple[str, ...]: - findings: list[str] = [] - if constraint.threshold_boundary_policy is not BoundaryPolicyV2.CLOSED: - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:BOUNDARY_POLICY:" - f"{constraint.threshold_boundary_policy.value}" - ) - if constraint.mask_policy is not VisibilityMaskPolicyV2.FULL_OBJECT: - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:MASK_POLICY:{constraint.mask_policy.value}" - ) - if ( - constraint.occluder_soundness_policy - is not OccluderSoundnessPolicyV2.EXACT_OR_OUTER_SHAPE_BOUND - ): - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:OCCLUDER_SOUNDNESS_POLICY:" - f"{constraint.occluder_soundness_policy.value}" - ) - if not _numeric_policy_is_zero(problem.numeric_policy): - findings.append("UNSUPPORTED_VISIBILITY_DOMAIN:NUMERIC_POLICY") - findings.extend(_fact_family_findings("OBJECTS", problem.scene.objects)) - findings.extend( - _fact_family_findings( - "GEOMETRY_INSTANCES", - problem.scene.geometry_instances, - ) - ) - findings.extend(_visibility_semantics_findings(problem, constraint)) - return tuple(sorted(set(findings))) - - -def _visibility_semantics_findings( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, - *, - supported_image_area_formulas: tuple[VisibilityMetricFormulaV2, ...] = ( - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA, - ), -) -> tuple[str, ...]: - expected = { - VisibilityMetricKindV2.VISIBLE_FRACTION: ( - constraint.visible_fraction_metric_definition_id, - constraint.visible_fraction_metric_definition_version, - ( - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_OVER_UNOCCLUDED_CLIPPED_PROJECTED_AREA, - ), - ), - VisibilityMetricKindV2.IMAGE_AREA_FRACTION: ( - constraint.image_area_metric_definition_id, - constraint.image_area_metric_definition_version, - supported_image_area_formulas, - ), - VisibilityMetricKindV2.TRUNCATED_FRACTION: ( - constraint.truncated_fraction_metric_definition_id, - constraint.truncated_fraction_metric_definition_version, - ( - VisibilityMetricFormulaV2.ONE_MINUS_CLIPPED_OVER_UNCLIPPED_PROJECTED_AREA, - ), - ), - } - findings: list[str] = [] - for definition in problem.visibility_semantics.definitions: - definition_id, version, formulas = expected[definition.kind] - if ( - definition.reference != (definition_id, version) - or definition.formula not in formulas - or definition.area_measure - is not VisibilityAreaMeasureV2.CONTINUOUS_PIXEL_PLANE_AREA - or definition.depth_policy - is not VisibilityDepthPolicyV2.NEAREST_POSITIVE_CAMERA_DEPTH_OCCLUDES - ): - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:METRIC_SEMANTICS:{definition.kind.value}" - ) - return tuple(findings) - - -def _fixed_subset_findings( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, -) -> tuple[str, ...]: - subject_id = problem.constraints.allowed_edit.subject_id - subject_occluders = tuple( - item.geometry_id - for item in problem.scene.geometry_instances.values or () - if item.owner_object_id == subject_id and item.role is GeometryRoleV2.OCCLUDER - ) - if subject_occluders: - return ( - ( - "UNSUPPORTED_VISIBILITY_DOMAIN:SUBJECT_OCCLUDER:" - f"{constraint.constraint_id}:{','.join(sorted(subject_occluders))}" - ), - ) - return () - - -def _moving_subset_findings( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, -) -> tuple[str, ...]: - findings: list[str] = [] - geometries = problem.scene.geometry_instances.values or () - occluders = tuple( - item.geometry_id for item in geometries if item.role is GeometryRoleV2.OCCLUDER - ) - if occluders: - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:OCCLUDER_GEOMETRY:" - + ",".join(sorted(occluders)) - ) - - subject_id = problem.constraints.allowed_edit.subject_id - objects = {item.object_id: item for item in problem.scene.objects.values or ()} - subject = objects[subject_id] - if not _is_identity_rotation(subject.pose.world_from_object.rotation): - findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:OBJECT_ROTATION:{subject_id}") - visual_geometries = tuple( - item - for item in geometries - if item.owner_object_id == subject_id and item.role is GeometryRoleV2.VISUAL - ) - if len(visual_geometries) != 1: - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_GEOMETRY_CARDINALITY:" - f"{subject_id}:{len(visual_geometries)}" - ) - else: - visual = visual_geometries[0] - if visual.approximation is not GeometryApproximationV2.EXACT: - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_APPROXIMATION:" - f"{visual.geometry_id}:{visual.approximation.value}" - ) - if not _uncertainty_is_zero(visual.uncertainty): - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_UNCERTAINTY:{visual.geometry_id}" - ) - if not isinstance(visual.shape, UprightBox3DV2): - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_SHAPE:" - f"{visual.geometry_id}:{visual.shape.shape_type}" - ) - if not _is_identity_rotation(visual.anchor_from_geometry.rotation): - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:VISUAL_ROTATION:{visual.geometry_id}" - ) - - camera_facts = problem.scene.cameras - findings.extend(_fact_family_findings("CAMERAS", camera_facts)) - cameras = {item.camera_id: item for item in camera_facts.values or ()} - camera = cameras.get(constraint.camera_id) - if camera is not None: - findings.extend(_camera_findings(camera)) - - if not findings and camera is not None: - visual = visual_geometries[0] - assert isinstance(visual.shape, UprightBox3DV2) - z_min, z_max = _box_depth_interval(subject, visual) - near = Fraction.from_float(camera.near_clip_m) - far = Fraction.from_float(camera.far_clip_m) - if z_min <= 0 or z_min < near or z_max > far: - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:DEPTH_CLIP:{visual.geometry_id}" - ) - return tuple(sorted(set(findings))) - - -def _camera_findings(camera: PinholeCameraV2) -> tuple[str, ...]: - findings: list[str] = [] - if not _is_identity_transform(camera.world_to_camera): - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:CAMERA_TRANSFORM:{camera.camera_id}" - ) - if camera.distortion_model is not CameraDistortionModelV2.NONE: - findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:DISTORTION:{camera.camera_id}") - if not _uncertainty_is_zero(camera.calibration_uncertainty): - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:CAMERA_UNCERTAINTY:{camera.camera_id}" - ) - intrinsics = camera.intrinsics_row_major - if ( - intrinsics[1] != 0.0 - or intrinsics[3] != 0.0 - or intrinsics[6:] != (0.0, 0.0, 1.0) - ): - findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:INTRINSICS:{camera.camera_id}") - if ( - camera.matrix_layout is not CameraMatrixLayoutV2.ROW_MAJOR - or camera.camera_axes is not CameraAxesV2.X_RIGHT_Y_DOWN_Z_FORWARD - or camera.pixel_convention is not CameraPixelConventionV2.CENTER_AT_HALF - or camera.depth_convention is not CameraDepthConventionV2.POSITIVE_Z_FORWARD - ): - findings.append( - f"UNSUPPORTED_VISIBILITY_DOMAIN:CAMERA_CONVENTION:{camera.camera_id}" - ) - return tuple(findings) - - -def _baseline_family_findings( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, -) -> tuple[str, ...]: - findings = list( - _fact_family_findings( - "BASELINE_OBSERVATIONS", - problem.scene.baseline_observations, - ) - ) - if FactCompletenessV2.EXACT not in constraint.accepted_baseline_completeness: - findings.append( - "UNSUPPORTED_VISIBILITY_DOMAIN:BASELINE_COMPLETENESS_POLICY:" - f"{constraint.constraint_id}" - ) - return tuple(findings) - - -def _fact_family_findings(label: str, facts: FactSetV2) -> tuple[str, ...]: - if facts.availability is FactAvailabilityV2.MISSING: - return (f"MISSING_FACT:{label}",) - if facts.availability is not FactAvailabilityV2.KNOWN: - return ( - ( - "UNSUPPORTED_VISIBILITY_DOMAIN:" - f"{label}_AVAILABILITY:{facts.availability.value}" - ), - ) - findings: list[str] = [] - if facts.completeness is not FactCompletenessV2.EXACT: - value = facts.completeness.value if facts.completeness is not None else "NONE" - findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:{label}_COMPLETENESS:{value}") - if facts.uncertainty is None or not _uncertainty_is_zero(facts.uncertainty): - findings.append(f"UNSUPPORTED_VISIBILITY_DOMAIN:{label}_UNCERTAINTY") - return tuple(findings) - - -def _classify_baselines( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, - object_ids: tuple[str, ...], -) -> _BaselineClassificationV2: - if not object_ids: - return _BaselineClassificationV2(state=_BaselineStateV2.PASS) - observations = { - ( - item.object_id, - item.camera_id, - item.metric_definition_id, - item.metric_definition_version, - ): item - for item in problem.scene.baseline_observations.values or () - } - specifications = ( - ( - "VISIBLE_FRACTION", - constraint.visible_fraction_metric_definition_id, - constraint.visible_fraction_metric_definition_version, - Fraction.from_float(constraint.minimum_visible_fraction), - True, - ), - ( - "IMAGE_AREA_FRACTION", - constraint.image_area_metric_definition_id, - constraint.image_area_metric_definition_version, - Fraction.from_float(constraint.minimum_image_area_fraction), - True, - ), - ( - "TRUNCATED_FRACTION", - constraint.truncated_fraction_metric_definition_id, - constraint.truncated_fraction_metric_definition_version, - Fraction.from_float(constraint.maximum_truncated_fraction), - False, - ), - ) - failures: list[str] = [] - ambiguous = False - for object_id in object_ids: - for label, definition_id, version, threshold, is_minimum in specifications: - observation = observations[ - (object_id, constraint.camera_id, definition_id, version) - ] - lower = Fraction.from_float(observation.normalized_lower_bound) - upper = Fraction.from_float(observation.normalized_upper_bound) - if is_minimum: - worst_passes = lower >= threshold - best_fails = upper < threshold - else: - worst_passes = upper <= threshold - best_fails = lower > threshold - if best_fails: - failures.append( - "EXACT_EMPTY:VISIBILITY_BASELINE:" - f"{constraint.constraint_id}:{object_id}:{label}" - ) - elif not worst_passes: - ambiguous = True - if failures: - return _BaselineClassificationV2( - state=_BaselineStateV2.FAIL, - failure_codes=tuple(sorted(set(failures))), - ) - if ambiguous: - return _BaselineClassificationV2(state=_BaselineStateV2.AMBIGUOUS) - return _BaselineClassificationV2(state=_BaselineStateV2.PASS) - - -def _projection_certificate( - problem: SemanticProblemV2, - constraint: VisibilityConstraintV2, -) -> _ProjectionCertificateV2: - subject_id = problem.constraints.allowed_edit.subject_id - subject = next( - item - for item in problem.scene.objects.values or () - if item.object_id == subject_id - ) - visual = next( - item - for item in problem.scene.geometry_instances.values or () - if item.owner_object_id == subject_id and item.role is GeometryRoleV2.VISUAL - ) - camera = next( - item - for item in problem.scene.cameras.values or () - if item.camera_id == constraint.camera_id - ) - assert isinstance(visual.shape, UprightBox3DV2) - - center_x = Fraction.from_float( - subject.pose.world_from_object.translation.x - ) + Fraction.from_float(visual.anchor_from_geometry.translation.x) - center_y = Fraction.from_float( - subject.pose.world_from_object.translation.y - ) + Fraction.from_float(visual.anchor_from_geometry.translation.y) - center_z = Fraction.from_float( - subject.pose.world_from_object.translation.z - ) + Fraction.from_float(visual.anchor_from_geometry.translation.z) - half_x = Fraction.from_float(visual.shape.size_m.x) / 2 - half_y = Fraction.from_float(visual.shape.size_m.y) / 2 - half_z = Fraction.from_float(visual.shape.size_m.z) / 2 - corners = tuple( - (center_x + sx * half_x, center_y + sy * half_y, center_z + sz * half_z) - for sx in (-1, 1) - for sy in (-1, 1) - for sz in (-1, 1) - ) - - intrinsics = tuple( - Fraction.from_float(item) for item in camera.intrinsics_row_major - ) - fx, cx = intrinsics[0], intrinsics[2] - fy, cy = intrinsics[4], intrinsics[5] - left = Fraction(1, 2) - right = Fraction(camera.width_px) - left - top = Fraction(1, 2) - bottom = Fraction(camera.height_px) - top - min_dx = max((left - cx) * z / fx - x for x, _, z in corners) - max_dx = min((right - cx) * z / fx - x for x, _, z in corners) - min_dy = max((top - cy) * z / fy - y for _, y, z in corners) - max_dy = min((bottom - cy) * z / fy - y for _, y, z in corners) - - near_z = center_z - half_z - projected_width = fx * Fraction.from_float(visual.shape.size_m.x) / near_z - projected_height = fy * Fraction.from_float(visual.shape.size_m.y) / near_z - image_area_fraction = (projected_width * projected_height) / ( - Fraction(camera.width_px) * Fraction(camera.height_px) - ) - return _ProjectionCertificateV2( - min_dx_m=min_dx, - min_dy_m=min_dy, - max_dx_m=max_dx, - max_dy_m=max_dy, - near_face_image_area_fraction=image_area_fraction, - ) - - -def _box_depth_interval( - subject: CanonicalObjectV2, - visual: GeometryInstanceV2, -) -> tuple[Fraction, Fraction]: - assert isinstance(visual.shape, UprightBox3DV2) - center = Fraction.from_float( - subject.pose.world_from_object.translation.z - ) + Fraction.from_float(visual.anchor_from_geometry.translation.z) - half = Fraction.from_float(visual.shape.size_m.z) / 2 - return center - half, center + half - - -def _revalidate_universe( - universe: ExactRectilinearRegionV2, - atomic_budget: RectilinearAtomicBudgetV2, -) -> ExactRectilinearRegionV2 | VisibilityDomainOutcomeV2: - validation = union_rectilinear_regions_v2( - universe, - universe, - atomic_budget=atomic_budget, - ) - failure = _maybe_nested_failure(validation) - if failure is not None: - return failure - return _exact_region(validation) - - -def _make_empty_region( - atomic_budget: RectilinearAtomicBudgetV2, -) -> ExactRectilinearRegionV2 | VisibilityDomainOutcomeV2: - outcome = normalize_rectilinear_region_v2((), atomic_budget=atomic_budget) - failure = _maybe_nested_failure(outcome) - if failure is not None: - return failure - return _exact_region(outcome) - - -def _is_identity_rotation(rotation: QuaternionV2) -> bool: - return (rotation.x, rotation.y, rotation.z, rotation.w) == (0.0, 0.0, 0.0, 1.0) - - -def _is_identity_transform(transform: RigidTransformV2) -> bool: - translation = transform.translation - return (translation.x, translation.y, translation.z) == ( - 0.0, - 0.0, - 0.0, - ) and _is_identity_rotation(transform.rotation) - - -def _numeric_policy_is_zero(policy: NumericPolicyV2) -> bool: - return all( - value == 0.0 - for value in ( - policy.linear_tolerance_m, - policy.area_tolerance_m2, - policy.angular_tolerance_rad, - policy.pixel_tolerance_px, - policy.fraction_tolerance, - ) - ) - - -def _uncertainty_is_zero(uncertainty: UncertaintyBudgetV2) -> bool: - return _numeric_policy_is_zero( - uncertainty.source_error - ) and _numeric_policy_is_zero(uncertainty.shape_approximation) - - -def _resolve_atomic_budget( - max_atomic_cells: int | None, - atomic_budget: RectilinearAtomicBudgetV2 | None, -) -> RectilinearAtomicBudgetV2: - if (max_atomic_cells is None) == (atomic_budget is None): - raise ValueError("provide exactly one of max_atomic_cells or atomic_budget") - if atomic_budget is not None: - if type(atomic_budget) is not RectilinearAtomicBudgetV2: - raise TypeError("atomic_budget must be a RectilinearAtomicBudgetV2") - atomic_budget.validate() - return atomic_budget - if type(max_atomic_cells) is not int: - raise TypeError("max_atomic_cells must be an exact int") - return RectilinearAtomicBudgetV2(limit=max_atomic_cells) - - -def _exact_region(outcome: RectilinearRegionOutcomeV2) -> ExactRectilinearRegionV2: - if outcome.kind is not RectilinearOutcomeKindV2.EXACT or outcome.region is None: - raise RuntimeError("nested rectilinear outcome is not exact") - return outcome.region - - -def _maybe_nested_failure( - outcome: RectilinearRegionOutcomeV2, -) -> VisibilityDomainOutcomeV2 | None: - if outcome.kind is RectilinearOutcomeKindV2.EXACT: - return None - if outcome.kind is RectilinearOutcomeKindV2.RESOURCE_LIMIT: - return _resource() - return _unknown(*outcome.finding_codes) - - -def _bracket( - inner: ExactRectilinearRegionV2, - outer: ExactRectilinearRegionV2, -) -> VisibilityDomainOutcomeV2: - return VisibilityDomainOutcomeV2( - kind=VisibilityDomainKindV2.BRACKET, - inner_allowed_delta=inner, - outer_allowed_delta=outer, - ) - - -def _empty(*findings: str) -> VisibilityDomainOutcomeV2: - return VisibilityDomainOutcomeV2( - kind=VisibilityDomainKindV2.EMPTY, - finding_codes=tuple(findings), - ) - - -def _unknown(*findings: str) -> VisibilityDomainOutcomeV2: - return VisibilityDomainOutcomeV2( - kind=VisibilityDomainKindV2.UNKNOWN, - finding_codes=tuple(findings), - ) - - -def _resource() -> VisibilityDomainOutcomeV2: - return VisibilityDomainOutcomeV2( - kind=VisibilityDomainKindV2.RESOURCE_LIMIT, - finding_codes=("RESOURCE_LIMIT:ATOMIC_CELLS",), - ) diff --git a/src/spatialcf/core/v2/zero_distortion.py b/src/spatialcf/core/v2/zero_distortion.py deleted file mode 100644 index 0f434bd..0000000 --- a/src/spatialcf/core/v2/zero_distortion.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Exact identity-projection capability for zero Brown-Conrady distortion.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import ClassVar - -from spatialcf.core.v2._internal.resources.domain_operations import ( - DomainOperationBudgetV2, -) -from spatialcf.core.v2.candidate_domain import CANDIDATE_DOMAIN_ALGORITHM_ID_V2 -from spatialcf.core.v2.cardinal_yaw import ( - CARDINAL_ALGORITHM_VERSION_V2_1, - CARDINAL_KERNEL_VERSION_V2_1, - reserve_cardinal_problem_structure_v2_1, -) -from spatialcf.core.v2.rectilinear_kernel import ( - RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M, - RECTILINEAR_KERNEL_ID_V2, -) -from spatialcf.domain.v2.base import ( - FactAvailabilityV2, - FactSetV2, - NumericPolicyV2, - UncertaintyBudgetV2, -) -from spatialcf.domain.v2.cardinal import ( - CanonicalSceneV2_1, - PinholeCameraV2_1, - SemanticProblemV2_1, -) -from spatialcf.domain.v2.result import ( - CoreSolverConfigV2, - DirectedOutwardGeometryKernelSpecV2, -) -from spatialcf.domain.v2.scene import ( - BrownConradyCoefficientsV2, - CameraDistortionModelV2, -) - -ZERO_DISTORTION_ALGORITHM_VERSION_V2_2 = "algorithm:2.2" - - -class ZeroDistortionResourceLimitV2(RuntimeError): - pass - - -class ZeroDistortionUnsupportedModelV2(RuntimeError): - def __init__(self, finding_code: str) -> None: - self.finding_code = finding_code - super().__init__(finding_code) - - -@dataclass(slots=True) -class ZeroDistortionDomainBudgetV2(DomainOperationBudgetV2): - _exhaustion_error_type: ClassVar[type[RuntimeError]] = ZeroDistortionResourceLimitV2 - - -@dataclass(frozen=True, slots=True) -class PreparedZeroDistortionProblemV2_2: - normalized_problem: SemanticProblemV2_1 - internal_config: CoreSolverConfigV2 - preprocessing_domain_operations: int - - -def reserve_zero_distortion_problem_structure_v2_2( - problem: SemanticProblemV2_1, - budget: ZeroDistortionDomainBudgetV2, -) -> None: - """Reserve the frozen camera pass before inspecting any camera deeply.""" - - reserve_cardinal_problem_structure_v2_1(problem, budget) - scene = problem.scene - if type(scene) is not CanonicalSceneV2_1: - raise TypeError("zero-distortion problem scene has the wrong exact type") - cameras = scene.cameras - if not isinstance(cameras, FactSetV2): - raise TypeError("camera facts have the wrong exact type") - branches = tuple( - values - for values in (cameras.values, cameras.inner_values, cameras.outer_values) - if values is not None - ) - budget.consume(1 + sum(len(values) for values in branches)) - - -def prepare_zero_distortion_problem_v2_2( - problem: SemanticProblemV2_1, - config: CoreSolverConfigV2, - preprocessing_domain_operations: int, -) -> PreparedZeroDistortionProblemV2_2: - cameras = _normalize_camera_facts(problem.scene.cameras) - normalized_scene = CanonicalSceneV2_1.model_validate( - problem.scene.model_copy(update={"cameras": cameras}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - normalized_problem = SemanticProblemV2_1.model_validate( - problem.model_copy(update={"scene": normalized_scene}).model_dump( - mode="python", - warnings="error", - ), - strict=True, - ) - remaining = config.max_domain_operations - preprocessing_domain_operations - if remaining < 1: - raise ZeroDistortionResourceLimitV2 - payload = config.model_dump(mode="python", warnings="error") - payload["algorithm_version"] = CARDINAL_ALGORITHM_VERSION_V2_1 - payload["max_domain_operations"] = remaining - return PreparedZeroDistortionProblemV2_2( - normalized_problem=normalized_problem, - internal_config=CoreSolverConfigV2.model_validate(payload, strict=True), - preprocessing_domain_operations=preprocessing_domain_operations, - ) - - -def registry_finding_v2_2(config: CoreSolverConfigV2) -> str | None: - if ( - config.algorithm_id != CANDIDATE_DOMAIN_ALGORITHM_ID_V2 - or config.algorithm_version != ZERO_DISTORTION_ALGORITHM_VERSION_V2_2 - ): - return ( - f"UNREGISTERED_ALGORITHM:{config.algorithm_id}@{config.algorithm_version}" - ) - kernel = config.geometry_kernel - registered = ( - type(kernel) is DirectedOutwardGeometryKernelSpecV2 - and kernel.kernel_id == RECTILINEAR_KERNEL_ID_V2 - and kernel.kernel_version == CARDINAL_KERNEL_VERSION_V2_1 - and kernel.certified_outward_error_m - == RECTILINEAR_KERNEL_CERTIFIED_OUTWARD_ERROR_M - ) - if registered: - return None - error = getattr(kernel, "certified_outward_error_m", "NONE") - return ( - f"UNREGISTERED_GEOMETRY_KERNEL:{kernel.kernel_id}" - f"@{kernel.kernel_version}:{kernel.soundness.value}:{error}" - ) - - -def camera_has_exact_identity_projection_v2(camera: PinholeCameraV2_1) -> bool: - """Return whether distortion is exactly identity with zero uncertainty.""" - - if type(camera) is not PinholeCameraV2_1: - return False - coefficients = camera.brown_conrady_coefficients - if camera.distortion_model is CameraDistortionModelV2.NONE: - distortion_is_identity = coefficients is None - else: - distortion_is_identity = ( - camera.distortion_model is CameraDistortionModelV2.BROWN_CONRADY - and type(coefficients) is BrownConradyCoefficientsV2 - and all( - value == 0.0 - for value in ( - coefficients.k1, - coefficients.k2, - coefficients.p1, - coefficients.p2, - coefficients.k3, - ) - ) - ) - return distortion_is_identity and _zero_uncertainty(camera.calibration_uncertainty) - - -def _zero_uncertainty(budget: UncertaintyBudgetV2) -> bool: - return _zero_policy(budget.source_error) and _zero_policy( - budget.shape_approximation - ) - - -def _zero_policy(policy: NumericPolicyV2) -> bool: - return all(value == 0.0 for value in policy.model_dump(mode="python").values()) - - -def _normalize_camera_facts( - facts: FactSetV2[PinholeCameraV2_1], -) -> FactSetV2[PinholeCameraV2_1]: - if facts.availability is FactAvailabilityV2.KNOWN: - uncertainty = facts.uncertainty - if uncertainty is None or not _zero_uncertainty(uncertainty): - raise ZeroDistortionUnsupportedModelV2( - "UNSUPPORTED_MODEL:NONZERO_CAMERA_FACT_UNCERTAINTY" - ) - payload: dict[str, object] = { - "availability": facts.availability, - "completeness": facts.completeness, - "uncertainty": facts.uncertainty, - } - for field_name in ("values", "inner_values", "outer_values"): - values = getattr(facts, field_name) - payload[field_name] = ( - None - if values is None - else tuple(_normalize_camera(item) for item in values) - ) - return FactSetV2[PinholeCameraV2_1].model_validate(payload, strict=True) - - -def _normalize_camera(camera: PinholeCameraV2_1) -> PinholeCameraV2_1: - if type(camera) is not PinholeCameraV2_1: - raise TypeError("camera fact has the wrong exact type") - if not _zero_uncertainty(camera.calibration_uncertainty): - raise ZeroDistortionUnsupportedModelV2( - "UNSUPPORTED_MODEL:NONZERO_CAMERA_CALIBRATION_UNCERTAINTY" - ) - coefficients = camera.brown_conrady_coefficients - if ( - camera.distortion_model is CameraDistortionModelV2.BROWN_CONRADY - and type(coefficients) is BrownConradyCoefficientsV2 - and any( - value != 0.0 - for value in ( - coefficients.k1, - coefficients.k2, - coefficients.p1, - coefficients.p2, - coefficients.k3, - ) - ) - ): - raise ZeroDistortionUnsupportedModelV2( - "UNSUPPORTED_MODEL:NONZERO_BROWN_CONRADY" - ) - if not camera_has_exact_identity_projection_v2(camera): - raise ZeroDistortionUnsupportedModelV2( - "UNSUPPORTED_MODEL:NONIDENTITY_CAMERA_PROJECTION" - ) - if camera.distortion_model is CameraDistortionModelV2.NONE: - return camera - payload = camera.model_dump(mode="python", warnings="error") - payload["distortion_model"] = CameraDistortionModelV2.NONE - payload["brown_conrady_coefficients"] = None - return PinholeCameraV2_1.model_validate(payload, strict=True) - - -__all__ = ( - "ZERO_DISTORTION_ALGORITHM_VERSION_V2_2", - "PreparedZeroDistortionProblemV2_2", - "ZeroDistortionDomainBudgetV2", - "ZeroDistortionResourceLimitV2", - "ZeroDistortionUnsupportedModelV2", - "camera_has_exact_identity_projection_v2", - "prepare_zero_distortion_problem_v2_2", - "registry_finding_v2_2", - "reserve_zero_distortion_problem_structure_v2_2", -) diff --git a/src/spatialcf/core/v2/continuous_yaw_solve_verifier_v2_9.py b/src/spatialcf/core/verification.py similarity index 81% rename from src/spatialcf/core/v2/continuous_yaw_solve_verifier_v2_9.py rename to src/spatialcf/core/verification.py index 3161190..044cca9 100644 --- a/src/spatialcf/core/v2/continuous_yaw_solve_verifier_v2_9.py +++ b/src/spatialcf/core/verification.py @@ -1,4 +1,4 @@ -"""Trusted expected-config replay verifier for competition v2.9 results.""" +"""Current trusted fresh-replay solve verifier.""" from __future__ import annotations @@ -7,11 +7,13 @@ from pydantic import TypeAdapter, ValidationError from pydantic_core import PydanticSerializationError -from spatialcf.core.v2.continuous_yaw_solver_v2_9 import ( - solve_continuous_yaw_minimum_cost_v2_9, +from spatialcf.core.solver import ( + solve_minimum_cost, ) -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.result import UncertifiedReasonV2 +from spatialcf.domain.serialization import canonical_json_bytes +from spatialcf.domain.solver import ( ContinuousYawCertifiedSuccessResultV2_9, ContinuousYawProvenUnsatResultV2_9, ContinuousYawSolverConfigV2_9, @@ -20,13 +22,11 @@ ContinuousYawSolveVerificationOutcomeV2_9, ContinuousYawUncertifiedResultV2_9, ) -from spatialcf.domain.v2.result import UncertifiedReasonV2 -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 _RESULT_ADAPTER = TypeAdapter(ContinuousYawSolveResultV2_9) -def verify_continuous_yaw_solve_result_v2_9( +def verify_solve_result( problem: SemanticProblemV2_3, expected_config: ContinuousYawSolverConfigV2_9, submitted_result: ContinuousYawSolveResultV2_9, @@ -44,8 +44,8 @@ def verify_continuous_yaw_solve_result_v2_9( return _mismatch("MISMATCH:CONTINUOUS_YAW_PROBLEM_HASH_V2_9") if ( submitted.solver_config.config_sha256 != config.config_sha256 - or canonical_json_bytes_v2(submitted.solver_config) - != canonical_json_bytes_v2(config) + or canonical_json_bytes(submitted.solver_config) + != canonical_json_bytes(config) ): return _mismatch("MISMATCH:CONTINUOUS_YAW_EXPECTED_CONFIG_V2_9") except (ArithmeticError, RuntimeWarning): @@ -53,7 +53,7 @@ def verify_continuous_yaw_solve_result_v2_9( try: with warnings.catch_warnings(): warnings.simplefilter("error", Warning) - replay = solve_continuous_yaw_minimum_cost_v2_9(problem, config) + replay = solve_minimum_cost(problem, config) except (ArithmeticError, RuntimeWarning): return _uncertified("NUMERIC_GAP:CONTINUOUS_YAW_SOLVE_REPLAY_V2_9") if replay.result is None: @@ -68,7 +68,7 @@ def verify_continuous_yaw_solve_result_v2_9( exact = ( type(fresh) is type(submitted) and fresh == submitted - and canonical_json_bytes_v2(fresh) == canonical_json_bytes_v2(submitted) + and canonical_json_bytes(fresh) == canonical_json_bytes(submitted) and fresh.solve_result_sha256 == submitted.solve_result_sha256 ) if not exact: @@ -91,14 +91,6 @@ def verify_continuous_yaw_solve_result_v2_9( ) -class ContinuousYawSolveVerifierV2_9: - @staticmethod - def verify(problem, expected_config, submitted_result): - return verify_continuous_yaw_solve_result_v2_9( - problem, expected_config, submitted_result - ) - - def _strict_inputs(problem, config, submitted): if type(problem) is not SemanticProblemV2_3 or type(config) is not ( ContinuousYawSolverConfigV2_9 @@ -150,7 +142,4 @@ def _uncertified(finding): ) -__all__ = ( - "ContinuousYawSolveVerifierV2_9", - "verify_continuous_yaw_solve_result_v2_9", -) +__all__ = ("verify_solve_result",) diff --git a/src/spatialcf/domain/v2/artifacts.py b/src/spatialcf/domain/artifacts.py similarity index 72% rename from src/spatialcf/domain/v2/artifacts.py rename to src/spatialcf/domain/artifacts.py index a4a4319..5c3c9a8 100644 --- a/src/spatialcf/domain/v2/artifacts.py +++ b/src/spatialcf/domain/artifacts.py @@ -1,4 +1,4 @@ -"""Auditable Canonical v2 domain and objective partition artifacts. +"""Auditable current domain and objective partition artifacts. These immutable contracts publish solver-produced regions, intervals, coverage claims, and upstream semantic hashes. They do not perform candidate search or @@ -16,22 +16,51 @@ from pydantic import BeforeValidator, Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, + FactSetV2, FiniteFloat, NonNegativeFiniteFloat, SchemaIdentityV2, Sha256Digest, - V2Model, - Vec2V2, + UncertaintyBudgetV2, + Vec2, ) -from spatialcf.domain.v2.geometry import PlanarRegionV2 -from spatialcf.domain.v2.objective import PairAxisKeyV2, SafetySlackUnitV2 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 +from spatialcf.domain.geometry import ( + CollisionBodyFactV2, + DirectedYawIntervalTransformV2_2, + GeometryApproximationV2, + GeometryRoleV2, + GeometryShapeV2, + PlanarRegionV2, +) +from spatialcf.domain.objective import PairAxisKeyV2, SafetySlackUnitV2 +from spatialcf.domain.problem import SemanticProblemV2 +from spatialcf.domain.scene import ( + BaselineObservation, + CanonicalObject, + CanonicalScene, + KnownFreeSpaceFact, + ObjectPose, + PinholeCamera, + SupportSurfaceFact, + WorkspaceBoundaryFact, +) +from spatialcf.domain.serialization import canonical_sha256 _CANDIDATE_DOMAIN_HASH_DOMAIN = "candidate-domain-artifact-v2" _RELATION_COST_PARTITION_HASH_DOMAIN = "relation-cost-partition-v2" _OBJECTIVE_PARTITION_HASH_DOMAIN = "objective-partition-artifact-v2" +_PROBLEM_HASH_DOMAIN_V2_2 = "spatialcf.semantic-problem.v2.2" +_CONFIG_HASH_DOMAIN_V2_5 = "spatialcf.strict-convex-candidate-config.v2.5" +_CONFIG_HASH_DOMAIN_V2_6 = "spatialcf.strict-convex-candidate-config.v2.6" +_CONFIG_HASH_DOMAIN_V2_7 = "spatialcf.strict-convex-candidate-config.v2.7" +_MAX_DETERMINISTIC_LIMIT_V2_5 = 2**63 - 1 +_DeterministicLimitV2_5 = Annotated[ + int, + Field(strict=True, ge=1, le=_MAX_DETERMINISTIC_LIMIT_V2_5), +] class RegionBoundStatusV2(StrEnum): @@ -88,7 +117,7 @@ class CandidateCompilationCoverageV2(StrEnum): PARTIAL = "PARTIAL" -class PlanarRegionBoundV2(V2Model): +class PlanarRegionBoundV2(CanonicalModel): """A non-empty region, a proven empty region, or an unavailable bound.""" status: RegionBoundStatusV2 @@ -118,7 +147,7 @@ def unavailable(cls) -> Self: return cls(status=RegionBoundStatusV2.UNAVAILABLE) -class PlanarDomainBoundsV2(V2Model): +class PlanarDomainBoundsV2(CanonicalModel): """Sound inner/outer description of one planar domain.""" inner_bound: PlanarRegionBoundV2 @@ -242,17 +271,17 @@ def _reject_boolean_indicator(value: object) -> object: ] -class CandidateDomainVariableV2(V2Model): +class CandidateDomainVariableV2(CanonicalModel): """Platform-neutral meaning and baseline of the two-dimensional variable.""" variable_kind: CandidateDomainVariableKindV2 = ( CandidateDomainVariableKindV2.SUBJECT_WORLD_XY_TRANSLATION_M ) subject_id: CanonicalId - baseline_anchor_world_xy_m: Vec2V2 + baseline_anchor_world_xy_m: Vec2 -class CompilationResourceUsageV2(V2Model): +class CompilationResourceUsageV2(CanonicalModel): """Deterministic counters consumed while compiling a candidate domain.""" domain_operations: NonNegativeResourceCount @@ -260,7 +289,7 @@ class CompilationResourceUsageV2(V2Model): refinement_steps: NonNegativeResourceCount -class ConstraintDomainShrinkStepV2(V2Model): +class ConstraintDomainShrinkStepV2(CanonicalModel): """One ordered hard-constraint intersection ledger entry.""" step_index: NonNegativeIndex @@ -315,7 +344,7 @@ def validate_compilation_step(self) -> Self: return self -class CandidateDomainArtifactV2(V2Model): +class CandidateDomainArtifactV2(CanonicalModel): """Published hard-domain bracket and deterministic shrink ledger.""" schema_identity: SchemaIdentityV2 = Field( @@ -451,10 +480,10 @@ def _ledger_is_structurally_complete(self) -> bool: @property def candidate_domain_artifact_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CANDIDATE_DOMAIN_HASH_DOMAIN) + return canonical_sha256(self, domain=_CANDIDATE_DOMAIN_HASH_DOMAIN) -class ConstraintSlackV2(V2Model): +class ConstraintSlackV2(CanonicalModel): """A dimensionless interval after frozen per-component normalization.""" constraint_id: CanonicalId @@ -469,7 +498,7 @@ def validate_interval(self) -> Self: return self -class RelationDamageBoundV2(V2Model): +class RelationDamageBoundV2(CanonicalModel): """Sound interval for one binary relation-state change indicator.""" key: PairAxisKeyV2 @@ -483,7 +512,7 @@ def validate_interval(self) -> Self: return self -class RelationCostCellV2(V2Model): +class RelationCostCellV2(CanonicalModel): cell_id: CanonicalId domain: PlanarDomainBoundsV2 relation_damage_vector: tuple[RelationDamageBoundV2, ...] = Field(min_length=1) @@ -498,7 +527,7 @@ def canonicalize_vector(self) -> Self: return self -class RelationCostPartitionV2(V2Model): +class RelationCostPartitionV2(CanonicalModel): schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2(schema_name="relation-cost-partition") ) @@ -530,13 +559,13 @@ def is_global_verification_eligible(self) -> bool: @property def relation_cost_partition_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_RELATION_COST_PARTITION_HASH_DOMAIN, ) -class NonNegativeIntervalV2(V2Model): +class NonNegativeIntervalV2(CanonicalModel): lower_bound: NonNegativeFiniteFloat upper_bound: NonNegativeFiniteFloat @@ -570,7 +599,7 @@ def _directed_binary64_sum( return published -class ObjectiveTermBoundsV2(V2Model): +class ObjectiveTermBoundsV2(CanonicalModel): translation_loss: NonNegativeIntervalV2 relation_damage_loss: NonNegativeIntervalV2 visibility_change_loss: NonNegativeIntervalV2 @@ -611,7 +640,7 @@ def total_upper_bound(self) -> float: ) -class ObjectivePartitionCellV2(V2Model): +class ObjectivePartitionCellV2(CanonicalModel): cell_id: CanonicalId parent_relation_cell_id: CanonicalId domain: PlanarDomainBoundsV2 @@ -634,7 +663,7 @@ def canonicalize_vectors(self) -> Self: return self -class ObjectivePartitionArtifactV2(V2Model): +class ObjectivePartitionArtifactV2(CanonicalModel): schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2( schema_name="objective-partition-artifact" @@ -672,7 +701,7 @@ def is_global_verification_eligible(self) -> bool: @property def objective_partition_artifact_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_OBJECTIVE_PARTITION_HASH_DOMAIN, ) @@ -840,3 +869,234 @@ def _region_geometry(region: PlanarRegionV2): ] polygons.append(Polygon(exterior, holes)) return unary_union(polygons) + + +class SchemaIdentityV2_2(CanonicalModel): + """Identity carried only by Canonical 2.2 root contracts.""" + + schema_name: CanonicalId + schema_version: Literal["2.2"] = "2.2" + + +class ObjectPoseV2_2(ObjectPose): + world_from_object: DirectedYawIntervalTransformV2_2 + + +class CanonicalObjectV2_2(CanonicalObject): + pose: ObjectPoseV2_2 + + +class GeometryInstanceV2_2(CanonicalModel): + geometry_id: CanonicalId + owner_object_id: CanonicalId | None + role: GeometryRoleV2 + anchor_from_geometry: DirectedYawIntervalTransformV2_2 + approximation: GeometryApproximationV2 + uncertainty: UncertaintyBudgetV2 + shape: GeometryShapeV2 + + +class SupportSurfaceFactV2_2(SupportSurfaceFact): + anchor_from_surface: DirectedYawIntervalTransformV2_2 + + +class PinholeCameraV2_2(PinholeCamera): + world_to_camera: DirectedYawIntervalTransformV2_2 + + +class CanonicalSceneV2_2(CanonicalScene): + """Canonical Scene whose transform-bearing facts use directed yaw.""" + + schema_identity: SchemaIdentityV2_2 = Field( + default_factory=lambda: SchemaIdentityV2_2(schema_name="canonical-scene") + ) + objects: FactSetV2[CanonicalObjectV2_2] + geometry_instances: FactSetV2[GeometryInstanceV2_2] + collision_bodies: FactSetV2[CollisionBodyFactV2] + workspace_boundaries: FactSetV2[WorkspaceBoundaryFact] + known_free_spaces: FactSetV2[KnownFreeSpaceFact] + support_surfaces: FactSetV2[SupportSurfaceFactV2_2] + cameras: FactSetV2[PinholeCameraV2_2] + baseline_observations: FactSetV2[BaselineObservation] + + @classmethod + def _expected_schema_identity(cls) -> SchemaIdentityV2_2: + return SchemaIdentityV2_2(schema_name="canonical-scene") + + +class SemanticProblemV2_2(SemanticProblemV2): + """Semantic Problem with a domain-separated Canonical 2.2 hash.""" + + schema_identity: SchemaIdentityV2_2 = Field( + default_factory=lambda: SchemaIdentityV2_2(schema_name="semantic-problem") + ) + scene: CanonicalSceneV2_2 + + @classmethod + def _expected_schema_identity(cls) -> SchemaIdentityV2_2: + return SchemaIdentityV2_2(schema_name="semantic-problem") + + @property + def semantic_problem_sha256(self) -> str: + return canonical_sha256(self, domain=_PROBLEM_HASH_DOMAIN_V2_2) + + +class StrictConvexCandidateCompilerConfigV2_5(CanonicalModel): + """Standalone bounded config for the strict-convex candidate stage.""" + + schema_identity: SchemaIdentityV2_2 = Field( + default_factory=lambda: SchemaIdentityV2_2( + schema_name="strict-convex-candidate-compiler-config" + ) + ) + algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( + "solver:canonical-branch-and-bound-v2" + ) + algorithm_version: Literal["algorithm:2.5"] = "algorithm:2.5" + so2_kernel_id: Literal["geometry-kernel:rational-so2-upright-box-directed-v2"] = ( + "geometry-kernel:rational-so2-upright-box-directed-v2" + ) + so2_kernel_version: Literal["kernel:2.2-continuous-yaw-upright-box"] = ( + "kernel:2.2-continuous-yaw-upright-box" + ) + obstacle_kernel_id: Literal[ + "geometry-kernel:rational-convex-translation-bracket-v2" + ] = "geometry-kernel:rational-convex-translation-bracket-v2" + obstacle_kernel_version: Literal["kernel:2.3-convex-translation-bracket"] = ( + "kernel:2.3-convex-translation-bracket" + ) + partition_kernel_id: Literal[ + "geometry-kernel:rational-convex-complement-partition-v2" + ] = "geometry-kernel:rational-convex-complement-partition-v2" + partition_kernel_version: Literal["kernel:2.4-topology-aware-convex-complement"] = ( + "kernel:2.4-topology-aware-convex-complement" + ) + max_domain_operations: _DeterministicLimitV2_5 + max_so2_atomic_steps: _DeterministicLimitV2_5 + + @model_validator(mode="after") + def validate_schema_identity(self) -> Self: + expected = SchemaIdentityV2_2( + schema_name="strict-convex-candidate-compiler-config" + ) + if self.schema_identity != expected: + raise ValueError("strict-convex candidate config identity must be fixed") + return self + + @property + def config_sha256(self) -> str: + return canonical_sha256(self, domain=_CONFIG_HASH_DOMAIN_V2_5) + + +class StrictConvexCandidateCompilerConfigV2_6(CanonicalModel): + """Standalone bounded config for multi-obstacle strict intersection.""" + + schema_identity: SchemaIdentityV2_2 = Field( + default_factory=lambda: SchemaIdentityV2_2( + schema_name="strict-convex-candidate-compiler-config" + ) + ) + algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( + "solver:canonical-branch-and-bound-v2" + ) + algorithm_version: Literal["algorithm:2.6"] = "algorithm:2.6" + so2_kernel_id: Literal["geometry-kernel:rational-so2-upright-box-directed-v2"] = ( + "geometry-kernel:rational-so2-upright-box-directed-v2" + ) + so2_kernel_version: Literal["kernel:2.2-continuous-yaw-upright-box"] = ( + "kernel:2.2-continuous-yaw-upright-box" + ) + obstacle_kernel_id: Literal[ + "geometry-kernel:rational-convex-translation-bracket-v2" + ] = "geometry-kernel:rational-convex-translation-bracket-v2" + obstacle_kernel_version: Literal["kernel:2.3-convex-translation-bracket"] = ( + "kernel:2.3-convex-translation-bracket" + ) + partition_kernel_id: Literal[ + "geometry-kernel:rational-convex-complement-partition-v2" + ] = "geometry-kernel:rational-convex-complement-partition-v2" + partition_kernel_version: Literal["kernel:2.4-topology-aware-convex-complement"] = ( + "kernel:2.4-topology-aware-convex-complement" + ) + intersection_kernel_id: Literal[ + "geometry-kernel:rational-strict-convex-intersection-v2" + ] = "geometry-kernel:rational-strict-convex-intersection-v2" + intersection_kernel_version: Literal["kernel:2.5-strict-convex-intersection"] = ( + "kernel:2.5-strict-convex-intersection" + ) + max_domain_operations: _DeterministicLimitV2_5 + max_so2_atomic_steps: _DeterministicLimitV2_5 + max_candidate_cells: _DeterministicLimitV2_5 + + @model_validator(mode="after") + def validate_schema_identity(self) -> Self: + expected = SchemaIdentityV2_2( + schema_name="strict-convex-candidate-compiler-config" + ) + if self.schema_identity != expected: + raise ValueError("strict-convex candidate config identity must be fixed") + return self + + @property + def config_sha256(self) -> str: + return canonical_sha256(self, domain=_CONFIG_HASH_DOMAIN_V2_6) + + +class StrictConvexCandidateCompilerConfigV2_7(CanonicalModel): + """Standalone bounded config for exact horizontal support projection.""" + + schema_identity: SchemaIdentityV2_2 = Field( + default_factory=lambda: SchemaIdentityV2_2( + schema_name="strict-convex-candidate-compiler-config" + ) + ) + algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( + "solver:canonical-branch-and-bound-v2" + ) + algorithm_version: Literal["algorithm:2.7"] = "algorithm:2.7" + so2_kernel_id: Literal["geometry-kernel:rational-so2-upright-box-directed-v2"] = ( + "geometry-kernel:rational-so2-upright-box-directed-v2" + ) + so2_kernel_version: Literal["kernel:2.2-continuous-yaw-upright-box"] = ( + "kernel:2.2-continuous-yaw-upright-box" + ) + obstacle_kernel_id: Literal[ + "geometry-kernel:rational-convex-translation-bracket-v2" + ] = "geometry-kernel:rational-convex-translation-bracket-v2" + obstacle_kernel_version: Literal["kernel:2.3-convex-translation-bracket"] = ( + "kernel:2.3-convex-translation-bracket" + ) + partition_kernel_id: Literal[ + "geometry-kernel:rational-convex-complement-partition-v2" + ] = "geometry-kernel:rational-convex-complement-partition-v2" + partition_kernel_version: Literal["kernel:2.4-topology-aware-convex-complement"] = ( + "kernel:2.4-topology-aware-convex-complement" + ) + intersection_kernel_id: Literal[ + "geometry-kernel:rational-strict-convex-intersection-v2" + ] = "geometry-kernel:rational-strict-convex-intersection-v2" + intersection_kernel_version: Literal["kernel:2.5-strict-convex-intersection"] = ( + "kernel:2.5-strict-convex-intersection" + ) + support_projection_kernel_id: Literal[ + "geometry-kernel:rational-continuous-yaw-support-projection-v2" + ] = "geometry-kernel:rational-continuous-yaw-support-projection-v2" + support_projection_kernel_version: Literal[ + "kernel:2.6-exact-horizontal-support-projection" + ] = "kernel:2.6-exact-horizontal-support-projection" + max_domain_operations: _DeterministicLimitV2_5 + max_so2_atomic_steps: _DeterministicLimitV2_5 + max_candidate_cells: _DeterministicLimitV2_5 + + @model_validator(mode="after") + def validate_schema_identity(self) -> Self: + expected = SchemaIdentityV2_2( + schema_name="strict-convex-candidate-compiler-config" + ) + if self.schema_identity != expected: + raise ValueError("strict-convex candidate config identity must be fixed") + return self + + @property + def config_sha256(self) -> str: + return canonical_sha256(self, domain=_CONFIG_HASH_DOMAIN_V2_7) diff --git a/src/spatialcf/domain/v2/base.py b/src/spatialcf/domain/base.py similarity index 91% rename from src/spatialcf/domain/v2/base.py rename to src/spatialcf/domain/base.py index 1ecd513..1bfe40b 100644 --- a/src/spatialcf/domain/v2/base.py +++ b/src/spatialcf/domain/base.py @@ -14,7 +14,7 @@ from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator -class V2Model(BaseModel): +class CanonicalModel(BaseModel): """Strict immutable base for every Canonical v2 value object.""" model_config = ConfigDict( @@ -64,7 +64,7 @@ def _require_unicode_nfc(value: object) -> object: ] -class SchemaIdentityV2(V2Model): +class SchemaIdentityV2(CanonicalModel): """Identity carried by a versioned Canonical v2 root contract.""" schema_name: CanonicalId @@ -89,14 +89,14 @@ class FactCompletenessV2(StrEnum): SAMPLED = "SAMPLED" -class Vec2V2(V2Model): +class Vec2(CanonicalModel): """Finite two-dimensional vector in the unit declared by its owner.""" x: FiniteFloat y: FiniteFloat -class Vec3V2(V2Model): +class Vec3(CanonicalModel): """Finite three-dimensional vector in the unit declared by its owner.""" x: FiniteFloat @@ -104,7 +104,7 @@ class Vec3V2(V2Model): z: FiniteFloat -class QuaternionV2(V2Model): +class Quaternion(CanonicalModel): """Unit quaternion with q/-q normalized to one deterministic sign.""" x: FiniteFloat @@ -141,21 +141,21 @@ def identity(cls) -> Self: return cls(x=0.0, y=0.0, z=0.0, w=1.0) -class RigidTransformV2(V2Model): +class RigidTransformV2(CanonicalModel): """Object-local to parent-frame rigid transform.""" - translation: Vec3V2 - rotation: QuaternionV2 + translation: Vec3 + rotation: Quaternion @classmethod def identity(cls) -> Self: return cls( - translation=Vec3V2(x=0.0, y=0.0, z=0.0), - rotation=QuaternionV2.identity(), + translation=Vec3(x=0.0, y=0.0, z=0.0), + rotation=Quaternion.identity(), ) -class NumericPolicyV2(V2Model): +class NumericPolicyV2(CanonicalModel): """Dimension-specific numeric tolerances; units are never conflated.""" linear_tolerance_m: NonNegativeFiniteFloat = 0.0 @@ -189,7 +189,7 @@ class ErrorCombinationV2(StrEnum): ADDITIVE_WORST_CASE = "ADDITIVE_WORST_CASE" -class UncertaintyCalculusV2(V2Model): +class UncertaintyCalculusV2(CanonicalModel): """Closed, platform-neutral interpretation of every uncertainty budget.""" linear_error_model: LinearErrorModelV2 = ( @@ -202,7 +202,7 @@ class UncertaintyCalculusV2(V2Model): combination: ErrorCombinationV2 = ErrorCombinationV2.ADDITIVE_WORST_CASE -class UncertaintyBudgetV2(V2Model): +class UncertaintyBudgetV2(CanonicalModel): """Source-side errors plus their fixed conservative interpretation.""" calculus: UncertaintyCalculusV2 = Field(default_factory=UncertaintyCalculusV2) @@ -210,10 +210,10 @@ class UncertaintyBudgetV2(V2Model): shape_approximation: NumericPolicyV2 = Field(default_factory=NumericPolicyV2) -FactT = TypeVar("FactT", bound=V2Model) +FactT = TypeVar("FactT", bound=CanonicalModel) -class FactSetV2(V2Model, Generic[FactT]): +class FactSetV2(CanonicalModel, Generic[FactT]): """Availability-aware unordered facts without empty/missing ambiguity. ``KNOWN`` exact/one-sided/sampled sets use ``values``. ``BRACKETED`` facts @@ -279,11 +279,11 @@ def _canonicalize_fact_values( field_name: Literal["values", "inner_values", "outer_values"], values: tuple[FactT, ...], ) -> None: - from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 + from spatialcf.domain.serialization import canonical_json_bytes - if any(not isinstance(value, V2Model) for value in values): + if any(not isinstance(value, CanonicalModel) for value in values): raise ValueError("FactSetV2 values must be immutable V2Model instances") - ordered = tuple(sorted(values, key=canonical_json_bytes_v2)) + ordered = tuple(sorted(values, key=canonical_json_bytes)) if any( left == right for index, left in enumerate(ordered) diff --git a/src/spatialcf/domain/v2/certificate.py b/src/spatialcf/domain/certificate.py similarity index 93% rename from src/spatialcf/domain/v2/certificate.py rename to src/spatialcf/domain/certificate.py index 7593ed0..67454f6 100644 --- a/src/spatialcf/domain/v2/certificate.py +++ b/src/spatialcf/domain/certificate.py @@ -1,4 +1,4 @@ -"""Platform-neutral semantic certificate contracts for Canonical v2. +"""Platform-neutral current semantic certificate contracts. These immutable models bind the inputs and outputs of an independent pure-core verification. Constructing a model does not itself prove region coverage, @@ -15,14 +15,14 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( + CanonicalModel, NonNegativeFiniteFloat, PositiveFiniteFloat, SchemaIdentityV2, Sha256Digest, - V2Model, ) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 +from spatialcf.domain.serialization import canonical_sha256 _GLOBAL_CERTIFICATE_HASH_DOMAIN = "global-optimality-certificate-v2" _UNSAT_CERTIFICATE_HASH_DOMAIN = "proven-unsat-certificate-v2" @@ -59,7 +59,7 @@ def _directed_binary64_gap_ceil(lower_bound: float, upper_bound: float) -> float return published -class GlobalOptimalityCertificateV2(V2Model): +class GlobalOptimalityCertificateV2(CanonicalModel): """Hash-closed total-loss optimality claim emitted by the core verifier. ``optimality_gap`` is the tight binary64 upper bound on the exact difference @@ -119,13 +119,13 @@ def validate_certificate(self) -> Self: @property def certificate_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_GLOBAL_CERTIFICATE_HASH_DOMAIN, ) -class ProvenUnsatCertificateV2(V2Model): +class ProvenUnsatCertificateV2(CanonicalModel): """Hash-closed claim that the complete hard-domain outer bound is empty.""" schema_identity: SchemaIdentityV2 = Field( @@ -149,7 +149,7 @@ def validate_certificate(self) -> Self: @property def certificate_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_UNSAT_CERTIFICATE_HASH_DOMAIN, ) diff --git a/src/spatialcf/domain/v2/constraints.py b/src/spatialcf/domain/constraints.py similarity index 77% rename from src/spatialcf/domain/v2/constraints.py rename to src/spatialcf/domain/constraints.py index c5f8896..be1351b 100644 --- a/src/spatialcf/domain/v2/constraints.py +++ b/src/spatialcf/domain/constraints.py @@ -1,4 +1,4 @@ -"""Platform-neutral Canonical v2 relation and hard-constraint contracts.""" +"""Platform-neutral Canonical relation and hard-constraint contracts.""" from __future__ import annotations @@ -7,13 +7,13 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, FactCompletenessV2, FiniteFloat, NonNegativeFiniteFloat, SchemaIdentityV2, - V2Model, ) UnitInterval = Annotated[ @@ -28,42 +28,42 @@ def _sort_unique_ids(values: tuple[str, ...], *, label: str) -> tuple[str, ...]: return tuple(sorted(values)) -class TranslationAxisV2(StrEnum): +class TranslationAxis(StrEnum): X = "X" Y = "Y" -class ImmutableFieldV2(StrEnum): +class ImmutableField(StrEnum): SUBJECT_Z = "SUBJECT_Z" SUBJECT_ROTATION = "SUBJECT_ROTATION" OTHER_OBJECTS = "OTHER_OBJECTS" CAMERAS = "CAMERAS" -class BoundaryPolicyV2(StrEnum): +class BoundaryPolicy(StrEnum): CLOSED = "CLOSED" STRICT_INTERIOR = "STRICT_INTERIOR" -class RegionAggregationV2(StrEnum): +class RegionAggregation(StrEnum): """How referenced planar fact regions form one semantic region.""" UNION = "UNION" INTERSECTION = "INTERSECTION" -class PositionRegionInterpretationV2(StrEnum): +class PositionRegionInterpretation(StrEnum): """Whether selected regions constrain the edit anchor or occupied solid.""" SUBJECT_ANCHOR_LOCUS = "SUBJECT_ANCHOR_LOCUS" SUBJECT_OCCUPANCY_CONTAINED = "SUBJECT_OCCUPANCY_CONTAINED" -class GeometrySetAggregationV2(StrEnum): +class GeometrySetAggregation(StrEnum): CLOSED_SOLID_UNION = "CLOSED_SOLID_UNION" -class CollisionClearanceMetricV2(StrEnum): +class CollisionClearanceMetric(StrEnum): """Closed collision predicate understood by the platform-neutral core.""" SOLID_INTERIOR_DISJOINT_AND_EUCLIDEAN_CLEARANCE = ( @@ -71,49 +71,49 @@ class CollisionClearanceMetricV2(StrEnum): ) -class SupportContactExceptionPolicyV2(StrEnum): +class SupportContactExceptionPolicy(StrEnum): RETAIN_SOLID_INTERIOR_DISJOINT_WAIVE_POSITIVE_CLEARANCE_ONLY_WHEN_NAMED_SUPPORT_PREDICATE_HOLDS = "RETAIN_SOLID_INTERIOR_DISJOINT_WAIVE_POSITIVE_CLEARANCE_ONLY_WHEN_NAMED_SUPPORT_PREDICATE_HOLDS" -class SupportAssignmentPolicyV2(StrEnum): +class SupportAssignmentPolicy(StrEnum): EXACT_SURFACE = "EXACT_SURFACE" -class SupportContactFeatureV2(StrEnum): +class SupportContactFeature(StrEnum): LOWEST_FACE_ALONG_SURFACE_NORMAL = "LOWEST_FACE_ALONG_SURFACE_NORMAL" -class SupportContactAggregationV2(StrEnum): +class SupportContactAggregation(StrEnum): UNION_ALL_SELECTED_FEATURES = "UNION_ALL_SELECTED_FEATURES" -class SupportOverlapMetricV2(StrEnum): +class SupportOverlapMetric(StrEnum): PROJECTED_CONTACT_UNION_INTERSECTION_AREA = ( "PROJECTED_CONTACT_UNION_INTERSECTION_AREA" ) -class SupportStabilityMetricV2(StrEnum): +class SupportStabilityMetric(StrEnum): FULL_CONTACT_UNION_CONTAINED_IN_SURFACE_INSET = ( "FULL_CONTACT_UNION_CONTAINED_IN_SURFACE_INSET" ) -class VisibilityMaskPolicyV2(StrEnum): +class VisibilityMaskPolicy(StrEnum): FULL_OBJECT = "FULL_OBJECT" -class OccluderSoundnessPolicyV2(StrEnum): +class OccluderSoundnessPolicy(StrEnum): EXACT_OR_OUTER_SHAPE_BOUND = "EXACT_OR_OUTER_SHAPE_BOUND" -class VisibilityMetricKindV2(StrEnum): +class VisibilityMetricKind(StrEnum): VISIBLE_FRACTION = "VISIBLE_FRACTION" IMAGE_AREA_FRACTION = "IMAGE_AREA_FRACTION" TRUNCATED_FRACTION = "TRUNCATED_FRACTION" -class VisibilityMetricFormulaV2(StrEnum): +class VisibilityMetricFormula(StrEnum): VISIBLE_CLIPPED_OVER_UNOCCLUDED_CLIPPED_PROJECTED_AREA = ( "VISIBLE_CLIPPED_OVER_UNOCCLUDED_CLIPPED_PROJECTED_AREA" ) @@ -128,21 +128,21 @@ class VisibilityMetricFormulaV2(StrEnum): ) -class VisibilityAreaMeasureV2(StrEnum): +class VisibilityAreaMeasure(StrEnum): CONTINUOUS_PIXEL_PLANE_AREA = "CONTINUOUS_PIXEL_PLANE_AREA" -class VisibilityDepthPolicyV2(StrEnum): +class VisibilityDepthPolicy(StrEnum): NEAREST_POSITIVE_CAMERA_DEPTH_OCCLUDES = "NEAREST_POSITIVE_CAMERA_DEPTH_OCCLUDES" -class RelationAxisV2(StrEnum): +class RelationAxis(StrEnum): HORIZONTAL = "HORIZONTAL" DEPTH = "DEPTH" DISTANCE = "DISTANCE" -class RelationV2(StrEnum): +class Relation(StrEnum): LEFT = "LEFT" RIGHT = "RIGHT" FRONT = "FRONT" @@ -151,98 +151,98 @@ class RelationV2(StrEnum): FAR = "FAR" @property - def axis(self) -> RelationAxisV2: + def axis(self) -> RelationAxis: return { - RelationV2.LEFT: RelationAxisV2.HORIZONTAL, - RelationV2.RIGHT: RelationAxisV2.HORIZONTAL, - RelationV2.FRONT: RelationAxisV2.DEPTH, - RelationV2.BEHIND: RelationAxisV2.DEPTH, - RelationV2.NEAR: RelationAxisV2.DISTANCE, - RelationV2.FAR: RelationAxisV2.DISTANCE, + Relation.LEFT: RelationAxis.HORIZONTAL, + Relation.RIGHT: RelationAxis.HORIZONTAL, + Relation.FRONT: RelationAxis.DEPTH, + Relation.BEHIND: RelationAxis.DEPTH, + Relation.NEAR: RelationAxis.DISTANCE, + Relation.FAR: RelationAxis.DISTANCE, }[self] @property - def opposite(self) -> RelationV2: + def opposite(self) -> Relation: return { - RelationV2.LEFT: RelationV2.RIGHT, - RelationV2.RIGHT: RelationV2.LEFT, - RelationV2.FRONT: RelationV2.BEHIND, - RelationV2.BEHIND: RelationV2.FRONT, - RelationV2.NEAR: RelationV2.FAR, - RelationV2.FAR: RelationV2.NEAR, + Relation.LEFT: Relation.RIGHT, + Relation.RIGHT: Relation.LEFT, + Relation.FRONT: Relation.BEHIND, + Relation.BEHIND: Relation.FRONT, + Relation.NEAR: Relation.FAR, + Relation.FAR: Relation.NEAR, }[self] -class RelationMeasurementV2(StrEnum): +class RelationMeasurement(StrEnum): PROJECTED_CENTER_DELTA_X = "PROJECTED_CENTER_DELTA_X" CAMERA_DEPTH_DELTA = "CAMERA_DEPTH_DELTA" SHAPE_GAP_XY = "SHAPE_GAP_XY" -class RelationRepresentativePointV2(StrEnum): +class RelationRepresentativePoint(StrEnum): RELATION_GEOMETRY_VOLUME_CENTROID = "RELATION_GEOMETRY_VOLUME_CENTROID" -class MeasurementOperandOrderV2(StrEnum): +class MeasurementOperandOrder(StrEnum): FIRST_MINUS_SECOND = "FIRST_MINUS_SECOND" -class RelationTolerancePolicyV2(StrEnum): +class RelationTolerancePolicy(StrEnum): SYMMETRIC_INNER_OUTER_MEASUREMENT_BRACKET = ( "SYMMETRIC_INNER_OUTER_MEASUREMENT_BRACKET" ) -class MeasurementComparatorV2(StrEnum): +class MeasurementComparator(StrEnum): LESS_THAN = "LESS_THAN" GREATER_THAN = "GREATER_THAN" -class MeasurementUnitV2(StrEnum): +class MeasurementUnit(StrEnum): PIXEL = "PIXEL" METRE = "METRE" -class AllowedEditV2(V2Model): +class AllowedEdit(CanonicalModel): constraint_id: CanonicalId subject_id: CanonicalId - translation_axes: tuple[TranslationAxisV2, ...] = ( - TranslationAxisV2.X, - TranslationAxisV2.Y, + translation_axes: tuple[TranslationAxis, ...] = ( + TranslationAxis.X, + TranslationAxis.Y, ) - immutable_fields: tuple[ImmutableFieldV2, ...] = ( - ImmutableFieldV2.SUBJECT_Z, - ImmutableFieldV2.SUBJECT_ROTATION, - ImmutableFieldV2.OTHER_OBJECTS, - ImmutableFieldV2.CAMERAS, + immutable_fields: tuple[ImmutableField, ...] = ( + ImmutableField.SUBJECT_Z, + ImmutableField.SUBJECT_ROTATION, + ImmutableField.OTHER_OBJECTS, + ImmutableField.CAMERAS, ) @model_validator(mode="after") def validate_exact_edit_surface(self) -> Self: - if self.translation_axes != (TranslationAxisV2.X, TranslationAxisV2.Y): + if self.translation_axes != (TranslationAxis.X, TranslationAxis.Y): raise ValueError("allowed edit must be exactly XY translation") expected = ( - ImmutableFieldV2.SUBJECT_Z, - ImmutableFieldV2.SUBJECT_ROTATION, - ImmutableFieldV2.OTHER_OBJECTS, - ImmutableFieldV2.CAMERAS, + ImmutableField.SUBJECT_Z, + ImmutableField.SUBJECT_ROTATION, + ImmutableField.OTHER_OBJECTS, + ImmutableField.CAMERAS, ) if self.immutable_fields != expected: raise ValueError("allowed edit must freeze all required immutable fields") return self -class AllowedPositionDomainConstraintV2(V2Model): +class AllowedPositionDomainConstraint(CanonicalModel): constraint_id: CanonicalId subject_id: CanonicalId workspace_fact_ids: tuple[CanonicalId, ...] - workspace_aggregation: RegionAggregationV2 + workspace_aggregation: RegionAggregation known_free_space_fact_ids: tuple[CanonicalId, ...] = () - known_free_space_aggregation: RegionAggregationV2 | None = None - region_interpretation: PositionRegionInterpretationV2 + known_free_space_aggregation: RegionAggregation | None = None + region_interpretation: PositionRegionInterpretation subject_occupancy_body_ids: tuple[CanonicalId, ...] = () - subject_occupancy_aggregation: GeometrySetAggregationV2 | None = None - boundary_policy: BoundaryPolicyV2 + subject_occupancy_aggregation: GeometrySetAggregation | None = None + boundary_policy: BoundaryPolicy required_completeness: tuple[FactCompletenessV2, ...] minimum_boundary_clearance_m: NonNegativeFiniteFloat @@ -277,7 +277,7 @@ def canonicalize_and_validate(self) -> Self: object.__setattr__(self, "subject_occupancy_body_ids", occupancy_ids) if ( self.region_interpretation - is PositionRegionInterpretationV2.SUBJECT_ANCHOR_LOCUS + is PositionRegionInterpretation.SUBJECT_ANCHOR_LOCUS ): if occupancy_ids or self.subject_occupancy_aggregation is not None: raise ValueError( @@ -287,7 +287,7 @@ def canonicalize_and_validate(self) -> Self: elif ( not occupancy_ids or self.subject_occupancy_aggregation - is not GeometrySetAggregationV2.CLOSED_SOLID_UNION + is not GeometrySetAggregation.CLOSED_SOLID_UNION ): raise ValueError( "occupied-space position semantics require subject occupancy body " @@ -316,7 +316,7 @@ def canonicalize_and_validate(self) -> Self: return self -class SupportContactExceptionV2(V2Model): +class SupportContactException(CanonicalModel): """One exact body pair whose positive clearance alone may be waived. The named support predicate must hold, while closed-solid interiors remain @@ -326,7 +326,7 @@ class SupportContactExceptionV2(V2Model): support_constraint_id: CanonicalId subject_body_id: CanonicalId obstacle_body_id: CanonicalId - policy: SupportContactExceptionPolicyV2 + policy: SupportContactExceptionPolicy @property def sort_key(self) -> tuple[str, str, str, str]: @@ -338,14 +338,14 @@ def sort_key(self) -> tuple[str, str, str, str]: ) -class CollisionConstraintV2(V2Model): +class CollisionConstraint(CanonicalModel): constraint_id: CanonicalId subject_body_ids: tuple[CanonicalId, ...] obstacle_body_ids: tuple[CanonicalId, ...] - clearance_metric: CollisionClearanceMetricV2 - boundary_policy: BoundaryPolicyV2 + clearance_metric: CollisionClearanceMetric + boundary_policy: BoundaryPolicy minimum_clearance_m: NonNegativeFiniteFloat - support_contact_exceptions: tuple[SupportContactExceptionV2, ...] = () + support_contact_exceptions: tuple[SupportContactException, ...] = () @model_validator(mode="after") def canonicalize_and_validate(self) -> Self: @@ -378,21 +378,21 @@ def canonicalize_and_validate(self) -> Self: return self -class SupportConstraintV2(V2Model): +class SupportConstraint(CanonicalModel): constraint_id: CanonicalId supported_object_id: CanonicalId surface_id: CanonicalId subject_contact_geometry_ids: tuple[CanonicalId, ...] - contact_feature: SupportContactFeatureV2 - contact_aggregation: SupportContactAggregationV2 + contact_feature: SupportContactFeature + contact_aggregation: SupportContactAggregation contact_gap_min_m: FiniteFloat contact_gap_max_m: FiniteFloat - overlap_metric: SupportOverlapMetricV2 + overlap_metric: SupportOverlapMetric minimum_overlap_area_m2: NonNegativeFiniteFloat - stability_metric: SupportStabilityMetricV2 + stability_metric: SupportStabilityMetric stability_margin_m: NonNegativeFiniteFloat - boundary_policy: BoundaryPolicyV2 - assignment_policy: SupportAssignmentPolicyV2 + boundary_policy: BoundaryPolicy + assignment_policy: SupportAssignmentPolicy @model_validator(mode="after") def canonicalize_and_validate(self) -> Self: @@ -411,28 +411,28 @@ def canonicalize_and_validate(self) -> Self: return self -class VisibilityMetricDefinitionV2(V2Model): +class VisibilityMetricDefinition(CanonicalModel): """One closed analytic visibility formula, never an adapter callback.""" metric_definition_id: CanonicalId metric_definition_version: CanonicalId - kind: VisibilityMetricKindV2 - formula: VisibilityMetricFormulaV2 - area_measure: VisibilityAreaMeasureV2 - depth_policy: VisibilityDepthPolicyV2 + kind: VisibilityMetricKind + formula: VisibilityMetricFormula + area_measure: VisibilityAreaMeasure + depth_policy: VisibilityDepthPolicy @model_validator(mode="after") def validate_formula_kind(self) -> Self: expected = { - VisibilityMetricKindV2.VISIBLE_FRACTION: ( - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_OVER_UNOCCLUDED_CLIPPED_PROJECTED_AREA, + VisibilityMetricKind.VISIBLE_FRACTION: ( + VisibilityMetricFormula.VISIBLE_CLIPPED_OVER_UNOCCLUDED_CLIPPED_PROJECTED_AREA, ), - VisibilityMetricKindV2.IMAGE_AREA_FRACTION: ( - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA, - VisibilityMetricFormulaV2.VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA, + VisibilityMetricKind.IMAGE_AREA_FRACTION: ( + VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_AREA_OVER_IMAGE_AREA, + VisibilityMetricFormula.VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA, ), - VisibilityMetricKindV2.TRUNCATED_FRACTION: ( - VisibilityMetricFormulaV2.ONE_MINUS_CLIPPED_OVER_UNCLIPPED_PROJECTED_AREA, + VisibilityMetricKind.TRUNCATED_FRACTION: ( + VisibilityMetricFormula.ONE_MINUS_CLIPPED_OVER_UNCLIPPED_PROJECTED_AREA, ), }[self.kind] if self.formula not in expected: @@ -444,14 +444,14 @@ def reference(self) -> tuple[str, str]: return self.metric_definition_id, self.metric_definition_version -class VisibilitySemanticsV2(V2Model): +class VisibilitySemantics(CanonicalModel): """Complete versioned formula registry consumed by the pure core.""" schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2(schema_name="visibility-semantics") ) semantics_id: CanonicalId - definitions: tuple[VisibilityMetricDefinitionV2, ...] + definitions: tuple[VisibilityMetricDefinition, ...] @model_validator(mode="after") def validate_complete_registry(self) -> Self: @@ -459,7 +459,7 @@ def validate_complete_registry(self) -> Self: raise ValueError("visibility semantics schema identity must be fixed") by_kind = {definition.kind: definition for definition in self.definitions} if len(by_kind) != len(self.definitions) or set(by_kind) != set( - VisibilityMetricKindV2 + VisibilityMetricKind ): raise ValueError( "visibility semantics require exactly one definition per metric kind" @@ -470,12 +470,12 @@ def validate_complete_registry(self) -> Self: object.__setattr__( self, "definitions", - tuple(by_kind[kind] for kind in VisibilityMetricKindV2), + tuple(by_kind[kind] for kind in VisibilityMetricKind), ) return self -class VisibilityConstraintV2(V2Model): +class VisibilityConstraint(CanonicalModel): """Hard visibility policy over three explicitly versioned normalized metrics.""" constraint_id: CanonicalId @@ -489,12 +489,12 @@ class VisibilityConstraintV2(V2Model): image_area_metric_definition_version: CanonicalId truncated_fraction_metric_definition_id: CanonicalId truncated_fraction_metric_definition_version: CanonicalId - mask_policy: VisibilityMaskPolicyV2 - occluder_soundness_policy: OccluderSoundnessPolicyV2 + mask_policy: VisibilityMaskPolicy + occluder_soundness_policy: OccluderSoundnessPolicy minimum_visible_fraction: UnitInterval minimum_image_area_fraction: UnitInterval maximum_truncated_fraction: UnitInterval - threshold_boundary_policy: BoundaryPolicyV2 + threshold_boundary_policy: BoundaryPolicy accepted_baseline_completeness: tuple[FactCompletenessV2, ...] @model_validator(mode="after") @@ -560,35 +560,35 @@ def canonicalize_and_validate(self) -> Self: return self -class RelationDefinitionV2(V2Model): - relation: RelationV2 - measurement: RelationMeasurementV2 - comparator: MeasurementComparatorV2 +class RelationDefinition(CanonicalModel): + relation: Relation + measurement: RelationMeasurement + comparator: MeasurementComparator threshold: FiniteFloat - unit: MeasurementUnitV2 - representative_point: RelationRepresentativePointV2 | None - operand_order: MeasurementOperandOrderV2 - boundary_policy: BoundaryPolicyV2 + unit: MeasurementUnit + representative_point: RelationRepresentativePoint | None + operand_order: MeasurementOperandOrder + boundary_policy: BoundaryPolicy tolerance: NonNegativeFiniteFloat - tolerance_policy: RelationTolerancePolicyV2 + tolerance_policy: RelationTolerancePolicy requires_both_visible: bool @model_validator(mode="after") def validate_measurement_contract(self) -> Self: expected_measurement = { - RelationAxisV2.HORIZONTAL: RelationMeasurementV2.PROJECTED_CENTER_DELTA_X, - RelationAxisV2.DEPTH: RelationMeasurementV2.CAMERA_DEPTH_DELTA, - RelationAxisV2.DISTANCE: RelationMeasurementV2.SHAPE_GAP_XY, + RelationAxis.HORIZONTAL: RelationMeasurement.PROJECTED_CENTER_DELTA_X, + RelationAxis.DEPTH: RelationMeasurement.CAMERA_DEPTH_DELTA, + RelationAxis.DISTANCE: RelationMeasurement.SHAPE_GAP_XY, }[self.relation.axis] expected_unit = ( - MeasurementUnitV2.PIXEL - if self.relation.axis is RelationAxisV2.HORIZONTAL - else MeasurementUnitV2.METRE + MeasurementUnit.PIXEL + if self.relation.axis is RelationAxis.HORIZONTAL + else MeasurementUnit.METRE ) expected_comparator = ( - MeasurementComparatorV2.LESS_THAN - if self.relation in {RelationV2.LEFT, RelationV2.FRONT, RelationV2.NEAR} - else MeasurementComparatorV2.GREATER_THAN + MeasurementComparator.LESS_THAN + if self.relation in {Relation.LEFT, Relation.FRONT, Relation.NEAR} + else MeasurementComparator.GREATER_THAN ) if self.measurement is not expected_measurement: raise ValueError("relation measurement does not match its axis") @@ -597,11 +597,11 @@ def validate_measurement_contract(self) -> Self: if self.comparator is not expected_comparator: raise ValueError("relation comparator does not match its direction") is_point_measurement = self.measurement in { - RelationMeasurementV2.PROJECTED_CENTER_DELTA_X, - RelationMeasurementV2.CAMERA_DEPTH_DELTA, + RelationMeasurement.PROJECTED_CENTER_DELTA_X, + RelationMeasurement.CAMERA_DEPTH_DELTA, } if is_point_measurement and self.representative_point is not ( - RelationRepresentativePointV2.RELATION_GEOMETRY_VOLUME_CENTROID + RelationRepresentativePoint.RELATION_GEOMETRY_VOLUME_CENTROID ): raise ValueError( "point relation measurement requires the relation-geometry " @@ -614,12 +614,12 @@ def validate_measurement_contract(self) -> Self: return self -class RelationSemanticsV2(V2Model): +class RelationSemantics(CanonicalModel): schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2(schema_name="relation-semantics") ) semantics_id: CanonicalId - definitions: tuple[RelationDefinitionV2, ...] + definitions: tuple[RelationDefinition, ...] @model_validator(mode="after") def validate_complete_relation_set(self) -> Self: @@ -629,15 +629,15 @@ def validate_complete_relation_set(self) -> Self: definition.relation: definition for definition in self.definitions } if len(by_relation) != len(self.definitions) or set(by_relation) != set( - RelationV2 + Relation ): raise ValueError( "relation semantics require exactly one definition per relation" ) for first, second in ( - (RelationV2.LEFT, RelationV2.RIGHT), - (RelationV2.FRONT, RelationV2.BEHIND), - (RelationV2.NEAR, RelationV2.FAR), + (Relation.LEFT, Relation.RIGHT), + (Relation.FRONT, Relation.BEHIND), + (Relation.NEAR, Relation.FAR), ): left = by_relation[first] right = by_relation[second] @@ -662,18 +662,18 @@ def validate_complete_relation_set(self) -> Self: object.__setattr__( self, "definitions", - tuple(by_relation[relation] for relation in RelationV2), + tuple(by_relation[relation] for relation in Relation), ) return self -class TargetRelationConstraintV2(V2Model): +class TargetRelationConstraint(CanonicalModel): constraint_id: CanonicalId subject_id: CanonicalId reference_id: CanonicalId camera_id: CanonicalId - relation_before: RelationV2 - relation_after: RelationV2 + relation_before: Relation + relation_after: Relation semantics_id: CanonicalId @model_validator(mode="after") @@ -685,17 +685,17 @@ def validate_opposite_relation(self) -> Self: return self -class CanonicalConstraintSetV2(V2Model): +class CanonicalConstraintSet(CanonicalModel): schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2(schema_name="canonical-constraint-set") ) constraint_set_id: CanonicalId - allowed_edit: AllowedEditV2 - position_domain: AllowedPositionDomainConstraintV2 - collision_constraints: tuple[CollisionConstraintV2, ...] = () - support_constraints: tuple[SupportConstraintV2, ...] = () - visibility_constraints: tuple[VisibilityConstraintV2, ...] - target_relation: TargetRelationConstraintV2 + allowed_edit: AllowedEdit + position_domain: AllowedPositionDomainConstraint + collision_constraints: tuple[CollisionConstraint, ...] = () + support_constraints: tuple[SupportConstraint, ...] = () + visibility_constraints: tuple[VisibilityConstraint, ...] + target_relation: TargetRelationConstraint @model_validator(mode="after") def validate_constraint_graph(self) -> Self: @@ -788,7 +788,7 @@ def constraint_ids(self) -> tuple[str, ...]: def slack_constraint_ids(self) -> tuple[str, ...]: """Hard predicates with a mathematical margin in the objective. - ``AllowedEditV2`` is a permission surface, not a predicate with a + ``AllowedEdit`` is a permission surface, not a predicate with a continuous slack, so it is deliberately excluded. """ diff --git a/src/spatialcf/domain/v2/edit.py b/src/spatialcf/domain/edit.py similarity index 75% rename from src/spatialcf/domain/v2/edit.py rename to src/spatialcf/domain/edit.py index 3036ae8..312629e 100644 --- a/src/spatialcf/domain/v2/edit.py +++ b/src/spatialcf/domain/edit.py @@ -1,4 +1,4 @@ -"""Canonical v2 platform-neutral edit contract.""" +"""Canonical platform-neutral edit contract.""" from __future__ import annotations @@ -6,19 +6,19 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, SchemaIdentityV2, Sha256Digest, - V2Model, - Vec2V2, + Vec2, ) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 +from spatialcf.domain.serialization import canonical_sha256 _CANONICAL_EDIT_HASH_DOMAIN = "canonical-edit-v2" -class CanonicalEditV2(V2Model): +class CanonicalEdit(CanonicalModel): """The sole permitted edit: translate one object frame in world X/Y.""" schema_identity: SchemaIdentityV2 = Field( @@ -26,13 +26,13 @@ class CanonicalEditV2(V2Model): ) semantic_problem_sha256: Sha256Digest subject_id: CanonicalId - translation_xy_m: Vec2V2 + translation_xy_m: Vec2 @model_validator(mode="after") def validate_and_canonicalize_edit(self) -> Self: if self.schema_identity.schema_name != "canonical-edit": raise ValueError("canonical edit schema identity must be fixed") - translation = Vec2V2( + translation = Vec2( x=0.0 if self.translation_xy_m.x == 0.0 else self.translation_xy_m.x, y=0.0 if self.translation_xy_m.y == 0.0 else self.translation_xy_m.y, ) @@ -41,4 +41,4 @@ def validate_and_canonicalize_edit(self) -> Self: @property def edit_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CANONICAL_EDIT_HASH_DOMAIN) + return canonical_sha256(self, domain=_CANONICAL_EDIT_HASH_DOMAIN) diff --git a/src/spatialcf/domain/v2/evidence.py b/src/spatialcf/domain/evidence.py similarity index 83% rename from src/spatialcf/domain/v2/evidence.py rename to src/spatialcf/domain/evidence.py index b585bbe..3d56076 100644 --- a/src/spatialcf/domain/v2/evidence.py +++ b/src/spatialcf/domain/evidence.py @@ -1,4 +1,4 @@ -"""Typed provenance and audit evidence outside Canonical v2 semantics. +"""Typed provenance and audit evidence outside Canonical semantics. This module may point at a semantic problem digest, but it never imports or contains the semantic problem. Core constraints, objectives, artifacts, and @@ -13,16 +13,16 @@ from pydantic import Field, ValidationInfo, field_validator, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, FiniteFloat, SchemaIdentityV2, Sha256Digest, - V2Model, ) -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, ) _EVIDENCE_HASH_DOMAIN = "spatialcf.evidence-envelope.v2" @@ -38,7 +38,7 @@ _NonNegativeStrictInt = Annotated[int, Field(strict=True, ge=0)] -class RawEvidenceKindV2(StrEnum): +class RawEvidenceKind(StrEnum): SOURCE_RECORD = "SOURCE_RECORD" RAW_OBSERVATION = "RAW_OBSERVATION" MAPPING_INPUT = "MAPPING_INPUT" @@ -47,7 +47,7 @@ class RawEvidenceKindV2(StrEnum): FINAL_AUDIT = "FINAL_AUDIT" -class EvidenceMediaTypeV2(StrEnum): +class EvidenceMediaType(StrEnum): APPLICATION_JSON = "application/json" IMAGE_PNG = "image/png" APPLICATION_NPY = "application/x-npy" @@ -55,7 +55,7 @@ class EvidenceMediaTypeV2(StrEnum): APPLICATION_OCTET_STREAM = "application/octet-stream" -class NativeLocatorKindV2(StrEnum): +class NativeLocatorKind(StrEnum): """Closed non-filesystem namespaces for opaque native identities.""" DATASET_KEY = "DATASET_KEY" @@ -63,10 +63,10 @@ class NativeLocatorKindV2(StrEnum): OBJECT_HANDLE = "OBJECT_HANDLE" -class NativeLocatorV2(V2Model): +class NativeLocator(CanonicalModel): """A typed native identity, never a host filesystem path.""" - kind: NativeLocatorKindV2 + kind: NativeLocatorKind value: _NativeLocatorValue @field_validator("value") @@ -76,7 +76,7 @@ def validate_locator_value(cls, value: str, info: ValidationInfo) -> str: kind = info.data.get("kind") if re.match(r"(?i)^file:", value): raise ValueError("native locator must not be a filesystem URI") - if kind is NativeLocatorKindV2.SCENE_GRAPH_PATH: + if kind is NativeLocatorKind.SCENE_GRAPH_PATH: if not value.startswith("/") or "\\" in value: raise ValueError( "SCENE_GRAPH_PATH locator must use absolute graph syntax" @@ -97,7 +97,7 @@ def validate_locator_value(cls, value: str, info: ValidationInfo) -> str: return value -class MappingProofKindV2(StrEnum): +class MappingProofKind(StrEnum): ENTITY_IDENTITY = "ENTITY_IDENTITY" GEOMETRY = "GEOMETRY" SUPPORT = "SUPPORT" @@ -107,13 +107,13 @@ class MappingProofKindV2(StrEnum): WRITE_BACK = "WRITE_BACK" -class MappingProofStatusV2(StrEnum): +class MappingProofStatus(StrEnum): VERIFIED = "VERIFIED" REJECTED = "REJECTED" INCOMPLETE = "INCOMPLETE" -class EvidenceMeasurementUnitV2(StrEnum): +class EvidenceMeasurementUnit(StrEnum): METRE = "METRE" SQUARE_METRE = "SQUARE_METRE" RADIAN = "RADIAN" @@ -121,50 +121,50 @@ class EvidenceMeasurementUnitV2(StrEnum): FRACTION = "FRACTION" -class AuditStatusV2(StrEnum): +class AuditStatus(StrEnum): PASS = "PASS" FAIL = "FAIL" NOT_RUN = "NOT_RUN" _MAPPING_ALLOWED_RAW_KINDS: dict[ - MappingProofKindV2, - frozenset[RawEvidenceKindV2], + MappingProofKind, + frozenset[RawEvidenceKind], ] = { - MappingProofKindV2.ENTITY_IDENTITY: frozenset( - {RawEvidenceKindV2.SOURCE_RECORD, RawEvidenceKindV2.MAPPING_INPUT} + MappingProofKind.ENTITY_IDENTITY: frozenset( + {RawEvidenceKind.SOURCE_RECORD, RawEvidenceKind.MAPPING_INPUT} ), - MappingProofKindV2.GEOMETRY: frozenset( - {RawEvidenceKindV2.RAW_OBSERVATION, RawEvidenceKindV2.MAPPING_INPUT} + MappingProofKind.GEOMETRY: frozenset( + {RawEvidenceKind.RAW_OBSERVATION, RawEvidenceKind.MAPPING_INPUT} ), - MappingProofKindV2.SUPPORT: frozenset( - {RawEvidenceKindV2.RAW_OBSERVATION, RawEvidenceKindV2.MAPPING_INPUT} + MappingProofKind.SUPPORT: frozenset( + {RawEvidenceKind.RAW_OBSERVATION, RawEvidenceKind.MAPPING_INPUT} ), - MappingProofKindV2.CAMERA: frozenset( - {RawEvidenceKindV2.RAW_OBSERVATION, RawEvidenceKindV2.MAPPING_INPUT} + MappingProofKind.CAMERA: frozenset( + {RawEvidenceKind.RAW_OBSERVATION, RawEvidenceKind.MAPPING_INPUT} ), - MappingProofKindV2.OBSERVATION_NORMALIZATION: frozenset( - {RawEvidenceKindV2.RAW_OBSERVATION, RawEvidenceKindV2.MAPPING_INPUT} + MappingProofKind.OBSERVATION_NORMALIZATION: frozenset( + {RawEvidenceKind.RAW_OBSERVATION, RawEvidenceKind.MAPPING_INPUT} ), - MappingProofKindV2.RELATION_NORMALIZATION: frozenset( - {RawEvidenceKindV2.RAW_OBSERVATION, RawEvidenceKindV2.MAPPING_INPUT} + MappingProofKind.RELATION_NORMALIZATION: frozenset( + {RawEvidenceKind.RAW_OBSERVATION, RawEvidenceKind.MAPPING_INPUT} ), - MappingProofKindV2.WRITE_BACK: frozenset( - {RawEvidenceKindV2.WRITE_BACK, RawEvidenceKindV2.RAW_OBSERVATION} + MappingProofKind.WRITE_BACK: frozenset( + {RawEvidenceKind.WRITE_BACK, RawEvidenceKind.RAW_OBSERVATION} ), } _AUDIT_ALLOWED_RAW_KINDS = frozenset( { - RawEvidenceKindV2.FINAL_AUDIT, - RawEvidenceKindV2.RUNTIME_LOG, - RawEvidenceKindV2.RAW_OBSERVATION, - RawEvidenceKindV2.WRITE_BACK, + RawEvidenceKind.FINAL_AUDIT, + RawEvidenceKind.RUNTIME_LOG, + RawEvidenceKind.RAW_OBSERVATION, + RawEvidenceKind.WRITE_BACK, } ) -class AdapterIdentityV2(V2Model): +class AdapterIdentity(CanonicalModel): """Identity of the fact/write-back adapter that produced this envelope.""" adapter_id: CanonicalId @@ -173,13 +173,13 @@ class AdapterIdentityV2(V2Model): implementation_sha256: Sha256Digest -class SourceIdentityV2(V2Model): +class SourceIdentity(CanonicalModel): """Immutable dataset item identity and its opaque native scene locator.""" source_id: CanonicalId source_version: CanonicalId source_partition: CanonicalId | None - native_scene_locator: NativeLocatorV2 + native_scene_locator: NativeLocator content_sha256: Sha256Digest raw_evidence_ids: tuple[CanonicalId, ...] @@ -196,20 +196,20 @@ def canonicalize_raw_evidence_ids(self) -> Self: return self -class NativeObjectBindingV2(V2Model): +class NativeObjectBinding(CanonicalModel): """One-to-one evidence mapping from a native entity to a Canonical ID.""" canonical_object_id: CanonicalId - native_object_locator: NativeLocatorV2 + native_object_locator: NativeLocator mapping_proof_id: CanonicalId -class RawEvidenceRefV2(V2Model): +class RawEvidenceRef(CanonicalModel): """Content-addressed reference to raw bytes outside the semantic payload.""" evidence_id: CanonicalId - kind: RawEvidenceKindV2 - media_type: EvidenceMediaTypeV2 + kind: RawEvidenceKind + media_type: EvidenceMediaType relative_path: _PortableRelativePath sha256: Sha256Digest byte_length: _NonNegativeStrictInt @@ -232,11 +232,11 @@ def validate_relative_path(cls, value: str) -> str: return value -class EvidenceMeasurementV2(V2Model): +class EvidenceMeasurement(CanonicalModel): """One finite, unit-explicit observation optionally enclosed by bounds.""" measurement_id: CanonicalId - unit: EvidenceMeasurementUnitV2 + unit: EvidenceMeasurementUnit value: FiniteFloat lower_bound: FiniteFloat | None upper_bound: FiniteFloat | None @@ -251,25 +251,25 @@ def validate_bounds(self) -> Self: and not self.lower_bound <= self.value <= self.upper_bound ): raise ValueError("measurement value must lie inside its bounds interval") - if self.unit is EvidenceMeasurementUnitV2.FRACTION: + if self.unit is EvidenceMeasurementUnit.FRACTION: values = (self.value, self.lower_bound, self.upper_bound) if any(value is not None and not 0.0 <= value <= 1.0 for value in values): raise ValueError("FRACTION measurements and bounds must lie in [0, 1]") return self -class MappingProofV2(V2Model): +class MappingProof(CanonicalModel): """Typed result of one native-to-Canonical mapping check.""" proof_id: CanonicalId - kind: MappingProofKindV2 + kind: MappingProofKind method_id: CanonicalId - status: MappingProofStatusV2 + status: MappingProofStatus canonical_ids: tuple[CanonicalId, ...] = Field(min_length=1) - native_locators: tuple[NativeLocatorV2, ...] = Field(min_length=1) + native_locators: tuple[NativeLocator, ...] = Field(min_length=1) raw_evidence_ids: tuple[CanonicalId, ...] = Field(min_length=1) reason_codes: tuple[CanonicalId, ...] - measurements: tuple[EvidenceMeasurementV2, ...] + measurements: tuple[EvidenceMeasurement, ...] canonical_edit_sha256: Sha256Digest | None = None @model_validator(mode="after") @@ -306,20 +306,20 @@ def canonicalize_and_validate_proof(self) -> Self: label="mapping measurements", ), ) - if self.status is MappingProofStatusV2.VERIFIED and self.reason_codes: + if self.status is MappingProofStatus.VERIFIED and self.reason_codes: raise ValueError("VERIFIED mapping proof must not carry reason codes") - if self.status is not MappingProofStatusV2.VERIFIED and not self.reason_codes: + if self.status is not MappingProofStatus.VERIFIED and not self.reason_codes: raise ValueError( "rejected or incomplete mapping proof requires a reason code" ) - if self.kind is MappingProofKindV2.ENTITY_IDENTITY and ( + if self.kind is MappingProofKind.ENTITY_IDENTITY and ( len(self.canonical_ids) != 1 or len(self.native_locators) != 1 ): raise ValueError( "ENTITY_IDENTITY proof requires exactly one Canonical ID and " "exactly one native locator" ) - if self.kind is MappingProofKindV2.WRITE_BACK: + if self.kind is MappingProofKind.WRITE_BACK: if self.canonical_edit_sha256 is None: raise ValueError("WRITE_BACK proof requires a Canonical Edit hash") elif self.canonical_edit_sha256 is not None: @@ -327,7 +327,7 @@ def canonicalize_and_validate_proof(self) -> Self: return self -class PreSemanticEvidenceEnvelopeV2(V2Model): +class PreSemanticEvidenceEnvelope(CanonicalModel): """Traceable source/mapping evidence before a semantic problem can exist. A source adapter may fail before it can construct any mapping proof, so the @@ -341,10 +341,10 @@ class PreSemanticEvidenceEnvelopeV2(V2Model): schema_name="pre-semantic-evidence-envelope" ) ) - adapter: AdapterIdentityV2 - source: SourceIdentityV2 - raw_evidence_refs: tuple[RawEvidenceRefV2, ...] - mapping_proofs: tuple[MappingProofV2, ...] + adapter: AdapterIdentity + source: SourceIdentity + raw_evidence_refs: tuple[RawEvidenceRef, ...] + mapping_proofs: tuple[MappingProof, ...] @model_validator(mode="after") def canonicalize_and_validate_envelope(self) -> Self: @@ -382,7 +382,7 @@ def _validate_references(self) -> None: raw_by_id = {item.evidence_id: item for item in self.raw_evidence_refs} raw_ids = set(raw_by_id) if any( - proof.kind is MappingProofKindV2.WRITE_BACK for proof in self.mapping_proofs + proof.kind is MappingProofKind.WRITE_BACK for proof in self.mapping_proofs ): raise ValueError( "pre-semantic evidence must not contain a WRITE_BACK proof" @@ -397,7 +397,7 @@ def _validate_references(self) -> None: _require_raw_kinds( self.source.raw_evidence_ids, raw_by_id, - allowed=frozenset({RawEvidenceKindV2.SOURCE_RECORD}), + allowed=frozenset({RawEvidenceKind.SOURCE_RECORD}), label="pre-semantic source evidence must use SOURCE_RECORD kind", ) referenced_raw_ids = set(self.source.raw_evidence_ids) @@ -426,16 +426,16 @@ def _validate_references(self) -> None: @property def pre_semantic_evidence_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_PRE_SEMANTIC_EVIDENCE_HASH_DOMAIN, ) def canonical_bytes(self) -> bytes: - return canonical_json_bytes_v2(self) + return canonical_json_bytes(self) -class RuntimeIdentityEvidenceV2(V2Model): +class RuntimeIdentityEvidence(CanonicalModel): """Opaque execution runtime identity used only by audit evidence.""" runtime_id: CanonicalId @@ -458,17 +458,17 @@ def canonicalize_raw_evidence_ids(self) -> Self: return self -class FinalAuditOutcomeV2(V2Model): +class FinalAuditOutcome(CanonicalModel): """One final replay/audit outcome; never a core score or solver status.""" audit_id: CanonicalId audit_method_id: CanonicalId - status: AuditStatusV2 + status: AuditStatus runtime_id: CanonicalId | None canonical_edit_sha256: Sha256Digest | None reason_codes: tuple[CanonicalId, ...] raw_evidence_ids: tuple[CanonicalId, ...] - measurements: tuple[EvidenceMeasurementV2, ...] + measurements: tuple[EvidenceMeasurement, ...] @model_validator(mode="after") def canonicalize_and_validate_audit(self) -> Self: @@ -494,11 +494,11 @@ def canonicalize_and_validate_audit(self) -> Self: label="audit measurements", ), ) - if self.status is AuditStatusV2.PASS: + if self.status is AuditStatus.PASS: if self.reason_codes: raise ValueError("PASS audit must not carry reason codes") self._require_executed_audit_fields("PASS") - elif self.status is AuditStatusV2.FAIL: + elif self.status is AuditStatus.FAIL: if not self.reason_codes: raise ValueError("FAIL audit requires a reason code") self._require_executed_audit_fields("FAIL") @@ -518,20 +518,20 @@ def _require_executed_audit_fields(self, status: str) -> None: raise ValueError(f"{status} audit requires raw evidence") -class EvidenceEnvelopeV2(V2Model): +class EvidenceEnvelope(CanonicalModel): """Independent provenance envelope bound only to a semantic problem digest.""" schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2(schema_name="evidence-envelope") ) semantic_problem_sha256: Sha256Digest - adapter: AdapterIdentityV2 - source: SourceIdentityV2 - native_object_bindings: tuple[NativeObjectBindingV2, ...] - raw_evidence_refs: tuple[RawEvidenceRefV2, ...] - mapping_proofs: tuple[MappingProofV2, ...] - runtime_identities: tuple[RuntimeIdentityEvidenceV2, ...] - final_audits: tuple[FinalAuditOutcomeV2, ...] + adapter: AdapterIdentity + source: SourceIdentity + native_object_bindings: tuple[NativeObjectBinding, ...] + raw_evidence_refs: tuple[RawEvidenceRef, ...] + mapping_proofs: tuple[MappingProof, ...] + runtime_identities: tuple[RuntimeIdentityEvidence, ...] + final_audits: tuple[FinalAuditOutcome, ...] @model_validator(mode="after") def canonicalize_and_validate_envelope(self) -> Self: @@ -596,10 +596,10 @@ def canonicalize_and_validate_envelope(self) -> Self: @property def evidence_envelope_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_EVIDENCE_HASH_DOMAIN) + return canonical_sha256(self, domain=_EVIDENCE_HASH_DOMAIN) def canonical_bytes(self) -> bytes: - return canonical_json_bytes_v2(self) + return canonical_json_bytes(self) def _validate_references(self) -> None: raw_ids = {item.evidence_id for item in self.raw_evidence_refs} @@ -618,7 +618,7 @@ def _validate_references(self) -> None: _require_raw_kinds( self.source.raw_evidence_ids, raw_by_id, - allowed=frozenset({RawEvidenceKindV2.SOURCE_RECORD}), + allowed=frozenset({RawEvidenceKind.SOURCE_RECORD}), label="source evidence must use SOURCE_RECORD kind", ) for proof in self.mapping_proofs: @@ -644,7 +644,7 @@ def _validate_references(self) -> None: _require_raw_kinds( runtime.raw_evidence_ids, raw_by_id, - allowed=frozenset({RawEvidenceKindV2.RUNTIME_LOG}), + allowed=frozenset({RawEvidenceKind.RUNTIME_LOG}), label="runtime evidence must use RUNTIME_LOG kind", ) for audit in self.final_audits: @@ -663,8 +663,8 @@ def _validate_references(self) -> None: "FINAL_AUDIT" ), ) - if audit.status is not AuditStatusV2.NOT_RUN and not any( - raw_by_id[evidence_id].kind is RawEvidenceKindV2.FINAL_AUDIT + if audit.status is not AuditStatus.NOT_RUN and not any( + raw_by_id[evidence_id].kind is RawEvidenceKind.FINAL_AUDIT for evidence_id in audit.raw_evidence_ids ): raise ValueError( @@ -688,8 +688,8 @@ def _validate_references(self) -> None: "native object binding references unknown mapping proof" ) if ( - proof.kind is not MappingProofKindV2.ENTITY_IDENTITY - or proof.status is not MappingProofStatusV2.VERIFIED + proof.kind is not MappingProofKind.ENTITY_IDENTITY + or proof.status is not MappingProofStatus.VERIFIED ): raise ValueError( "native object binding requires a verified identity proof" @@ -725,8 +725,8 @@ def _sorted_unique_strings(values: tuple[str, ...], *, label: str) -> tuple[str, def _sorted_unique_native_locators( - values: tuple[NativeLocatorV2, ...], -) -> tuple[NativeLocatorV2, ...]: + values: tuple[NativeLocator, ...], +) -> tuple[NativeLocator, ...]: keys = tuple((value.kind.value, value.value) for value in values) if len(set(keys)) != len(keys): raise ValueError("native_locators must be unique") @@ -734,11 +734,11 @@ def _sorted_unique_native_locators( def _sorted_unique_models( - values: tuple[V2Model, ...], + values: tuple[CanonicalModel, ...], *, id_field: str, label: str, -) -> tuple[V2Model, ...]: +) -> tuple[CanonicalModel, ...]: identities = tuple(getattr(value, id_field) for value in values) if len(set(identities)) != len(identities): raise ValueError(f"{label} IDs must be unique") @@ -758,9 +758,9 @@ def _require_known_ids( def _require_raw_kinds( referenced_ids: tuple[str, ...], - raw_by_id: dict[str, RawEvidenceRefV2], + raw_by_id: dict[str, RawEvidenceRef], *, - allowed: frozenset[RawEvidenceKindV2], + allowed: frozenset[RawEvidenceKind], label: str, ) -> None: incompatible = tuple( diff --git a/src/spatialcf/domain/v2/geometry.py b/src/spatialcf/domain/geometry.py similarity index 85% rename from src/spatialcf/domain/v2/geometry.py rename to src/spatialcf/domain/geometry.py index 24d3bff..881beb8 100644 --- a/src/spatialcf/domain/v2/geometry.py +++ b/src/spatialcf/domain/geometry.py @@ -1,4 +1,4 @@ -"""Platform-neutral Canonical v2 geometry facts. +"""Platform-neutral current solver geometry facts. Geometry describes what is known about a scene. Clearance, contact policy, candidate selection, and platform provenance intentionally do not belong here. @@ -12,16 +12,16 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, FiniteFloat, RigidTransformV2, UncertaintyBudgetV2, - V2Model, - Vec2V2, - Vec3V2, + Vec2, + Vec3, ) -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 +from spatialcf.domain.serialization import canonical_json_bytes class RingWindingV2(StrEnum): @@ -71,11 +71,11 @@ def _canonical_ring_coordinates( return coordinates[first_index:] + coordinates[:first_index] -class PlanarRingV2(V2Model): +class PlanarRingV2(CanonicalModel): """A simple canonical ring without a repeated closing vertex.""" winding: RingWindingV2 - vertices: tuple[Vec2V2, ...] = Field(min_length=3) + vertices: tuple[Vec2, ...] = Field(min_length=3) @model_validator(mode="after") def validate_and_canonicalize_ring(self) -> Self: @@ -121,12 +121,12 @@ def validate_and_canonicalize_ring(self) -> Self: object.__setattr__( self, "vertices", - tuple(Vec2V2(x=x, y=y) for x, y in canonical), + tuple(Vec2(x=x, y=y) for x, y in canonical), ) return self -class PlanarPolygonComponentV2(V2Model): +class PlanarPolygonComponentV2(CanonicalModel): """One valid polygon component with canonical, pairwise-disjoint holes.""" exterior: PlanarRingV2 @@ -139,7 +139,7 @@ def validate_and_canonicalize_component(self) -> Self: if any(hole.winding is not RingWindingV2.CLOCKWISE for hole in self.holes): raise ValueError("polygon holes must be CLOCKWISE") - ordered_holes = tuple(sorted(self.holes, key=canonical_json_bytes_v2)) + ordered_holes = tuple(sorted(self.holes, key=canonical_json_bytes)) if len(set(ordered_holes)) != len(ordered_holes): raise ValueError("polygon holes must be unique") polygon = _polygon(self.exterior, ordered_holes) @@ -149,14 +149,14 @@ def validate_and_canonicalize_component(self) -> Self: return self -class PlanarRegionV2(V2Model): +class PlanarRegionV2(CanonicalModel): """A canonical non-empty union of disjoint polygon components.""" components: tuple[PlanarPolygonComponentV2, ...] = Field(min_length=1) @model_validator(mode="after") def validate_and_canonicalize_components(self) -> Self: - ordered = tuple(sorted(self.components, key=canonical_json_bytes_v2)) + ordered = tuple(sorted(self.components, key=canonical_json_bytes)) if len(set(ordered)) != len(ordered): raise ValueError("region components must be unique") polygons = tuple(_polygon(item.exterior, item.holes) for item in ordered) @@ -168,14 +168,14 @@ def validate_and_canonicalize_components(self) -> Self: return self -class UprightBox3DV2(V2Model): +class UprightBox3DV2(CanonicalModel): """An object-local box whose instance transform may rotate only around Z.""" shape_type: Literal["UPRIGHT_BOX_3D"] = "UPRIGHT_BOX_3D" origin_convention: Literal["CENTERED_AT_GEOMETRY_FRAME"] = ( "CENTERED_AT_GEOMETRY_FRAME" ) - size_m: Vec3V2 + size_m: Vec3 @model_validator(mode="after") def validate_positive_size(self) -> Self: @@ -184,7 +184,7 @@ def validate_positive_size(self) -> Self: return self -class ExtrudedPlanarPolygonV2(V2Model): +class ExtrudedPlanarPolygonV2(CanonicalModel): """One object-local planar polygon extruded through a finite Z interval.""" shape_type: Literal["EXTRUDED_PLANAR_POLYGON"] = "EXTRUDED_PLANAR_POLYGON" @@ -208,7 +208,7 @@ def validate_z_interval(self) -> Self: ] -class GeometryInstanceV2(V2Model): +class GeometryInstanceV2(CanonicalModel): """One role-specific shape anchored in an object frame or the world frame.""" geometry_id: CanonicalId @@ -237,7 +237,7 @@ def validate_upright_instance(self) -> Self: return self -class CollisionBodyFactV2(V2Model): +class CollisionBodyFactV2(CanonicalModel): """A collision body referencing collision-role geometry facts. ``owner_object_id=None`` denotes a typed environment body. The Scene @@ -262,6 +262,33 @@ def canonicalize_geometry_ids(self) -> Self: return self +class DirectedYawIntervalTransformV2_2(CanonicalModel): + """Finite local-to-parent translation plus binary64 horizontal yaw.""" + + kind: Literal["DIRECTED_YAW_INTERVAL"] = "DIRECTED_YAW_INTERVAL" + translation: Vec3 + yaw_radians: FiniteFloat + + @model_validator(mode="after") + def canonicalize_signed_zero(self) -> Self: + translation = self.translation + object.__setattr__( + self, + "translation", + Vec3( + x=0.0 if translation.x == 0.0 else translation.x, + y=0.0 if translation.y == 0.0 else translation.y, + z=0.0 if translation.z == 0.0 else translation.z, + ), + ) + object.__setattr__( + self, + "yaw_radians", + 0.0 if self.yaw_radians == 0.0 else self.yaw_radians, + ) + return self + + def _polygon( exterior: PlanarRingV2, holes: tuple[PlanarRingV2, ...], diff --git a/src/spatialcf/domain/models.py b/src/spatialcf/domain/models.py deleted file mode 100644 index 37b91f0..0000000 --- a/src/spatialcf/domain/models.py +++ /dev/null @@ -1,234 +0,0 @@ -import math -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator -from pydantic.functional_serializers import SerializerFunctionWrapHandler - -from spatialcf.domain.enums import Relation - - -class FrozenModel(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - -class Vec2(FrozenModel): - x: float - y: float - - -class Vec3(FrozenModel): - x: float - y: float - z: float - - -class Quaternion(FrozenModel): - x: float - y: float - z: float - w: float - - -class BBox2D(FrozenModel): - xmin: float - ymin: float - xmax: float - ymax: float - - @property - def center_x(self) -> float: - return (self.xmin + self.xmax) / 2.0 - - @property - def area(self) -> float: - return max(0.0, self.xmax - self.xmin) * max(0.0, self.ymax - self.ymin) - - -class ObjectView(FrozenModel): - camera_id: str - bbox: BBox2D - camera_depth: float - visible_fraction: float = Field(ge=0.0, le=1.0) - image_area_fraction: float = Field(ge=0.0, le=1.0) - truncated_fraction: float = Field(ge=0.0, le=1.0) - - -class OBB(FrozenModel): - center: Vec3 - extent: Vec3 - rotation: Quaternion - - -class CollisionObstacle(FrozenModel): - """Native geometry plus solver-only clearance for collision checks.""" - - obstacle_id: str = Field(min_length=1) - source_object_id: str = Field(min_length=1) - clearance_m: float = Field(ge=0.0, strict=True) - obb: OBB - - def conservative_obb(self) -> OBB: - clearance_diameter = 2.0 * self.clearance_m - return self.obb.model_copy( - update={ - "extent": Vec3( - x=self.obb.extent.x + clearance_diameter, - y=self.obb.extent.y + clearance_diameter, - z=self.obb.extent.z + clearance_diameter, - ) - } - ) - - -class PlanarPolygon(FrozenModel): - """One canonical polygon component without a repeated closing vertex.""" - - exterior: tuple[Vec2, ...] = Field(min_length=3) - holes: tuple[tuple[Vec2, ...], ...] = Field(default_factory=tuple) - - @model_validator(mode="after") - def validate_rings(self) -> "PlanarPolygon": - for label, ring in ( - ("exterior", self.exterior), - *((f"hole {index}", hole) for index, hole in enumerate(self.holes)), - ): - if len(ring) < 3: - raise ValueError(f"{label} must contain at least three vertices") - coordinates = tuple((point.x, point.y) for point in ring) - if any( - not math.isfinite(value) - for coordinate in coordinates - for value in coordinate - ): - raise ValueError(f"{label} vertices must be finite") - if len(set(coordinates)) < 3: - raise ValueError(f"{label} must contain three distinct vertices") - if coordinates[0] == coordinates[-1]: - raise ValueError(f"{label} must omit the repeated closing vertex") - return self - - -class SubjectPositionRegion(FrozenModel): - """A source-bound allowed XY position-anchor locus for one subject.""" - - region_id: str = Field(min_length=1) - subject_object_id: str = Field(min_length=1) - source_kind: Literal[ - "ai2thor-navigation-v1", - "ai2thor-receptacle-trigger-grid-v1", - ] - source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - components: tuple[PlanarPolygon, ...] = Field(default_factory=tuple) - - -class SceneObject(FrozenModel): - object_id: str - name: str - category: str - movable: bool - request_eligible: bool = Field(default=True, strict=True) - position: Vec3 - rotation: Quaternion - obb: OBB - support_object_id: str | None = None - views: dict[str, ObjectView] = Field(default_factory=dict) - - @model_serializer(mode="wrap") - def serialize_request_eligibility( - self, - handler: SerializerFunctionWrapHandler, - ) -> dict[str, object]: - payload = handler(self) - if "request_eligible" not in self.model_fields_set: - payload.pop("request_eligible", None) - return payload - - -class Camera(FrozenModel): - camera_id: str - width: int = Field(gt=0) - height: int = Field(gt=0) - intrinsics: tuple[float, ...] = Field(min_length=9, max_length=9) - world_to_camera: tuple[float, ...] = Field(min_length=16, max_length=16) - - -class Scene(FrozenModel): - scene_id: str - source: str - coordinate_system: Literal["RH_METERS_Z_UP"] = "RH_METERS_Z_UP" - room_polygon_xy: tuple[Vec2, ...] = Field(min_length=3) - cameras: tuple[Camera, ...] - objects: tuple[SceneObject, ...] - collision_obstacles: tuple[CollisionObstacle, ...] = Field(default_factory=tuple) - subject_position_regions: tuple[SubjectPositionRegion, ...] = Field( - default_factory=tuple - ) - pinned_object_ids: frozenset[str] = Field(default_factory=frozenset) - generation_seed: int - - @model_validator(mode="after") - def validate_subject_position_regions(self) -> "Scene": - object_ids = {obj.object_id for obj in self.objects} - region_ids = tuple(region.region_id for region in self.subject_position_regions) - if len(region_ids) != len(set(region_ids)): - raise ValueError("subject position region IDs must be unique") - if any( - region.subject_object_id not in object_ids - for region in self.subject_position_regions - ): - raise ValueError("subject position region references an unknown object") - return self - - @model_serializer(mode="wrap") - def serialize_analysis_overlays( - self, - handler: SerializerFunctionWrapHandler, - ) -> dict[str, object]: - payload = handler(self) - if "collision_obstacles" not in self.model_fields_set: - payload.pop("collision_obstacles", None) - if "subject_position_regions" not in self.model_fields_set: - payload.pop("subject_position_regions", None) - return payload - - def object_by_id(self, object_id: str) -> SceneObject: - matches = [obj for obj in self.objects if obj.object_id == object_id] - if len(matches) != 1: - raise KeyError(f"Expected one object {object_id!r}, found {len(matches)}") - return matches[0] - - def camera_by_id(self, camera_id: str) -> Camera: - matches = [cam for cam in self.cameras if cam.camera_id == camera_id] - if len(matches) != 1: - raise KeyError(f"Expected one camera {camera_id!r}, found {len(matches)}") - return matches[0] - - def children_by_support(self) -> dict[str, tuple[str, ...]]: - children: dict[str, list[str]] = {} - for obj in self.objects: - if obj.support_object_id is not None: - children.setdefault(obj.support_object_id, []).append(obj.object_id) - return { - support_id: tuple(sorted(object_ids)) - for support_id, object_ids in sorted(children.items()) - } - - -class InterventionSpec(FrozenModel): - subject_id: str - reference_id: str - relation_before: Relation - relation_after: Relation - camera_id: str - - @model_validator(mode="after") - def validate_flip(self) -> "InterventionSpec": - if self.subject_id == self.reference_id: - raise ValueError("subject and reference must differ") - if self.relation_before == self.relation_after: - raise ValueError("counterfactual must change the relation") - if self.relation_before.axis is not self.relation_after.axis: - raise ValueError("counterfactual relations must share one relation axis") - if self.relation_before.opposite != self.relation_after: - raise ValueError("MVP supports opposite relation flips only") - return self diff --git a/src/spatialcf/domain/v2/objective.py b/src/spatialcf/domain/objective.py similarity index 93% rename from src/spatialcf/domain/v2/objective.py rename to src/spatialcf/domain/objective.py index 10eef9c..4806208 100644 --- a/src/spatialcf/domain/v2/objective.py +++ b/src/spatialcf/domain/objective.py @@ -1,4 +1,4 @@ -"""Platform-neutral Canonical v2 minimum-total-cost objective contracts.""" +"""Platform-neutral current minimum-total-cost objective contracts.""" from __future__ import annotations @@ -7,15 +7,15 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, NonNegativeFiniteFloat, PositiveFiniteFloat, SchemaIdentityV2, - V2Model, ) -from spatialcf.domain.v2.constraints import RelationAxisV2 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 +from spatialcf.domain.constraints import RelationAxis +from spatialcf.domain.serialization import canonical_sha256 _OBJECTIVE_HASH_DOMAIN = "spatialcf.objective-spec.v2" @@ -85,7 +85,7 @@ class SafetyComponentKindV2(StrEnum): TARGET_RELATION_THRESHOLD_MARGIN = "TARGET_RELATION_THRESHOLD_MARGIN" -class PairAxisKeyV2(V2Model): +class PairAxisKeyV2(CanonicalModel): """One canonical pair-axis key with a frozen measurement operand order. Damage is symmetric as a pair cost, but directional relation measurements @@ -95,7 +95,7 @@ class PairAxisKeyV2(V2Model): first_object_id: CanonicalId second_object_id: CanonicalId - axis: RelationAxisV2 + axis: RelationAxis @model_validator(mode="after") def canonicalize_unordered_pair(self) -> Self: @@ -119,7 +119,7 @@ def measurement_operand_ids(self) -> tuple[str, str]: return self.first_object_id, self.second_object_id -class ObjectCameraKeyV2(V2Model): +class ObjectCameraKeyV2(CanonicalModel): """One normalized visibility metric, not an ambiguous native observation.""" object_id: CanonicalId @@ -137,17 +137,17 @@ def sort_key(self) -> tuple[str, str, str, str]: ) -class PairAxisWeightV2(V2Model): +class PairAxisWeightV2(CanonicalModel): key: PairAxisKeyV2 damage_weight: PositiveFiniteFloat -class ObjectCameraWeightV2(V2Model): +class ObjectCameraWeightV2(CanonicalModel): key: ObjectCameraKeyV2 change_weight: PositiveFiniteFloat -class SafetySlackComponentV2(V2Model): +class SafetySlackComponentV2(CanonicalModel): """One signed raw hard-constraint margin and its unit conversion. ``raw_margin / normalizer`` is dimensionless. Component kinds freeze these @@ -173,7 +173,7 @@ class SafetySlackComponentV2(V2Model): normalizer: PositiveFiniteFloat -class ConstraintSafetyTargetV2(V2Model): +class ConstraintSafetyTargetV2(CanonicalModel): """Frozen scalar slack and deficit calculus for one hard constraint. The constraint slack is the minimum of all ``raw_margin / normalizer`` @@ -204,7 +204,7 @@ def canonicalize_components(self) -> Self: return self -class TranslationTermV2(V2Model): +class TranslationTermV2(CanonicalModel): """Weighted Euclidean L2 displacement of the subject in world XY.""" weight: PositiveFiniteFloat @@ -212,7 +212,7 @@ class TranslationTermV2(V2Model): metric: TranslationMetricV2 -class RelationDamageTermV2(V2Model): +class RelationDamageTermV2(CanonicalModel): """Weighted sum of pair-axis relation label-set change indicators. A component is one iff the set of satisfied labels on that pair-axis @@ -241,7 +241,7 @@ def canonicalize_universe(self) -> Self: return self -class VisibilityChangeTermV2(V2Model): +class VisibilityChangeTermV2(CanonicalModel): """Weighted sum of absolute normalized visibility metric deltas.""" weight: NonNegativeFiniteFloat @@ -270,7 +270,7 @@ def canonicalize_universe(self) -> Self: return self -class SafetyAggregationV2(V2Model): +class SafetyAggregationV2(CanonicalModel): """Sum normalized hard-constraint target deficits in canonical ID order.""" kind: SafetyAggregationKindV2 @@ -291,7 +291,7 @@ def canonicalize_targets(self) -> Self: return self -class SafetyMarginTermV2(V2Model): +class SafetyMarginTermV2(CanonicalModel): weight: NonNegativeFiniteFloat normalizer: PositiveFiniteFloat aggregation: SafetyAggregationV2 @@ -307,7 +307,7 @@ class SafetyMarginTermV2(V2Model): ) -class ObjectiveSpecV2(V2Model): +class ObjectiveSpecV2(CanonicalModel): """The one wire-complete minimum-total-cost formula. Each of translation, relation damage, visibility change, and summed safety @@ -351,4 +351,4 @@ def production_eligible(self) -> bool: def objective_spec_sha256(self) -> str: """Digest under the one frozen domain for objective references.""" - return canonical_sha256_v2(self, domain=_OBJECTIVE_HASH_DOMAIN) + return canonical_sha256(self, domain=_OBJECTIVE_HASH_DOMAIN) diff --git a/src/spatialcf/domain/v2/problem.py b/src/spatialcf/domain/problem.py similarity index 89% rename from src/spatialcf/domain/v2/problem.py rename to src/spatialcf/domain/problem.py index 88f7895..2f808d6 100644 --- a/src/spatialcf/domain/v2/problem.py +++ b/src/spatialcf/domain/problem.py @@ -1,47 +1,52 @@ -"""Closed, platform-neutral Canonical v2 semantic problem contract.""" +"""Closed, platform-neutral current semantic problem contract.""" from __future__ import annotations from collections.abc import Iterable -from typing import Any, Self +from threading import RLock +from typing import Any, Literal, Self from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( + CanonicalId, + CanonicalModel, FactAvailabilityV2, FactCompletenessV2, FactSetV2, + FiniteFloat, NumericPolicyV2, SchemaIdentityV2, - V2Model, + Vec3, ) -from spatialcf.domain.v2.constraints import ( - CanonicalConstraintSetV2, - MeasurementUnitV2, - PositionRegionInterpretationV2, - RelationAxisV2, - RelationSemanticsV2, - VisibilityConstraintV2, - VisibilityMetricKindV2, - VisibilitySemanticsV2, +from spatialcf.domain.constraints import ( + CanonicalConstraintSet, + MeasurementUnit, + PositionRegionInterpretation, + RelationAxis, + RelationSemantics, + VisibilityConstraint, + VisibilityMetricKind, + VisibilitySemantics, ) -from spatialcf.domain.v2.geometry import GeometryApproximationV2, GeometryRoleV2 -from spatialcf.domain.v2.objective import ( +from spatialcf.domain.geometry import GeometryApproximationV2, GeometryRoleV2 +from spatialcf.domain.objective import ( ObjectiveSpecV2, PairAxisKeyV2, SafetyComponentKindV2, SafetySlackUnitV2, ) -from spatialcf.domain.v2.scene import BaselineObservationV2, CanonicalSceneV2 -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.domain.scene import BaselineObservation, CanonicalScene, PinholeCamera +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, ) _PROBLEM_HASH_DOMAIN = "spatialcf.semantic-problem.v2" +_PROBLEM_HASH_DOMAIN_V2_3 = "spatialcf.semantic-problem.v2.3" -class SemanticProblemV2(V2Model): +class SemanticProblemV2(CanonicalModel): """Everything the production core may consume for one optimization problem. Adapter provenance, native locators, runtime outcomes, and audit evidence are @@ -53,10 +58,10 @@ class SemanticProblemV2(V2Model): schema_identity: SchemaIdentityV2 = Field( default_factory=lambda: SchemaIdentityV2(schema_name="semantic-problem") ) - scene: CanonicalSceneV2 - constraints: CanonicalConstraintSetV2 - relation_semantics: RelationSemanticsV2 - visibility_semantics: VisibilitySemanticsV2 + scene: CanonicalScene + constraints: CanonicalConstraintSet + relation_semantics: RelationSemantics + visibility_semantics: VisibilitySemantics objective: ObjectiveSpecV2 numeric_policy: NumericPolicyV2 @@ -103,10 +108,10 @@ def _expected_schema_identity(cls) -> SchemaIdentityV2: def semantic_problem_sha256(self) -> str: """Domain-separated digest of semantic bytes only.""" - return canonical_sha256_v2(self, domain=_PROBLEM_HASH_DOMAIN) + return canonical_sha256(self, domain=_PROBLEM_HASH_DOMAIN) def canonical_bytes(self) -> bytes: - return canonical_json_bytes_v2(self) + return canonical_json_bytes(self) @property def certification_blockers(self) -> tuple[str, ...]: @@ -175,7 +180,7 @@ def _validate_position_references(self) -> None: ) if ( position.region_interpretation - is not PositionRegionInterpretationV2.SUBJECT_OCCUPANCY_CONTAINED + is not PositionRegionInterpretation.SUBJECT_OCCUPANCY_CONTAINED ): return @@ -419,21 +424,21 @@ def _validate_visibility_references(self, objects: dict[str, Any]) -> None: constraint.visible_fraction_metric_definition_id, constraint.visible_fraction_metric_definition_version, ), - VisibilityMetricKindV2.VISIBLE_FRACTION, + VisibilityMetricKind.VISIBLE_FRACTION, ), ( ( constraint.image_area_metric_definition_id, constraint.image_area_metric_definition_version, ), - VisibilityMetricKindV2.IMAGE_AREA_FRACTION, + VisibilityMetricKind.IMAGE_AREA_FRACTION, ), ( ( constraint.truncated_fraction_metric_definition_id, constraint.truncated_fraction_metric_definition_version, ), - VisibilityMetricKindV2.TRUNCATED_FRACTION, + VisibilityMetricKind.TRUNCATED_FRACTION, ), ) for reference, expected_kind in typed_references: @@ -554,7 +559,7 @@ def _validate_relation_universe(self, objects: dict[str, Any]) -> None: ) for other_id in objects if other_id != subject_id - for axis in RelationAxisV2 + for axis in RelationAxis } - {target_key} if not required <= provided: raise ValueError( @@ -712,7 +717,7 @@ def _validate_safety_universe(self) -> None: SafetyComponentKindV2.TARGET_RELATION_THRESHOLD_MARGIN, ( SafetySlackUnitV2.PIXEL - if definition.unit is MeasurementUnitV2.PIXEL + if definition.unit is MeasurementUnit.PIXEL else SafetySlackUnitV2.METRE ), ) @@ -1068,7 +1073,7 @@ def _required_role_objects(self) -> dict[GeometryRoleV2, set[str]]: def _visibility_metric_refs( - constraint: VisibilityConstraintV2, + constraint: VisibilityConstraint, ) -> tuple[tuple[str, str], ...]: return ( ( @@ -1086,7 +1091,7 @@ def _visibility_metric_refs( ) -def _possible_values(facts: FactSetV2) -> tuple[V2Model, ...]: +def _possible_values(facts: FactSetV2) -> tuple[CanonicalModel, ...]: if facts.availability is not FactAvailabilityV2.KNOWN: return () if facts.completeness is FactCompletenessV2.BRACKETED: @@ -1094,7 +1099,7 @@ def _possible_values(facts: FactSetV2) -> tuple[V2Model, ...]: return facts.values or () -def _guaranteed_values(facts: FactSetV2) -> tuple[V2Model, ...]: +def _guaranteed_values(facts: FactSetV2) -> tuple[CanonicalModel, ...]: if facts.availability is not FactAvailabilityV2.KNOWN: return () if facts.completeness is FactCompletenessV2.OUTER_BOUND: @@ -1104,7 +1109,7 @@ def _guaranteed_values(facts: FactSetV2) -> tuple[V2Model, ...]: return facts.values or () -def _ids(values: Iterable[V2Model], id_field: str) -> set[str]: +def _ids(values: Iterable[CanonicalModel], id_field: str) -> set[str]: return {getattr(item, id_field) for item in values} @@ -1147,7 +1152,7 @@ def _validate_required_ids( raise ValueError(f"{family_name} contains dangling required IDs") -def _observation_key(observation: BaselineObservationV2) -> tuple[str, str, str, str]: +def _observation_key(observation: BaselineObservation) -> tuple[str, str, str, str]: return ( observation.object_id, observation.camera_id, @@ -1157,7 +1162,7 @@ def _observation_key(observation: BaselineObservationV2) -> tuple[str, str, str, def _validate_required_observations( - facts: FactSetV2[BaselineObservationV2], + facts: FactSetV2[BaselineObservation], required_keys: set[tuple[str, str, str, str]], ) -> None: if not required_keys or facts.availability is FactAvailabilityV2.MISSING: @@ -1173,3 +1178,104 @@ def _validate_required_observations( and not required_keys <= possible ): raise ValueError("visibility metric lacks a possible normalized baseline") + + +_CURRENT_CAMERA_MODEL_NAMES = frozenset( + { + "SchemaIdentityV2_3", + "UprightWorldToCameraTransformV2_3", + "PinholeCameraV2_3", + "CanonicalSceneV2_3", + "SemanticProblemV2_3", + } +) +_CURRENT_CAMERA_MODEL_INSTALL_LOCK = RLock() + + +def _install_current_camera_problem_models() -> None: + """Resolve the one artifacts/problem inheritance cycle on first use.""" + + if "SemanticProblemV2_3" in globals(): + return + with _CURRENT_CAMERA_MODEL_INSTALL_LOCK: + if "SemanticProblemV2_3" in globals(): + return + _build_current_camera_problem_models_locked() + + +def _build_current_camera_problem_models_locked() -> None: + """Build the complete V2.3 family while its installation lock is held.""" + + from spatialcf.domain.artifacts import CanonicalSceneV2_2, SemanticProblemV2_2 + + class SchemaIdentityV2_3(CanonicalModel): + """Identity carried only by Canonical 2.3 root contracts.""" + + schema_name: CanonicalId + schema_version: Literal["2.3"] = "2.3" + + SchemaIdentityV2_3.__qualname__ = SchemaIdentityV2_3.__name__ + globals()[SchemaIdentityV2_3.__name__] = SchemaIdentityV2_3 + + class UprightWorldToCameraTransformV2_3(CanonicalModel): + """Fixed upright world-to-camera basis with arbitrary horizontal azimuth.""" + + kind: Literal["UPRIGHT_WORLD_TO_CAMERA"] = "UPRIGHT_WORLD_TO_CAMERA" + azimuth_radians: FiniteFloat + translation: Vec3 + + UprightWorldToCameraTransformV2_3.__qualname__ = ( + UprightWorldToCameraTransformV2_3.__name__ + ) + globals()[UprightWorldToCameraTransformV2_3.__name__] = ( + UprightWorldToCameraTransformV2_3 + ) + + class PinholeCameraV2_3(PinholeCamera): + """Pinhole camera whose frame uses the Canonical 2.3 upright basis.""" + + world_to_camera: UprightWorldToCameraTransformV2_3 + + PinholeCameraV2_3.__qualname__ = PinholeCameraV2_3.__name__ + globals()[PinholeCameraV2_3.__name__] = PinholeCameraV2_3 + + class CanonicalSceneV2_3(CanonicalSceneV2_2): + """Canonical 2.3 Scene with an explicit upright camera frame.""" + + schema_identity: SchemaIdentityV2_3 = Field( + default_factory=lambda: SchemaIdentityV2_3(schema_name="canonical-scene") + ) + cameras: FactSetV2[PinholeCameraV2_3] + + @classmethod + def _expected_schema_identity(cls) -> SchemaIdentityV2_3: + return SchemaIdentityV2_3(schema_name="canonical-scene") + + CanonicalSceneV2_3.__qualname__ = CanonicalSceneV2_3.__name__ + globals()[CanonicalSceneV2_3.__name__] = CanonicalSceneV2_3 + + class SemanticProblemV2_3(SemanticProblemV2_2): + """Semantic Problem with a domain-separated Canonical 2.3 hash.""" + + schema_identity: SchemaIdentityV2_3 = Field( + default_factory=lambda: SchemaIdentityV2_3(schema_name="semantic-problem") + ) + scene: CanonicalSceneV2_3 + + @classmethod + def _expected_schema_identity(cls) -> SchemaIdentityV2_3: + return SchemaIdentityV2_3(schema_name="semantic-problem") + + @property + def semantic_problem_sha256(self) -> str: + return canonical_sha256(self, domain=_PROBLEM_HASH_DOMAIN_V2_3) + + SemanticProblemV2_3.__qualname__ = SemanticProblemV2_3.__name__ + globals()[SemanticProblemV2_3.__name__] = SemanticProblemV2_3 + + +def __getattr__(name: str) -> object: + if name not in _CURRENT_CAMERA_MODEL_NAMES: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + _install_current_camera_problem_models() + return globals()[name] diff --git a/src/spatialcf/domain/enums.py b/src/spatialcf/domain/request.py similarity index 65% rename from src/spatialcf/domain/enums.py rename to src/spatialcf/domain/request.py index af6523d..3f2d93e 100644 --- a/src/spatialcf/domain/enums.py +++ b/src/spatialcf/domain/request.py @@ -1,5 +1,11 @@ +"""Version-free counterfactual request values and relation result enums.""" + from enum import StrEnum +from pydantic import model_validator + +from spatialcf.domain.scene import FrozenModel + class RelationAxis(StrEnum): HORIZONTAL = "horizontal" @@ -69,3 +75,23 @@ class QualityTier(StrEnum): PURE = "PURE" LOW_LEAKAGE = "LOW_LEAKAGE" REJECTED = "REJECTED" + + +class InterventionSpec(FrozenModel): + subject_id: str + reference_id: str + relation_before: Relation + relation_after: Relation + camera_id: str + + @model_validator(mode="after") + def validate_flip(self) -> "InterventionSpec": + if self.subject_id == self.reference_id: + raise ValueError("subject and reference must differ") + if self.relation_before == self.relation_after: + raise ValueError("counterfactual must change the relation") + if self.relation_before.axis is not self.relation_after.axis: + raise ValueError("counterfactual relations must share one relation axis") + if self.relation_before.opposite != self.relation_after: + raise ValueError("MVP supports opposite relation flips only") + return self diff --git a/src/spatialcf/domain/v2/result.py b/src/spatialcf/domain/result.py similarity index 96% rename from src/spatialcf/domain/v2/result.py rename to src/spatialcf/domain/result.py index cf203ba..0f4ce09 100644 --- a/src/spatialcf/domain/v2/result.py +++ b/src/spatialcf/domain/result.py @@ -1,4 +1,4 @@ -"""Canonical v2 deterministic core configuration and solve-result contracts. +"""Current deterministic core configuration and solve-result contracts. The result state machine closes all in-record hash references. Its structural eligibility checks are necessary inputs to certification, not a replacement @@ -13,25 +13,25 @@ from pydantic import BeforeValidator, Field, model_validator -from spatialcf.domain.v2.artifacts import ( +from spatialcf.domain.artifacts import ( CandidateDomainArtifactV2, ObjectivePartitionArtifactV2, ObjectiveTermBoundsV2, RelationCostPartitionV2, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, NonNegativeFiniteFloat, SchemaIdentityV2, Sha256Digest, - V2Model, ) -from spatialcf.domain.v2.certificate import ( +from spatialcf.domain.certificate import ( GlobalOptimalityCertificateV2, ProvenUnsatCertificateV2, ) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.serialization import canonical_sha256 _CORE_SOLVER_CONFIG_HASH_DOMAIN = "core-solver-config-v2" _SOLVE_RESULT_HASH_DOMAIN = "canonical-solve-result-v2" @@ -67,7 +67,7 @@ def _reject_boolean_exact_error(value: object) -> object: ] -class _GeometryKernelSpecBaseV2(V2Model): +class _GeometryKernelSpecBaseV2(CanonicalModel): """Shared identity fields for a versioned numerical geometry kernel.""" schema_identity: SchemaIdentityV2 = Field( @@ -117,7 +117,7 @@ class UnverifiedBinary64GeometryKernelSpecV2(_GeometryKernelSpecBaseV2): ] -class CoreSolverConfigV2(V2Model): +class CoreSolverConfigV2(CanonicalModel): """Finite deterministic work limits and algorithm identity for core solve. A zero branch-node limit means no branch expansion beyond an analytic root @@ -146,7 +146,7 @@ def validate_schema_identity(self) -> Self: @property def core_solver_config_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_CORE_SOLVER_CONFIG_HASH_DOMAIN, ) @@ -166,7 +166,7 @@ class UncertifiedReasonV2(StrEnum): COMPILATION_INCOMPLETE = "COMPILATION_INCOMPLETE" -class _CanonicalSolveResultBaseV2(V2Model): +class _CanonicalSolveResultBaseV2(CanonicalModel): """Shared identity, config, hash, and reference-closure helpers.""" schema_identity: SchemaIdentityV2 = Field( @@ -255,7 +255,7 @@ def _validate_certifiable_geometry_kernel(self) -> None: @property def solve_result_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_SOLVE_RESULT_HASH_DOMAIN) + return canonical_sha256(self, domain=_SOLVE_RESULT_HASH_DOMAIN) class CertifiedSuccessResultV2(_CanonicalSolveResultBaseV2): @@ -272,7 +272,7 @@ class CertifiedSuccessResultV2(_CanonicalSolveResultBaseV2): candidate_domain: CandidateDomainArtifactV2 relation_cost_partition: RelationCostPartitionV2 objective_partition: ObjectivePartitionArtifactV2 - edit: CanonicalEditV2 + edit: CanonicalEdit global_loss_lower_bound: NonNegativeFiniteFloat witness_loss_bounds: ObjectiveTermBoundsV2 certificate: GlobalOptimalityCertificateV2 diff --git a/src/spatialcf/domain/v2/scene.py b/src/spatialcf/domain/scene.py similarity index 66% rename from src/spatialcf/domain/v2/scene.py rename to src/spatialcf/domain/scene.py index 31f2427..de0e3bb 100644 --- a/src/spatialcf/domain/v2/scene.py +++ b/src/spatialcf/domain/scene.py @@ -1,4 +1,4 @@ -"""Canonical v2 semantic scene facts and their closed reference graph.""" +"""Version-free scene values and Canonical semantic scene facts.""" from __future__ import annotations @@ -6,10 +6,12 @@ from enum import StrEnum from typing import Annotated, Literal, Self -from pydantic import Field, StrictBool, model_validator +from pydantic import ConfigDict, Field, StrictBool, model_serializer, model_validator +from pydantic.functional_serializers import SerializerFunctionWrapHandler -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, FactAvailabilityV2, FactCompletenessV2, FactSetV2, @@ -18,10 +20,11 @@ RigidTransformV2, SchemaIdentityV2, UncertaintyBudgetV2, - V2Model, - Vec3V2, ) -from spatialcf.domain.v2.geometry import ( +from spatialcf.domain.base import ( + Vec3 as CanonicalVec3, +) +from spatialcf.domain.geometry import ( CollisionBodyFactV2, GeometryApproximationV2, GeometryInstanceV2, @@ -29,6 +32,223 @@ PlanarRegionV2, ) + +class FrozenModel(CanonicalModel): + """Legacy value-model behavior retained inside the current domain owner.""" + + model_config = ConfigDict( + frozen=True, + extra="forbid", + strict=False, + allow_inf_nan=True, + validate_default=False, + revalidate_instances="never", + ) + + +class Vec2(FrozenModel): + x: float + y: float + + +class Vec3(FrozenModel): + x: float + y: float + z: float + + +class Quaternion(FrozenModel): + x: float + y: float + z: float + w: float + + +class BBox2D(FrozenModel): + xmin: float + ymin: float + xmax: float + ymax: float + + @property + def center_x(self) -> float: + return (self.xmin + self.xmax) / 2.0 + + @property + def area(self) -> float: + return max(0.0, self.xmax - self.xmin) * max(0.0, self.ymax - self.ymin) + + +class ObjectView(FrozenModel): + camera_id: str + bbox: BBox2D + camera_depth: float + visible_fraction: float = Field(ge=0.0, le=1.0) + image_area_fraction: float = Field(ge=0.0, le=1.0) + truncated_fraction: float = Field(ge=0.0, le=1.0) + + +class OBB(FrozenModel): + center: Vec3 + extent: Vec3 + rotation: Quaternion + + +class CollisionObstacle(FrozenModel): + """Native geometry plus solver-only clearance for collision checks.""" + + obstacle_id: str = Field(min_length=1) + source_object_id: str = Field(min_length=1) + clearance_m: float = Field(ge=0.0, strict=True) + obb: OBB + + def conservative_obb(self) -> OBB: + clearance_diameter = 2.0 * self.clearance_m + return self.obb.model_copy( + update={ + "extent": Vec3( + x=self.obb.extent.x + clearance_diameter, + y=self.obb.extent.y + clearance_diameter, + z=self.obb.extent.z + clearance_diameter, + ) + } + ) + + +class PlanarPolygon(FrozenModel): + """One canonical polygon component without a repeated closing vertex.""" + + exterior: tuple[Vec2, ...] = Field(min_length=3) + holes: tuple[tuple[Vec2, ...], ...] = Field(default_factory=tuple) + + @model_validator(mode="after") + def validate_rings(self) -> PlanarPolygon: + for label, ring in ( + ("exterior", self.exterior), + *((f"hole {index}", hole) for index, hole in enumerate(self.holes)), + ): + if len(ring) < 3: + raise ValueError(f"{label} must contain at least three vertices") + coordinates = tuple((point.x, point.y) for point in ring) + if any( + not math.isfinite(value) + for coordinate in coordinates + for value in coordinate + ): + raise ValueError(f"{label} vertices must be finite") + if len(set(coordinates)) < 3: + raise ValueError(f"{label} must contain three distinct vertices") + if coordinates[0] == coordinates[-1]: + raise ValueError(f"{label} must omit the repeated closing vertex") + return self + + +class SubjectPositionRegion(FrozenModel): + """A source-bound allowed XY position-anchor locus for one subject.""" + + region_id: str = Field(min_length=1) + subject_object_id: str = Field(min_length=1) + source_kind: Literal[ + "ai2thor-navigation-v1", + "ai2thor-receptacle-trigger-grid-v1", + ] + source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + components: tuple[PlanarPolygon, ...] = Field(default_factory=tuple) + + +class SceneObject(FrozenModel): + object_id: str + name: str + category: str + movable: bool + request_eligible: bool = Field(default=True, strict=True) + position: Vec3 + rotation: Quaternion + obb: OBB + support_object_id: str | None = None + views: dict[str, ObjectView] = Field(default_factory=dict) + + @model_serializer(mode="wrap") + def serialize_request_eligibility( + self, + handler: SerializerFunctionWrapHandler, + ) -> dict[str, object]: + payload = handler(self) + if "request_eligible" not in self.model_fields_set: + payload.pop("request_eligible", None) + return payload + + +class Camera(FrozenModel): + camera_id: str + width: int = Field(gt=0) + height: int = Field(gt=0) + intrinsics: tuple[float, ...] = Field(min_length=9, max_length=9) + world_to_camera: tuple[float, ...] = Field(min_length=16, max_length=16) + + +class Scene(FrozenModel): + scene_id: str + source: str + coordinate_system: Literal["RH_METERS_Z_UP"] = "RH_METERS_Z_UP" + room_polygon_xy: tuple[Vec2, ...] = Field(min_length=3) + cameras: tuple[Camera, ...] + objects: tuple[SceneObject, ...] + collision_obstacles: tuple[CollisionObstacle, ...] = Field(default_factory=tuple) + subject_position_regions: tuple[SubjectPositionRegion, ...] = Field( + default_factory=tuple + ) + pinned_object_ids: frozenset[str] = Field(default_factory=frozenset) + generation_seed: int + + @model_validator(mode="after") + def validate_subject_position_regions(self) -> Scene: + object_ids = {obj.object_id for obj in self.objects} + region_ids = tuple(region.region_id for region in self.subject_position_regions) + if len(region_ids) != len(set(region_ids)): + raise ValueError("subject position region IDs must be unique") + if any( + region.subject_object_id not in object_ids + for region in self.subject_position_regions + ): + raise ValueError("subject position region references an unknown object") + return self + + @model_serializer(mode="wrap") + def serialize_analysis_overlays( + self, + handler: SerializerFunctionWrapHandler, + ) -> dict[str, object]: + payload = handler(self) + if "collision_obstacles" not in self.model_fields_set: + payload.pop("collision_obstacles", None) + if "subject_position_regions" not in self.model_fields_set: + payload.pop("subject_position_regions", None) + return payload + + def object_by_id(self, object_id: str) -> SceneObject: + matches = [obj for obj in self.objects if obj.object_id == object_id] + if len(matches) != 1: + raise KeyError(f"Expected one object {object_id!r}, found {len(matches)}") + return matches[0] + + def camera_by_id(self, camera_id: str) -> Camera: + matches = [cam for cam in self.cameras if cam.camera_id == camera_id] + if len(matches) != 1: + raise KeyError(f"Expected one camera {camera_id!r}, found {len(matches)}") + return matches[0] + + def children_by_support(self) -> dict[str, tuple[str, ...]]: + children: dict[str, list[str]] = {} + for obj in self.objects: + if obj.support_object_id is not None: + children.setdefault(obj.support_object_id, []).append(obj.object_id) + return { + support_id: tuple(sorted(object_ids)) + for support_id, object_ids in sorted(children.items()) + } + + _NormalizedFraction = Annotated[ float, Field(strict=True, allow_inf_nan=False, ge=0.0, le=1.0), @@ -36,35 +256,35 @@ _PositiveStrictInt = Annotated[int, Field(strict=True, gt=0)] -class RegionBoundaryPolicyV2(StrEnum): +class RegionBoundaryPolicy(StrEnum): """Whether a geometric fact includes its mathematical boundary.""" CLOSED = "CLOSED" OPEN = "OPEN" -class CameraMatrixLayoutV2(StrEnum): +class CameraMatrixLayout(StrEnum): ROW_MAJOR = "ROW_MAJOR" -class CameraAxesV2(StrEnum): +class CameraAxes(StrEnum): X_RIGHT_Y_DOWN_Z_FORWARD = "X_RIGHT_Y_DOWN_Z_FORWARD" -class CameraPixelConventionV2(StrEnum): +class CameraPixelConvention(StrEnum): CENTER_AT_HALF = "CENTER_AT_HALF" -class CameraDepthConventionV2(StrEnum): +class CameraDepthConvention(StrEnum): POSITIVE_Z_FORWARD = "POSITIVE_Z_FORWARD" -class CameraDistortionModelV2(StrEnum): +class CameraDistortionModel(StrEnum): NONE = "NONE" BROWN_CONRADY = "BROWN_CONRADY" -class BrownConradyCoefficientsV2(V2Model): +class BrownConradyCoefficients(CanonicalModel): """Named coefficients for the normalized-coordinate Brown-Conrady model. The frozen order semantics are radial ``k1, k2, k3`` and tangential @@ -79,14 +299,14 @@ class BrownConradyCoefficientsV2(V2Model): k3: FiniteFloat -class ObjectPoseV2(V2Model): +class ObjectPose(CanonicalModel): """The world pose of the object's unique pivot/edit anchor.""" anchor_kind: Literal["OBJECT_PIVOT"] = "OBJECT_PIVOT" world_from_object: RigidTransformV2 -class ObjectSupportAssignmentV2(V2Model): +class ObjectSupportAssignment(CanonicalModel): """Availability-aware baseline support assignment for one object.""" availability: FactAvailabilityV2 @@ -116,37 +336,37 @@ def not_applicable(cls) -> Self: return cls(availability=FactAvailabilityV2.NOT_APPLICABLE) -class CanonicalObjectV2(V2Model): +class CanonicalObject(CanonicalModel): """A stable semantic object identity without source aliases or provenance.""" object_id: CanonicalId category_id: CanonicalId movable: StrictBool - pose: ObjectPoseV2 - support_assignment: ObjectSupportAssignmentV2 + pose: ObjectPose + support_assignment: ObjectSupportAssignment -class WorkspaceBoundaryFactV2(V2Model): +class WorkspaceBoundaryFact(CanonicalModel): """A claimed workspace extent; it is not itself an edit constraint.""" fact_id: CanonicalId region_world_xy: PlanarRegionV2 - boundary_policy: RegionBoundaryPolicyV2 + boundary_policy: RegionBoundaryPolicy region_approximation: GeometryApproximationV2 geometry_uncertainty: UncertaintyBudgetV2 -class KnownFreeSpaceFactV2(V2Model): +class KnownFreeSpaceFact(CanonicalModel): """A claimed known-free extent, distinct from workspace and subject domain.""" fact_id: CanonicalId region_world_xy: PlanarRegionV2 - boundary_policy: RegionBoundaryPolicyV2 + boundary_policy: RegionBoundaryPolicy region_approximation: GeometryApproximationV2 geometry_uncertainty: UncertaintyBudgetV2 -class PinholeCameraV2(V2Model): +class PinholeCamera(CanonicalModel): """One fully specified calibrated pinhole camera convention.""" camera_id: CanonicalId @@ -157,16 +377,14 @@ class PinholeCameraV2(V2Model): max_length=9, ) world_to_camera: RigidTransformV2 - matrix_layout: CameraMatrixLayoutV2 = CameraMatrixLayoutV2.ROW_MAJOR - camera_axes: CameraAxesV2 = CameraAxesV2.X_RIGHT_Y_DOWN_Z_FORWARD - pixel_convention: CameraPixelConventionV2 = CameraPixelConventionV2.CENTER_AT_HALF - depth_convention: CameraDepthConventionV2 = ( - CameraDepthConventionV2.POSITIVE_Z_FORWARD - ) + matrix_layout: CameraMatrixLayout = CameraMatrixLayout.ROW_MAJOR + camera_axes: CameraAxes = CameraAxes.X_RIGHT_Y_DOWN_Z_FORWARD + pixel_convention: CameraPixelConvention = CameraPixelConvention.CENTER_AT_HALF + depth_convention: CameraDepthConvention = CameraDepthConvention.POSITIVE_Z_FORWARD near_clip_m: PositiveFiniteFloat far_clip_m: PositiveFiniteFloat - distortion_model: CameraDistortionModelV2 = CameraDistortionModelV2.NONE - brown_conrady_coefficients: BrownConradyCoefficientsV2 | None = None + distortion_model: CameraDistortionModel = CameraDistortionModel.NONE + brown_conrady_coefficients: BrownConradyCoefficients | None = None calibration_uncertainty: UncertaintyBudgetV2 @model_validator(mode="after") @@ -180,7 +398,7 @@ def validate_projection_contract(self) -> Self: raise ValueError("far_clip_m must be greater than near_clip_m") has_coefficients = self.brown_conrady_coefficients is not None if has_coefficients is not ( - self.distortion_model is CameraDistortionModelV2.BROWN_CONRADY + self.distortion_model is CameraDistortionModel.BROWN_CONRADY ): raise ValueError( "Brown-Conrady coefficients do not match the distortion model" @@ -188,7 +406,7 @@ def validate_projection_contract(self) -> Self: return self -class BaselineObservationV2(V2Model): +class BaselineObservation(CanonicalModel): """One normalized, definition-bound object/camera metric interval.""" observation_id: CanonicalId @@ -213,7 +431,7 @@ def validate_normalized_interval(self) -> Self: return self -class SupportSurfaceFactV2(V2Model): +class SupportSurfaceFact(CanonicalModel): """A planar region on a collision body in an explicit anchor frame. ``anchor_from_surface`` maps surface coordinates into the owner object's @@ -225,10 +443,10 @@ class SupportSurfaceFactV2(V2Model): owner_object_id: CanonicalId | None supporting_body_id: CanonicalId anchor_from_surface: RigidTransformV2 - normal_in_anchor: Vec3V2 + normal_in_anchor: CanonicalVec3 region_uv: PlanarRegionV2 region_approximation: GeometryApproximationV2 - boundary_policy: RegionBoundaryPolicyV2 + boundary_policy: RegionBoundaryPolicy geometry_uncertainty: UncertaintyBudgetV2 @model_validator(mode="after") @@ -252,7 +470,7 @@ def validate_plane_and_normal(self) -> Self: return self -class CanonicalSceneV2(V2Model): +class CanonicalScene(CanonicalModel): """Versioned platform-neutral facts with a canonical closed reference graph.""" schema_identity: SchemaIdentityV2 = Field( @@ -264,14 +482,14 @@ class CanonicalSceneV2(V2Model): ) ) coordinate_system: Literal["RH_METERS_Z_UP"] = "RH_METERS_Z_UP" - objects: FactSetV2[CanonicalObjectV2] + objects: FactSetV2[CanonicalObject] geometry_instances: FactSetV2[GeometryInstanceV2] collision_bodies: FactSetV2[CollisionBodyFactV2] - workspace_boundaries: FactSetV2[WorkspaceBoundaryFactV2] - known_free_spaces: FactSetV2[KnownFreeSpaceFactV2] - support_surfaces: FactSetV2[SupportSurfaceFactV2] - cameras: FactSetV2[PinholeCameraV2] - baseline_observations: FactSetV2[BaselineObservationV2] + workspace_boundaries: FactSetV2[WorkspaceBoundaryFact] + known_free_spaces: FactSetV2[KnownFreeSpaceFact] + support_surfaces: FactSetV2[SupportSurfaceFact] + cameras: FactSetV2[PinholeCamera] + baseline_observations: FactSetV2[BaselineObservation] @model_validator(mode="after") def canonicalize_and_validate_scene(self) -> Self: @@ -425,7 +643,7 @@ def _canonicalize_fact_ids( ) -> FactSetV2: if facts.availability is not FactAvailabilityV2.KNOWN: return facts - updates: dict[str, tuple[V2Model, ...]] = {} + updates: dict[str, tuple[CanonicalModel, ...]] = {} for value_field in ("values", "inner_values", "outer_values"): values = getattr(facts, value_field) if values is None: @@ -461,7 +679,7 @@ def _canonicalize_fact_ids( def _values_for_branch( facts: FactSetV2, branch: Literal["inner", "outer"], -) -> tuple[V2Model, ...]: +) -> tuple[CanonicalModel, ...]: if facts.availability is not FactAvailabilityV2.KNOWN: return () if facts.completeness is FactCompletenessV2.BRACKETED: @@ -478,7 +696,7 @@ def _by_id( facts: FactSetV2, id_field: str, branch: Literal["inner", "outer"], -) -> dict[str, V2Model]: +) -> dict[str, CanonicalModel]: return {getattr(item, id_field): item for item in _values_for_branch(facts, branch)} diff --git a/src/spatialcf/domain/v2/serialization.py b/src/spatialcf/domain/serialization.py similarity index 91% rename from src/spatialcf/domain/v2/serialization.py rename to src/spatialcf/domain/serialization.py index a2aaaf4..bb7d670 100644 --- a/src/spatialcf/domain/v2/serialization.py +++ b/src/spatialcf/domain/serialization.py @@ -18,7 +18,7 @@ from pydantic import BaseModel, TypeAdapter -from spatialcf.domain.v2.base import CanonicalId, Sha256Digest, V2Model +from spatialcf.domain.base import CanonicalId, CanonicalModel, Sha256Digest CANONICAL_HASH_PREFIX_V2 = b"spatialcf-canonical-json-v2\0" CANONICAL_JSON_NUMBER_GRAMMAR_V2 = "python-json-3.11-finite-v1" @@ -26,7 +26,7 @@ def _canonical_json_value_v2(value: Any) -> Any: - if isinstance(value, V2Model): + if isinstance(value, CanonicalModel): validated = type(value).model_validate(value, strict=True) return _canonical_json_value_v2( validated.model_dump( @@ -75,14 +75,14 @@ def _encoded_sort_key(value: Any) -> bytes: ).encode("utf-8") -def canonical_json_bytes_v2(value: Any) -> bytes: +def canonical_json_bytes(value: Any) -> bytes: """Return compact UTF-8 JSON using the frozen v2 number grammar.""" normalized = _canonical_json_value_v2(value) return _encoded_sort_key(normalized) -def canonical_sha256_v2(value: Any, *, domain: CanonicalId) -> Sha256Digest: +def canonical_sha256(value: Any, *, domain: CanonicalId) -> Sha256Digest: """Hash canonical bytes with a required semantic domain separator.""" validated_domain = _CANONICAL_ID_ADAPTER.validate_python(domain, strict=True) @@ -90,6 +90,6 @@ def canonical_sha256_v2(value: Any, *, domain: CanonicalId) -> Sha256Digest: CANONICAL_HASH_PREFIX_V2 + validated_domain.encode("utf-8") + b"\0" - + canonical_json_bytes_v2(value) + + canonical_json_bytes(value) ) return hashlib.sha256(digest_input).hexdigest() diff --git a/src/spatialcf/domain/v2/continuous_yaw_solver_v2_9.py b/src/spatialcf/domain/solver.py similarity index 50% rename from src/spatialcf/domain/v2/continuous_yaw_solver_v2_9.py rename to src/spatialcf/domain/solver.py index 6b36a4b..6d76afe 100644 --- a/src/spatialcf/domain/v2/continuous_yaw_solver_v2_9.py +++ b/src/spatialcf/domain/solver.py @@ -1,7 +1,7 @@ -"""Strict domain-only wire contracts for the continuous-yaw solver. +"""Strict domain-only wire contracts for the current continuous-yaw solver. The records in this module are structural data, not replay capabilities. A -consumer must pass a submitted result to the pure-core v2.9 verifier before +consumer must pass a submitted result to the pure-core v2.8 verifier before trusting its stage references, resource telemetry, witness, or certificate. """ @@ -14,41 +14,40 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.artifacts import ( +from spatialcf.domain.artifacts import ( ConstraintSlackV2, ObjectiveTermBoundsV2, + SchemaIdentityV2_2, + StrictConvexCandidateCompilerConfigV2_7, ) -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( CanonicalId, + CanonicalModel, NonNegativeFiniteFloat, PositiveFiniteFloat, Sha256Digest, - V2Model, -) -from spatialcf.domain.v2.certificate import OptimalityClaimV2 -from spatialcf.domain.v2.continuous_yaw_camera import SchemaIdentityV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - StrictConvexCandidateCompilerConfigV2_7, ) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.result import UncertifiedReasonV2 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -_CONFIG_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-solver-config.v2.9" -_CANDIDATE_REFS_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-candidate-refs.v2.9" -_OBJECTIVE_CELLS_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-objective-cells.v2.9" -_WITNESS_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-witness-evaluation.v2.9" -_CERTIFICATE_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-certificate.v2.9" -_SOLVE_RESULT_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-solve-result.v2.9" -_MAX_DETERMINISTIC_LIMIT_V2_9 = 2**63 - 1 - -PositiveDeterministicLimitV2_9 = Annotated[ +from spatialcf.domain.certificate import OptimalityClaimV2 +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.problem import SchemaIdentityV2_3 +from spatialcf.domain.result import UncertifiedReasonV2 +from spatialcf.domain.serialization import canonical_sha256 + +_CONFIG_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-solver-config.v2.8" +_CANDIDATE_REFS_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-candidate-refs.v2.8" +_OBJECTIVE_CELLS_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-objective-cells.v2.8" +_WITNESS_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-witness-evaluation.v2.8" +_CERTIFICATE_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-certificate.v2.8" +_SOLVE_RESULT_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-solve-result.v2.8" +_MAX_DETERMINISTIC_LIMIT_V2_8 = 2**63 - 1 + +PositiveDeterministicLimitV2_8 = Annotated[ int, - Field(strict=True, ge=1, le=_MAX_DETERMINISTIC_LIMIT_V2_9), + Field(strict=True, ge=1, le=_MAX_DETERMINISTIC_LIMIT_V2_8), ] -NonNegativeDeterministicLimitV2_9 = Annotated[ +NonNegativeDeterministicLimitV2_8 = Annotated[ int, - Field(strict=True, ge=0, le=_MAX_DETERMINISTIC_LIMIT_V2_9), + Field(strict=True, ge=0, le=_MAX_DETERMINISTIC_LIMIT_V2_8), ] @@ -87,7 +86,500 @@ def _directed_binary64_gap_ceil(lower_bound: float, upper_bound: float) -> float return published -class ContinuousYawResourceUsageV2_9(V2Model): +class ContinuousYawResourceUsageV2_8(CanonicalModel): + """One cumulative generation ledger for the complete v2.8 solve.""" + + domain_operations: NonNegativeDeterministicLimitV2_8 + so2_atomic_steps: NonNegativeDeterministicLimitV2_8 + candidate_cells: NonNegativeDeterministicLimitV2_8 + objective_partition_cells: NonNegativeDeterministicLimitV2_8 + branch_nodes: Literal[0] = 0 + refinement_steps: Literal[0] = 0 + + +class ContinuousYawSolverConfigV2_8(CanonicalModel): + """Closed public policy for the complete continuous-yaw solve.""" + + schema_identity: SchemaIdentityV2_2 = Field( + default_factory=lambda: SchemaIdentityV2_2( + schema_name="continuous-yaw-solver-config" + ) + ) + algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( + "solver:canonical-branch-and-bound-v2" + ) + algorithm_version: Literal["algorithm:2.8"] = "algorithm:2.8" + candidate_config: StrictConvexCandidateCompilerConfigV2_7 + target_projection_kernel_id: Literal[ + "geometry-kernel:rational-continuous-yaw-shape-gap-v2" + ] = "geometry-kernel:rational-continuous-yaw-shape-gap-v2" + visibility_projection_kernel_id: Literal[ + "geometry-kernel:rational-continuous-yaw-fixed-camera-visibility-v2" + ] = "geometry-kernel:rational-continuous-yaw-fixed-camera-visibility-v2" + objective_kernel_id: Literal[ + "objective-kernel:rational-continuous-yaw-cell-bounds-v2" + ] = "objective-kernel:rational-continuous-yaw-cell-bounds-v2" + max_objective_partition_cells: PositiveDeterministicLimitV2_8 + max_branch_nodes: Literal[0] = 0 + max_refinement_steps: Literal[0] = 0 + target_optimality_gap: NonNegativeFiniteFloat + + @model_validator(mode="after") + def validate_identity(self) -> Self: + expected = SchemaIdentityV2_2(schema_name="continuous-yaw-solver-config") + if self.schema_identity != expected: + raise ValueError("continuous-yaw solver config identity must be fixed") + return self + + @property + def config_sha256(self) -> Sha256Digest: + return canonical_sha256(self, domain=_CONFIG_HASH_DOMAIN_V2_8) + + +class ContinuousYawCandidateRefsV2_8(CanonicalModel): + """Hash chain for the private T15, target, and visibility stages.""" + + semantic_problem_sha256: Sha256Digest + solver_config_sha256: Sha256Digest + t15_candidate_artifact_sha256: Sha256Digest + target_candidate_stage_sha256: Sha256Digest | None = None + visibility_candidate_stage_sha256: Sha256Digest | None = None + + @model_validator(mode="after") + def validate_prefix(self) -> Self: + if ( + self.visibility_candidate_stage_sha256 is not None + and self.target_candidate_stage_sha256 is None + ): + raise ValueError("visibility stage requires a target stage reference") + return self + + @property + def candidate_refs_sha256(self) -> Sha256Digest: + return canonical_sha256(self, domain=_CANDIDATE_REFS_HASH_DOMAIN_V2_8) + + +class ContinuousYawObjectiveCellV2_8(CanonicalModel): + """One objective enclosure over a private strict-convex candidate cell.""" + + _SEQUENCE_HASH_DOMAIN: ClassVar[str] = _OBJECTIVE_CELLS_HASH_DOMAIN_V2_8 + + cell_id: CanonicalId + outer_domain_sha256: Sha256Digest + inner_domain_sha256: Sha256Digest | None = None + term_loss_bounds: ObjectiveTermBoundsV2 + constraint_slacks: tuple[ConstraintSlackV2, ...] = Field(min_length=1) + + @model_validator(mode="after") + def canonicalize_slacks(self) -> Self: + slack_ids = tuple(slack.constraint_id for slack in self.constraint_slacks) + if len(slack_ids) != len(set(slack_ids)): + raise ValueError("objective cell constraint slacks must be unique") + object.__setattr__( + self, + "constraint_slacks", + tuple(sorted(self.constraint_slacks, key=lambda item: item.constraint_id)), + ) + return self + + @classmethod + def sequence_sha256( + cls, + cells: tuple[ContinuousYawObjectiveCellV2_8, ...], + ) -> Sha256Digest: + if type(cells) is not tuple: + raise TypeError("objective cells must be an exact tuple") + checked = tuple( + cls.model_validate(cell.model_dump(mode="python"), strict=True) + for cell in cells + ) + ordered = tuple(sorted(checked, key=lambda cell: cell.cell_id)) + cell_ids = tuple(cell.cell_id for cell in ordered) + if len(cell_ids) != len(set(cell_ids)): + raise ValueError("objective cell IDs must be unique") + return canonical_sha256(ordered, domain=cls._SEQUENCE_HASH_DOMAIN) + + +class ContinuousYawWitnessEvaluationV2_8(CanonicalModel): + """Fresh point loss and slack enclosure for one concrete edit.""" + + objective_cell_id: CanonicalId + edit: CanonicalEdit + witness_loss_bounds: ObjectiveTermBoundsV2 + constraint_slacks: tuple[ConstraintSlackV2, ...] = Field(min_length=1) + + @model_validator(mode="after") + def canonicalize_slacks(self) -> Self: + slack_ids = tuple(slack.constraint_id for slack in self.constraint_slacks) + if len(slack_ids) != len(set(slack_ids)): + raise ValueError("witness constraint slacks must be unique") + object.__setattr__( + self, + "constraint_slacks", + tuple(sorted(self.constraint_slacks, key=lambda item: item.constraint_id)), + ) + return self + + @property + def witness_evaluation_sha256(self) -> Sha256Digest: + return canonical_sha256(self, domain=_WITNESS_HASH_DOMAIN_V2_8) + + +class ContinuousYawGlobalOptimalityCertificateV2_8(CanonicalModel): + """Directed scalar optimum claim over the complete v2.8 candidate chain.""" + + semantic_problem_sha256: Sha256Digest + solver_config_sha256: Sha256Digest + candidate_refs_sha256: Sha256Digest + objective_cells_sha256: Sha256Digest + witness_evaluation_sha256: Sha256Digest + edit_sha256: Sha256Digest + loss_lower_bound: NonNegativeFiniteFloat + loss_upper_bound: NonNegativeFiniteFloat + optimality_gap: NonNegativeFiniteFloat + optimality_claim: OptimalityClaimV2 + epsilon: PositiveFiniteFloat | None = None + final_resource_usage: ContinuousYawResourceUsageV2_8 + + @model_validator(mode="after") + def validate_claim(self) -> Self: + if self.loss_lower_bound > self.loss_upper_bound: + raise ValueError("loss lower bound cannot exceed loss upper bound") + expected_gap = _directed_binary64_gap_ceil( + self.loss_lower_bound, + self.loss_upper_bound, + ) + if self.optimality_gap != expected_gap: + raise ValueError("optimality gap must be the directed exact difference") + if self.optimality_claim is OptimalityClaimV2.EXACT: + if self.optimality_gap != 0.0 or self.epsilon is not None: + raise ValueError("EXACT requires zero gap and no epsilon") + return self + if self.epsilon is None or self.optimality_gap > self.epsilon: + raise ValueError("EPSILON_OPTIMAL requires epsilon covering the gap") + return self + + @property + def certificate_sha256(self) -> Sha256Digest: + return canonical_sha256(self, domain=_CERTIFICATE_HASH_DOMAIN_V2_8) + + +def _canonical_objective_cells( + cells: tuple[ContinuousYawObjectiveCellV2_8, ...], +) -> tuple[ContinuousYawObjectiveCellV2_8, ...]: + ordered = tuple(sorted(cells, key=lambda cell: cell.cell_id)) + cell_ids = tuple(cell.cell_id for cell in ordered) + if len(cell_ids) != len(set(cell_ids)): + raise ValueError("objective cell IDs must be unique") + return ordered + + +def _validate_usage_limits( + usage: ContinuousYawResourceUsageV2_8, + config: ContinuousYawSolverConfigV2_8, +) -> None: + candidate_config = config.candidate_config + if usage.domain_operations > candidate_config.max_domain_operations: + raise ValueError("resource usage exceeds the domain-operation limit") + if usage.so2_atomic_steps > candidate_config.max_so2_atomic_steps: + raise ValueError("resource usage exceeds the SO(2) atomic-step limit") + if usage.candidate_cells > candidate_config.max_candidate_cells: + raise ValueError("resource usage exceeds the candidate-cell limit") + if usage.objective_partition_cells > config.max_objective_partition_cells: + raise ValueError("resource usage exceeds the objective-cell limit") + + +class _ContinuousYawSolveResultBaseV2_8(CanonicalModel): + semantic_problem_sha256: Sha256Digest + solver_config: ContinuousYawSolverConfigV2_8 + + @property + def solve_result_sha256(self) -> Sha256Digest: + return canonical_sha256(self, domain=_SOLVE_RESULT_HASH_DOMAIN_V2_8) + + +class ContinuousYawCertifiedSuccessResultV2_8(_ContinuousYawSolveResultBaseV2_8): + status: Literal[ContinuousYawSolveStatusV2.CERTIFIED_SUCCESS] = ( + ContinuousYawSolveStatusV2.CERTIFIED_SUCCESS + ) + candidate_refs: ContinuousYawCandidateRefsV2_8 + objective_cells: tuple[ContinuousYawObjectiveCellV2_8, ...] = Field(min_length=1) + selected_witness: ContinuousYawWitnessEvaluationV2_8 + global_loss_lower_bound: NonNegativeFiniteFloat + witness_loss_bounds: ObjectiveTermBoundsV2 + final_resource_usage: ContinuousYawResourceUsageV2_8 + certificate: ContinuousYawGlobalOptimalityCertificateV2_8 + + @model_validator(mode="after") + def validate_success(self) -> Self: + refs = self.candidate_refs + config_hash = self.solver_config.config_sha256 + if refs.semantic_problem_sha256 != self.semantic_problem_sha256: + raise ValueError("candidate refs problem hash is not closed") + if refs.solver_config_sha256 != config_hash: + raise ValueError("candidate refs config hash is not closed") + if ( + refs.target_candidate_stage_sha256 is None + or refs.visibility_candidate_stage_sha256 is None + ): + raise ValueError("success requires the complete candidate stage chain") + cells = _canonical_objective_cells(self.objective_cells) + object.__setattr__(self, "objective_cells", cells) + selected_cells = tuple( + cell + for cell in cells + if cell.cell_id == self.selected_witness.objective_cell_id + ) + if len(selected_cells) != 1 or selected_cells[0].inner_domain_sha256 is None: + raise ValueError("selected witness requires one objective inner cell") + if self.selected_witness.edit.semantic_problem_sha256 != ( + self.semantic_problem_sha256 + ): + raise ValueError("selected edit problem hash is not closed") + if self.selected_witness.witness_loss_bounds != self.witness_loss_bounds: + raise ValueError("selected witness loss bounds are not closed") + if self.global_loss_lower_bound > self.witness_loss_bounds.total_upper_bound: + raise ValueError("global lower bound exceeds witness upper bound") + _validate_usage_limits(self.final_resource_usage, self.solver_config) + if self.final_resource_usage.objective_partition_cells != len(cells): + raise ValueError("objective-cell usage must equal published cells") + + certificate = self.certificate + expected = ( + self.semantic_problem_sha256, + config_hash, + refs.candidate_refs_sha256, + ContinuousYawObjectiveCellV2_8.sequence_sha256(cells), + self.selected_witness.witness_evaluation_sha256, + self.selected_witness.edit.edit_sha256, + ) + actual = ( + certificate.semantic_problem_sha256, + certificate.solver_config_sha256, + certificate.candidate_refs_sha256, + certificate.objective_cells_sha256, + certificate.witness_evaluation_sha256, + certificate.edit_sha256, + ) + if actual != expected: + raise ValueError("success certificate references are not closed") + if certificate.loss_lower_bound != self.global_loss_lower_bound: + raise ValueError("certificate lower bound is not closed") + if certificate.loss_upper_bound != self.witness_loss_bounds.total_upper_bound: + raise ValueError("certificate upper bound is not closed") + if certificate.final_resource_usage != self.final_resource_usage: + raise ValueError("certificate resource usage is not closed") + if certificate.optimality_gap > self.solver_config.target_optimality_gap: + raise ValueError("certificate exceeds the configured optimality gap") + return self + + +class ContinuousYawProvenUnsatResultV2_8(_ContinuousYawSolveResultBaseV2_8): + status: Literal[ContinuousYawSolveStatusV2.PROVEN_UNSAT] = ( + ContinuousYawSolveStatusV2.PROVEN_UNSAT + ) + candidate_refs: ContinuousYawCandidateRefsV2_8 + empty_outer_stage: Literal["T15", "TARGET_RELATION", "VISIBILITY"] + empty_outer_stage_sha256: Sha256Digest + final_resource_usage: ContinuousYawResourceUsageV2_8 + + @model_validator(mode="after") + def validate_unsat(self) -> Self: + refs = self.candidate_refs + if refs.semantic_problem_sha256 != self.semantic_problem_sha256: + raise ValueError("candidate refs problem hash is not closed") + if refs.solver_config_sha256 != self.solver_config.config_sha256: + raise ValueError("candidate refs config hash is not closed") + expected_stage_sha = { + "T15": refs.t15_candidate_artifact_sha256, + "TARGET_RELATION": refs.target_candidate_stage_sha256, + "VISIBILITY": refs.visibility_candidate_stage_sha256, + }[self.empty_outer_stage] + if ( + expected_stage_sha is None + or self.empty_outer_stage_sha256 != expected_stage_sha + ): + raise ValueError("empty-outer stage hash is not closed") + if self.final_resource_usage.objective_partition_cells != 0: + raise ValueError("UNSAT cannot publish objective cells") + _validate_usage_limits(self.final_resource_usage, self.solver_config) + return self + + +class ContinuousYawUncertifiedResultV2_8(_ContinuousYawSolveResultBaseV2_8): + status: Literal[ContinuousYawSolveStatusV2.UNCERTIFIED] = ( + ContinuousYawSolveStatusV2.UNCERTIFIED + ) + uncertified_reason: UncertifiedReasonV2 + candidate_refs: ContinuousYawCandidateRefsV2_8 | None = None + objective_cells: tuple[ContinuousYawObjectiveCellV2_8, ...] = () + final_resource_usage: ContinuousYawResourceUsageV2_8 | None = None + finding_codes: tuple[str, ...] = Field(min_length=1) + + @model_validator(mode="after") + def validate_uncertified(self) -> Self: + findings = _canonical_findings(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + refs = self.candidate_refs + if refs is not None and ( + refs.semantic_problem_sha256 != self.semantic_problem_sha256 + or refs.solver_config_sha256 != self.solver_config.config_sha256 + ): + raise ValueError("uncertified candidate refs are not closed") + cells = _canonical_objective_cells(self.objective_cells) + object.__setattr__(self, "objective_cells", cells) + if cells and refs is None: + raise ValueError("objective cells require candidate stage references") + usage = self.final_resource_usage + if usage is not None: + _validate_usage_limits(usage, self.solver_config) + if usage.objective_partition_cells < len(cells): + raise ValueError("resource usage undercounts objective cells") + elif refs is not None or cells: + raise ValueError("artifact-bearing uncertified result requires usage") + return self + + +ContinuousYawSolveResultV2_8: TypeAlias = Annotated[ + ContinuousYawCertifiedSuccessResultV2_8 + | ContinuousYawProvenUnsatResultV2_8 + | ContinuousYawUncertifiedResultV2_8, + Field(discriminator="status"), +] + + +class ContinuousYawMinimumCostSolveOutcomeV2_8(CanonicalModel): + """Fresh-solve result plus exact non-capability generation telemetry.""" + + result: ContinuousYawSolveResultV2_8 | None + finding_codes: tuple[str, ...] = () + cumulative_generation_usage: ContinuousYawResourceUsageV2_8 | None = None + proposal_count: NonNegativeDeterministicLimitV2_8 = 0 + evaluated_proposal_count: NonNegativeDeterministicLimitV2_8 = 0 + + @model_validator(mode="after") + def validate_outcome(self) -> Self: + findings = _canonical_findings(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + if self.evaluated_proposal_count > self.proposal_count: + raise ValueError("evaluated proposal count cannot exceed proposal count") + result = self.result + usage = self.cumulative_generation_usage + if result is None: + if not findings: + raise ValueError("missing result requires an input finding") + if ( + usage is not None + or self.proposal_count + or self.evaluated_proposal_count + ): + raise ValueError("missing result cannot carry generation telemetry") + return self + if usage != result.final_resource_usage: + raise ValueError("outcome usage must equal result usage") + if type(result) is ContinuousYawCertifiedSuccessResultV2_8: + if findings: + raise ValueError("certified success cannot carry findings") + if self.proposal_count != len(result.objective_cells): + raise ValueError("success proposal count must equal objective cells") + if self.evaluated_proposal_count != self.proposal_count: + raise ValueError("success must evaluate every proposal") + return self + if type(result) is ContinuousYawProvenUnsatResultV2_8: + if findings or self.proposal_count or self.evaluated_proposal_count: + raise ValueError("proven UNSAT cannot carry findings or proposals") + return self + if findings != result.finding_codes: + raise ValueError("uncertified findings must equal result findings") + if self.proposal_count > len(result.objective_cells): + raise ValueError("proposal count exceeds published objective cells") + return self + + +class ContinuousYawSolveVerificationOutcomeV2_8(CanonicalModel): + """Fresh replay comparison outcome; verified refs exist only on a match.""" + + kind: ContinuousYawSolveVerificationKindV2 + semantic_problem_sha256: Sha256Digest | None = None + solver_config_sha256: Sha256Digest | None = None + submitted_solve_result_sha256: Sha256Digest | None = None + verified_status: ContinuousYawSolveStatusV2 | None = None + replay_generation_usage: ContinuousYawResourceUsageV2_8 | None = None + proposal_count: NonNegativeDeterministicLimitV2_8 = 0 + evaluated_proposal_count: NonNegativeDeterministicLimitV2_8 = 0 + uncertified_reason: UncertifiedReasonV2 | None = None + finding_codes: tuple[str, ...] = () + + @model_validator(mode="after") + def validate_verification(self) -> Self: + findings = _canonical_findings(self.finding_codes) + object.__setattr__(self, "finding_codes", findings) + if self.evaluated_proposal_count > self.proposal_count: + raise ValueError("evaluated proposal count cannot exceed proposal count") + if self.replay_generation_usage is None and ( + self.proposal_count or self.evaluated_proposal_count + ): + raise ValueError("proposal telemetry requires replay usage") + refs = ( + self.semantic_problem_sha256, + self.solver_config_sha256, + self.submitted_solve_result_sha256, + ) + if self.kind is ContinuousYawSolveVerificationKindV2.VERIFIED: + if any(value is None for value in refs): + raise ValueError("VERIFIED requires all replay references") + if self.verified_status is None: + raise ValueError("VERIFIED requires the matched solve status") + if self.uncertified_reason is not None or findings: + raise ValueError("VERIFIED cannot carry failure diagnostics") + if self.verified_status is ContinuousYawSolveStatusV2.CERTIFIED_SUCCESS: + if ( + self.replay_generation_usage is None + or self.proposal_count < 1 + or self.evaluated_proposal_count != self.proposal_count + ): + raise ValueError( + "verified success requires full proposal telemetry" + ) + elif self.verified_status is ContinuousYawSolveStatusV2.PROVEN_UNSAT and ( + self.replay_generation_usage is None + or self.proposal_count + or self.evaluated_proposal_count + ): + raise ValueError("verified UNSAT requires zero-proposal telemetry") + return self + if any(value is not None for value in refs) or self.verified_status is not None: + raise ValueError("failure verification cannot carry verified refs") + if not findings: + raise ValueError("failure verification requires at least one finding") + if self.kind is ContinuousYawSolveVerificationKindV2.MISMATCH: + if self.uncertified_reason is not None: + raise ValueError("MISMATCH cannot carry an uncertified reason") + return self + if self.uncertified_reason is None: + raise ValueError("UNCERTIFIED verification requires a reason") + return self + + +_CONFIG_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-solver-config.v2.9" +_CANDIDATE_REFS_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-candidate-refs.v2.9" +_OBJECTIVE_CELLS_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-objective-cells.v2.9" +_WITNESS_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-witness-evaluation.v2.9" +_CERTIFICATE_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-certificate.v2.9" +_SOLVE_RESULT_HASH_DOMAIN_V2_9 = "spatialcf.continuous-yaw-solve-result.v2.9" +_MAX_DETERMINISTIC_LIMIT_V2_9 = 2**63 - 1 + +PositiveDeterministicLimitV2_9 = Annotated[ + int, + Field(strict=True, ge=1, le=_MAX_DETERMINISTIC_LIMIT_V2_9), +] +NonNegativeDeterministicLimitV2_9 = Annotated[ + int, + Field(strict=True, ge=0, le=_MAX_DETERMINISTIC_LIMIT_V2_9), +] + + +class ContinuousYawResourceUsageV2_9(CanonicalModel): """One cumulative generation ledger for the complete v2.9 solve.""" domain_operations: NonNegativeDeterministicLimitV2_9 @@ -98,7 +590,7 @@ class ContinuousYawResourceUsageV2_9(V2Model): refinement_steps: Literal[0] = 0 -class ContinuousYawSolverConfigV2_9(V2Model): +class ContinuousYawSolverConfigV2_9(CanonicalModel): """Closed public policy for the complete continuous-yaw solve.""" schema_identity: SchemaIdentityV2_3 = Field( @@ -137,10 +629,10 @@ def validate_identity(self) -> Self: @property def config_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CONFIG_HASH_DOMAIN_V2_9) + return canonical_sha256(self, domain=_CONFIG_HASH_DOMAIN_V2_9) -class ContinuousYawCandidateRefsV2_9(V2Model): +class ContinuousYawCandidateRefsV2_9(CanonicalModel): """Hash chain for original input, projected prefix, camera, and hard stages.""" semantic_problem_sha256: Sha256Digest @@ -162,10 +654,10 @@ def validate_prefix(self) -> Self: @property def candidate_refs_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CANDIDATE_REFS_HASH_DOMAIN_V2_9) + return canonical_sha256(self, domain=_CANDIDATE_REFS_HASH_DOMAIN_V2_9) -class ContinuousYawObjectiveCellV2_9(V2Model): +class ContinuousYawObjectiveCellV2_9(CanonicalModel): """One objective enclosure over a private strict-convex candidate cell.""" _SEQUENCE_HASH_DOMAIN: ClassVar[str] = _OBJECTIVE_CELLS_HASH_DOMAIN_V2_9 @@ -203,14 +695,14 @@ def sequence_sha256( cell_ids = tuple(cell.cell_id for cell in ordered) if len(cell_ids) != len(set(cell_ids)): raise ValueError("objective cell IDs must be unique") - return canonical_sha256_v2(ordered, domain=cls._SEQUENCE_HASH_DOMAIN) + return canonical_sha256(ordered, domain=cls._SEQUENCE_HASH_DOMAIN) -class ContinuousYawWitnessEvaluationV2_9(V2Model): +class ContinuousYawWitnessEvaluationV2_9(CanonicalModel): """Fresh point loss and slack enclosure for one concrete edit.""" objective_cell_id: CanonicalId - edit: CanonicalEditV2 + edit: CanonicalEdit witness_loss_bounds: ObjectiveTermBoundsV2 constraint_slacks: tuple[ConstraintSlackV2, ...] = Field(min_length=1) @@ -228,10 +720,10 @@ def canonicalize_slacks(self) -> Self: @property def witness_evaluation_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_WITNESS_HASH_DOMAIN_V2_9) + return canonical_sha256(self, domain=_WITNESS_HASH_DOMAIN_V2_9) -class ContinuousYawGlobalOptimalityCertificateV2_9(V2Model): +class ContinuousYawGlobalOptimalityCertificateV2_9(CanonicalModel): """Directed scalar optimum claim over the complete v2.9 candidate chain.""" semantic_problem_sha256: Sha256Digest @@ -268,10 +760,10 @@ def validate_claim(self) -> Self: @property def certificate_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CERTIFICATE_HASH_DOMAIN_V2_9) + return canonical_sha256(self, domain=_CERTIFICATE_HASH_DOMAIN_V2_9) -def _canonical_objective_cells( +def _canonicalize_v2_9_objective_cells( cells: tuple[ContinuousYawObjectiveCellV2_9, ...], ) -> tuple[ContinuousYawObjectiveCellV2_9, ...]: ordered = tuple(sorted(cells, key=lambda cell: cell.cell_id)) @@ -281,7 +773,7 @@ def _canonical_objective_cells( return ordered -def _validate_usage_limits( +def _validate_v2_9_usage_limits( usage: ContinuousYawResourceUsageV2_9, config: ContinuousYawSolverConfigV2_9, ) -> None: @@ -296,14 +788,14 @@ def _validate_usage_limits( raise ValueError("resource usage exceeds the objective-cell limit") -class _ContinuousYawSolveResultBaseV2_9(V2Model): +class _ContinuousYawSolveResultBaseV2_9(CanonicalModel): semantic_problem_sha256: Sha256Digest candidate_problem_sha256: Sha256Digest solver_config: ContinuousYawSolverConfigV2_9 @property def solve_result_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_SOLVE_RESULT_HASH_DOMAIN_V2_9) + return canonical_sha256(self, domain=_SOLVE_RESULT_HASH_DOMAIN_V2_9) class ContinuousYawCertifiedSuccessResultV2_9(_ContinuousYawSolveResultBaseV2_9): @@ -333,7 +825,7 @@ def validate_success(self) -> Self: or refs.visibility_candidate_stage_sha256 is None ): raise ValueError("success requires the complete candidate stage chain") - cells = _canonical_objective_cells(self.objective_cells) + cells = _canonicalize_v2_9_objective_cells(self.objective_cells) object.__setattr__(self, "objective_cells", cells) selected_cells = tuple( cell @@ -350,7 +842,7 @@ def validate_success(self) -> Self: raise ValueError("selected witness loss bounds are not closed") if self.global_loss_lower_bound > self.witness_loss_bounds.total_upper_bound: raise ValueError("global lower bound exceeds witness upper bound") - _validate_usage_limits(self.final_resource_usage, self.solver_config) + _validate_v2_9_usage_limits(self.final_resource_usage, self.solver_config) if self.final_resource_usage.objective_partition_cells != len(cells): raise ValueError("objective-cell usage must equal published cells") @@ -416,7 +908,7 @@ def validate_unsat(self) -> Self: raise ValueError("empty-outer stage hash is not closed") if self.final_resource_usage.objective_partition_cells != 0: raise ValueError("UNSAT cannot publish objective cells") - _validate_usage_limits(self.final_resource_usage, self.solver_config) + _validate_v2_9_usage_limits(self.final_resource_usage, self.solver_config) return self @@ -441,13 +933,13 @@ def validate_uncertified(self) -> Self: or refs.solver_config_sha256 != self.solver_config.config_sha256 ): raise ValueError("uncertified candidate refs are not closed") - cells = _canonical_objective_cells(self.objective_cells) + cells = _canonicalize_v2_9_objective_cells(self.objective_cells) object.__setattr__(self, "objective_cells", cells) if cells and refs is None: raise ValueError("objective cells require candidate stage references") usage = self.final_resource_usage if usage is not None: - _validate_usage_limits(usage, self.solver_config) + _validate_v2_9_usage_limits(usage, self.solver_config) if usage.objective_partition_cells < len(cells): raise ValueError("resource usage undercounts objective cells") elif refs is not None or cells: @@ -463,7 +955,7 @@ def validate_uncertified(self) -> Self: ] -class ContinuousYawMinimumCostSolveOutcomeV2_9(V2Model): +class ContinuousYawMinimumCostSolveOutcomeV2_9(CanonicalModel): """Fresh-solve result plus exact non-capability generation telemetry.""" result: ContinuousYawSolveResultV2_9 | None @@ -514,7 +1006,7 @@ def validate_outcome(self) -> Self: return self -class ContinuousYawSolveVerificationOutcomeV2_9(V2Model): +class ContinuousYawSolveVerificationOutcomeV2_9(CanonicalModel): """Fresh replay comparison outcome; verified refs exist only on a match.""" kind: ContinuousYawSolveVerificationKindV2 @@ -580,18 +1072,30 @@ def validate_verification(self) -> Self: __all__ = ( + "ContinuousYawCandidateRefsV2_8", "ContinuousYawCandidateRefsV2_9", + "ContinuousYawCertifiedSuccessResultV2_8", "ContinuousYawCertifiedSuccessResultV2_9", + "ContinuousYawGlobalOptimalityCertificateV2_8", "ContinuousYawGlobalOptimalityCertificateV2_9", + "ContinuousYawMinimumCostSolveOutcomeV2_8", "ContinuousYawMinimumCostSolveOutcomeV2_9", + "ContinuousYawObjectiveCellV2_8", "ContinuousYawObjectiveCellV2_9", + "ContinuousYawProvenUnsatResultV2_8", "ContinuousYawProvenUnsatResultV2_9", + "ContinuousYawResourceUsageV2_8", "ContinuousYawResourceUsageV2_9", + "ContinuousYawSolveResultV2_8", "ContinuousYawSolveResultV2_9", "ContinuousYawSolveStatusV2", "ContinuousYawSolveVerificationKindV2", + "ContinuousYawSolveVerificationOutcomeV2_8", "ContinuousYawSolveVerificationOutcomeV2_9", + "ContinuousYawSolverConfigV2_8", "ContinuousYawSolverConfigV2_9", + "ContinuousYawUncertifiedResultV2_8", "ContinuousYawUncertifiedResultV2_9", + "ContinuousYawWitnessEvaluationV2_8", "ContinuousYawWitnessEvaluationV2_9", ) diff --git a/src/spatialcf/domain/source.py b/src/spatialcf/domain/source.py new file mode 100644 index 0000000..235d78b --- /dev/null +++ b/src/spatialcf/domain/source.py @@ -0,0 +1,147 @@ +"""Immutable source identities and the current source-plan manifest.""" + +from __future__ import annotations + +import re +from typing import Annotated, Literal, Self + +from pydantic import Field, model_validator + +from spatialcf.domain.base import CanonicalModel + +_PORTABLE_COMPONENT = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$") + + +class SolverConfig(CanonicalModel): + """Frozen solver settings carried by the current source-plan wire.""" + + optimality_tolerance: Annotated[float, Field(strict=True, allow_inf_nan=False)] = ( + 1e-6 + ) + numeric_tolerance: Annotated[float, Field(strict=True, allow_inf_nan=False)] = 1e-9 + target_interior_margin: Annotated[ + float, Field(strict=True, allow_inf_nan=False) + ] = 5e-7 + initial_disk_segments: int = Field(default=128, strict=True) + max_disk_segments: int = Field(default=8192, strict=True) + timeout_seconds: None = None + + @model_validator(mode="after") + def require_frozen_defaults(self) -> Self: + if self.model_dump(mode="python") != { + "optimality_tolerance": 1e-6, + "numeric_tolerance": 1e-9, + "target_interior_margin": 5e-7, + "initial_disk_segments": 128, + "max_disk_segments": 8192, + "timeout_seconds": None, + }: + raise ValueError("final pilot requires the frozen default solver config") + return self + + +class LegacyAI2ThorSource(CanonicalModel): + """One current AI2-THOR built-in scene source.""" + + kind: Literal["legacy-ai2thor"] + scene_name: str = Field(min_length=1) + + +class ProceduralSource(CanonicalModel): + """One content-bound current ProcTHOR source.""" + + kind: Literal["procedural"] + dataset_id: str = Field(min_length=1) + revision: str = Field(pattern=r"^[0-9a-f]{40}$") + split: Literal["train", "val", "test"] + index: int = Field(ge=0, strict=True) + source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + scene_alias: str = Field(min_length=1) + loader_id: str = Field(min_length=1) + loader_version: str = Field(min_length=1) + + +SourceLocator = Annotated[ + LegacyAI2ThorSource | ProceduralSource, + Field(discriminator="kind"), +] + + +def _source_scene_id(source: SourceLocator) -> str: + if isinstance(source, LegacyAI2ThorSource): + return source.scene_name + return source.scene_alias + + +def _source_identity(source: SourceLocator) -> tuple[object, ...]: + if isinstance(source, LegacyAI2ThorSource): + return (source.kind, source.scene_name) + return ( + source.kind, + source.dataset_id, + source.revision, + source.split, + source.index, + ) + + +class SourcePlanEntry(CanonicalModel): + """One exact source in the prefix that must receive a terminal outcome.""" + + source_id: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") + scene_id: str = Field(min_length=1) + source: SourceLocator + + @model_validator(mode="after") + def validate_source_identity(self) -> Self: + if _PORTABLE_COMPONENT.fullmatch(self.source_id) is None: + raise ValueError("source_id is not a portable component") + if _source_scene_id(self.source) != self.scene_id: + raise ValueError("source scene_id does not match its source identity") + return self + + +class SourcePlanManifest(CanonicalModel): + """Canonical current source prefix and request-enumeration policy.""" + + schema_version: Literal["certified-ai2thor-source-plan-manifest-v1"] + plan_version: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") + batch_version: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") + width: int = Field(gt=0, strict=True) + height: int = Field(gt=0, strict=True) + seed: int = Field(strict=True) + camera_policy: Literal["all-observed-source-cameras-v1"] + use_navigation_feasibility: bool = Field(strict=True) + solver_config: SolverConfig + sources: tuple[SourcePlanEntry, ...] + + @model_validator(mode="after") + def validate_source_prefix(self) -> Self: + for label, value in ( + ("plan_version", self.plan_version), + ("batch_version", self.batch_version), + ): + if _PORTABLE_COMPONENT.fullmatch(value) is None: + raise ValueError(f"{label} is not a portable component") + source_ids = tuple(item.source_id for item in self.sources) + if len(set(source_ids)) != len(source_ids): + raise ValueError("source_id values must be unique") + if source_ids != tuple(sorted(source_ids)): + raise ValueError("sources must use canonical source-id order") + scene_ids = tuple(item.scene_id for item in self.sources) + if len(set(scene_ids)) != len(scene_ids): + raise ValueError("source scene_id values must be unique") + locators = tuple(_source_identity(item.source) for item in self.sources) + if len(set(locators)) != len(locators): + raise ValueError("source locators must be unique") + return self + + +__all__ = ( + "LegacyAI2ThorSource", + "ProceduralSource", + "SolverConfig", + "SourceLocator", + "SourcePlanEntry", + "SourcePlanManifest", +) diff --git a/src/spatialcf/domain/v2/__init__.py b/src/spatialcf/domain/v2/__init__.py deleted file mode 100644 index 0c13d87..0000000 --- a/src/spatialcf/domain/v2/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Canonical v2 contracts for the platform-neutral SpatialCF core. - -Consumers import concrete types from their defining modules. This package -initializer deliberately re-exports nothing so importing it cannot pull the -evidence/publication layer into the semantic dependency graph. -""" diff --git a/src/spatialcf/domain/v2/cardinal.py b/src/spatialcf/domain/v2/cardinal.py deleted file mode 100644 index 118dbe3..0000000 --- a/src/spatialcf/domain/v2/cardinal.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Exact cardinal-yaw Canonical 2.1 semantic contracts. - -The wire deliberately does not contain a quaternion. A quarter turn is an -exact integer in ``{0, 1, 2, 3}``, measured counter-clockwise about positive Z. -All operations use only rational addition, sign changes, and coordinate swaps. -""" - -from __future__ import annotations - -import math -from fractions import Fraction -from typing import Annotated, Literal, Self - -from pydantic import BeforeValidator, Field, model_validator - -from spatialcf.domain.v2.base import ( - CanonicalId, - FactSetV2, - UncertaintyBudgetV2, - V2Model, - Vec3V2, -) -from spatialcf.domain.v2.geometry import ( - CollisionBodyFactV2, - GeometryApproximationV2, - GeometryRoleV2, - GeometryShapeV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import ( - BaselineObservationV2, - CanonicalObjectV2, - CanonicalSceneV2, - KnownFreeSpaceFactV2, - ObjectPoseV2, - PinholeCameraV2, - SupportSurfaceFactV2, - WorkspaceBoundaryFactV2, -) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -_PROBLEM_HASH_DOMAIN_V2_1 = "spatialcf.semantic-problem.v2.1" - - -class SchemaIdentityV2_1(V2Model): - """Identity carried only by Canonical 2.1 root contracts.""" - - schema_name: CanonicalId - schema_version: Literal["2.1"] = "2.1" - - -def _require_exact_quarter_turn(value: object) -> object: - if type(value) is not int or value not in range(4): - raise ValueError("quarter_turns_ccw must be an exact integer from 0 through 3") - return value - - -ExactQuarterTurnsV2 = Annotated[ - Literal[0, 1, 2, 3], - BeforeValidator(_require_exact_quarter_turn), -] - - -class ExactCardinalYawTransformV2(V2Model): - """Exact local-to-parent Z-cardinal transform in metres.""" - - kind: Literal["EXACT_CARDINAL_YAW"] = "EXACT_CARDINAL_YAW" - translation: Vec3V2 - quarter_turns_ccw: ExactQuarterTurnsV2 - - @model_validator(mode="after") - def canonicalize_signed_zero(self) -> Self: - translation = self.translation - object.__setattr__( - self, - "translation", - Vec3V2( - x=0.0 if translation.x == 0.0 else translation.x, - y=0.0 if translation.y == 0.0 else translation.y, - z=0.0 if translation.z == 0.0 else translation.z, - ), - ) - return self - - @classmethod - def identity(cls) -> Self: - return cls( - translation=Vec3V2(x=0.0, y=0.0, z=0.0), - quarter_turns_ccw=0, - ) - - -ExactRationalPoint3V2 = tuple[Fraction, Fraction, Fraction] - - -def _rotate_exact_cardinal_v2( - quarter_turns_ccw: int, - point: ExactRationalPoint3V2, -) -> ExactRationalPoint3V2: - x, y, z = point - if quarter_turns_ccw == 0: - return x, y, z - if quarter_turns_ccw == 1: - return -y, x, z - if quarter_turns_ccw == 2: - return -x, -y, z - return y, -x, z - - -def apply_exact_cardinal_transform_v2( - transform: ExactCardinalYawTransformV2, - point: ExactRationalPoint3V2, -) -> ExactRationalPoint3V2: - """Apply ``p_parent = t + R_q p_local`` exactly.""" - - rotated = _rotate_exact_cardinal_v2(transform.quarter_turns_ccw, point) - translation = transform.translation - return ( - Fraction.from_float(translation.x) + rotated[0], - Fraction.from_float(translation.y) + rotated[1], - Fraction.from_float(translation.z) + rotated[2], - ) - - -def _exact_binary64(value: Fraction, field_name: str) -> float: - try: - candidate = float(value) - except (OverflowError, ValueError) as error: - raise ValueError( - f"{field_name} is not a finite exact binary64 value" - ) from error - if not math.isfinite(candidate) or Fraction.from_float(candidate) != value: - raise ValueError(f"{field_name} is not an exact binary64 value") - return 0.0 if candidate == 0.0 else candidate - - -def compose_exact_cardinal_transforms_v2( - parent_from_intermediate: ExactCardinalYawTransformV2, - intermediate_from_local: ExactCardinalYawTransformV2, -) -> ExactCardinalYawTransformV2: - """Compose two exact transforms, rejecting an unrepresentable wire result.""" - - right_translation = intermediate_from_local.translation - transformed_translation = apply_exact_cardinal_transform_v2( - parent_from_intermediate, - ( - Fraction.from_float(right_translation.x), - Fraction.from_float(right_translation.y), - Fraction.from_float(right_translation.z), - ), - ) - return ExactCardinalYawTransformV2( - translation=Vec3V2( - x=_exact_binary64(transformed_translation[0], "translation.x"), - y=_exact_binary64(transformed_translation[1], "translation.y"), - z=_exact_binary64(transformed_translation[2], "translation.z"), - ), - quarter_turns_ccw=( - parent_from_intermediate.quarter_turns_ccw - + intermediate_from_local.quarter_turns_ccw - ) - % 4, - ) - - -def invert_exact_cardinal_transform_v2( - parent_from_local: ExactCardinalYawTransformV2, -) -> ExactCardinalYawTransformV2: - """Return the exact local-from-parent inverse.""" - - inverse_quarter_turns = (-parent_from_local.quarter_turns_ccw) % 4 - translation = parent_from_local.translation - inverse_translation = _rotate_exact_cardinal_v2( - inverse_quarter_turns, - ( - -Fraction.from_float(translation.x), - -Fraction.from_float(translation.y), - -Fraction.from_float(translation.z), - ), - ) - return ExactCardinalYawTransformV2( - translation=Vec3V2( - x=_exact_binary64(inverse_translation[0], "translation.x"), - y=_exact_binary64(inverse_translation[1], "translation.y"), - z=_exact_binary64(inverse_translation[2], "translation.z"), - ), - quarter_turns_ccw=inverse_quarter_turns, - ) - - -class ObjectPoseV2_1(ObjectPoseV2): - world_from_object: ExactCardinalYawTransformV2 - - -class CanonicalObjectV2_1(CanonicalObjectV2): - pose: ObjectPoseV2_1 - - -class GeometryInstanceV2_1(V2Model): - geometry_id: CanonicalId - owner_object_id: CanonicalId | None - role: GeometryRoleV2 - anchor_from_geometry: ExactCardinalYawTransformV2 - approximation: GeometryApproximationV2 - uncertainty: UncertaintyBudgetV2 - shape: GeometryShapeV2 - - -class SupportSurfaceFactV2_1(SupportSurfaceFactV2): - anchor_from_surface: ExactCardinalYawTransformV2 - - @model_validator(mode="after") - def require_exact_positive_z_normal(self) -> Self: - normal = self.normal_in_anchor - if (normal.x, normal.y, normal.z) != (0.0, 0.0, 1.0): - raise ValueError("cardinal support surface normal must be exact +Z") - return self - - -class PinholeCameraV2_1(PinholeCameraV2): - world_to_camera: ExactCardinalYawTransformV2 - - -class CanonicalSceneV2_1(CanonicalSceneV2): - """Canonical scene root whose transform-bearing facts use the 2.1 wire.""" - - schema_identity: SchemaIdentityV2_1 = Field( - default_factory=lambda: SchemaIdentityV2_1(schema_name="canonical-scene") - ) - objects: FactSetV2[CanonicalObjectV2_1] - geometry_instances: FactSetV2[GeometryInstanceV2_1] - collision_bodies: FactSetV2[CollisionBodyFactV2] - workspace_boundaries: FactSetV2[WorkspaceBoundaryFactV2] - known_free_spaces: FactSetV2[KnownFreeSpaceFactV2] - support_surfaces: FactSetV2[SupportSurfaceFactV2_1] - cameras: FactSetV2[PinholeCameraV2_1] - baseline_observations: FactSetV2[BaselineObservationV2] - - @classmethod - def _expected_schema_identity(cls) -> SchemaIdentityV2_1: - return SchemaIdentityV2_1(schema_name="canonical-scene") - - -class SemanticProblemV2_1(SemanticProblemV2): - """Semantic problem root with an independently domain-separated 2.1 hash.""" - - schema_identity: SchemaIdentityV2_1 = Field( - default_factory=lambda: SchemaIdentityV2_1(schema_name="semantic-problem") - ) - scene: CanonicalSceneV2_1 - - @classmethod - def _expected_schema_identity(cls) -> SchemaIdentityV2_1: - return SchemaIdentityV2_1(schema_name="semantic-problem") - - @property - def semantic_problem_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_PROBLEM_HASH_DOMAIN_V2_1) - - -__all__ = ( - "CanonicalObjectV2_1", - "CanonicalSceneV2_1", - "ExactCardinalYawTransformV2", - "GeometryInstanceV2_1", - "ObjectPoseV2_1", - "PinholeCameraV2_1", - "SchemaIdentityV2_1", - "SemanticProblemV2_1", - "SupportSurfaceFactV2_1", - "apply_exact_cardinal_transform_v2", - "compose_exact_cardinal_transforms_v2", - "invert_exact_cardinal_transform_v2", -) diff --git a/src/spatialcf/domain/v2/continuous_yaw.py b/src/spatialcf/domain/v2/continuous_yaw.py deleted file mode 100644 index 7c00817..0000000 --- a/src/spatialcf/domain/v2/continuous_yaw.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Standalone Canonical wire for directed continuous-yaw proof kernels. - -This module intentionally defines no Scene or SemanticProblem root. A finite -binary64 yaw is an exact input value whose irrational trigonometric image must -be enclosed by a registered directed kernel before any consumer can use it. -""" - -from __future__ import annotations - -from typing import Literal, Self - -from pydantic import model_validator - -from spatialcf.domain.v2.base import FiniteFloat, V2Model, Vec3V2 - - -class DirectedYawIntervalTransformV2_2(V2Model): - """Finite local-to-parent translation plus binary64 horizontal yaw.""" - - kind: Literal["DIRECTED_YAW_INTERVAL"] = "DIRECTED_YAW_INTERVAL" - translation: Vec3V2 - yaw_radians: FiniteFloat - - @model_validator(mode="after") - def canonicalize_signed_zero(self) -> Self: - translation = self.translation - object.__setattr__( - self, - "translation", - Vec3V2( - x=0.0 if translation.x == 0.0 else translation.x, - y=0.0 if translation.y == 0.0 else translation.y, - z=0.0 if translation.z == 0.0 else translation.z, - ), - ) - object.__setattr__( - self, - "yaw_radians", - 0.0 if self.yaw_radians == 0.0 else self.yaw_radians, - ) - return self - - -__all__ = ("DirectedYawIntervalTransformV2_2",) diff --git a/src/spatialcf/domain/v2/continuous_yaw_camera.py b/src/spatialcf/domain/v2/continuous_yaw_camera.py deleted file mode 100644 index 4907aee..0000000 --- a/src/spatialcf/domain/v2/continuous_yaw_camera.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Canonical 2.3 roots for one exact upright arbitrary-azimuth camera. - -This module is a domain-only wire boundary. It deliberately contains no -projection math, solver logic, adapter inference, evidence, or platform code. -""" - -from __future__ import annotations - -from typing import Literal - -from pydantic import Field - -from spatialcf.domain.v2.base import ( - CanonicalId, - FactSetV2, - FiniteFloat, - V2Model, - Vec3V2, -) -from spatialcf.domain.v2.continuous_yaw_candidate import ( - CanonicalSceneV2_2, - SemanticProblemV2_2, -) -from spatialcf.domain.v2.scene import PinholeCameraV2 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -_PROBLEM_HASH_DOMAIN_V2_3 = "spatialcf.semantic-problem.v2.3" - - -class SchemaIdentityV2_3(V2Model): - """Identity carried only by Canonical 2.3 root contracts.""" - - schema_name: CanonicalId - schema_version: Literal["2.3"] = "2.3" - - -class UprightWorldToCameraTransformV2_3(V2Model): - """Fixed upright world-to-camera basis with arbitrary horizontal azimuth.""" - - kind: Literal["UPRIGHT_WORLD_TO_CAMERA"] = "UPRIGHT_WORLD_TO_CAMERA" - azimuth_radians: FiniteFloat - translation: Vec3V2 - - -class PinholeCameraV2_3(PinholeCameraV2): - """Pinhole camera whose frame uses the Canonical 2.3 upright basis.""" - - world_to_camera: UprightWorldToCameraTransformV2_3 - - -class CanonicalSceneV2_3(CanonicalSceneV2_2): - """Canonical 2.3 Scene with an explicit upright camera frame.""" - - schema_identity: SchemaIdentityV2_3 = Field( - default_factory=lambda: SchemaIdentityV2_3(schema_name="canonical-scene") - ) - cameras: FactSetV2[PinholeCameraV2_3] - - @classmethod - def _expected_schema_identity(cls) -> SchemaIdentityV2_3: - return SchemaIdentityV2_3(schema_name="canonical-scene") - - -class SemanticProblemV2_3(SemanticProblemV2_2): - """Semantic Problem with a domain-separated Canonical 2.3 hash.""" - - schema_identity: SchemaIdentityV2_3 = Field( - default_factory=lambda: SchemaIdentityV2_3(schema_name="semantic-problem") - ) - scene: CanonicalSceneV2_3 - - @classmethod - def _expected_schema_identity(cls) -> SchemaIdentityV2_3: - return SchemaIdentityV2_3(schema_name="semantic-problem") - - @property - def semantic_problem_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_PROBLEM_HASH_DOMAIN_V2_3) - - -__all__ = ( - "CanonicalSceneV2_3", - "PinholeCameraV2_3", - "SchemaIdentityV2_3", - "SemanticProblemV2_3", - "UprightWorldToCameraTransformV2_3", -) diff --git a/src/spatialcf/domain/v2/continuous_yaw_candidate.py b/src/spatialcf/domain/v2/continuous_yaw_candidate.py deleted file mode 100644 index 37f8e76..0000000 --- a/src/spatialcf/domain/v2/continuous_yaw_candidate.py +++ /dev/null @@ -1,293 +0,0 @@ -"""Versioned continuous-yaw Scene roots and candidate compiler config. - -This module is a domain-only wire boundary. It deliberately imports no core, -adapter, evidence, publication, or platform module. -""" - -from __future__ import annotations - -from typing import Annotated, Literal, Self - -from pydantic import Field, model_validator - -from spatialcf.domain.v2.base import ( - CanonicalId, - FactSetV2, - UncertaintyBudgetV2, - V2Model, -) -from spatialcf.domain.v2.continuous_yaw import DirectedYawIntervalTransformV2_2 -from spatialcf.domain.v2.geometry import ( - CollisionBodyFactV2, - GeometryApproximationV2, - GeometryRoleV2, - GeometryShapeV2, -) -from spatialcf.domain.v2.problem import SemanticProblemV2 -from spatialcf.domain.v2.scene import ( - BaselineObservationV2, - CanonicalObjectV2, - CanonicalSceneV2, - KnownFreeSpaceFactV2, - ObjectPoseV2, - PinholeCameraV2, - SupportSurfaceFactV2, - WorkspaceBoundaryFactV2, -) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -_PROBLEM_HASH_DOMAIN_V2_2 = "spatialcf.semantic-problem.v2.2" -_CONFIG_HASH_DOMAIN_V2_5 = "spatialcf.strict-convex-candidate-config.v2.5" -_CONFIG_HASH_DOMAIN_V2_6 = "spatialcf.strict-convex-candidate-config.v2.6" -_CONFIG_HASH_DOMAIN_V2_7 = "spatialcf.strict-convex-candidate-config.v2.7" -_MAX_DETERMINISTIC_LIMIT_V2_5 = 2**63 - 1 -_DeterministicLimitV2_5 = Annotated[ - int, - Field(strict=True, ge=1, le=_MAX_DETERMINISTIC_LIMIT_V2_5), -] - - -class SchemaIdentityV2_2(V2Model): - """Identity carried only by Canonical 2.2 root contracts.""" - - schema_name: CanonicalId - schema_version: Literal["2.2"] = "2.2" - - -class ObjectPoseV2_2(ObjectPoseV2): - world_from_object: DirectedYawIntervalTransformV2_2 - - -class CanonicalObjectV2_2(CanonicalObjectV2): - pose: ObjectPoseV2_2 - - -class GeometryInstanceV2_2(V2Model): - geometry_id: CanonicalId - owner_object_id: CanonicalId | None - role: GeometryRoleV2 - anchor_from_geometry: DirectedYawIntervalTransformV2_2 - approximation: GeometryApproximationV2 - uncertainty: UncertaintyBudgetV2 - shape: GeometryShapeV2 - - -class SupportSurfaceFactV2_2(SupportSurfaceFactV2): - anchor_from_surface: DirectedYawIntervalTransformV2_2 - - -class PinholeCameraV2_2(PinholeCameraV2): - world_to_camera: DirectedYawIntervalTransformV2_2 - - -class CanonicalSceneV2_2(CanonicalSceneV2): - """Canonical Scene whose transform-bearing facts use directed yaw.""" - - schema_identity: SchemaIdentityV2_2 = Field( - default_factory=lambda: SchemaIdentityV2_2(schema_name="canonical-scene") - ) - objects: FactSetV2[CanonicalObjectV2_2] - geometry_instances: FactSetV2[GeometryInstanceV2_2] - collision_bodies: FactSetV2[CollisionBodyFactV2] - workspace_boundaries: FactSetV2[WorkspaceBoundaryFactV2] - known_free_spaces: FactSetV2[KnownFreeSpaceFactV2] - support_surfaces: FactSetV2[SupportSurfaceFactV2_2] - cameras: FactSetV2[PinholeCameraV2_2] - baseline_observations: FactSetV2[BaselineObservationV2] - - @classmethod - def _expected_schema_identity(cls) -> SchemaIdentityV2_2: - return SchemaIdentityV2_2(schema_name="canonical-scene") - - -class SemanticProblemV2_2(SemanticProblemV2): - """Semantic Problem with a domain-separated Canonical 2.2 hash.""" - - schema_identity: SchemaIdentityV2_2 = Field( - default_factory=lambda: SchemaIdentityV2_2(schema_name="semantic-problem") - ) - scene: CanonicalSceneV2_2 - - @classmethod - def _expected_schema_identity(cls) -> SchemaIdentityV2_2: - return SchemaIdentityV2_2(schema_name="semantic-problem") - - @property - def semantic_problem_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_PROBLEM_HASH_DOMAIN_V2_2) - - -class StrictConvexCandidateCompilerConfigV2_5(V2Model): - """Standalone bounded config for the strict-convex candidate stage.""" - - schema_identity: SchemaIdentityV2_2 = Field( - default_factory=lambda: SchemaIdentityV2_2( - schema_name="strict-convex-candidate-compiler-config" - ) - ) - algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( - "solver:canonical-branch-and-bound-v2" - ) - algorithm_version: Literal["algorithm:2.5"] = "algorithm:2.5" - so2_kernel_id: Literal["geometry-kernel:rational-so2-upright-box-directed-v2"] = ( - "geometry-kernel:rational-so2-upright-box-directed-v2" - ) - so2_kernel_version: Literal["kernel:2.2-continuous-yaw-upright-box"] = ( - "kernel:2.2-continuous-yaw-upright-box" - ) - obstacle_kernel_id: Literal[ - "geometry-kernel:rational-convex-translation-bracket-v2" - ] = "geometry-kernel:rational-convex-translation-bracket-v2" - obstacle_kernel_version: Literal["kernel:2.3-convex-translation-bracket"] = ( - "kernel:2.3-convex-translation-bracket" - ) - partition_kernel_id: Literal[ - "geometry-kernel:rational-convex-complement-partition-v2" - ] = "geometry-kernel:rational-convex-complement-partition-v2" - partition_kernel_version: Literal["kernel:2.4-topology-aware-convex-complement"] = ( - "kernel:2.4-topology-aware-convex-complement" - ) - max_domain_operations: _DeterministicLimitV2_5 - max_so2_atomic_steps: _DeterministicLimitV2_5 - - @model_validator(mode="after") - def validate_schema_identity(self) -> Self: - expected = SchemaIdentityV2_2( - schema_name="strict-convex-candidate-compiler-config" - ) - if self.schema_identity != expected: - raise ValueError("strict-convex candidate config identity must be fixed") - return self - - @property - def config_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_CONFIG_HASH_DOMAIN_V2_5) - - -class StrictConvexCandidateCompilerConfigV2_6(V2Model): - """Standalone bounded config for multi-obstacle strict intersection.""" - - schema_identity: SchemaIdentityV2_2 = Field( - default_factory=lambda: SchemaIdentityV2_2( - schema_name="strict-convex-candidate-compiler-config" - ) - ) - algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( - "solver:canonical-branch-and-bound-v2" - ) - algorithm_version: Literal["algorithm:2.6"] = "algorithm:2.6" - so2_kernel_id: Literal["geometry-kernel:rational-so2-upright-box-directed-v2"] = ( - "geometry-kernel:rational-so2-upright-box-directed-v2" - ) - so2_kernel_version: Literal["kernel:2.2-continuous-yaw-upright-box"] = ( - "kernel:2.2-continuous-yaw-upright-box" - ) - obstacle_kernel_id: Literal[ - "geometry-kernel:rational-convex-translation-bracket-v2" - ] = "geometry-kernel:rational-convex-translation-bracket-v2" - obstacle_kernel_version: Literal["kernel:2.3-convex-translation-bracket"] = ( - "kernel:2.3-convex-translation-bracket" - ) - partition_kernel_id: Literal[ - "geometry-kernel:rational-convex-complement-partition-v2" - ] = "geometry-kernel:rational-convex-complement-partition-v2" - partition_kernel_version: Literal["kernel:2.4-topology-aware-convex-complement"] = ( - "kernel:2.4-topology-aware-convex-complement" - ) - intersection_kernel_id: Literal[ - "geometry-kernel:rational-strict-convex-intersection-v2" - ] = "geometry-kernel:rational-strict-convex-intersection-v2" - intersection_kernel_version: Literal["kernel:2.5-strict-convex-intersection"] = ( - "kernel:2.5-strict-convex-intersection" - ) - max_domain_operations: _DeterministicLimitV2_5 - max_so2_atomic_steps: _DeterministicLimitV2_5 - max_candidate_cells: _DeterministicLimitV2_5 - - @model_validator(mode="after") - def validate_schema_identity(self) -> Self: - expected = SchemaIdentityV2_2( - schema_name="strict-convex-candidate-compiler-config" - ) - if self.schema_identity != expected: - raise ValueError("strict-convex candidate config identity must be fixed") - return self - - @property - def config_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_CONFIG_HASH_DOMAIN_V2_6) - - -class StrictConvexCandidateCompilerConfigV2_7(V2Model): - """Standalone bounded config for exact horizontal support projection.""" - - schema_identity: SchemaIdentityV2_2 = Field( - default_factory=lambda: SchemaIdentityV2_2( - schema_name="strict-convex-candidate-compiler-config" - ) - ) - algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( - "solver:canonical-branch-and-bound-v2" - ) - algorithm_version: Literal["algorithm:2.7"] = "algorithm:2.7" - so2_kernel_id: Literal["geometry-kernel:rational-so2-upright-box-directed-v2"] = ( - "geometry-kernel:rational-so2-upright-box-directed-v2" - ) - so2_kernel_version: Literal["kernel:2.2-continuous-yaw-upright-box"] = ( - "kernel:2.2-continuous-yaw-upright-box" - ) - obstacle_kernel_id: Literal[ - "geometry-kernel:rational-convex-translation-bracket-v2" - ] = "geometry-kernel:rational-convex-translation-bracket-v2" - obstacle_kernel_version: Literal["kernel:2.3-convex-translation-bracket"] = ( - "kernel:2.3-convex-translation-bracket" - ) - partition_kernel_id: Literal[ - "geometry-kernel:rational-convex-complement-partition-v2" - ] = "geometry-kernel:rational-convex-complement-partition-v2" - partition_kernel_version: Literal["kernel:2.4-topology-aware-convex-complement"] = ( - "kernel:2.4-topology-aware-convex-complement" - ) - intersection_kernel_id: Literal[ - "geometry-kernel:rational-strict-convex-intersection-v2" - ] = "geometry-kernel:rational-strict-convex-intersection-v2" - intersection_kernel_version: Literal["kernel:2.5-strict-convex-intersection"] = ( - "kernel:2.5-strict-convex-intersection" - ) - support_projection_kernel_id: Literal[ - "geometry-kernel:rational-continuous-yaw-support-projection-v2" - ] = "geometry-kernel:rational-continuous-yaw-support-projection-v2" - support_projection_kernel_version: Literal[ - "kernel:2.6-exact-horizontal-support-projection" - ] = "kernel:2.6-exact-horizontal-support-projection" - max_domain_operations: _DeterministicLimitV2_5 - max_so2_atomic_steps: _DeterministicLimitV2_5 - max_candidate_cells: _DeterministicLimitV2_5 - - @model_validator(mode="after") - def validate_schema_identity(self) -> Self: - expected = SchemaIdentityV2_2( - schema_name="strict-convex-candidate-compiler-config" - ) - if self.schema_identity != expected: - raise ValueError("strict-convex candidate config identity must be fixed") - return self - - @property - def config_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_CONFIG_HASH_DOMAIN_V2_7) - - -__all__ = ( - "CanonicalObjectV2_2", - "CanonicalSceneV2_2", - "GeometryInstanceV2_2", - "ObjectPoseV2_2", - "PinholeCameraV2_2", - "SchemaIdentityV2_2", - "SemanticProblemV2_2", - "StrictConvexCandidateCompilerConfigV2_5", - "StrictConvexCandidateCompilerConfigV2_6", - "StrictConvexCandidateCompilerConfigV2_7", - "SupportSurfaceFactV2_2", -) diff --git a/src/spatialcf/domain/v2/continuous_yaw_solver.py b/src/spatialcf/domain/v2/continuous_yaw_solver.py deleted file mode 100644 index 68986e7..0000000 --- a/src/spatialcf/domain/v2/continuous_yaw_solver.py +++ /dev/null @@ -1,580 +0,0 @@ -"""Strict domain-only wire contracts for the continuous-yaw solver. - -The records in this module are structural data, not replay capabilities. A -consumer must pass a submitted result to the pure-core v2.8 verifier before -trusting its stage references, resource telemetry, witness, or certificate. -""" - -from __future__ import annotations - -import math -from enum import StrEnum -from fractions import Fraction -from typing import Annotated, ClassVar, Literal, Self, TypeAlias - -from pydantic import Field, model_validator - -from spatialcf.domain.v2.artifacts import ( - ConstraintSlackV2, - ObjectiveTermBoundsV2, -) -from spatialcf.domain.v2.base import ( - CanonicalId, - NonNegativeFiniteFloat, - PositiveFiniteFloat, - Sha256Digest, - V2Model, -) -from spatialcf.domain.v2.certificate import OptimalityClaimV2 -from spatialcf.domain.v2.continuous_yaw_candidate import ( - SchemaIdentityV2_2, - StrictConvexCandidateCompilerConfigV2_7, -) -from spatialcf.domain.v2.edit import CanonicalEditV2 -from spatialcf.domain.v2.result import UncertifiedReasonV2 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -_CONFIG_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-solver-config.v2.8" -_CANDIDATE_REFS_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-candidate-refs.v2.8" -_OBJECTIVE_CELLS_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-objective-cells.v2.8" -_WITNESS_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-witness-evaluation.v2.8" -_CERTIFICATE_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-certificate.v2.8" -_SOLVE_RESULT_HASH_DOMAIN_V2_8 = "spatialcf.continuous-yaw-solve-result.v2.8" -_MAX_DETERMINISTIC_LIMIT_V2_8 = 2**63 - 1 - -PositiveDeterministicLimitV2_8 = Annotated[ - int, - Field(strict=True, ge=1, le=_MAX_DETERMINISTIC_LIMIT_V2_8), -] -NonNegativeDeterministicLimitV2_8 = Annotated[ - int, - Field(strict=True, ge=0, le=_MAX_DETERMINISTIC_LIMIT_V2_8), -] - - -class ContinuousYawSolveStatusV2(StrEnum): - CERTIFIED_SUCCESS = "CERTIFIED_SUCCESS" - PROVEN_UNSAT = "PROVEN_UNSAT" - UNCERTIFIED = "UNCERTIFIED" - - -class ContinuousYawSolveVerificationKindV2(StrEnum): - VERIFIED = "VERIFIED" - MISMATCH = "MISMATCH" - UNCERTIFIED = "UNCERTIFIED" - - -def _canonical_findings(values: tuple[str, ...]) -> tuple[str, ...]: - if type(values) is not tuple or any( - type(value) is not str or not value.strip() for value in values - ): - raise TypeError("finding_codes must be exact non-blank strings") - return tuple(sorted(set(values))) - - -def _directed_binary64_gap_ceil(lower_bound: float, upper_bound: float) -> float: - exact = Fraction.from_float(upper_bound) - Fraction.from_float(lower_bound) - try: - published = float(exact) - except OverflowError as error: - raise ValueError("optimality gap must be finite binary64") from error - if not math.isfinite(published): - raise ValueError("optimality gap must be finite binary64") - if Fraction.from_float(published) < exact: - published = math.nextafter(published, math.inf) - if not math.isfinite(published): - raise ValueError("optimality gap must be finite binary64") - return published - - -class ContinuousYawResourceUsageV2_8(V2Model): - """One cumulative generation ledger for the complete v2.8 solve.""" - - domain_operations: NonNegativeDeterministicLimitV2_8 - so2_atomic_steps: NonNegativeDeterministicLimitV2_8 - candidate_cells: NonNegativeDeterministicLimitV2_8 - objective_partition_cells: NonNegativeDeterministicLimitV2_8 - branch_nodes: Literal[0] = 0 - refinement_steps: Literal[0] = 0 - - -class ContinuousYawSolverConfigV2_8(V2Model): - """Closed public policy for the complete continuous-yaw solve.""" - - schema_identity: SchemaIdentityV2_2 = Field( - default_factory=lambda: SchemaIdentityV2_2( - schema_name="continuous-yaw-solver-config" - ) - ) - algorithm_id: Literal["solver:canonical-branch-and-bound-v2"] = ( - "solver:canonical-branch-and-bound-v2" - ) - algorithm_version: Literal["algorithm:2.8"] = "algorithm:2.8" - candidate_config: StrictConvexCandidateCompilerConfigV2_7 - target_projection_kernel_id: Literal[ - "geometry-kernel:rational-continuous-yaw-shape-gap-v2" - ] = "geometry-kernel:rational-continuous-yaw-shape-gap-v2" - visibility_projection_kernel_id: Literal[ - "geometry-kernel:rational-continuous-yaw-fixed-camera-visibility-v2" - ] = "geometry-kernel:rational-continuous-yaw-fixed-camera-visibility-v2" - objective_kernel_id: Literal[ - "objective-kernel:rational-continuous-yaw-cell-bounds-v2" - ] = "objective-kernel:rational-continuous-yaw-cell-bounds-v2" - max_objective_partition_cells: PositiveDeterministicLimitV2_8 - max_branch_nodes: Literal[0] = 0 - max_refinement_steps: Literal[0] = 0 - target_optimality_gap: NonNegativeFiniteFloat - - @model_validator(mode="after") - def validate_identity(self) -> Self: - expected = SchemaIdentityV2_2(schema_name="continuous-yaw-solver-config") - if self.schema_identity != expected: - raise ValueError("continuous-yaw solver config identity must be fixed") - return self - - @property - def config_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CONFIG_HASH_DOMAIN_V2_8) - - -class ContinuousYawCandidateRefsV2_8(V2Model): - """Hash chain for the private T15, target, and visibility stages.""" - - semantic_problem_sha256: Sha256Digest - solver_config_sha256: Sha256Digest - t15_candidate_artifact_sha256: Sha256Digest - target_candidate_stage_sha256: Sha256Digest | None = None - visibility_candidate_stage_sha256: Sha256Digest | None = None - - @model_validator(mode="after") - def validate_prefix(self) -> Self: - if ( - self.visibility_candidate_stage_sha256 is not None - and self.target_candidate_stage_sha256 is None - ): - raise ValueError("visibility stage requires a target stage reference") - return self - - @property - def candidate_refs_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CANDIDATE_REFS_HASH_DOMAIN_V2_8) - - -class ContinuousYawObjectiveCellV2_8(V2Model): - """One objective enclosure over a private strict-convex candidate cell.""" - - _SEQUENCE_HASH_DOMAIN: ClassVar[str] = _OBJECTIVE_CELLS_HASH_DOMAIN_V2_8 - - cell_id: CanonicalId - outer_domain_sha256: Sha256Digest - inner_domain_sha256: Sha256Digest | None = None - term_loss_bounds: ObjectiveTermBoundsV2 - constraint_slacks: tuple[ConstraintSlackV2, ...] = Field(min_length=1) - - @model_validator(mode="after") - def canonicalize_slacks(self) -> Self: - slack_ids = tuple(slack.constraint_id for slack in self.constraint_slacks) - if len(slack_ids) != len(set(slack_ids)): - raise ValueError("objective cell constraint slacks must be unique") - object.__setattr__( - self, - "constraint_slacks", - tuple(sorted(self.constraint_slacks, key=lambda item: item.constraint_id)), - ) - return self - - @classmethod - def sequence_sha256( - cls, - cells: tuple[ContinuousYawObjectiveCellV2_8, ...], - ) -> Sha256Digest: - if type(cells) is not tuple: - raise TypeError("objective cells must be an exact tuple") - checked = tuple( - cls.model_validate(cell.model_dump(mode="python"), strict=True) - for cell in cells - ) - ordered = tuple(sorted(checked, key=lambda cell: cell.cell_id)) - cell_ids = tuple(cell.cell_id for cell in ordered) - if len(cell_ids) != len(set(cell_ids)): - raise ValueError("objective cell IDs must be unique") - return canonical_sha256_v2(ordered, domain=cls._SEQUENCE_HASH_DOMAIN) - - -class ContinuousYawWitnessEvaluationV2_8(V2Model): - """Fresh point loss and slack enclosure for one concrete edit.""" - - objective_cell_id: CanonicalId - edit: CanonicalEditV2 - witness_loss_bounds: ObjectiveTermBoundsV2 - constraint_slacks: tuple[ConstraintSlackV2, ...] = Field(min_length=1) - - @model_validator(mode="after") - def canonicalize_slacks(self) -> Self: - slack_ids = tuple(slack.constraint_id for slack in self.constraint_slacks) - if len(slack_ids) != len(set(slack_ids)): - raise ValueError("witness constraint slacks must be unique") - object.__setattr__( - self, - "constraint_slacks", - tuple(sorted(self.constraint_slacks, key=lambda item: item.constraint_id)), - ) - return self - - @property - def witness_evaluation_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_WITNESS_HASH_DOMAIN_V2_8) - - -class ContinuousYawGlobalOptimalityCertificateV2_8(V2Model): - """Directed scalar optimum claim over the complete v2.8 candidate chain.""" - - semantic_problem_sha256: Sha256Digest - solver_config_sha256: Sha256Digest - candidate_refs_sha256: Sha256Digest - objective_cells_sha256: Sha256Digest - witness_evaluation_sha256: Sha256Digest - edit_sha256: Sha256Digest - loss_lower_bound: NonNegativeFiniteFloat - loss_upper_bound: NonNegativeFiniteFloat - optimality_gap: NonNegativeFiniteFloat - optimality_claim: OptimalityClaimV2 - epsilon: PositiveFiniteFloat | None = None - final_resource_usage: ContinuousYawResourceUsageV2_8 - - @model_validator(mode="after") - def validate_claim(self) -> Self: - if self.loss_lower_bound > self.loss_upper_bound: - raise ValueError("loss lower bound cannot exceed loss upper bound") - expected_gap = _directed_binary64_gap_ceil( - self.loss_lower_bound, - self.loss_upper_bound, - ) - if self.optimality_gap != expected_gap: - raise ValueError("optimality gap must be the directed exact difference") - if self.optimality_claim is OptimalityClaimV2.EXACT: - if self.optimality_gap != 0.0 or self.epsilon is not None: - raise ValueError("EXACT requires zero gap and no epsilon") - return self - if self.epsilon is None or self.optimality_gap > self.epsilon: - raise ValueError("EPSILON_OPTIMAL requires epsilon covering the gap") - return self - - @property - def certificate_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_CERTIFICATE_HASH_DOMAIN_V2_8) - - -def _canonical_objective_cells( - cells: tuple[ContinuousYawObjectiveCellV2_8, ...], -) -> tuple[ContinuousYawObjectiveCellV2_8, ...]: - ordered = tuple(sorted(cells, key=lambda cell: cell.cell_id)) - cell_ids = tuple(cell.cell_id for cell in ordered) - if len(cell_ids) != len(set(cell_ids)): - raise ValueError("objective cell IDs must be unique") - return ordered - - -def _validate_usage_limits( - usage: ContinuousYawResourceUsageV2_8, - config: ContinuousYawSolverConfigV2_8, -) -> None: - candidate_config = config.candidate_config - if usage.domain_operations > candidate_config.max_domain_operations: - raise ValueError("resource usage exceeds the domain-operation limit") - if usage.so2_atomic_steps > candidate_config.max_so2_atomic_steps: - raise ValueError("resource usage exceeds the SO(2) atomic-step limit") - if usage.candidate_cells > candidate_config.max_candidate_cells: - raise ValueError("resource usage exceeds the candidate-cell limit") - if usage.objective_partition_cells > config.max_objective_partition_cells: - raise ValueError("resource usage exceeds the objective-cell limit") - - -class _ContinuousYawSolveResultBaseV2_8(V2Model): - semantic_problem_sha256: Sha256Digest - solver_config: ContinuousYawSolverConfigV2_8 - - @property - def solve_result_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_SOLVE_RESULT_HASH_DOMAIN_V2_8) - - -class ContinuousYawCertifiedSuccessResultV2_8(_ContinuousYawSolveResultBaseV2_8): - status: Literal[ContinuousYawSolveStatusV2.CERTIFIED_SUCCESS] = ( - ContinuousYawSolveStatusV2.CERTIFIED_SUCCESS - ) - candidate_refs: ContinuousYawCandidateRefsV2_8 - objective_cells: tuple[ContinuousYawObjectiveCellV2_8, ...] = Field(min_length=1) - selected_witness: ContinuousYawWitnessEvaluationV2_8 - global_loss_lower_bound: NonNegativeFiniteFloat - witness_loss_bounds: ObjectiveTermBoundsV2 - final_resource_usage: ContinuousYawResourceUsageV2_8 - certificate: ContinuousYawGlobalOptimalityCertificateV2_8 - - @model_validator(mode="after") - def validate_success(self) -> Self: - refs = self.candidate_refs - config_hash = self.solver_config.config_sha256 - if refs.semantic_problem_sha256 != self.semantic_problem_sha256: - raise ValueError("candidate refs problem hash is not closed") - if refs.solver_config_sha256 != config_hash: - raise ValueError("candidate refs config hash is not closed") - if ( - refs.target_candidate_stage_sha256 is None - or refs.visibility_candidate_stage_sha256 is None - ): - raise ValueError("success requires the complete candidate stage chain") - cells = _canonical_objective_cells(self.objective_cells) - object.__setattr__(self, "objective_cells", cells) - selected_cells = tuple( - cell - for cell in cells - if cell.cell_id == self.selected_witness.objective_cell_id - ) - if len(selected_cells) != 1 or selected_cells[0].inner_domain_sha256 is None: - raise ValueError("selected witness requires one objective inner cell") - if self.selected_witness.edit.semantic_problem_sha256 != ( - self.semantic_problem_sha256 - ): - raise ValueError("selected edit problem hash is not closed") - if self.selected_witness.witness_loss_bounds != self.witness_loss_bounds: - raise ValueError("selected witness loss bounds are not closed") - if self.global_loss_lower_bound > self.witness_loss_bounds.total_upper_bound: - raise ValueError("global lower bound exceeds witness upper bound") - _validate_usage_limits(self.final_resource_usage, self.solver_config) - if self.final_resource_usage.objective_partition_cells != len(cells): - raise ValueError("objective-cell usage must equal published cells") - - certificate = self.certificate - expected = ( - self.semantic_problem_sha256, - config_hash, - refs.candidate_refs_sha256, - ContinuousYawObjectiveCellV2_8.sequence_sha256(cells), - self.selected_witness.witness_evaluation_sha256, - self.selected_witness.edit.edit_sha256, - ) - actual = ( - certificate.semantic_problem_sha256, - certificate.solver_config_sha256, - certificate.candidate_refs_sha256, - certificate.objective_cells_sha256, - certificate.witness_evaluation_sha256, - certificate.edit_sha256, - ) - if actual != expected: - raise ValueError("success certificate references are not closed") - if certificate.loss_lower_bound != self.global_loss_lower_bound: - raise ValueError("certificate lower bound is not closed") - if certificate.loss_upper_bound != self.witness_loss_bounds.total_upper_bound: - raise ValueError("certificate upper bound is not closed") - if certificate.final_resource_usage != self.final_resource_usage: - raise ValueError("certificate resource usage is not closed") - if certificate.optimality_gap > self.solver_config.target_optimality_gap: - raise ValueError("certificate exceeds the configured optimality gap") - return self - - -class ContinuousYawProvenUnsatResultV2_8(_ContinuousYawSolveResultBaseV2_8): - status: Literal[ContinuousYawSolveStatusV2.PROVEN_UNSAT] = ( - ContinuousYawSolveStatusV2.PROVEN_UNSAT - ) - candidate_refs: ContinuousYawCandidateRefsV2_8 - empty_outer_stage: Literal["T15", "TARGET_RELATION", "VISIBILITY"] - empty_outer_stage_sha256: Sha256Digest - final_resource_usage: ContinuousYawResourceUsageV2_8 - - @model_validator(mode="after") - def validate_unsat(self) -> Self: - refs = self.candidate_refs - if refs.semantic_problem_sha256 != self.semantic_problem_sha256: - raise ValueError("candidate refs problem hash is not closed") - if refs.solver_config_sha256 != self.solver_config.config_sha256: - raise ValueError("candidate refs config hash is not closed") - expected_stage_sha = { - "T15": refs.t15_candidate_artifact_sha256, - "TARGET_RELATION": refs.target_candidate_stage_sha256, - "VISIBILITY": refs.visibility_candidate_stage_sha256, - }[self.empty_outer_stage] - if ( - expected_stage_sha is None - or self.empty_outer_stage_sha256 != expected_stage_sha - ): - raise ValueError("empty-outer stage hash is not closed") - if self.final_resource_usage.objective_partition_cells != 0: - raise ValueError("UNSAT cannot publish objective cells") - _validate_usage_limits(self.final_resource_usage, self.solver_config) - return self - - -class ContinuousYawUncertifiedResultV2_8(_ContinuousYawSolveResultBaseV2_8): - status: Literal[ContinuousYawSolveStatusV2.UNCERTIFIED] = ( - ContinuousYawSolveStatusV2.UNCERTIFIED - ) - uncertified_reason: UncertifiedReasonV2 - candidate_refs: ContinuousYawCandidateRefsV2_8 | None = None - objective_cells: tuple[ContinuousYawObjectiveCellV2_8, ...] = () - final_resource_usage: ContinuousYawResourceUsageV2_8 | None = None - finding_codes: tuple[str, ...] = Field(min_length=1) - - @model_validator(mode="after") - def validate_uncertified(self) -> Self: - findings = _canonical_findings(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - refs = self.candidate_refs - if refs is not None and ( - refs.semantic_problem_sha256 != self.semantic_problem_sha256 - or refs.solver_config_sha256 != self.solver_config.config_sha256 - ): - raise ValueError("uncertified candidate refs are not closed") - cells = _canonical_objective_cells(self.objective_cells) - object.__setattr__(self, "objective_cells", cells) - if cells and refs is None: - raise ValueError("objective cells require candidate stage references") - usage = self.final_resource_usage - if usage is not None: - _validate_usage_limits(usage, self.solver_config) - if usage.objective_partition_cells < len(cells): - raise ValueError("resource usage undercounts objective cells") - elif refs is not None or cells: - raise ValueError("artifact-bearing uncertified result requires usage") - return self - - -ContinuousYawSolveResultV2_8: TypeAlias = Annotated[ - ContinuousYawCertifiedSuccessResultV2_8 - | ContinuousYawProvenUnsatResultV2_8 - | ContinuousYawUncertifiedResultV2_8, - Field(discriminator="status"), -] - - -class ContinuousYawMinimumCostSolveOutcomeV2_8(V2Model): - """Fresh-solve result plus exact non-capability generation telemetry.""" - - result: ContinuousYawSolveResultV2_8 | None - finding_codes: tuple[str, ...] = () - cumulative_generation_usage: ContinuousYawResourceUsageV2_8 | None = None - proposal_count: NonNegativeDeterministicLimitV2_8 = 0 - evaluated_proposal_count: NonNegativeDeterministicLimitV2_8 = 0 - - @model_validator(mode="after") - def validate_outcome(self) -> Self: - findings = _canonical_findings(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - if self.evaluated_proposal_count > self.proposal_count: - raise ValueError("evaluated proposal count cannot exceed proposal count") - result = self.result - usage = self.cumulative_generation_usage - if result is None: - if not findings: - raise ValueError("missing result requires an input finding") - if ( - usage is not None - or self.proposal_count - or self.evaluated_proposal_count - ): - raise ValueError("missing result cannot carry generation telemetry") - return self - if usage != result.final_resource_usage: - raise ValueError("outcome usage must equal result usage") - if type(result) is ContinuousYawCertifiedSuccessResultV2_8: - if findings: - raise ValueError("certified success cannot carry findings") - if self.proposal_count != len(result.objective_cells): - raise ValueError("success proposal count must equal objective cells") - if self.evaluated_proposal_count != self.proposal_count: - raise ValueError("success must evaluate every proposal") - return self - if type(result) is ContinuousYawProvenUnsatResultV2_8: - if findings or self.proposal_count or self.evaluated_proposal_count: - raise ValueError("proven UNSAT cannot carry findings or proposals") - return self - if findings != result.finding_codes: - raise ValueError("uncertified findings must equal result findings") - if self.proposal_count > len(result.objective_cells): - raise ValueError("proposal count exceeds published objective cells") - return self - - -class ContinuousYawSolveVerificationOutcomeV2_8(V2Model): - """Fresh replay comparison outcome; verified refs exist only on a match.""" - - kind: ContinuousYawSolveVerificationKindV2 - semantic_problem_sha256: Sha256Digest | None = None - solver_config_sha256: Sha256Digest | None = None - submitted_solve_result_sha256: Sha256Digest | None = None - verified_status: ContinuousYawSolveStatusV2 | None = None - replay_generation_usage: ContinuousYawResourceUsageV2_8 | None = None - proposal_count: NonNegativeDeterministicLimitV2_8 = 0 - evaluated_proposal_count: NonNegativeDeterministicLimitV2_8 = 0 - uncertified_reason: UncertifiedReasonV2 | None = None - finding_codes: tuple[str, ...] = () - - @model_validator(mode="after") - def validate_verification(self) -> Self: - findings = _canonical_findings(self.finding_codes) - object.__setattr__(self, "finding_codes", findings) - if self.evaluated_proposal_count > self.proposal_count: - raise ValueError("evaluated proposal count cannot exceed proposal count") - if self.replay_generation_usage is None and ( - self.proposal_count or self.evaluated_proposal_count - ): - raise ValueError("proposal telemetry requires replay usage") - refs = ( - self.semantic_problem_sha256, - self.solver_config_sha256, - self.submitted_solve_result_sha256, - ) - if self.kind is ContinuousYawSolveVerificationKindV2.VERIFIED: - if any(value is None for value in refs): - raise ValueError("VERIFIED requires all replay references") - if self.verified_status is None: - raise ValueError("VERIFIED requires the matched solve status") - if self.uncertified_reason is not None or findings: - raise ValueError("VERIFIED cannot carry failure diagnostics") - if self.verified_status is ContinuousYawSolveStatusV2.CERTIFIED_SUCCESS: - if ( - self.replay_generation_usage is None - or self.proposal_count < 1 - or self.evaluated_proposal_count != self.proposal_count - ): - raise ValueError( - "verified success requires full proposal telemetry" - ) - elif self.verified_status is ContinuousYawSolveStatusV2.PROVEN_UNSAT and ( - self.replay_generation_usage is None - or self.proposal_count - or self.evaluated_proposal_count - ): - raise ValueError("verified UNSAT requires zero-proposal telemetry") - return self - if any(value is not None for value in refs) or self.verified_status is not None: - raise ValueError("failure verification cannot carry verified refs") - if not findings: - raise ValueError("failure verification requires at least one finding") - if self.kind is ContinuousYawSolveVerificationKindV2.MISMATCH: - if self.uncertified_reason is not None: - raise ValueError("MISMATCH cannot carry an uncertified reason") - return self - if self.uncertified_reason is None: - raise ValueError("UNCERTIFIED verification requires a reason") - return self - - -__all__ = ( - "ContinuousYawCandidateRefsV2_8", - "ContinuousYawCertifiedSuccessResultV2_8", - "ContinuousYawGlobalOptimalityCertificateV2_8", - "ContinuousYawMinimumCostSolveOutcomeV2_8", - "ContinuousYawObjectiveCellV2_8", - "ContinuousYawProvenUnsatResultV2_8", - "ContinuousYawResourceUsageV2_8", - "ContinuousYawSolveResultV2_8", - "ContinuousYawSolveStatusV2", - "ContinuousYawSolveVerificationKindV2", - "ContinuousYawSolveVerificationOutcomeV2_8", - "ContinuousYawSolverConfigV2_8", - "ContinuousYawUncertifiedResultV2_8", - "ContinuousYawWitnessEvaluationV2_8", -) diff --git a/src/spatialcf/domain/v2/publication.py b/src/spatialcf/domain/v2/publication.py deleted file mode 100644 index c024cfd..0000000 --- a/src/spatialcf/domain/v2/publication.py +++ /dev/null @@ -1,787 +0,0 @@ -"""Typed terminal publication records outside Canonical v2 semantics. - -Publication binds a resolved pure-core result to provenance and one final audit. -It never changes the core status. In particular, platform/write-back failures -cannot be relabelled as semantic UNSAT, and an audit failure preserves the -certified core result that it audited. - -Records embed the complete production ``ObjectiveSpecV2`` and close its digest -to the core result. An independent publication verifier must still resolve -``semantic_problem_sha256`` and check that its actual objective is identical; -record construction is structural validation, not a substitute for resolution. -""" - -from __future__ import annotations - -from enum import StrEnum -from typing import Annotated, Literal, Self, TypeAlias - -from pydantic import Field, model_validator - -from spatialcf.domain.v2.base import ( - CanonicalId, - SchemaIdentityV2, - Sha256Digest, - V2Model, -) -from spatialcf.domain.v2.evidence import ( - AuditStatusV2, - EvidenceEnvelopeV2, - FinalAuditOutcomeV2, - MappingProofKindV2, - MappingProofStatusV2, - MappingProofV2, - PreSemanticEvidenceEnvelopeV2, -) -from spatialcf.domain.v2.objective import ObjectiveModeV2, ObjectiveSpecV2 -from spatialcf.domain.v2.result import ( - CanonicalSolveResultV2, - CertifiedSuccessResultV2, - ProvenUnsatResultV2, - SolveStatusV2, - UncertifiedResultV2, -) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -_PAIR_HASH_DOMAIN = "spatialcf.pair-record.v2" -_REJECTION_HASH_DOMAIN = "spatialcf.rejection-record.v2" -_FINAL_AUDIT_HASH_DOMAIN = "spatialcf.final-audit-outcome.v2" -_MANIFEST_HASH_DOMAIN = "spatialcf.publication-manifest.v2" - -FindingIdV2 = Annotated[ - str, - Field(strict=True, pattern=r"^CV2-[0-9]{3}$"), -] - - -class PublicationRecordKindV2(StrEnum): - PAIR = "PAIR" - REJECTION = "REJECTION" - - -class RejectionStageV2(StrEnum): - SOURCE_ADAPTER = "SOURCE_ADAPTER" - SEMANTIC_VALIDATION = "SEMANTIC_VALIDATION" - CORE_SOLVE = "CORE_SOLVE" - WRITE_BACK = "WRITE_BACK" - FINAL_AUDIT = "FINAL_AUDIT" - PUBLICATION = "PUBLICATION" - - -class FindingClassificationV2(StrEnum): - GENERAL_CONSTRAINT = "GENERAL_CONSTRAINT" - ADAPTER_MAPPING = "ADAPTER_MAPPING" - PLATFORM_EXECUTION_NOISE = "PLATFORM_EXECUTION_NOISE" - - -class RejectionCodeV2(StrEnum): - SOURCE_ADAPTER_REJECTED = "SOURCE_ADAPTER_REJECTED" - SEMANTIC_VALIDATION_REJECTED = "SEMANTIC_VALIDATION_REJECTED" - CORE_PROVEN_UNSAT = "CORE_PROVEN_UNSAT" - CORE_UNCERTIFIED = "CORE_UNCERTIFIED" - WRITE_BACK_REJECTED = "WRITE_BACK_REJECTED" - FINAL_AUDIT_FAILED = "FINAL_AUDIT_FAILED" - PUBLICATION_REJECTED = "PUBLICATION_REJECTED" - - -class RejectionOwnerV2(StrEnum): - SOURCE_ADAPTER = "SOURCE_ADAPTER" - CANONICAL_CONTRACT = "CANONICAL_CONTRACT" - CORE_SOLVER = "CORE_SOLVER" - WRITE_BACK_ADAPTER = "WRITE_BACK_ADAPTER" - PLATFORM_RUNTIME = "PLATFORM_RUNTIME" - PUBLICATION = "PUBLICATION" - - -_FINDING_CLASSIFICATIONS = { - "CV2-001": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-002": FindingClassificationV2.ADAPTER_MAPPING, - "CV2-003": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-004": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-005": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-006": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-007": FindingClassificationV2.ADAPTER_MAPPING, - "CV2-008": FindingClassificationV2.ADAPTER_MAPPING, - "CV2-009": FindingClassificationV2.PLATFORM_EXECUTION_NOISE, - "CV2-010": FindingClassificationV2.ADAPTER_MAPPING, - "CV2-011": FindingClassificationV2.PLATFORM_EXECUTION_NOISE, - "CV2-012": FindingClassificationV2.PLATFORM_EXECUTION_NOISE, - "CV2-013": FindingClassificationV2.PLATFORM_EXECUTION_NOISE, - "CV2-014": FindingClassificationV2.PLATFORM_EXECUTION_NOISE, - "CV2-015": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-016": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-017": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-018": FindingClassificationV2.ADAPTER_MAPPING, - "CV2-019": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-020": FindingClassificationV2.ADAPTER_MAPPING, - "CV2-021": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-022": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-023": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-024": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-025": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-026": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-027": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-028": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-029": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-030": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-031": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-032": FindingClassificationV2.GENERAL_CONSTRAINT, - "CV2-033": FindingClassificationV2.GENERAL_CONSTRAINT, -} - -_STAGE_CODES = { - RejectionStageV2.SOURCE_ADAPTER: frozenset( - {RejectionCodeV2.SOURCE_ADAPTER_REJECTED} - ), - RejectionStageV2.SEMANTIC_VALIDATION: frozenset( - {RejectionCodeV2.SEMANTIC_VALIDATION_REJECTED} - ), - RejectionStageV2.CORE_SOLVE: frozenset( - {RejectionCodeV2.CORE_PROVEN_UNSAT, RejectionCodeV2.CORE_UNCERTIFIED} - ), - RejectionStageV2.WRITE_BACK: frozenset({RejectionCodeV2.WRITE_BACK_REJECTED}), - RejectionStageV2.FINAL_AUDIT: frozenset({RejectionCodeV2.FINAL_AUDIT_FAILED}), - RejectionStageV2.PUBLICATION: frozenset({RejectionCodeV2.PUBLICATION_REJECTED}), -} - -_STAGE_CLASSIFICATIONS = { - RejectionStageV2.SOURCE_ADAPTER: frozenset( - {FindingClassificationV2.ADAPTER_MAPPING} - ), - RejectionStageV2.SEMANTIC_VALIDATION: frozenset( - { - FindingClassificationV2.GENERAL_CONSTRAINT, - FindingClassificationV2.ADAPTER_MAPPING, - } - ), - RejectionStageV2.CORE_SOLVE: frozenset( - {FindingClassificationV2.GENERAL_CONSTRAINT} - ), - RejectionStageV2.WRITE_BACK: frozenset( - { - FindingClassificationV2.ADAPTER_MAPPING, - FindingClassificationV2.PLATFORM_EXECUTION_NOISE, - } - ), - RejectionStageV2.FINAL_AUDIT: frozenset(FindingClassificationV2), - RejectionStageV2.PUBLICATION: frozenset( - {FindingClassificationV2.GENERAL_CONSTRAINT} - ), -} - - -class FindingReferenceV2(V2Model): - """Stable inventory finding and closed rejection code, never free text.""" - - finding_id: FindingIdV2 - classification: FindingClassificationV2 - code: RejectionCodeV2 - - @model_validator(mode="after") - def validate_inventory_classification(self) -> Self: - expected = _FINDING_CLASSIFICATIONS.get(self.finding_id) - if expected is None: - raise ValueError("finding_id is absent from the frozen CV2 inventory") - if self.classification is not expected: - raise ValueError( - "finding classification does not match the frozen CV2 inventory" - ) - return self - - -class PairRecordV2(V2Model): - """Publishable production pair with a certified result and passing audit.""" - - schema_identity: SchemaIdentityV2 = Field( - default_factory=lambda: SchemaIdentityV2(schema_name="pair-record") - ) - record_kind: Literal[PublicationRecordKindV2.PAIR] = PublicationRecordKindV2.PAIR - request_id: CanonicalId - semantic_problem_sha256: Sha256Digest - core_solver_config_sha256: Sha256Digest - objective_spec: ObjectiveSpecV2 - solve_result: CanonicalSolveResultV2 - solve_result_sha256: Sha256Digest - edit_sha256: Sha256Digest - certificate_sha256: Sha256Digest - evidence_envelope: EvidenceEnvelopeV2 - evidence_envelope_sha256: Sha256Digest - final_audit_id: CanonicalId - final_audit_sha256: Sha256Digest - - @model_validator(mode="after") - def validate_pair(self) -> Self: - if self.schema_identity != SchemaIdentityV2(schema_name="pair-record"): - raise ValueError("pair-record schema identity must be fixed") - if self.solve_result.status is not SolveStatusV2.CERTIFIED_SUCCESS: - raise ValueError( - "PairRecordV2 requires a CERTIFIED_SUCCESS core solve result" - ) - assert isinstance(self.solve_result, CertifiedSuccessResultV2) - _validate_success_reference_closure( - semantic_problem_sha256=self.semantic_problem_sha256, - core_solver_config_sha256=self.core_solver_config_sha256, - objective_spec=self.objective_spec, - solve_result=self.solve_result, - solve_result_sha256=self.solve_result_sha256, - edit_sha256=self.edit_sha256, - certificate_sha256=self.certificate_sha256, - evidence_envelope=self.evidence_envelope, - evidence_envelope_sha256=self.evidence_envelope_sha256, - ) - _validate_verified_write_back( - self.evidence_envelope, - edit_sha256=self.edit_sha256, - subject_id=self.solve_result.edit.subject_id, - ) - _validate_selected_audit( - evidence_envelope=self.evidence_envelope, - final_audit_id=self.final_audit_id, - final_audit_sha256=self.final_audit_sha256, - edit_sha256=self.edit_sha256, - required_status=AuditStatusV2.PASS, - ) - return self - - @property - def final_audit(self) -> FinalAuditOutcomeV2: - return self.evidence_envelope.final_audits[0] - - @property - def pair_record_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_PAIR_HASH_DOMAIN) - - -class RejectionRecordV2(V2Model): - """Terminal structured rejection that preserves the stage's true state.""" - - schema_identity: SchemaIdentityV2 = Field( - default_factory=lambda: SchemaIdentityV2(schema_name="rejection-record") - ) - record_kind: Literal[PublicationRecordKindV2.REJECTION] = ( - PublicationRecordKindV2.REJECTION - ) - request_id: CanonicalId - stage: RejectionStageV2 - findings: tuple[FindingReferenceV2, ...] = Field(min_length=1) - semantic_problem_sha256: Sha256Digest | None = None - core_solver_config_sha256: Sha256Digest | None = None - objective_spec: ObjectiveSpecV2 | None = None - pre_semantic_evidence: PreSemanticEvidenceEnvelopeV2 | None = None - pre_semantic_evidence_sha256: Sha256Digest | None = None - solve_result: CanonicalSolveResultV2 | None = None - solve_result_sha256: Sha256Digest | None = None - edit_sha256: Sha256Digest | None = None - certificate_sha256: Sha256Digest | None = None - evidence_envelope: EvidenceEnvelopeV2 | None = None - evidence_envelope_sha256: Sha256Digest | None = None - final_audit_id: CanonicalId | None = None - final_audit_sha256: Sha256Digest | None = None - - @model_validator(mode="after") - def validate_rejection(self) -> Self: - if self.schema_identity != SchemaIdentityV2(schema_name="rejection-record"): - raise ValueError("rejection-record schema identity must be fixed") - self._canonicalize_and_validate_findings() - - if self.stage in { - RejectionStageV2.SOURCE_ADAPTER, - RejectionStageV2.SEMANTIC_VALIDATION, - }: - self._validate_pre_core() - elif self.stage is RejectionStageV2.CORE_SOLVE: - self._validate_core_solve() - elif self.stage is RejectionStageV2.WRITE_BACK: - self._validate_write_back() - elif self.stage is RejectionStageV2.FINAL_AUDIT: - self._validate_post_success_audit(AuditStatusV2.FAIL) - else: - self._validate_post_success_audit(AuditStatusV2.PASS) - return self - - def _canonicalize_and_validate_findings(self) -> None: - finding_ids = tuple(finding.finding_id for finding in self.findings) - if len(set(finding_ids)) != len(finding_ids): - raise ValueError("rejection finding IDs must be unique") - for finding in self.findings: - if finding.code not in _STAGE_CODES[self.stage]: - raise ValueError("rejection code is incompatible with rejection stage") - if finding.classification not in _STAGE_CLASSIFICATIONS[self.stage]: - raise ValueError( - "finding classification is incompatible with rejection stage" - ) - object.__setattr__( - self, - "findings", - tuple(sorted(self.findings, key=lambda finding: finding.finding_id)), - ) - - def _validate_pre_core(self) -> None: - carried = ( - self.semantic_problem_sha256, - self.core_solver_config_sha256, - self.objective_spec, - self.solve_result, - self.solve_result_sha256, - self.edit_sha256, - self.certificate_sha256, - self.evidence_envelope, - self.evidence_envelope_sha256, - self.final_audit_id, - self.final_audit_sha256, - ) - if any(value is not None for value in carried): - raise ValueError( - "SOURCE_ADAPTER/SEMANTIC_VALIDATION pre-core rejection must not " - "fabricate core or publication artifacts" - ) - if ( - self.pre_semantic_evidence is None - or self.pre_semantic_evidence_sha256 is None - ): - raise ValueError("pre-core rejection requires pre-semantic evidence") - if self.pre_semantic_evidence_sha256 != ( - self.pre_semantic_evidence.pre_semantic_evidence_sha256 - ): - raise ValueError("pre-semantic evidence hash is not closed") - if self.stage is RejectionStageV2.SEMANTIC_VALIDATION and not any( - proof.status - in { - MappingProofStatusV2.REJECTED, - MappingProofStatusV2.INCOMPLETE, - } - for proof in self.pre_semantic_evidence.mapping_proofs - ): - raise ValueError( - "SEMANTIC_VALIDATION requires a non-VERIFIED mapping proof" - ) - - def _validate_core_solve(self) -> None: - self._forbid_pre_semantic_evidence() - if self.solve_result is None or self.solve_result.status not in { - SolveStatusV2.PROVEN_UNSAT, - SolveStatusV2.UNCERTIFIED, - }: - raise ValueError( - "CORE_SOLVE rejection requires PROVEN_UNSAT or UNCERTIFIED result" - ) - assert isinstance( - self.solve_result, - ProvenUnsatResultV2 | UncertifiedResultV2, - ) - _validate_common_result_and_evidence_closure( - semantic_problem_sha256=self.semantic_problem_sha256, - core_solver_config_sha256=self.core_solver_config_sha256, - solve_result=self.solve_result, - solve_result_sha256=self.solve_result_sha256, - evidence_envelope=self.evidence_envelope, - evidence_envelope_sha256=self.evidence_envelope_sha256, - ) - if self.evidence_envelope is None: - raise AssertionError("validated closure requires evidence") - if any( - proof.kind is MappingProofKindV2.WRITE_BACK - for proof in self.evidence_envelope.mapping_proofs - ): - raise ValueError( - "CORE_SOLVE rejection must not contain future WRITE_BACK evidence" - ) - if self.evidence_envelope.final_audits: - raise ValueError("CORE_SOLVE rejection must precede final audit") - if any( - value is not None - for value in ( - self.objective_spec, - self.edit_sha256, - self.final_audit_id, - self.final_audit_sha256, - ) - ): - raise ValueError( - "CORE_SOLVE rejection must not carry production edit or audit fields" - ) - - expected_certificate = ( - self.solve_result.certificate.certificate_sha256 - if isinstance(self.solve_result, ProvenUnsatResultV2) - else None - ) - if self.certificate_sha256 != expected_certificate: - raise ValueError( - "PROVEN_UNSAT certificate hash is not closed, or UNCERTIFIED " - "carries a semantic certificate" - ) - expected_code = ( - RejectionCodeV2.CORE_PROVEN_UNSAT - if isinstance(self.solve_result, ProvenUnsatResultV2) - else RejectionCodeV2.CORE_UNCERTIFIED - ) - if any(finding.code is not expected_code for finding in self.findings): - raise ValueError("CORE_SOLVE rejection code does not match core status") - - def _validate_write_back(self) -> None: - self._forbid_pre_semantic_evidence() - result = self._require_success_closure() - if self.evidence_envelope is None: - raise AssertionError("validated closure requires evidence") - if self.evidence_envelope.final_audits or any( - value is not None - for value in (self.final_audit_id, self.final_audit_sha256) - ): - raise ValueError("WRITE_BACK rejection must precede final audit") - if self.edit_sha256 is None: - raise AssertionError("validated success closure requires edit hash") - _validate_failed_write_back( - self.evidence_envelope, - edit_sha256=self.edit_sha256, - subject_id=result.edit.subject_id, - ) - if result.status.value != "CERTIFIED_SUCCESS": - raise AssertionError("success closure returned a non-success result") - - def _validate_post_success_audit( - self, - required_status: AuditStatusV2, - ) -> None: - self._forbid_pre_semantic_evidence() - result = self._require_success_closure() - if self.evidence_envelope is None or self.edit_sha256 is None: - raise AssertionError("validated success closure requires evidence and edit") - if self.final_audit_id is None or self.final_audit_sha256 is None: - raise ValueError(f"{self.stage.value} requires a selected final audit") - _validate_verified_write_back( - self.evidence_envelope, - edit_sha256=self.edit_sha256, - subject_id=result.edit.subject_id, - ) - _validate_selected_audit( - evidence_envelope=self.evidence_envelope, - final_audit_id=self.final_audit_id, - final_audit_sha256=self.final_audit_sha256, - edit_sha256=self.edit_sha256, - required_status=required_status, - ) - - def _require_success_closure(self) -> CertifiedSuccessResultV2: - if ( - self.solve_result is None - or self.solve_result.status is not SolveStatusV2.CERTIFIED_SUCCESS - ): - raise ValueError( - f"{self.stage.value} rejection must preserve the core " - "CERTIFIED_SUCCESS result; it cannot relabel PROVEN_UNSAT" - ) - assert isinstance(self.solve_result, CertifiedSuccessResultV2) - required = ( - self.semantic_problem_sha256, - self.core_solver_config_sha256, - self.objective_spec, - self.solve_result_sha256, - self.edit_sha256, - self.certificate_sha256, - self.evidence_envelope, - self.evidence_envelope_sha256, - ) - if any(value is None for value in required): - raise ValueError( - f"{self.stage.value} rejection requires complete success hash closure" - ) - assert self.semantic_problem_sha256 is not None - assert self.core_solver_config_sha256 is not None - assert self.objective_spec is not None - assert self.solve_result_sha256 is not None - assert self.edit_sha256 is not None - assert self.certificate_sha256 is not None - assert self.evidence_envelope is not None - assert self.evidence_envelope_sha256 is not None - _validate_success_reference_closure( - semantic_problem_sha256=self.semantic_problem_sha256, - core_solver_config_sha256=self.core_solver_config_sha256, - objective_spec=self.objective_spec, - solve_result=self.solve_result, - solve_result_sha256=self.solve_result_sha256, - edit_sha256=self.edit_sha256, - certificate_sha256=self.certificate_sha256, - evidence_envelope=self.evidence_envelope, - evidence_envelope_sha256=self.evidence_envelope_sha256, - ) - return self.solve_result - - def _forbid_pre_semantic_evidence(self) -> None: - if ( - self.pre_semantic_evidence is not None - or self.pre_semantic_evidence_sha256 is not None - ): - raise ValueError( - f"{self.stage.value} must use post-semantic EvidenceEnvelopeV2" - ) - - @property - def owners(self) -> tuple[RejectionOwnerV2, ...]: - return tuple( - sorted( - { - _derive_owner(self.stage, finding.classification) - for finding in self.findings - }, - key=lambda owner: owner.value, - ) - ) - - @property - def final_audit(self) -> FinalAuditOutcomeV2 | None: - if self.final_audit_id is None or self.evidence_envelope is None: - return None - return next( - ( - audit - for audit in self.evidence_envelope.final_audits - if audit.audit_id == self.final_audit_id - ), - None, - ) - - @property - def rejection_record_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_REJECTION_HASH_DOMAIN) - - -TerminalRecordV2: TypeAlias = Annotated[ - PairRecordV2 | RejectionRecordV2, - Field(discriminator="record_kind"), -] - - -class PublicationManifestV2(V2Model): - """Canonical terminal set with exactly one outcome per request.""" - - schema_identity: SchemaIdentityV2 = Field( - default_factory=lambda: SchemaIdentityV2(schema_name="publication-manifest") - ) - records: tuple[TerminalRecordV2, ...] = Field(min_length=1) - - @model_validator(mode="after") - def canonicalize_and_validate_manifest(self) -> Self: - if self.schema_identity != SchemaIdentityV2(schema_name="publication-manifest"): - raise ValueError("publication-manifest schema identity must be fixed") - request_ids = tuple(record.request_id for record in self.records) - if len(set(request_ids)) != len(request_ids): - raise ValueError( - "publication terminal request_id values must be globally unique" - ) - object.__setattr__( - self, - "records", - tuple(sorted(self.records, key=lambda record: record.request_id)), - ) - return self - - @property - def publication_manifest_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_MANIFEST_HASH_DOMAIN) - - -def _validate_success_reference_closure( - *, - semantic_problem_sha256: Sha256Digest, - core_solver_config_sha256: Sha256Digest, - objective_spec: ObjectiveSpecV2, - solve_result: CertifiedSuccessResultV2, - solve_result_sha256: Sha256Digest, - edit_sha256: Sha256Digest, - certificate_sha256: Sha256Digest, - evidence_envelope: EvidenceEnvelopeV2, - evidence_envelope_sha256: Sha256Digest, -) -> None: - _validate_common_result_and_evidence_closure( - semantic_problem_sha256=semantic_problem_sha256, - core_solver_config_sha256=core_solver_config_sha256, - solve_result=solve_result, - solve_result_sha256=solve_result_sha256, - evidence_envelope=evidence_envelope, - evidence_envelope_sha256=evidence_envelope_sha256, - ) - if objective_spec.mode is not ObjectiveModeV2.PRODUCTION: - raise ValueError("publication requires a PRODUCTION ObjectiveSpecV2") - if objective_spec.objective_spec_sha256 != ( - solve_result.objective_partition.objective_spec_sha256 - ): - raise ValueError("production objective hash is not closed to solve result") - if edit_sha256 != solve_result.edit.edit_sha256: - raise ValueError("Canonical Edit hash is not closed to solve result") - if certificate_sha256 != solve_result.certificate.certificate_sha256: - raise ValueError("semantic certificate hash is not closed to solve result") - if solve_result.edit.subject_id not in { - binding.canonical_object_id - for binding in evidence_envelope.native_object_bindings - }: - raise ValueError( - "Canonical Edit subject requires a verified native object binding" - ) - - -def _validate_common_result_and_evidence_closure( - *, - semantic_problem_sha256: Sha256Digest | None, - core_solver_config_sha256: Sha256Digest | None, - solve_result: CanonicalSolveResultV2, - solve_result_sha256: Sha256Digest | None, - evidence_envelope: EvidenceEnvelopeV2 | None, - evidence_envelope_sha256: Sha256Digest | None, -) -> None: - required = ( - semantic_problem_sha256, - core_solver_config_sha256, - solve_result_sha256, - evidence_envelope, - evidence_envelope_sha256, - ) - if any(value is None for value in required): - raise ValueError( - "result/evidence hash closure requires every digest and object" - ) - assert semantic_problem_sha256 is not None - assert core_solver_config_sha256 is not None - assert solve_result_sha256 is not None - assert evidence_envelope is not None - assert evidence_envelope_sha256 is not None - if semantic_problem_sha256 != solve_result.semantic_problem_sha256: - raise ValueError("semantic problem hash is not closed to solve result") - if semantic_problem_sha256 != evidence_envelope.semantic_problem_sha256: - raise ValueError("semantic problem hash is not closed to evidence envelope") - if core_solver_config_sha256 != ( - solve_result.core_solver_config.core_solver_config_sha256 - ): - raise ValueError("core solver config hash is not closed to solve result") - if solve_result_sha256 != solve_result.solve_result_sha256: - raise ValueError("solve result hash is not closed") - if evidence_envelope_sha256 != evidence_envelope.evidence_envelope_sha256: - raise ValueError("evidence envelope hash is not closed") - - -def _validate_selected_audit( - *, - evidence_envelope: EvidenceEnvelopeV2, - final_audit_id: CanonicalId, - final_audit_sha256: Sha256Digest, - edit_sha256: Sha256Digest, - required_status: AuditStatusV2, -) -> None: - if len(evidence_envelope.final_audits) != 1: - raise ValueError("publication requires exactly one unambiguous final audit") - audit = evidence_envelope.final_audits[0] - if audit.audit_id != final_audit_id: - raise ValueError("selected final audit ID is not closed") - if audit.status is not required_status: - raise ValueError( - f"publication final audit must have {required_status.value} status" - ) - if audit.canonical_edit_sha256 != edit_sha256: - raise ValueError("final audit Canonical Edit hash is not closed") - if final_audit_sha256 != canonical_sha256_v2( - audit, - domain=_FINAL_AUDIT_HASH_DOMAIN, - ): - raise ValueError("final audit hash is not closed") - - -def _validate_verified_write_back( - evidence_envelope: EvidenceEnvelopeV2, - *, - edit_sha256: Sha256Digest, - subject_id: CanonicalId, -) -> None: - matching = _matching_write_back_proofs( - evidence_envelope, - edit_sha256=edit_sha256, - subject_id=subject_id, - ) - if not any(proof.status is MappingProofStatusV2.VERIFIED for proof in matching): - raise ValueError( - "post-write-back publication requires a matching VERIFIED WRITE_BACK proof" - ) - if any( - proof.status - in { - MappingProofStatusV2.REJECTED, - MappingProofStatusV2.INCOMPLETE, - } - for proof in matching - ): - raise ValueError( - "matching edit has a conflicting non-VERIFIED WRITE_BACK proof" - ) - - -def _validate_failed_write_back( - evidence_envelope: EvidenceEnvelopeV2, - *, - edit_sha256: Sha256Digest, - subject_id: CanonicalId, -) -> None: - matching = _matching_write_back_proofs( - evidence_envelope, - edit_sha256=edit_sha256, - subject_id=subject_id, - ) - if not any( - proof.status - in { - MappingProofStatusV2.REJECTED, - MappingProofStatusV2.INCOMPLETE, - } - for proof in matching - ): - raise ValueError( - "WRITE_BACK rejection requires a matching non-VERIFIED write-back proof" - ) - if any(proof.status is MappingProofStatusV2.VERIFIED for proof in matching): - raise ValueError( - "WRITE_BACK rejection conflicts with a matching VERIFIED write-back proof" - ) - - -def _matching_write_back_proofs( - evidence_envelope: EvidenceEnvelopeV2, - *, - edit_sha256: Sha256Digest, - subject_id: CanonicalId, -) -> tuple[MappingProofV2, ...]: - subject_binding = next( - ( - binding - for binding in evidence_envelope.native_object_bindings - if binding.canonical_object_id == subject_id - ), - None, - ) - if subject_binding is None: - return () - return tuple( - proof - for proof in evidence_envelope.mapping_proofs - if proof.kind is MappingProofKindV2.WRITE_BACK - and proof.canonical_edit_sha256 == edit_sha256 - and subject_id in proof.canonical_ids - and subject_binding.native_object_locator in proof.native_locators - ) - - -def _derive_owner( - stage: RejectionStageV2, - classification: FindingClassificationV2, -) -> RejectionOwnerV2: - if stage is RejectionStageV2.PUBLICATION: - return RejectionOwnerV2.PUBLICATION - if classification is FindingClassificationV2.PLATFORM_EXECUTION_NOISE: - return RejectionOwnerV2.PLATFORM_RUNTIME - if classification is FindingClassificationV2.ADAPTER_MAPPING: - if stage in { - RejectionStageV2.WRITE_BACK, - RejectionStageV2.FINAL_AUDIT, - }: - return RejectionOwnerV2.WRITE_BACK_ADAPTER - return RejectionOwnerV2.SOURCE_ADAPTER - if stage is RejectionStageV2.CORE_SOLVE: - return RejectionOwnerV2.CORE_SOLVER - return RejectionOwnerV2.CANONICAL_CONTRACT diff --git a/src/spatialcf/generation/_internal/evidence/__init__.py b/src/spatialcf/generation/_internal/evidence/__init__.py deleted file mode 100644 index a79e2a9..0000000 --- a/src/spatialcf/generation/_internal/evidence/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Private evidence owners for current capture and roster generation.""" diff --git a/src/spatialcf/generation/_internal/evidence/camera.py b/src/spatialcf/generation/_internal/evidence/camera.py deleted file mode 100644 index a932117..0000000 --- a/src/spatialcf/generation/_internal/evidence/camera.py +++ /dev/null @@ -1,2023 +0,0 @@ -"""Deterministic source-only camera evidence for current capture.""" - -from __future__ import annotations - -import math -from dataclasses import asdict -from typing import Literal, Self - -from pydantic import Field, model_validator - -from spatialcf.adapters.ai2thor import ( - AI2ThorAgentPose, - AI2ThorCameraApplication, - AI2ThorNativePosition, - AI2ThorObservation, - ai2thor_camera_world_to_camera, -) -from spatialcf.domain.models import OBB, Camera, Quaternion, Scene, Vec3 -from spatialcf.domain.v2.base import FiniteFloat, Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import canonical_sha256_v2 -from spatialcf.geometry.transforms import ai2thor_position_to_world -from spatialcf.relations.engine import RelationEngine - -_PAIR_CAMERA_RADIUS_M = 2.0 -_PAIR_CAMERA_DIRECTIONS = ( - (0.0, -1.0), - (1.0, 0.0), - (0.0, 1.0), - (-1.0, 0.0), -) -_PAIR_CAMERA_HORIZONS_DEGREES = (0.0, 30.0) -_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_5 = 0.2 -_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_6 = 0.21 -_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_7 = 0.25 -_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_8 = 0.25 + math.sqrt(2.0) * 0.5e-6 - - -class _CameraConversionError(ValueError): - pass - - -def _pair_midpoint( - scene: Scene, - subject_object_id: str, - reference_object_id: str, -) -> tuple[float, float]: - subject = scene.object_by_id(subject_object_id) - reference = scene.object_by_id(reference_object_id) - return ( - (subject.obb.center.x + reference.obb.center.x) / 2.0, - (subject.obb.center.y + reference.obb.center.y) / 2.0, - ) - - -def _validate_pair_camera_inputs( - scene: Scene, - subject_object_id: str, - reference_object_id: str, - reachable_positions: tuple[AI2ThorNativePosition, ...], -) -> None: - if type(scene) is not Scene: - raise ValueError("scene must be an exact canonical Scene") - if ( - type(subject_object_id) is not str - or not subject_object_id - or type(reference_object_id) is not str - or not reference_object_id - or subject_object_id == reference_object_id - ): - raise ValueError("camera pair must use two distinct non-empty object IDs") - scene.object_by_id(subject_object_id) - scene.object_by_id(reference_object_id) - if ( - type(reachable_positions) is not tuple - or not reachable_positions - or any(type(item) is not AI2ThorNativePosition for item in reachable_positions) - ): - raise ValueError("reachable positions must be a non-empty exact tuple") - if len(set(reachable_positions)) != len(reachable_positions): - raise ValueError("reachable positions must be unique") - - -def _ring_positions( - midpoint_x: float, - midpoint_z: float, - directions: tuple[tuple[float, float], ...], - reachable_positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - selected: list[AI2ThorNativePosition] = [] - selected_set: set[AI2ThorNativePosition] = set() - for direction_x, direction_z in directions: - target_x = midpoint_x + _PAIR_CAMERA_RADIUS_M * direction_x - target_z = midpoint_z + _PAIR_CAMERA_RADIUS_M * direction_z - nearest = min( - reachable_positions, - key=lambda item: ( - (item.x - target_x) ** 2 + (item.z - target_z) ** 2, - item.x, - item.z, - item.y, - ), - ) - if nearest not in selected_set: - selected.append(nearest) - selected_set.add(nearest) - return tuple(selected) - - -def deterministic_pair_camera_poses( - scene: Scene, - subject_object_id: str, - reference_object_id: str, - reachable_positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorAgentPose, ...]: - """Return at most eight frozen Tier-1 poses around one object pair.""" - - _validate_pair_camera_inputs( - scene, - subject_object_id, - reference_object_id, - reachable_positions, - ) - midpoint_x, midpoint_z = _pair_midpoint( - scene, - subject_object_id, - reference_object_id, - ) - selected = _ring_positions( - midpoint_x, - midpoint_z, - _PAIR_CAMERA_DIRECTIONS, - reachable_positions, - ) - poses: list[AI2ThorAgentPose] = [] - for position in selected: - yaw = ( - math.degrees(math.atan2(midpoint_x - position.x, midpoint_z - position.z)) - % 360.0 - ) - for horizon in _PAIR_CAMERA_HORIZONS_DEGREES: - poses.append( - AI2ThorAgentPose( - position=position, - yaw_degrees=yaw, - horizon_degrees=horizon, - standing=True, - ) - ) - return tuple(poses) - - -def _clearance_rotation_matrix( - rotation: Quaternion, -) -> tuple[tuple[float, float, float], ...]: - values = (rotation.x, rotation.y, rotation.z, rotation.w) - if not all(math.isfinite(value) for value in values): - raise ValueError("camera clearance OBB rotation must be finite") - maximum = max(abs(value) for value in values) - if maximum == 0.0: - raise ValueError("camera clearance OBB rotation must be nonzero") - scaled = tuple(value / maximum for value in values) - norm = math.sqrt(sum(value * value for value in scaled)) - x, y, z, w = (value / norm for value in scaled) - return ( - ( - 1.0 - 2.0 * (y * y + z * z), - 2.0 * (x * y - z * w), - 2.0 * (x * z + y * w), - ), - ( - 2.0 * (x * y + z * w), - 1.0 - 2.0 * (x * x + z * z), - 2.0 * (y * z - x * w), - ), - ( - 2.0 * (x * z - y * w), - 2.0 * (y * z + x * w), - 1.0 - 2.0 * (x * x + y * y), - ), - ) - - -def _projected_corners(obb: OBB) -> tuple[tuple[float, float], ...]: - extents = (obb.extent.x, obb.extent.y, obb.extent.z) - centers = (obb.center.x, obb.center.y, obb.center.z) - if not all(math.isfinite(value) and value > 0.0 for value in extents): - raise ValueError("camera clearance OBB extents must be finite and positive") - if not all(math.isfinite(value) for value in centers): - raise ValueError("camera clearance OBB center must be finite") - rotation = _clearance_rotation_matrix(obb.rotation) - points = set() - for x_sign in (-1.0, 1.0): - for y_sign in (-1.0, 1.0): - for z_sign in (-1.0, 1.0): - local = ( - x_sign * extents[0] / 2.0, - y_sign * extents[1] / 2.0, - z_sign * extents[2] / 2.0, - ) - world = tuple( - centers[axis] - + sum(rotation[axis][inner] * local[inner] for inner in range(3)) - for axis in range(3) - ) - points.add((world[0], world[1])) - return tuple(sorted(points)) - - -def _cross( - origin: tuple[float, float], - left: tuple[float, float], - right: tuple[float, float], -) -> float: - return (left[0] - origin[0]) * (right[1] - origin[1]) - (left[1] - origin[1]) * ( - right[0] - origin[0] - ) - - -def _convex_hull( - points: tuple[tuple[float, float], ...], -) -> tuple[tuple[float, float], ...]: - unique = tuple(sorted(set(points))) - if len(unique) < 3: - raise ValueError("camera clearance OBB projection must have positive area") - lower: list[tuple[float, float]] = [] - for point in unique: - while len(lower) >= 2 and _cross(lower[-2], lower[-1], point) <= 0.0: - lower.pop() - lower.append(point) - upper: list[tuple[float, float]] = [] - for point in reversed(unique): - while len(upper) >= 2 and _cross(upper[-2], upper[-1], point) <= 0.0: - upper.pop() - upper.append(point) - hull = tuple(lower[:-1] + upper[:-1]) - if len(hull) < 3: - raise ValueError("camera clearance OBB projection must have positive area") - return hull - - -def _point_segment_distance( - point: tuple[float, float], - start: tuple[float, float], - end: tuple[float, float], -) -> float: - delta = (end[0] - start[0], end[1] - start[1]) - length_squared = delta[0] * delta[0] + delta[1] * delta[1] - if length_squared == 0.0: - return math.dist(point, start) - fraction = max( - 0.0, - min( - 1.0, - ((point[0] - start[0]) * delta[0] + (point[1] - start[1]) * delta[1]) - / length_squared, - ), - ) - nearest = ( - start[0] + fraction * delta[0], - start[1] + fraction * delta[1], - ) - return math.dist(point, nearest) - - -def _point_polygon_distance( - point: tuple[float, float], - polygon: tuple[tuple[float, float], ...], -) -> float: - crosses = tuple( - _cross(polygon[index], polygon[(index + 1) % len(polygon)], point) - for index in range(len(polygon)) - ) - if all(value >= 0.0 for value in crosses) or all(value <= 0.0 for value in crosses): - return 0.0 - return min( - _point_segment_distance( - point, - polygon[index], - polygon[(index + 1) % len(polygon)], - ) - for index in range(len(polygon)) - ) - - -def _filter_competition_native_camera_positions( - scene: Scene, - positions: tuple[AI2ThorNativePosition, ...], - *, - clearance_radius_m: float, -) -> tuple[AI2ThorNativePosition, ...]: - if type(scene) is not Scene: - raise TypeError("camera clearance scene must be an exact Scene") - checked_scene = Scene.model_validate(scene.model_dump(mode="python"), strict=True) - if type(positions) is not tuple or any( - type(position) is not AI2ThorNativePosition for position in positions - ): - raise TypeError("camera clearance requires an exact position tuple") - if not positions: - raise TypeError("camera clearance requires a non-empty exact position tuple") - if len(set(positions)) != len(positions): - raise ValueError("camera clearance positions must be unique") - footprints = tuple( - _convex_hull(_projected_corners(item.obb)) - for item in checked_scene.objects - if item.movable - ) - accepted = tuple( - position - for position in positions - if all( - _point_polygon_distance((position.x, position.z), footprint) - > clearance_radius_m - for footprint in footprints - ) - ) - return tuple(sorted(accepted, key=lambda item: (item.x, item.z, item.y))) - - -def filter_competition_native_camera_positions_v2_9_5( - scene: Scene, - positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - return _filter_competition_native_camera_positions( - scene, - positions, - clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_5, - ) - - -def filter_competition_native_camera_positions_v2_9_6( - scene: Scene, - positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - return _filter_competition_native_camera_positions( - scene, - positions, - clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_6, - ) - - -def filter_competition_native_camera_positions_v2_9_7( - scene: Scene, - positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - return _filter_competition_native_camera_positions( - scene, - positions, - clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_7, - ) - - -def filter_competition_native_camera_positions_v2_9_8( - scene: Scene, - positions: tuple[AI2ThorNativePosition, ...], -) -> tuple[AI2ThorNativePosition, ...]: - return _filter_competition_native_camera_positions( - scene, - positions, - clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_8, - ) - - -def _legacy_camera(matrix: tuple[float, ...]) -> tuple[float, dict[str, float]]: - cosine, negative_sine = matrix[0], matrix[1] - sine, second_cosine = matrix[8], matrix[9] - if math.isclose(math.hypot(sine, second_cosine), 0.0, rel_tol=0.0, abs_tol=1e-12): - raise _CameraConversionError("MISSING_FACT:COMPLETE_UPRIGHT_CAMERA_DEPTH_BASIS") - expected = ( - (matrix[2], 0.0), - (matrix[4], 0.0), - (matrix[5], 0.0), - (matrix[6], 1.0), - (matrix[10], 0.0), - (matrix[12], 0.0), - (matrix[13], 0.0), - (matrix[14], 0.0), - (matrix[15], 1.0), - (negative_sine, -sine), - (cosine, second_cosine), - ) - if any( - not math.isclose(actual, wanted, rel_tol=0.0, abs_tol=1e-9) - for actual, wanted in expected - ) or not math.isclose(math.hypot(sine, cosine), 1.0, rel_tol=0.0, abs_tol=1e-9): - raise _CameraConversionError("UNSUPPORTED_MODEL:CAMERA_NOT_EXACT_UPRIGHT") - angle = 0.0 if sine == 0.0 else math.atan2(sine, cosine) - return angle, {"x": matrix[3], "y": -matrix[7], "z": matrix[11]} - - -_MAX_POSE_BANK_MEMBERS = 256 -_MAX_POSITION_RESIDUAL_M = 1e-5 -_MAX_ANGLE_RESIDUAL_DEGREES = 1e-4 -_POLICY_HASH_DOMAIN = "spatialcf.competition-native-camera-policy.v2.9.3" -_POSE_BANK_HASH_DOMAIN = "spatialcf.competition-native-camera-pose-bank.v2.9.3" -_EVIDENCE_HASH_DOMAIN = "spatialcf.competition-native-source-camera-evidence.v2.9.3" -_PLACEMENT_ROSTER_HASH_DOMAIN = ( - "spatialcf.competition-native-camera-placement-roster.v2.9.4" -) -_LEGACY_POSE_POLICY_VERSION = "deterministic-pair-camera-tier-1:1" -_SOLVER_UPRIGHT_POSE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright:2" -) -_EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain:3" -) -_COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.2m:4" -) -_CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.21m:5" -) -_RESET_PER_POSE_EDITABLE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.21m-reset-per-pose:6" -) -_GRID_MARGIN_EDITABLE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.25m:7" -) -_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.25m-physics-paused:8" -) -_SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.25m-physics-paused-final-settle:9" -) - - -def _editable_pose_policy_versions() -> frozenset[str]: - return frozenset( - { - _EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION, - _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION, - _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, - _RESET_PER_POSE_EDITABLE_POLICY_VERSION, - _GRID_MARGIN_EDITABLE_POLICY_VERSION, - _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - } - ) - - -class CompetitionNativeCameraPoseV2_9_3(V2Model): - """One exact native TeleportFull pose on the canonical evidence wire.""" - - pose_version: Literal["competition-native-camera-pose:2.9.3"] = ( - "competition-native-camera-pose:2.9.3" - ) - x: FiniteFloat - y: FiniteFloat - z: FiniteFloat - yaw_degrees: FiniteFloat - horizon_degrees: FiniteFloat - standing: bool - - -def _policy_payload( - pose_policy_version: str = _LEGACY_POSE_POLICY_VERSION, -) -> dict[str, object]: - if pose_policy_version not in { - _LEGACY_POSE_POLICY_VERSION, - _SOLVER_UPRIGHT_POSE_POLICY_VERSION, - _EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION, - _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION, - _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, - _RESET_PER_POSE_EDITABLE_POLICY_VERSION, - _GRID_MARGIN_EDITABLE_POLICY_VERSION, - _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - }: - raise ValueError("camera evidence pose policy version is unsupported") - return { - "camera_id": "main", - "maximum_pose_bank_count": 256, - "maximum_truncated_fraction": 0.5, - "minimum_image_area_fraction": 0.0025, - "minimum_visible_fraction": 0.2, - "policy_version": "competition-native-camera-selection-policy:2.9.3", - "pose_policy_version": pose_policy_version, - } - - -class CompetitionNativeCameraPolicyV2_9_3(V2Model): - """Frozen literal source-camera selection policy and its own digest.""" - - policy_version: Literal["competition-native-camera-selection-policy:2.9.3"] = ( - "competition-native-camera-selection-policy:2.9.3" - ) - pose_policy_version: Literal[ - "deterministic-pair-camera-tier-1:1", - "deterministic-pair-camera-tier-1-solver-upright:2", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain:3", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.2m:4", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.21m:5", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.21m-reset-per-pose:6", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.25m:7", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.25m-physics-paused:8", - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.25m-physics-paused-final-settle:9", - ] = _LEGACY_POSE_POLICY_VERSION - camera_id: Literal["main"] = "main" - maximum_pose_bank_count: Literal[256] = 256 - minimum_visible_fraction: Literal[0.2] = 0.2 - minimum_image_area_fraction: Literal[0.0025] = 0.0025 - maximum_truncated_fraction: Literal[0.5] = 0.5 - policy_sha256: Sha256Digest - - @model_validator(mode="after") - def validate_policy_digest(self) -> Self: - expected = canonical_sha256_v2( - _policy_payload(self.pose_policy_version), - domain=_POLICY_HASH_DOMAIN, - ) - if self.policy_sha256 != expected: - raise ValueError("camera evidence policy digest mismatch") - return self - - -class CompetitionNativeCameraScoreV2_9_3(V2Model): - """The two literal source-only counts used by camera selection.""" - - score_version: Literal["competition-native-camera-score:2.9.3"] = ( - "competition-native-camera-score:2.9.3" - ) - movable_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) - all_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) - - @model_validator(mode="after") - def validate_score_counts(self) -> Self: - if ( - self.movable_scene_unique_category_qualifying_count - > self.all_scene_unique_category_qualifying_count - ): - raise ValueError("camera evidence movable score exceeds total score") - return self - - -class CompetitionNativeCameraPlacementPositionV2_9_4(V2Model): - """One exact native subject anchor considered by camera selection.""" - - position_version: Literal["competition-native-camera-placement-position:2.9.4"] = ( - "competition-native-camera-placement-position:2.9.4" - ) - x: FiniteFloat - y: FiniteFloat - z: FiniteFloat - - -class CompetitionNativeCameraPlacementRosterEntryV2_9_4(V2Model): - """One source subject and its complete canonical native placement roster.""" - - entry_version: Literal["competition-native-camera-placement-roster-entry:2.9.4"] = ( - "competition-native-camera-placement-roster-entry:2.9.4" - ) - subject_object_id: str = Field(strict=True, min_length=1, max_length=512) - support_object_id: str = Field(strict=True, min_length=1, max_length=512) - positions: tuple[CompetitionNativeCameraPlacementPositionV2_9_4, ...] = Field( - min_length=1 - ) - - @model_validator(mode="after") - def validate_positions(self) -> Self: - ordered = tuple( - sorted( - set(self.positions), - key=lambda item: (item.x, item.z, item.y), - ) - ) - if self.positions != ordered: - raise ValueError("camera placement positions must be unique and canonical") - return self - - -class CompetitionNativeCameraScoreV2_9_4(V2Model): - """Source visibility plus the native edit domain visible from one pose.""" - - score_version: Literal["competition-native-camera-score:2.9.4"] = ( - "competition-native-camera-score:2.9.4" - ) - placement_roster_sha256: Sha256Digest - visible_native_placement_subject_count: int = Field(strict=True, ge=0) - visible_native_placement_count: int = Field(strict=True, ge=0) - movable_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) - all_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) - - @model_validator(mode="after") - def validate_score_counts(self) -> Self: - if ( - self.movable_scene_unique_category_qualifying_count - > self.all_scene_unique_category_qualifying_count - or self.visible_native_placement_subject_count - > self.movable_scene_unique_category_qualifying_count - or self.visible_native_placement_subject_count - > self.visible_native_placement_count - ): - raise ValueError("editable camera score counts are inconsistent") - return self - - -CompetitionNativeCameraScoreFamilyV2_9_3 = ( - CompetitionNativeCameraScoreV2_9_3 | CompetitionNativeCameraScoreV2_9_4 -) - - -def _strict_placement_roster_v2_9_4( - placement_roster: object, -) -> tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...]: - if type(placement_roster) is not tuple or any( - type(item) is not CompetitionNativeCameraPlacementRosterEntryV2_9_4 - for item in placement_roster - ): - raise TypeError("camera placement roster must be an exact entry tuple") - checked = tuple( - CompetitionNativeCameraPlacementRosterEntryV2_9_4.model_validate( - item.model_dump(mode="python"), strict=True - ) - for item in placement_roster - ) - if not checked: - raise ValueError("camera placement roster must not be empty") - if tuple( - sorted(checked, key=lambda item: item.subject_object_id) - ) != checked or len({item.subject_object_id for item in checked}) != len(checked): - raise ValueError("camera placement roster must be unique and canonical") - return checked - - -def competition_native_camera_placement_roster_sha256_v2_9_4( - placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], -) -> Sha256Digest: - """Hash one exact source placement roster in its independent domain.""" - - checked = _strict_placement_roster_v2_9_4(placement_roster) - payload = { - "placement_roster_version": "competition-native-camera-placement-roster:2.9.4", - "subjects": tuple(item.model_dump(mode="json") for item in checked), - } - return canonical_sha256_v2(payload, domain=_PLACEMENT_ROSTER_HASH_DOMAIN) - - -def select_competition_native_camera_score_index_v2_9_4( - pose_scores: tuple[CompetitionNativeCameraScoreV2_9_4, ...], -) -> int: - """Select edit-domain coverage, then source visibility, then bank index.""" - - if ( - type(pose_scores) is not tuple - or not pose_scores - or any( - type(item) is not CompetitionNativeCameraScoreV2_9_4 for item in pose_scores - ) - ): - raise TypeError("editable camera score ledger must be an exact nonempty tuple") - checked = tuple( - CompetitionNativeCameraScoreV2_9_4.model_validate( - item.model_dump(mode="python"), strict=True - ) - for item in pose_scores - ) - if ( - len(checked) > _MAX_POSE_BANK_MEMBERS - or len({item.placement_roster_sha256 for item in checked}) != 1 - ): - raise ValueError("editable camera score ledger is not source-aligned") - return min( - range(len(checked)), - key=lambda index: ( - -checked[index].visible_native_placement_subject_count, - -checked[index].visible_native_placement_count, - -checked[index].movable_scene_unique_category_qualifying_count, - -checked[index].all_scene_unique_category_qualifying_count, - index, - ), - ) - - -def _strict_score_ledger( - pose_scores: object, -) -> tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...]: - if type(pose_scores) is not tuple: - raise TypeError("camera evidence score ledger must be an exact score tuple") - if not pose_scores or len(pose_scores) > _MAX_POSE_BANK_MEMBERS: - raise ValueError( - "camera evidence score ledger must contain 1 through 256 scores" - ) - score_type: type[CompetitionNativeCameraScoreFamilyV2_9_3] - if all(type(item) is CompetitionNativeCameraScoreV2_9_3 for item in pose_scores): - score_type = CompetitionNativeCameraScoreV2_9_3 - elif all(type(item) is CompetitionNativeCameraScoreV2_9_4 for item in pose_scores): - score_type = CompetitionNativeCameraScoreV2_9_4 - else: - raise TypeError("camera evidence score ledger mixes score versions") - return tuple( - score_type.model_validate(item.model_dump(mode="python"), strict=True) - for item in pose_scores - ) - - -def select_competition_native_camera_score_index_v2_9_3( - pose_scores: tuple[CompetitionNativeCameraScoreV2_9_3, ...], -) -> int: - """Select the literal score argmax, breaking complete ties by bank index.""" - - checked = _strict_score_ledger(pose_scores) - if any(type(item) is not CompetitionNativeCameraScoreV2_9_3 for item in checked): - raise TypeError("legacy camera selector requires 2.9.3 scores") - return min( - range(len(checked)), - key=lambda index: ( - -checked[index].movable_scene_unique_category_qualifying_count, - -checked[index].all_scene_unique_category_qualifying_count, - index, - ), - ) - - -def _select_competition_native_camera_score_index( - pose_scores: tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...], -) -> int: - checked = _strict_score_ledger(pose_scores) - if type(checked[0]) is CompetitionNativeCameraScoreV2_9_3: - return select_competition_native_camera_score_index_v2_9_3(checked) # type: ignore[arg-type] - return select_competition_native_camera_score_index_v2_9_4(checked) # type: ignore[arg-type] - - -def verify_competition_native_camera_observation_binding_v2_9_3( - requested_pose: CompetitionNativeCameraPoseV2_9_3, - observed_pose: CompetitionNativeCameraPoseV2_9_3, - observed_native_camera_position: tuple[float, float, float], - camera: Camera, -) -> None: - """Close persisted native observation fields to one requested main Camera.""" - - if ( - type(requested_pose) is not CompetitionNativeCameraPoseV2_9_3 - or type(observed_pose) is not CompetitionNativeCameraPoseV2_9_3 - ): - raise TypeError( - "camera evidence camera observation binding poses must be exact" - ) - if ( - type(observed_native_camera_position) is not tuple - or len(observed_native_camera_position) != 3 - or any(type(item) is not float for item in observed_native_camera_position) - ): - raise TypeError( - "camera evidence camera observation binding native position must be exact" - ) - if type(camera) is not Camera or camera.camera_id != "main": - raise TypeError( - "camera evidence camera observation binding Camera must be exact main" - ) - position_residual_m = math.dist( - (requested_pose.x, requested_pose.y, requested_pose.z), - (observed_pose.x, observed_pose.y, observed_pose.z), - ) - yaw_residual_degrees = abs( - (observed_pose.yaw_degrees - requested_pose.yaw_degrees + 180.0) % 360.0 - 180.0 - ) - horizon_residual_degrees = abs( - observed_pose.horizon_degrees - requested_pose.horizon_degrees - ) - if ( - position_residual_m > _MAX_POSITION_RESIDUAL_M - or yaw_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES - or horizon_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES - or observed_pose.standing is not requested_pose.standing - ): - raise ValueError( - "camera evidence camera observation binding does not close requested pose" - ) - native_position = Vec3( - x=observed_native_camera_position[0], - y=observed_native_camera_position[1], - z=observed_native_camera_position[2], - ) - expected_world_to_camera = ai2thor_camera_world_to_camera( - ai2thor_position_to_world(native_position), - yaw_degrees=observed_pose.yaw_degrees, - horizon_degrees=observed_pose.horizon_degrees, - ) - if camera.world_to_camera != expected_world_to_camera: - raise ValueError( - "camera evidence camera observation binding does not close main Camera" - ) - - -def _evidence_payload( - *, - source_id: str, - scene_id: str, - source_locator_sha256: str, - runtime_identity_sha256: str, - source_capture_sha256: str, - policy_sha256: str, - pose_bank_sha256: str, - pose_bank_count: int, - pose_scores: tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...], - selected_pose_index: int, - requested_pose: CompetitionNativeCameraPoseV2_9_3, - observed_pose: CompetitionNativeCameraPoseV2_9_3, - observed_native_camera_position: tuple[float, float, float], - camera: Camera, - score: CompetitionNativeCameraScoreFamilyV2_9_3, - rgb_png_sha256: str, - depth_npy_sha256: str, - instance_png_sha256: str, - pointcloud_ply_sha256: str, - is_scene_at_rest: bool, -) -> dict[str, object]: - return { - "camera": camera.model_dump(mode="json"), - "depth_npy_sha256": depth_npy_sha256, - "evidence_version": "competition-native-source-camera-evidence:2.9.3", - "instance_png_sha256": instance_png_sha256, - "is_scene_at_rest": is_scene_at_rest, - "observed_native_camera_position": observed_native_camera_position, - "observed_pose": observed_pose.model_dump(mode="json"), - "pointcloud_ply_sha256": pointcloud_ply_sha256, - "policy_sha256": policy_sha256, - "pose_bank_count": pose_bank_count, - "pose_bank_sha256": pose_bank_sha256, - "pose_scores": tuple(item.model_dump(mode="json") for item in pose_scores), - "requested_pose": requested_pose.model_dump(mode="json"), - "rgb_png_sha256": rgb_png_sha256, - "runtime_identity_sha256": runtime_identity_sha256, - "scene_id": scene_id, - "score": score.model_dump(mode="json"), - "selected_pose_index": selected_pose_index, - "source_capture_sha256": source_capture_sha256, - "source_id": source_id, - "source_locator_sha256": source_locator_sha256, - } - - -class CompetitionNativeSourceCameraEvidenceV2_9_3(V2Model): - """One selected source camera with complete immutable capture lineage.""" - - evidence_version: Literal["competition-native-source-camera-evidence:2.9.3"] = ( - "competition-native-source-camera-evidence:2.9.3" - ) - source_id: str = Field(strict=True, min_length=1, max_length=512) - scene_id: str = Field(strict=True, min_length=1, max_length=512) - source_locator_sha256: Sha256Digest - runtime_identity_sha256: Sha256Digest - source_capture_sha256: Sha256Digest - policy_sha256: Sha256Digest - pose_bank_sha256: Sha256Digest - pose_bank_count: int = Field(strict=True, ge=1, le=256) - pose_scores: tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...] = Field( - min_length=1, max_length=256 - ) - selected_pose_index: int = Field(strict=True, ge=0, le=255) - requested_pose: CompetitionNativeCameraPoseV2_9_3 - observed_pose: CompetitionNativeCameraPoseV2_9_3 - observed_native_camera_position: tuple[FiniteFloat, FiniteFloat, FiniteFloat] - camera: Camera - score: CompetitionNativeCameraScoreFamilyV2_9_3 - rgb_png_sha256: Sha256Digest - depth_npy_sha256: Sha256Digest - instance_png_sha256: Sha256Digest - pointcloud_ply_sha256: Sha256Digest - is_scene_at_rest: bool - camera_evidence_sha256: Sha256Digest - - @model_validator(mode="after") - def validate_evidence(self) -> Self: - checked_scores = _strict_score_ledger(self.pose_scores) - if len(checked_scores) != self.pose_bank_count: - raise ValueError("camera evidence score ledger is not bank-aligned") - if self.selected_pose_index >= self.pose_bank_count: - raise ValueError("camera evidence selected index is outside pose bank") - if ( - self.selected_pose_index - != _select_competition_native_camera_score_index(checked_scores) - or self.score != checked_scores[self.selected_pose_index] - ): - raise ValueError("camera evidence selected index is not the literal argmax") - if self.camera.camera_id != "main": - raise ValueError("camera evidence must persist the main camera") - verify_competition_native_camera_observation_binding_v2_9_3( - self.requested_pose, - self.observed_pose, - self.observed_native_camera_position, - self.camera, - ) - expected_policies = { - canonical_sha256_v2( - _policy_payload(version), - domain=_POLICY_HASH_DOMAIN, - ) - for version in ( - _LEGACY_POSE_POLICY_VERSION, - _SOLVER_UPRIGHT_POSE_POLICY_VERSION, - _EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION, - _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION, - _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, - _RESET_PER_POSE_EDITABLE_POLICY_VERSION, - _GRID_MARGIN_EDITABLE_POLICY_VERSION, - _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - ) - } - if self.policy_sha256 not in expected_policies: - raise ValueError("camera evidence policy digest mismatch") - editable_policy_sha256s = { - canonical_sha256_v2( - _policy_payload(version), - domain=_POLICY_HASH_DOMAIN, - ) - for version in _editable_pose_policy_versions() - } - if (type(checked_scores[0]) is CompetitionNativeCameraScoreV2_9_4) != ( - self.policy_sha256 in editable_policy_sha256s - ): - raise ValueError("camera evidence score version does not bind policy") - expected = canonical_sha256_v2( - _evidence_payload( - source_id=self.source_id, - scene_id=self.scene_id, - source_locator_sha256=self.source_locator_sha256, - runtime_identity_sha256=self.runtime_identity_sha256, - source_capture_sha256=self.source_capture_sha256, - policy_sha256=self.policy_sha256, - pose_bank_sha256=self.pose_bank_sha256, - pose_bank_count=self.pose_bank_count, - pose_scores=checked_scores, - selected_pose_index=self.selected_pose_index, - requested_pose=self.requested_pose, - observed_pose=self.observed_pose, - observed_native_camera_position=self.observed_native_camera_position, - camera=self.camera, - score=self.score, - rgb_png_sha256=self.rgb_png_sha256, - depth_npy_sha256=self.depth_npy_sha256, - instance_png_sha256=self.instance_png_sha256, - pointcloud_ply_sha256=self.pointcloud_ply_sha256, - is_scene_at_rest=self.is_scene_at_rest, - ), - domain=_EVIDENCE_HASH_DOMAIN, - ) - if self.camera_evidence_sha256 != expected: - raise ValueError("camera evidence digest mismatch") - return self - - -def _strict_scene(scene: object) -> Scene: - if type(scene) is not Scene: - raise TypeError("camera evidence scene must be an exact Scene") - return Scene.model_validate(scene.model_dump(mode="python"), strict=True) - - -def _strict_native_pose(pose: object, *, label: str) -> AI2ThorAgentPose: - if type(pose) is not AI2ThorAgentPose: - raise TypeError(f"{label} must be an exact AI2ThorAgentPose") - if type(pose.position) is not AI2ThorNativePosition: - raise TypeError(f"{label} position must be exact") - return AI2ThorAgentPose( - position=AI2ThorNativePosition(**asdict(pose.position)), - yaw_degrees=pose.yaw_degrees, - horizon_degrees=pose.horizon_degrees, - standing=pose.standing, - ) - - -def _wire_pose(pose: AI2ThorAgentPose) -> CompetitionNativeCameraPoseV2_9_3: - return CompetitionNativeCameraPoseV2_9_3( - x=pose.position.x, - y=pose.position.y, - z=pose.position.z, - yaw_degrees=pose.yaw_degrees, - horizon_degrees=pose.horizon_degrees, - standing=pose.standing, - ) - - -def _pose_key( - pose: CompetitionNativeCameraPoseV2_9_3, -) -> tuple[float, float, float, float, float, bool]: - return ( - pose.x, - pose.y, - pose.z, - pose.yaw_degrees, - pose.horizon_degrees, - pose.standing, - ) - - -def build_competition_native_camera_pose_bank_v2_9_3( - scene: Scene, - pairs: tuple[tuple[str, str], ...], - reachable_positions: tuple[AI2ThorNativePosition, ...], - fallback_pose: AI2ThorAgentPose, - *, - policy: CompetitionNativeCameraPolicyV2_9_3 | None = None, -) -> tuple[CompetitionNativeCameraPoseV2_9_3, ...]: - """Build the bounded, permutation-invariant Tier-1 source pose bank.""" - - checked_scene = _strict_scene(scene) - if type(pairs) is not tuple or any( - type(pair) is not tuple - or len(pair) != 2 - or any(type(item) is not str or not item for item in pair) - for pair in pairs - ): - raise TypeError("camera evidence pairs must be an exact tuple of string pairs") - if len(set(pairs)) != len(pairs): - raise ValueError("camera evidence pairs must be unique") - if ( - type(reachable_positions) is not tuple - or not reachable_positions - or any(type(item) is not AI2ThorNativePosition for item in reachable_positions) - ): - raise TypeError("reachable positions must be a non-empty exact tuple") - checked_positions = tuple( - AI2ThorNativePosition(**asdict(item)) for item in reachable_positions - ) - if len(set(checked_positions)) != len(checked_positions): - raise ValueError("reachable positions must be unique") - checked_fallback = _strict_native_pose(fallback_pose, label="fallback pose") - checked_policy = ( - build_competition_native_camera_policy_v2_9_3() - if policy is None - else _strict_policy(policy) - ) - solver_upright = checked_policy.pose_policy_version in { - _SOLVER_UPRIGHT_POSE_POLICY_VERSION, - *_editable_pose_policy_versions(), - } - if ( - checked_policy.pose_policy_version - == _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION - ): - checked_positions = filter_competition_native_camera_positions_v2_9_5( - checked_scene, - checked_positions, - ) - if pairs and not checked_positions: - raise ValueError( - "camera evidence has no collision-safe reachable positions" - ) - elif checked_policy.pose_policy_version in { - _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, - _RESET_PER_POSE_EDITABLE_POLICY_VERSION, - }: - checked_positions = filter_competition_native_camera_positions_v2_9_6( - checked_scene, - checked_positions, - ) - if pairs and not checked_positions: - raise ValueError( - "camera evidence has no contact-margin-safe reachable positions" - ) - elif checked_policy.pose_policy_version in { - _GRID_MARGIN_EDITABLE_POLICY_VERSION, - _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, - }: - checked_positions = filter_competition_native_camera_positions_v2_9_7( - checked_scene, - checked_positions, - ) - if pairs and not checked_positions: - raise ValueError( - "camera evidence has no grid-margin-safe reachable positions" - ) - elif ( - checked_policy.pose_policy_version - == _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION - ): - checked_positions = filter_competition_native_camera_positions_v2_9_8( - checked_scene, - checked_positions, - ) - if pairs and not checked_positions: - raise ValueError( - "camera evidence has no quantization-safe reachable positions" - ) - - poses: set[CompetitionNativeCameraPoseV2_9_3] = set() - for subject_object_id, support_object_id in pairs: - subject = checked_scene.object_by_id(subject_object_id) - checked_scene.object_by_id(support_object_id) - if ( - not subject.movable - or subject.support_object_id != support_object_id - or subject_object_id == support_object_id - ): - raise ValueError("camera evidence pair does not bind movable support") - generated = deterministic_pair_camera_poses( - checked_scene, - subject_object_id, - support_object_id, - checked_positions, - ) - poses.update( - _wire_pose(item) - for item in generated - if not solver_upright or item.horizon_degrees == 0.0 - ) - if len(poses) > _MAX_POSE_BANK_MEMBERS: - raise ValueError("camera evidence pose bank exceeds 256 members") - - if not poses: - if solver_upright: - checked_fallback = AI2ThorAgentPose( - position=checked_fallback.position, - yaw_degrees=checked_fallback.yaw_degrees, - horizon_degrees=0.0, - standing=checked_fallback.standing, - ) - poses.add(_wire_pose(checked_fallback)) - return tuple(sorted(poses, key=_pose_key)) - - -def build_competition_native_camera_policy_v2_9_3() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the one frozen 2.9.3 source-camera selection policy.""" - - payload = _policy_payload(_LEGACY_POSE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_solver_upright_camera_policy_v2_9_3() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the bounded camera policy supported by the certified solver.""" - - payload = _policy_payload(_SOLVER_UPRIGHT_POSE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_editable_camera_policy_v2_9_3() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the upright policy that ranks source-native edit coverage.""" - - payload = _policy_payload(_EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_collision_safe_editable_camera_policy_v2_9_5() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the source-only edit policy with fixed 0.2m movable clearance.""" - - payload = _policy_payload(_COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_contact_margin_editable_camera_policy_v2_9_6() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the edit policy with 0.2m agent plus fixed 1cm margin.""" - - payload = _policy_payload(_CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_reset_per_pose_editable_camera_policy_v2_9_7() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the contact-safe policy that resets the source per pose.""" - - payload = _policy_payload(_RESET_PER_POSE_EDITABLE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_grid_margin_editable_camera_policy_v2_9_8() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the sequential source policy with one-grid movable margin.""" - - payload = _policy_payload(_GRID_MARGIN_EDITABLE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_paused_camera_policy_v2_9_9() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the one-grid policy whose observation bank pauses physics.""" - - payload = _policy_payload(_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def build_competition_native_settled_camera_policy_v2_9_10() -> ( - CompetitionNativeCameraPolicyV2_9_3 -): - """Return the paused-bank policy that freezes only after final settlement.""" - - payload = _policy_payload(_SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION) - return CompetitionNativeCameraPolicyV2_9_3( - **payload, - policy_sha256=canonical_sha256_v2(payload, domain=_POLICY_HASH_DOMAIN), - ) - - -def _strict_pose_bank( - pose_bank: object, -) -> tuple[CompetitionNativeCameraPoseV2_9_3, ...]: - if type(pose_bank) is not tuple or any( - type(item) is not CompetitionNativeCameraPoseV2_9_3 for item in pose_bank - ): - raise TypeError("camera evidence pose bank must be an exact pose tuple") - checked = tuple( - CompetitionNativeCameraPoseV2_9_3.model_validate( - item.model_dump(mode="python"), strict=True - ) - for item in pose_bank - ) - if not checked or len(checked) > _MAX_POSE_BANK_MEMBERS: - raise ValueError("camera evidence pose bank must contain 1 through 256 poses") - if tuple(sorted(set(checked), key=_pose_key)) != checked: - raise ValueError("camera evidence pose bank is not unique and canonical") - return checked - - -def competition_native_camera_pose_bank_sha256_v2_9_3( - pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], -) -> Sha256Digest: - """Hash one exact canonical pose bank in its independent domain.""" - - checked = _strict_pose_bank(pose_bank) - payload = { - "pose_bank_version": "competition-native-camera-pose-bank:2.9.3", - "poses": tuple(item.model_dump(mode="json") for item in checked), - } - return canonical_sha256_v2(payload, domain=_POSE_BANK_HASH_DOMAIN) - - -def score_competition_native_camera_scene_v2_9_3( - scene: Scene, -) -> CompetitionNativeCameraScoreV2_9_3: - """Count qualifying objects whose category occurs once in the scene.""" - - checked = _strict_scene(scene) - checked.camera_by_id("main") - category_counts: dict[str, int] = {} - for item in checked.objects: - category_counts[item.category] = category_counts.get(item.category, 0) + 1 - - qualifying = [] - for item in checked.objects: - view = item.views.get("main") - if ( - category_counts[item.category] == 1 - and item.request_eligible - and view is not None - and view.visible_fraction >= RelationEngine.MIN_VISIBLE_FRACTION - and view.image_area_fraction >= RelationEngine.MIN_IMAGE_AREA_FRACTION - and view.truncated_fraction <= RelationEngine.MAX_TRUNCATED_FRACTION - ): - qualifying.append(item) - return CompetitionNativeCameraScoreV2_9_3( - movable_scene_unique_category_qualifying_count=sum( - item.movable for item in qualifying - ), - all_scene_unique_category_qualifying_count=len(qualifying), - ) - - -def _rotation_matrix_values(obb: OBB) -> tuple[tuple[float, float, float], ...]: - rotation = obb.rotation - norm = math.sqrt(rotation.x**2 + rotation.y**2 + rotation.z**2 + rotation.w**2) - if not math.isfinite(norm) or norm <= 1e-12: - raise ValueError("camera placement OBB rotation is invalid") - x, y, z, w = ( - rotation.x / norm, - rotation.y / norm, - rotation.z / norm, - rotation.w / norm, - ) - return ( - ( - 1.0 - 2.0 * (y * y + z * z), - 2.0 * (x * y - z * w), - 2.0 * (x * z + y * w), - ), - ( - 2.0 * (x * y + z * w), - 1.0 - 2.0 * (x * x + z * z), - 2.0 * (y * z - x * w), - ), - ( - 2.0 * (x * z - y * w), - 2.0 * (y * z + x * w), - 1.0 - 2.0 * (x * x + y * y), - ), - ) - - -def _translated_obb_fully_visible_v2_9_4( - scene: Scene, - subject_object_id: str, - position: CompetitionNativeCameraPlacementPositionV2_9_4, -) -> bool: - subject = scene.object_by_id(subject_object_id) - camera = scene.camera_by_id("main") - delta = ( - position.x - subject.position.x, - position.z - subject.position.y, - position.y - subject.position.z, - ) - rotation = _rotation_matrix_values(subject.obb) - center = ( - subject.obb.center.x, - subject.obb.center.y, - subject.obb.center.z, - ) - matrix = camera.world_to_camera - fx, fy = camera.intrinsics[0], camera.intrinsics[4] - cx, cy = camera.intrinsics[2], camera.intrinsics[5] - projected: list[tuple[float, float]] = [] - for x_sign in (-1.0, 1.0): - for y_sign in (-1.0, 1.0): - for z_sign in (-1.0, 1.0): - local = ( - x_sign * subject.obb.extent.x / 2.0, - y_sign * subject.obb.extent.y / 2.0, - z_sign * subject.obb.extent.z / 2.0, - ) - world = tuple( - center[axis] - + delta[axis] - + sum(rotation[axis][inner] * local[inner] for inner in range(3)) - for axis in range(3) - ) - homogeneous = tuple( - sum(matrix[row * 4 + column] * world[column] for column in range(3)) - + matrix[row * 4 + 3] - for row in range(4) - ) - if homogeneous[3] == 0.0: - raise ValueError("camera placement projected a point to infinity") - camera_x = homogeneous[0] / homogeneous[3] - camera_y = homogeneous[1] / homogeneous[3] - camera_z = homogeneous[2] / homogeneous[3] - if camera_z <= 1e-12: - return False - projected.append( - ( - fx * camera_x / camera_z + cx, - cy - fy * camera_y / camera_z, - ) - ) - min_x = min(item[0] for item in projected) - max_x = max(item[0] for item in projected) - min_y = min(item[1] for item in projected) - max_y = max(item[1] for item in projected) - if not ( - min_x >= 0.5 - and max_x <= camera.width - 0.5 - and min_y >= 0.5 - and max_y <= camera.height - 0.5 - ): - return False - image_area_fraction = ( - (max_x - min_x) * (max_y - min_y) / (camera.width * camera.height) - ) - return image_area_fraction >= RelationEngine.MIN_IMAGE_AREA_FRACTION - - -def score_competition_native_editable_camera_scene_v2_9_4( - scene: Scene, - placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], -) -> CompetitionNativeCameraScoreV2_9_4: - """Score one frozen scene by complete visible native edit coverage.""" - - observed = _strict_scene(scene) - base = score_competition_native_camera_scene_v2_9_3(observed) - roster = _strict_placement_roster_v2_9_4(placement_roster) - category_counts: dict[str, int] = {} - for item in observed.objects: - category_counts[item.category] = category_counts.get(item.category, 0) + 1 - - visible_subjects = 0 - visible_positions = 0 - for entry in roster: - subject = observed.object_by_id(entry.subject_object_id) - observed.object_by_id(entry.support_object_id) - view = subject.views.get("main") - if ( - not subject.movable - or not subject.request_eligible - or subject.support_object_id != entry.support_object_id - or category_counts[subject.category] != 1 - or view is None - or view.visible_fraction < RelationEngine.MIN_VISIBLE_FRACTION - or view.image_area_fraction < RelationEngine.MIN_IMAGE_AREA_FRACTION - or view.truncated_fraction > RelationEngine.MAX_TRUNCATED_FRACTION - ): - continue - count = sum( - _translated_obb_fully_visible_v2_9_4( - observed, - entry.subject_object_id, - position, - ) - for position in entry.positions - ) - visible_positions += count - visible_subjects += count > 0 - return CompetitionNativeCameraScoreV2_9_4( - placement_roster_sha256=( - competition_native_camera_placement_roster_sha256_v2_9_4(roster) - ), - visible_native_placement_subject_count=visible_subjects, - visible_native_placement_count=visible_positions, - movable_scene_unique_category_qualifying_count=( - base.movable_scene_unique_category_qualifying_count - ), - all_scene_unique_category_qualifying_count=( - base.all_scene_unique_category_qualifying_count - ), - ) - - -def score_competition_native_editable_camera_application_v2_9_4( - *, - source_scene: Scene, - pose: CompetitionNativeCameraPoseV2_9_3, - application: AI2ThorCameraApplication, - placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], - policy: CompetitionNativeCameraPolicyV2_9_3, -) -> CompetitionNativeCameraScoreV2_9_4: - """Close and score one source observation by native edit coverage.""" - - checked_policy = _strict_policy(policy) - if checked_policy.pose_policy_version not in _editable_pose_policy_versions(): - raise ValueError("editable camera score requires the edit-domain policy") - score_competition_native_source_camera_application_v2_9_3( - source_scene=source_scene, - pose=pose, - application=application, - policy=checked_policy, - ) - checked_application = _strict_application(application) - return score_competition_native_editable_camera_scene_v2_9_4( - checked_application.observed_scene, - placement_roster, - ) - - -def _strict_application(value: object) -> AI2ThorCameraApplication: - if type(value) is not AI2ThorCameraApplication: - raise TypeError("camera evidence application must be exact") - requested_pose = _strict_native_pose(value.requested_pose, label="requested pose") - observed_pose = _strict_native_pose(value.observed_pose, label="observed pose") - if type(value.observed_camera_position) is not AI2ThorNativePosition: - raise TypeError("camera evidence observed camera position must be exact") - observed_position = AI2ThorNativePosition(**asdict(value.observed_camera_position)) - observed_scene = _strict_scene(value.observed_scene) - if type(value.observation) is not AI2ThorObservation: - raise TypeError("camera evidence observation must be exact") - observation = value.observation - if type(observation.scene) is not Scene: - raise TypeError("camera evidence observation scene must be exact") - if any( - type(blob) is not bytes - for blob in ( - observation.rgb_png, - observation.depth_npy, - observation.instance_png, - observation.pointcloud_ply, - ) - ): - raise TypeError("camera evidence observation assets must be exact bytes") - if type(observation.is_scene_at_rest) is not bool or any( - type(key) is not str or type(count) is not int or count < 0 - for key, count in observation.instance_pixel_counts.items() - ): - raise TypeError("camera evidence observation metadata must be exact") - rebuilt_observation = AI2ThorObservation.create( - scene=_strict_scene(observation.scene), - rgb_png=observation.rgb_png, - depth_npy=observation.depth_npy, - instance_png=observation.instance_png, - pointcloud_ply=observation.pointcloud_ply, - instance_pixel_counts=observation.instance_pixel_counts, - is_scene_at_rest=observation.is_scene_at_rest, - ) - if ( - rebuilt_observation != observation - or rebuilt_observation.scene != observed_scene - ): - raise ValueError("camera evidence observation binding or asset hash changed") - residuals = ( - value.position_residual_m, - value.yaw_residual_degrees, - value.horizon_residual_degrees, - ) - if any(type(item) is not float or not math.isfinite(item) for item in residuals): - raise TypeError( - "camera evidence application residuals must be exact finite floats" - ) - expected_residuals = ( - math.dist( - ( - requested_pose.position.x, - requested_pose.position.y, - requested_pose.position.z, - ), - ( - observed_pose.position.x, - observed_pose.position.y, - observed_pose.position.z, - ), - ), - abs( - (observed_pose.yaw_degrees - requested_pose.yaw_degrees + 180.0) % 360.0 - - 180.0 - ), - abs(observed_pose.horizon_degrees - requested_pose.horizon_degrees), - ) - if any( - not math.isclose( - actual, - expected, - rel_tol=0.0, - abs_tol=4.0 * max(math.ulp(actual), math.ulp(expected)), - ) - for actual, expected in zip(residuals, expected_residuals, strict=True) - ): - raise ValueError("camera evidence application residual changed") - if ( - value.position_residual_m > _MAX_POSITION_RESIDUAL_M - or value.yaw_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES - or value.horizon_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES - ): - raise ValueError("camera evidence application pose drift exceeds tolerance") - if observed_pose.standing is not requested_pose.standing: - raise ValueError("camera evidence application standing state changed") - if not rebuilt_observation.is_scene_at_rest: - raise ValueError("camera evidence application is not at rest") - return AI2ThorCameraApplication( - requested_pose=requested_pose, - observed_pose=observed_pose, - observed_camera_position=observed_position, - observed_scene=observed_scene, - observation=rebuilt_observation, - position_residual_m=value.position_residual_m, - yaw_residual_degrees=value.yaw_residual_degrees, - horizon_residual_degrees=value.horizon_residual_degrees, - ) - - -def _validate_application_source_closure( - source_scene: Scene, - application: AI2ThorCameraApplication, -) -> None: - observed = application.observed_scene - if ( - observed.scene_id != source_scene.scene_id - or observed.source != source_scene.source - or observed.coordinate_system != source_scene.coordinate_system - or observed.room_polygon_xy != source_scene.room_polygon_xy - or observed.collision_obstacles != source_scene.collision_obstacles - or observed.subject_position_regions != source_scene.subject_position_regions - or observed.pinned_object_ids != source_scene.pinned_object_ids - or observed.generation_seed != source_scene.generation_seed - ): - raise ValueError("camera evidence application source root changed") - - source_object_ids = tuple(item.object_id for item in source_scene.objects) - observed_object_ids = tuple(item.object_id for item in observed.objects) - if source_object_ids != observed_object_ids: - raise ValueError("camera evidence application source object roster changed") - for source_object, observed_object in zip( - source_scene.objects, observed.objects, strict=True - ): - if ( - observed_object.object_id != source_object.object_id - or observed_object.name != source_object.name - or observed_object.category != source_object.category - or observed_object.movable is not source_object.movable - or observed_object.request_eligible is not source_object.request_eligible - or observed_object.support_object_id != source_object.support_object_id - or observed_object.position != source_object.position - or observed_object.rotation != source_object.rotation - or observed_object.obb != source_object.obb - ): - raise ValueError("camera evidence application source object changed") - - source_camera_ids = tuple(item.camera_id for item in source_scene.cameras) - observed_camera_ids = tuple(item.camera_id for item in observed.cameras) - if source_camera_ids != observed_camera_ids: - raise ValueError("camera evidence application source camera roster changed") - for source_camera, observed_camera in zip( - source_scene.cameras, observed.cameras, strict=True - ): - if ( - observed_camera.camera_id != source_camera.camera_id - or observed_camera.width != source_camera.width - or observed_camera.height != source_camera.height - or observed_camera.intrinsics != source_camera.intrinsics - or ( - source_camera.camera_id != "main" - and observed_camera.world_to_camera != source_camera.world_to_camera - ) - ): - raise ValueError("camera evidence application source camera changed") - - -def score_competition_native_source_camera_application_v2_9_3( - *, - source_scene: Scene, - pose: CompetitionNativeCameraPoseV2_9_3, - application: AI2ThorCameraApplication, - policy: CompetitionNativeCameraPolicyV2_9_3 | None = None, -) -> CompetitionNativeCameraScoreV2_9_3: - """Close and score one bank-aligned application without retaining siblings.""" - - source = _strict_scene(source_scene) - source.camera_by_id("main") - if type(pose) is not CompetitionNativeCameraPoseV2_9_3: - raise TypeError("camera evidence pose must be exact") - checked_pose = CompetitionNativeCameraPoseV2_9_3.model_validate( - pose.model_dump(mode="python"), strict=True - ) - checked_application = _strict_application(application) - if _wire_pose(checked_application.requested_pose) != checked_pose: - raise ValueError("camera evidence application is not bank-aligned") - if checked_application.observed_scene.scene_id != source.scene_id: - raise ValueError("camera evidence application scene identity mismatch") - _validate_application_source_closure(source, checked_application) - observed_camera = checked_application.observed_scene.camera_by_id("main") - if policy is not None: - verify_competition_native_solver_camera_binding_v2_9_3( - policy, - checked_pose, - observed_camera, - ) - return score_competition_native_camera_scene_v2_9_3( - checked_application.observed_scene - ) - - -def _strict_policy( - policy: object, -) -> CompetitionNativeCameraPolicyV2_9_3: - if type(policy) is not CompetitionNativeCameraPolicyV2_9_3: - raise TypeError("camera evidence policy must be exact") - return CompetitionNativeCameraPolicyV2_9_3.model_validate( - policy.model_dump(mode="python"), strict=True - ) - - -def verify_competition_native_solver_camera_binding_v2_9_3( - policy: CompetitionNativeCameraPolicyV2_9_3, - pose: CompetitionNativeCameraPoseV2_9_3, - camera: Camera, -) -> None: - """Require exact upright camera semantics only for the solver policy.""" - - checked_policy = _strict_policy(policy) - if type(pose) is not CompetitionNativeCameraPoseV2_9_3: - raise TypeError("solver camera pose must be exact") - checked_pose = CompetitionNativeCameraPoseV2_9_3.model_validate( - pose.model_dump(mode="python"), strict=True - ) - if type(camera) is not Camera or camera.camera_id != "main": - raise TypeError("solver camera must be exact main") - checked_camera = Camera.model_validate( - camera.model_dump(mode="python"), strict=True - ) - if checked_policy.pose_policy_version == _LEGACY_POSE_POLICY_VERSION: - return - if checked_pose.horizon_degrees != 0.0: - raise ValueError("solver-upright camera pose must use horizon zero") - try: - _legacy_camera(checked_camera.world_to_camera) - except _CameraConversionError as error: - raise ValueError("solver-upright camera matrix is unsupported") from error - - -def build_competition_native_source_camera_evidence_v2_9_3( - *, - source_id: str, - scene_id: str, - source_locator_sha256: str, - runtime_identity_sha256: str, - source_capture_sha256: str, - source_scene: Scene, - policy: CompetitionNativeCameraPolicyV2_9_3, - pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], - pose_scores: tuple[CompetitionNativeCameraScoreV2_9_3, ...], - selected_application: AI2ThorCameraApplication, -) -> CompetitionNativeSourceCameraEvidenceV2_9_3: - """Build evidence from a complete light ledger and one replayed winner.""" - - checked_source_scene = _strict_scene(source_scene) - if checked_source_scene.scene_id != scene_id: - raise ValueError("camera evidence source scene identity mismatch") - checked_source_scene.camera_by_id("main") - checked_policy = _strict_policy(policy) - checked_bank = _strict_pose_bank(pose_bank) - checked_scores = _strict_score_ledger(pose_scores) - if len(checked_scores) != len(checked_bank): - raise ValueError("camera evidence score ledger is not bank-aligned") - selected_index = select_competition_native_camera_score_index_v2_9_3(checked_scores) - selected_pose = checked_bank[selected_index] - selected = _strict_application(selected_application) - selected_score = score_competition_native_source_camera_application_v2_9_3( - source_scene=checked_source_scene, - pose=selected_pose, - application=selected, - ) - if selected_score != checked_scores[selected_index]: - raise ValueError("camera evidence replay score differs from frozen score") - - observed_pose = _wire_pose(selected.observed_pose) - observed_position = ( - selected.observed_camera_position.x, - selected.observed_camera_position.y, - selected.observed_camera_position.z, - ) - camera = selected.observed_scene.camera_by_id("main") - observation = selected.observation - pose_bank_sha256 = competition_native_camera_pose_bank_sha256_v2_9_3(checked_bank) - payload = _evidence_payload( - source_id=source_id, - scene_id=scene_id, - source_locator_sha256=source_locator_sha256, - runtime_identity_sha256=runtime_identity_sha256, - source_capture_sha256=source_capture_sha256, - policy_sha256=checked_policy.policy_sha256, - pose_bank_sha256=pose_bank_sha256, - pose_bank_count=len(checked_bank), - pose_scores=checked_scores, - selected_pose_index=selected_index, - requested_pose=selected_pose, - observed_pose=observed_pose, - observed_native_camera_position=observed_position, - camera=camera, - score=selected_score, - rgb_png_sha256=observation.rgb_png_sha256, - depth_npy_sha256=observation.depth_npy_sha256, - instance_png_sha256=observation.instance_png_sha256, - pointcloud_ply_sha256=observation.pointcloud_ply_sha256, - is_scene_at_rest=observation.is_scene_at_rest, - ) - return CompetitionNativeSourceCameraEvidenceV2_9_3( - **payload, - camera_evidence_sha256=canonical_sha256_v2( - payload, domain=_EVIDENCE_HASH_DOMAIN - ), - ) - - -def build_competition_native_source_camera_evidence_v2_9_4( - *, - source_id: str, - scene_id: str, - source_locator_sha256: str, - runtime_identity_sha256: str, - source_capture_sha256: str, - source_scene: Scene, - policy: CompetitionNativeCameraPolicyV2_9_3, - pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], - pose_scores: tuple[CompetitionNativeCameraScoreV2_9_4, ...], - placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], - selected_application: AI2ThorCameraApplication, -) -> CompetitionNativeSourceCameraEvidenceV2_9_3: - """Build source evidence whose winner maximizes visible edit coverage.""" - - checked_source_scene = _strict_scene(source_scene) - if checked_source_scene.scene_id != scene_id: - raise ValueError("camera evidence source scene identity mismatch") - checked_source_scene.camera_by_id("main") - checked_policy = _strict_policy(policy) - if checked_policy.pose_policy_version not in _editable_pose_policy_versions(): - raise ValueError("editable camera evidence requires the edit-domain policy") - checked_bank = _strict_pose_bank(pose_bank) - checked_scores = _strict_score_ledger(pose_scores) - if any( - type(item) is not CompetitionNativeCameraScoreV2_9_4 for item in checked_scores - ): - raise TypeError("editable camera evidence requires 2.9.4 scores") - if len(checked_scores) != len(checked_bank): - raise ValueError("camera evidence score ledger is not bank-aligned") - checked_roster = _strict_placement_roster_v2_9_4(placement_roster) - selected_index = select_competition_native_camera_score_index_v2_9_4( - checked_scores # type: ignore[arg-type] - ) - selected_pose = checked_bank[selected_index] - selected = _strict_application(selected_application) - selected_score = score_competition_native_editable_camera_application_v2_9_4( - source_scene=checked_source_scene, - pose=selected_pose, - application=selected, - placement_roster=checked_roster, - policy=checked_policy, - ) - if selected_score != checked_scores[selected_index]: - raise ValueError("camera evidence replay score differs from frozen score") - - observed_pose = _wire_pose(selected.observed_pose) - observed_position = ( - selected.observed_camera_position.x, - selected.observed_camera_position.y, - selected.observed_camera_position.z, - ) - camera = selected.observed_scene.camera_by_id("main") - observation = selected.observation - payload = _evidence_payload( - source_id=source_id, - scene_id=scene_id, - source_locator_sha256=source_locator_sha256, - runtime_identity_sha256=runtime_identity_sha256, - source_capture_sha256=source_capture_sha256, - policy_sha256=checked_policy.policy_sha256, - pose_bank_sha256=competition_native_camera_pose_bank_sha256_v2_9_3( - checked_bank - ), - pose_bank_count=len(checked_bank), - pose_scores=checked_scores, - selected_pose_index=selected_index, - requested_pose=selected_pose, - observed_pose=observed_pose, - observed_native_camera_position=observed_position, - camera=camera, - score=selected_score, - rgb_png_sha256=observation.rgb_png_sha256, - depth_npy_sha256=observation.depth_npy_sha256, - instance_png_sha256=observation.instance_png_sha256, - pointcloud_ply_sha256=observation.pointcloud_ply_sha256, - is_scene_at_rest=observation.is_scene_at_rest, - ) - return CompetitionNativeSourceCameraEvidenceV2_9_3( - **payload, - camera_evidence_sha256=canonical_sha256_v2( - payload, domain=_EVIDENCE_HASH_DOMAIN - ), - ) - - -def select_competition_native_source_camera_evidence_v2_9_3( - *, - source_id: str, - scene_id: str, - source_locator_sha256: str, - runtime_identity_sha256: str, - source_capture_sha256: str, - source_scene: Scene, - policy: CompetitionNativeCameraPolicyV2_9_3, - pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], - applications: tuple[AI2ThorCameraApplication, ...], -) -> CompetitionNativeSourceCameraEvidenceV2_9_3: - """Select one complete source observation using literal scene scores only.""" - - checked_source_scene = _strict_scene(source_scene) - checked_bank = _strict_pose_bank(pose_bank) - if type(applications) is not tuple or any( - type(item) is not AI2ThorCameraApplication for item in applications - ): - raise TypeError("camera evidence applications must be an exact tuple") - if len(applications) != len(checked_bank): - raise ValueError("camera evidence applications are not bank-aligned") - checked_applications = tuple(_strict_application(item) for item in applications) - scores: list[CompetitionNativeCameraScoreV2_9_3] = [] - for index, (pose, application) in enumerate( - zip(checked_bank, checked_applications, strict=True) - ): - try: - score = score_competition_native_source_camera_application_v2_9_3( - source_scene=checked_source_scene, - pose=pose, - application=application, - policy=policy, - ) - except ValueError as error: - if "bank-aligned" in str(error): - raise ValueError( - f"camera evidence application {index} is not bank-aligned" - ) from error - raise - scores.append(score) - - pose_scores = tuple(scores) - selected_index = select_competition_native_camera_score_index_v2_9_3(pose_scores) - return build_competition_native_source_camera_evidence_v2_9_3( - source_id=source_id, - scene_id=scene_id, - source_locator_sha256=source_locator_sha256, - runtime_identity_sha256=runtime_identity_sha256, - source_capture_sha256=source_capture_sha256, - source_scene=checked_source_scene, - policy=policy, - pose_bank=checked_bank, - pose_scores=pose_scores, - selected_application=checked_applications[selected_index], - ) - - -def verify_competition_native_source_camera_evidence_v2_9_3( - evidence: CompetitionNativeSourceCameraEvidenceV2_9_3, - *, - source_id: str, - scene_id: str, - source_locator_sha256: str, - runtime_identity_sha256: str, - source_capture_sha256: str, - policy: CompetitionNativeCameraPolicyV2_9_3, - pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], - source_scene: Scene, - selected_scene: Scene, - selected_camera: Camera, - selected_application: AI2ThorCameraApplication, -) -> CompetitionNativeSourceCameraEvidenceV2_9_3: - """Close persisted evidence against independently supplied capture bindings.""" - - if type(evidence) is not CompetitionNativeSourceCameraEvidenceV2_9_3: - raise TypeError("camera evidence must be exact") - checked = CompetitionNativeSourceCameraEvidenceV2_9_3.model_validate( - evidence.model_dump(mode="python"), strict=True - ) - checked_policy = _strict_policy(policy) - checked_bank = _strict_pose_bank(pose_bank) - source = _strict_scene(source_scene) - scene = _strict_scene(selected_scene) - if type(selected_camera) is not Camera: - raise TypeError("camera evidence selected camera must be exact") - camera = Camera.model_validate( - selected_camera.model_dump(mode="python"), strict=True - ) - application = _strict_application(selected_application) - if source.scene_id != scene_id: - raise ValueError("camera evidence source scene identity does not close") - _validate_application_source_closure(source, application) - expected_lineage = ( - source_id, - scene_id, - source_locator_sha256, - runtime_identity_sha256, - source_capture_sha256, - ) - if expected_lineage != ( - checked.source_id, - checked.scene_id, - checked.source_locator_sha256, - checked.runtime_identity_sha256, - checked.source_capture_sha256, - ): - raise ValueError("camera evidence lineage does not close") - if ( - checked.policy_sha256 != checked_policy.policy_sha256 - or checked.pose_bank_sha256 - != competition_native_camera_pose_bank_sha256_v2_9_3(checked_bank) - or checked.pose_bank_count != len(checked_bank) - or checked.selected_pose_index >= len(checked_bank) - or checked_bank[checked.selected_pose_index] != checked.requested_pose - or checked.requested_pose != _wire_pose(application.requested_pose) - ): - raise ValueError("camera evidence policy or pose bank does not close") - if ( - scene.scene_id != scene_id - or application.observed_scene != scene - or application.observation.scene != scene - or scene.camera_by_id("main") != camera - or checked.camera != camera - or checked.observed_pose != _wire_pose(application.observed_pose) - or checked.observed_native_camera_position - != ( - application.observed_camera_position.x, - application.observed_camera_position.y, - application.observed_camera_position.z, - ) - or checked.score != score_competition_native_camera_scene_v2_9_3(scene) - or checked.rgb_png_sha256 != application.observation.rgb_png_sha256 - or checked.depth_npy_sha256 != application.observation.depth_npy_sha256 - or checked.instance_png_sha256 != application.observation.instance_png_sha256 - or checked.pointcloud_ply_sha256 - != application.observation.pointcloud_ply_sha256 - or checked.is_scene_at_rest is not application.observation.is_scene_at_rest - ): - raise ValueError("camera evidence selected capture does not close") - verify_competition_native_solver_camera_binding_v2_9_3( - checked_policy, - checked.requested_pose, - camera, - ) - return checked - - -CameraPolicy = CompetitionNativeCameraPolicyV2_9_3 -SourceCameraEvidence = CompetitionNativeSourceCameraEvidenceV2_9_3 -build_settled_camera_policy = build_competition_native_settled_camera_policy_v2_9_10 -verify_source_camera_evidence = verify_competition_native_source_camera_evidence_v2_9_3 - -__all__ = ( - "CameraPolicy", - "SourceCameraEvidence", - "build_settled_camera_policy", - "verify_source_camera_evidence", -) diff --git a/src/spatialcf/generation/_internal/evidence/surface.py b/src/spatialcf/generation/_internal/evidence/surface.py deleted file mode 100644 index 2f2ac4d..0000000 --- a/src/spatialcf/generation/_internal/evidence/surface.py +++ /dev/null @@ -1,521 +0,0 @@ -"""Capture-bound receptacle surface evidence for the current roster.""" - -from __future__ import annotations - -from dataclasses import asdict -from typing import TYPE_CHECKING, Literal, Self - -from pydantic import Field, model_validator - -from spatialcf.adapters.ai2thor import ( - AI2ThorNativePosition, - AI2ThorReceptacleSpawnMap, - AI2ThorReceptacleSurfacePatch, - AI2ThorRuntimeIdentity, - build_receptacle_support_position_region, -) -from spatialcf.domain.v2.base import FiniteFloat, Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import canonical_sha256_v2 - -if TYPE_CHECKING: - from spatialcf.generation.capture.models import ( - CompetitionNativeSourceCaptureV2_9, - ) - -_PATCH_HASH_DOMAIN = "spatialcf.competition-native-receptacle-surface-patch.v2.9.2" -_SUBJECT_EVIDENCE_HASH_DOMAIN = ( - "spatialcf.competition-native-subject-surface-evidence.v2.9.2" -) -_SOURCE_EVIDENCE_HASH_DOMAIN = ( - "spatialcf.competition-native-source-surface-evidence.v2.9.2" -) -_RUNTIME_IDENTITY_HASH_DOMAIN = "spatialcf.competition-native-runtime-identity.v2.9.2" - - -def _patch_payload( - *, - patch_index: int, - x_min: float, - x_max: float, - native_y: float, - z_min: float, - z_max: float, -) -> dict[str, object]: - return { - "native_y": native_y, - "patch_index": patch_index, - "x_max": x_max, - "x_min": x_min, - "z_max": z_max, - "z_min": z_min, - } - - -class CompetitionNativeReceptacleSurfacePatchV2_9_2(V2Model): - """One ordered raw 21x21 trigger-grid patch and its independent digest.""" - - patch_index: int = Field(strict=True, ge=0) - x_min: FiniteFloat - x_max: FiniteFloat - native_y: FiniteFloat - z_min: FiniteFloat - z_max: FiniteFloat - patch_sha256: Sha256Digest - - @model_validator(mode="after") - def validate_patch(self) -> Self: - if self.x_min >= self.x_max or self.z_min >= self.z_max: - raise ValueError("surface evidence patch must have positive area") - expected = canonical_sha256_v2( - _patch_payload( - patch_index=self.patch_index, - x_min=self.x_min, - x_max=self.x_max, - native_y=self.native_y, - z_min=self.z_min, - z_max=self.z_max, - ), - domain=_PATCH_HASH_DOMAIN, - ) - if self.patch_sha256 != expected: - raise ValueError("surface evidence patch digest mismatch") - return self - - -def _subject_payload( - *, - subject_object_id: str, - support_object_id: str, - native_subject_object_id: str, - native_support_object_id: str, - runtime_identity_sha256: str, - scene_sha256: str, - positions_sha256: str, - spawn_map_source_sha256: str, - placement_sha256: str, - source_capture_sha256: str, - patches: tuple[CompetitionNativeReceptacleSurfacePatchV2_9_2, ...], -) -> dict[str, object]: - return { - "native_support_object_id": native_support_object_id, - "native_subject_object_id": native_subject_object_id, - "patches": tuple(item.model_dump(mode="json") for item in patches), - "placement_sha256": placement_sha256, - "positions_sha256": positions_sha256, - "runtime_identity_sha256": runtime_identity_sha256, - "scene_sha256": scene_sha256, - "source_capture_sha256": source_capture_sha256, - "spawn_map_source_sha256": spawn_map_source_sha256, - "subject_object_id": subject_object_id, - "support_object_id": support_object_id, - } - - -class CompetitionNativeSubjectSurfaceEvidenceV2_9_2(V2Model): - """All source-capture bindings for one receptacle-supported subject.""" - - subject_object_id: str = Field(strict=True, min_length=1, max_length=512) - support_object_id: str = Field(strict=True, min_length=1, max_length=512) - native_subject_object_id: str = Field(strict=True, min_length=1, max_length=512) - native_support_object_id: str = Field(strict=True, min_length=1, max_length=512) - runtime_identity_sha256: Sha256Digest - scene_sha256: Sha256Digest - positions_sha256: Sha256Digest - spawn_map_source_sha256: Sha256Digest - placement_sha256: Sha256Digest - source_capture_sha256: Sha256Digest - patches: tuple[CompetitionNativeReceptacleSurfacePatchV2_9_2, ...] = Field( - min_length=1, - max_length=256, - ) - subject_surface_evidence_sha256: Sha256Digest - - @model_validator(mode="after") - def validate_subject_evidence(self) -> Self: - if self.subject_object_id == self.support_object_id: - raise ValueError("surface evidence subject and support must differ") - if tuple(item.patch_index for item in self.patches) != tuple( - range(len(self.patches)) - ): - raise ValueError("surface evidence patch indexes are not canonical") - patch_keys = tuple( - ( - item.native_y, - item.x_min, - item.z_min, - item.x_max, - item.z_max, - ) - for item in self.patches - ) - if patch_keys != tuple(sorted(set(patch_keys))): - raise ValueError("surface evidence patches are not unique and canonical") - expected = canonical_sha256_v2( - _subject_payload( - subject_object_id=self.subject_object_id, - support_object_id=self.support_object_id, - native_subject_object_id=self.native_subject_object_id, - native_support_object_id=self.native_support_object_id, - runtime_identity_sha256=self.runtime_identity_sha256, - scene_sha256=self.scene_sha256, - positions_sha256=self.positions_sha256, - spawn_map_source_sha256=self.spawn_map_source_sha256, - placement_sha256=self.placement_sha256, - source_capture_sha256=self.source_capture_sha256, - patches=self.patches, - ), - domain=_SUBJECT_EVIDENCE_HASH_DOMAIN, - ) - if self.subject_surface_evidence_sha256 != expected: - raise ValueError("subject surface evidence digest mismatch") - return self - - -def _source_payload( - *, - source_id: str, - scene_id: str, - source_capture_sha256: str, - subjects: tuple[CompetitionNativeSubjectSurfaceEvidenceV2_9_2, ...], -) -> dict[str, object]: - return { - "evidence_version": "competition-native-source-surface-evidence:2.9.2", - "scene_id": scene_id, - "source_capture_sha256": source_capture_sha256, - "source_id": source_id, - "subjects": tuple(item.model_dump(mode="json") for item in subjects), - } - - -class CompetitionNativeSourceSurfaceEvidenceV2_9_2(V2Model): - """One sibling surface-evidence row for an unchanged accepted capture.""" - - evidence_version: Literal["competition-native-source-surface-evidence:2.9.2"] = ( - "competition-native-source-surface-evidence:2.9.2" - ) - source_id: str = Field(strict=True, min_length=1, max_length=512) - scene_id: str = Field(strict=True, min_length=1, max_length=512) - source_capture_sha256: Sha256Digest - subjects: tuple[CompetitionNativeSubjectSurfaceEvidenceV2_9_2, ...] = Field( - max_length=96 - ) - surface_evidence_sha256: Sha256Digest - - @model_validator(mode="after") - def validate_source_evidence(self) -> Self: - subject_ids = tuple(item.subject_object_id for item in self.subjects) - if subject_ids != tuple(sorted(set(subject_ids))): - raise ValueError("source surface evidence subjects are not canonical") - if any( - item.source_capture_sha256 != self.source_capture_sha256 - for item in self.subjects - ): - raise ValueError("source surface evidence capture lineage mismatch") - expected = canonical_sha256_v2( - _source_payload( - source_id=self.source_id, - scene_id=self.scene_id, - source_capture_sha256=self.source_capture_sha256, - subjects=self.subjects, - ), - domain=_SOURCE_EVIDENCE_HASH_DOMAIN, - ) - if self.surface_evidence_sha256 != expected: - raise ValueError("source surface evidence digest mismatch") - return self - - -def _strict_spawn_map(value: object) -> AI2ThorReceptacleSpawnMap: - if type(value) is not AI2ThorReceptacleSpawnMap: - raise TypeError("surface evidence spawn map must be exact") - if type(value.runtime_identity) is not AI2ThorRuntimeIdentity: - raise TypeError("surface evidence runtime identity must be exact") - if type(value.surface_patches) is not tuple or any( - type(item) is not AI2ThorReceptacleSurfacePatch - for item in value.surface_patches - ): - raise TypeError("surface evidence patches must be an exact tuple") - return AI2ThorReceptacleSpawnMap( - scene_id=value.scene_id, - subject_object_id=value.subject_object_id, - support_object_id=value.support_object_id, - native_subject_object_id=value.native_subject_object_id, - native_support_object_id=value.native_support_object_id, - runtime_identity=AI2ThorRuntimeIdentity(**asdict(value.runtime_identity)), - positions=tuple(value.positions), - positions_sha256=value.positions_sha256, - scene_sha256=value.scene_sha256, - source_sha256=value.source_sha256, - surface_patches=tuple( - AI2ThorReceptacleSurfacePatch(**asdict(item)) - for item in value.surface_patches - ), - ) - - -def _build_patch( - patch_index: int, - patch: AI2ThorReceptacleSurfacePatch, -) -> CompetitionNativeReceptacleSurfacePatchV2_9_2: - payload = _patch_payload( - patch_index=patch_index, - x_min=patch.x_min, - x_max=patch.x_max, - native_y=patch.native_y, - z_min=patch.z_min, - z_max=patch.z_max, - ) - return CompetitionNativeReceptacleSurfacePatchV2_9_2( - **payload, - patch_sha256=canonical_sha256_v2(payload, domain=_PATCH_HASH_DOMAIN), - ) - - -def verify_competition_native_source_surface_evidence_v2_9_2( - capture: CompetitionNativeSourceCaptureV2_9, - evidence: CompetitionNativeSourceSurfaceEvidenceV2_9_2, -) -> CompetitionNativeSourceSurfaceEvidenceV2_9_2: - """Freshly replay every persisted patch binding against one capture.""" - - from spatialcf.generation.capture.models import ( - CompetitionNativePlacementAvailabilityV2_9, - CompetitionNativeRuntimeIdentityV2_9, - CompetitionNativeSourceCaptureV2_9, - CompetitionNativeSupportKindV2_9, - ) - - if type(capture) is not CompetitionNativeSourceCaptureV2_9: - raise TypeError("surface evidence capture must be exact") - if type(evidence) is not CompetitionNativeSourceSurfaceEvidenceV2_9_2: - raise TypeError("surface evidence must be exact") - checked_capture = CompetitionNativeSourceCaptureV2_9.model_validate( - capture.model_dump(mode="python"), - strict=True, - ) - checked_evidence = CompetitionNativeSourceSurfaceEvidenceV2_9_2.model_validate( - evidence.model_dump(mode="python"), - strict=True, - ) - if ( - checked_evidence.source_id != checked_capture.source.source_id - or checked_evidence.scene_id != checked_capture.scene.scene_id - or checked_evidence.source_capture_sha256 - != checked_capture.source_capture_sha256 - ): - raise ValueError("surface evidence does not bind source capture") - - placements = {item.object_id: item for item in checked_capture.placement_facts} - supports = {item.object_id: item for item in checked_capture.support_facts} - expected_subject_ids = tuple( - sorted( - item.object_id - for item in checked_capture.placement_facts - if item.availability - is CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN - ) - ) - if tuple(item.subject_object_id for item in checked_evidence.subjects) != ( - expected_subject_ids - ): - raise ValueError("surface evidence does not close capture placements") - - runtime = AI2ThorRuntimeIdentity( - **checked_capture.runtime_identity.model_dump(mode="python") - ) - runtime_digest = canonical_sha256_v2( - CompetitionNativeRuntimeIdentityV2_9(**asdict(runtime)), - domain=_RUNTIME_IDENTITY_HASH_DOMAIN, - ) - for subject in checked_evidence.subjects: - placement = placements[subject.subject_object_id] - support = supports[subject.subject_object_id] - support_object_id = support.support_object_id - if ( - support.support_kind is not CompetitionNativeSupportKindV2_9.RECEPTACLE - or support_object_id is None - or subject.support_object_id != support_object_id - or subject.native_subject_object_id != support.native_object_id - or subject.native_support_object_id - != supports[support_object_id].native_object_id - or subject.runtime_identity_sha256 != runtime_digest - or subject.placement_sha256 != placement.placement_sha256 - or subject.source_capture_sha256 != checked_capture.source_capture_sha256 - or placement.position_region is None - ): - raise ValueError("surface evidence subject does not bind capture facts") - spawn_map = AI2ThorReceptacleSpawnMap( - scene_id=checked_capture.scene.scene_id, - subject_object_id=subject.subject_object_id, - support_object_id=subject.support_object_id, - native_subject_object_id=subject.native_subject_object_id, - native_support_object_id=subject.native_support_object_id, - runtime_identity=runtime, - positions=tuple( - AI2ThorNativePosition(x=item.x, y=item.y, z=item.z) - for item in placement.native_positions - ), - positions_sha256=subject.positions_sha256, - scene_sha256=subject.scene_sha256, - source_sha256=subject.spawn_map_source_sha256, - surface_patches=tuple( - AI2ThorReceptacleSurfacePatch( - x_min=item.x_min, - x_max=item.x_max, - native_y=item.native_y, - z_min=item.z_min, - z_max=item.z_max, - ) - for item in subject.patches - ), - ) - if ( - build_receptacle_support_position_region( - checked_capture.scene, - spawn_map, - ) - != placement.position_region - ): - raise ValueError("surface evidence placement region changed on replay") - return checked_evidence - - -def build_competition_native_source_surface_evidence_v2_9_2( - capture: CompetitionNativeSourceCaptureV2_9, - spawn_maps: tuple[AI2ThorReceptacleSpawnMap, ...], -) -> CompetitionNativeSourceSurfaceEvidenceV2_9_2: - """Close raw patch ownership against one unchanged source capture.""" - - from spatialcf.generation.capture.models import ( - CompetitionNativePlacementAvailabilityV2_9, - CompetitionNativeRuntimeIdentityV2_9, - CompetitionNativeSourceCaptureV2_9, - CompetitionNativeSupportKindV2_9, - ) - - if type(capture) is not CompetitionNativeSourceCaptureV2_9: - raise TypeError("surface evidence capture must be exact") - checked_capture = CompetitionNativeSourceCaptureV2_9.model_validate( - capture.model_dump(mode="python"), - strict=True, - ) - if type(spawn_maps) is not tuple: - raise TypeError("surface evidence spawn_maps must be an exact tuple") - checked_maps = tuple( - sorted( - (_strict_spawn_map(item) for item in spawn_maps), - key=lambda item: item.subject_object_id, - ) - ) - map_subject_ids = tuple(item.subject_object_id for item in checked_maps) - if len(map_subject_ids) != len(set(map_subject_ids)): - raise ValueError("surface evidence spawn-map subjects must be unique") - - placement_by_id = {item.object_id: item for item in checked_capture.placement_facts} - support_by_id = {item.object_id: item for item in checked_capture.support_facts} - expected_subject_ids = tuple( - sorted( - item.object_id - for item in checked_capture.placement_facts - if item.availability - is CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN - ) - ) - if map_subject_ids != expected_subject_ids: - raise ValueError("surface evidence spawn maps do not close capture placements") - - subjects: list[CompetitionNativeSubjectSurfaceEvidenceV2_9_2] = [] - for spawn_map in checked_maps: - placement = placement_by_id[spawn_map.subject_object_id] - support = support_by_id[spawn_map.subject_object_id] - support_object_id = support.support_object_id - if ( - support.support_kind is not CompetitionNativeSupportKindV2_9.RECEPTACLE - or support_object_id is None - or spawn_map.scene_id != checked_capture.scene.scene_id - or spawn_map.support_object_id != support_object_id - or spawn_map.native_subject_object_id != support.native_object_id - or spawn_map.native_support_object_id - != support_by_id[support_object_id].native_object_id - or not spawn_map.surface_patches - or placement.position_region is None - or build_receptacle_support_position_region( - checked_capture.scene, - spawn_map, - ) - != placement.position_region - ): - raise ValueError("surface evidence spawn map does not bind capture facts") - expected_runtime = CompetitionNativeRuntimeIdentityV2_9( - **asdict(spawn_map.runtime_identity) - ) - if expected_runtime != checked_capture.runtime_identity: - raise ValueError("surface evidence runtime does not bind source capture") - patches = tuple( - _build_patch(index, patch) - for index, patch in enumerate(spawn_map.surface_patches) - ) - runtime_identity_sha256 = canonical_sha256_v2( - checked_capture.runtime_identity, - domain=_RUNTIME_IDENTITY_HASH_DOMAIN, - ) - payload = _subject_payload( - subject_object_id=spawn_map.subject_object_id, - support_object_id=spawn_map.support_object_id, - native_subject_object_id=spawn_map.native_subject_object_id, - native_support_object_id=spawn_map.native_support_object_id, - runtime_identity_sha256=runtime_identity_sha256, - scene_sha256=spawn_map.scene_sha256, - positions_sha256=spawn_map.positions_sha256, - spawn_map_source_sha256=spawn_map.source_sha256, - placement_sha256=placement.placement_sha256, - source_capture_sha256=checked_capture.source_capture_sha256, - patches=patches, - ) - subjects.append( - CompetitionNativeSubjectSurfaceEvidenceV2_9_2( - **payload, - subject_surface_evidence_sha256=canonical_sha256_v2( - payload, - domain=_SUBJECT_EVIDENCE_HASH_DOMAIN, - ), - ) - ) - - subject_tuple = tuple(subjects) - source_payload = _source_payload( - source_id=checked_capture.source.source_id, - scene_id=checked_capture.scene.scene_id, - source_capture_sha256=checked_capture.source_capture_sha256, - subjects=subject_tuple, - ) - evidence = CompetitionNativeSourceSurfaceEvidenceV2_9_2( - source_id=checked_capture.source.source_id, - scene_id=checked_capture.scene.scene_id, - source_capture_sha256=checked_capture.source_capture_sha256, - subjects=subject_tuple, - surface_evidence_sha256=canonical_sha256_v2( - source_payload, - domain=_SOURCE_EVIDENCE_HASH_DOMAIN, - ), - ) - return verify_competition_native_source_surface_evidence_v2_9_2( - checked_capture, - evidence, - ) - - -ReceptacleSurfacePatch = CompetitionNativeReceptacleSurfacePatchV2_9_2 -SourceSurfaceEvidence = CompetitionNativeSourceSurfaceEvidenceV2_9_2 -SubjectSurfaceEvidence = CompetitionNativeSubjectSurfaceEvidenceV2_9_2 -build_source_surface_evidence = build_competition_native_source_surface_evidence_v2_9_2 -verify_source_surface_evidence = ( - verify_competition_native_source_surface_evidence_v2_9_2 -) - -__all__ = ( - "ReceptacleSurfacePatch", - "SourceSurfaceEvidence", - "SubjectSurfaceEvidence", - "build_source_surface_evidence", - "verify_source_surface_evidence", -) diff --git a/src/spatialcf/generation/_internal/execution/__init__.py b/src/spatialcf/generation/_internal/execution/__init__.py deleted file mode 100644 index 806d771..0000000 --- a/src/spatialcf/generation/_internal/execution/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Current native audit and run owners.""" - -from spatialcf.generation._internal.execution.audit import ( - EndpointAudit, - EndpointAuditRejected, -) -from spatialcf.generation._internal.execution.correspondence import ( - CaptureSourceCorrespondence, - RequestLineage, -) -from spatialcf.generation._internal.execution.run import ( - AuditExecution, - AuditRun, - execute_audit, - verify_audit_run, -) - -__all__ = ( - "AuditExecution", - "AuditRun", - "CaptureSourceCorrespondence", - "EndpointAudit", - "EndpointAuditRejected", - "RequestLineage", - "execute_audit", - "verify_audit_run", -) diff --git a/src/spatialcf/generation/_internal/execution/audit.py b/src/spatialcf/generation/_internal/execution/audit.py deleted file mode 100644 index 5aa61f7..0000000 --- a/src/spatialcf/generation/_internal/execution/audit.py +++ /dev/null @@ -1,974 +0,0 @@ -"""Current one-shot endpoint audit authority.""" - -from __future__ import annotations - -import hashlib -import json -import math -import warnings -from dataclasses import dataclass -from typing import Literal, Self - -from pydantic import Field, model_validator - -from spatialcf.adapters.ai2thor import ( - AI2ThorAdapter, - AI2ThorNativePosition, - AI2ThorNativeReturnError, - AI2ThorObservation, - AI2ThorPoseApplication, - AI2ThorReceptacleSpawnMap, - AI2ThorRuntimeError, - AI2ThorSettlementTimeout, -) -from spatialcf.core.v2.continuous_yaw_solve_verifier_v2_9 import ( - verify_continuous_yaw_solve_result_v2_9, -) -from spatialcf.domain.enums import QualityTier, SolverStatus -from spatialcf.domain.models import OBB, Camera, InterventionSpec, Scene -from spatialcf.domain.v2.base import ( - CanonicalId, - FiniteFloat, - NonNegativeFiniteFloat, - Sha256Digest, - V2Model, - Vec3V2, -) -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( - ContinuousYawCertifiedSuccessResultV2_9, - ContinuousYawSolverConfigV2_9, - ContinuousYawSolveVerificationKindV2, -) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 -from spatialcf.generation._internal.evidence.camera import ( - CameraPolicy, - SourceCameraEvidence, - verify_competition_native_camera_observation_binding_v2_9_3, -) -from spatialcf.generation._internal.evidence.camera import ( - CompetitionNativeCameraPoseV2_9_3 as CameraPose, -) -from spatialcf.generation._internal.execution.correspondence import ( - CaptureSourceCorrespondence, - legacy_sha256, -) -from spatialcf.generation._internal.planning.campaign import RuntimePosePolicy -from spatialcf.generation._internal.planning.models import ProxyBundle -from spatialcf.verification.verifier import VerificationResult, Verifier - -_AUDIT_HASH_DOMAIN = "spatialcf.competition-native-endpoint-audit.v2.9.6" -_OBSERVATION_DOMAIN = b"spatialcf.competition-native-observation.v2.9\0" -_MAX_POSITION_RESIDUAL_M = 1e-5 -_MAX_RUNTIME_POSITION_RESIDUAL_M = 1e-4 -_OBJECT_GEOMETRY_TOLERANCE_M = 1e-5 -_CAMERA_INTRINSIC_TOLERANCE = 1e-8 -_CAMERA_EXTRINSIC_TOLERANCE = 1e-5 - - -def observation_sha256(observation: AI2ThorObservation) -> str: - if type(observation) is not AI2ThorObservation: - raise TypeError("native observation digest requires an exact observation") - assets = { - "depth_npy_sha256": hashlib.sha256(observation.depth_npy).hexdigest(), - "instance_png_sha256": hashlib.sha256(observation.instance_png).hexdigest(), - "pointcloud_ply_sha256": hashlib.sha256(observation.pointcloud_ply).hexdigest(), - "rgb_png_sha256": hashlib.sha256(observation.rgb_png).hexdigest(), - } - stored = { - "depth_npy_sha256": observation.depth_npy_sha256, - "instance_png_sha256": observation.instance_png_sha256, - "pointcloud_ply_sha256": observation.pointcloud_ply_sha256, - "rgb_png_sha256": observation.rgb_png_sha256, - } - if assets != stored: - raise ValueError("native observation stored asset digests do not match bytes") - counts = observation.instance_pixel_counts - if any( - type(key) is not str or type(value) is not int for key, value in counts.items() - ): - raise TypeError("native observation pixel counts must be exact") - payload = json.dumps( - { - "assets": assets, - "instance_pixel_counts": dict(sorted(counts.items())), - "is_scene_at_rest": observation.is_scene_at_rest, - "scene_sha256": legacy_sha256(observation.scene), - }, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - return hashlib.sha256(_OBSERVATION_DOMAIN + payload).hexdigest() - - -class EndpointAudit(V2Model): - """Flat current bbox/runtime-pose endpoint audit.""" - - audit_version: Literal["competition-native-endpoint-audit:2.9.6"] = ( - "competition-native-endpoint-audit:2.9.6" - ) - audit_scope: Literal[ - "ONE_CAMERA_REPLAY_ONE_DELEGATED_BBOX_PATCH_SOLVE_ONE_FRESH_PATCH_" - "ONE_NATIVE_ACTION_ONE_BOUNDED_SUBJECT_POSE" - ] = ( - "ONE_CAMERA_REPLAY_ONE_DELEGATED_BBOX_PATCH_SOLVE_ONE_FRESH_PATCH_" - "ONE_NATIVE_ACTION_ONE_BOUNDED_SUBJECT_POSE" - ) - native_action: Literal["PlaceObjectAtPoint"] = "PlaceObjectAtPoint" - native_audit_status: Literal["PASSED"] = "PASSED" - evidence_eligible: Literal[False] = False - case_id: CanonicalId - native_scene_id: CanonicalId - subject_native_object_id: CanonicalId - reference_native_object_id: CanonicalId - semantic_problem_sha256: Sha256Digest - solver_config_sha256: Sha256Digest - solve_result_sha256: Sha256Digest - edit_sha256: Sha256Digest - proxy_bundle_sha256: Sha256Digest - spawn_map_source_sha256: Sha256Digest - observed_scene_sha256: Sha256Digest - after_observation_sha256: Sha256Digest - commanded_position: Vec3V2 - observed_position: Vec3V2 - position_residual_m: NonNegativeFiniteFloat - relation_before: CanonicalId - relation_after: CanonicalId - verification_status: Literal["success"] = "success" - verification_quality: Literal["PURE", "LOW_LEAKAGE"] - relation_damage_count: int = Field(strict=True, ge=0) - relation_damage_items: tuple[CanonicalId, ...] - semantic_normalization_scope: Literal[ - "FRESH_COLLATERAL_THEN_FROZEN_SOLVER_PROJECTION_V1" - ] = "FRESH_COLLATERAL_THEN_FROZEN_SOLVER_PROJECTION_V1" - frozen_source_scene_sha256: Sha256Digest - fresh_source_scene_sha256: Sha256Digest - source_correspondence_sha256: Sha256Digest - fresh_spawn_map_source_sha256: Sha256Digest - source_capture_sha256: Sha256Digest - runtime_identity_sha256: Sha256Digest - placement_sha256: Sha256Digest - surface_evidence_sha256: Sha256Digest - subject_surface_evidence_sha256: Sha256Digest - patch_index: int = Field(strict=True, ge=0) - patch_sha256: Sha256Digest - fresh_subject_surface_evidence_sha256: Sha256Digest - fresh_patch_sha256: Sha256Digest - endpoint_plan_sha256: Sha256Digest - camera_evidence: SourceCameraEvidence - camera_policy: CameraPolicy - fresh_observed_pose: CameraPose - fresh_observed_native_camera_position: tuple[FiniteFloat, FiniteFloat, FiniteFloat] - fresh_camera: Camera - camera_replay_observation_sha256: Sha256Digest - runtime_collision_delegated_native_object_ids: tuple[CanonicalId, ...] - runtime_pose_policy: RuntimePosePolicy - subject_rotation_residual_degrees: NonNegativeFiniteFloat - subject_obb_corner_residual_m: NonNegativeFiniteFloat - visibility_semantics_id: Literal["visibility-semantics:analytic-bbox-v1"] = ( - "visibility-semantics:analytic-bbox-v1" - ) - image_area_metric_definition_id: Literal[ - "visibility:visible-clipped-projected-bounding-box-area-fraction" - ] = "visibility:visible-clipped-projected-bounding-box-area-fraction" - image_area_metric_definition_version: Literal["definition:2"] = "definition:2" - image_area_metric_formula: Literal[ - "VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA" - ] = "VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA" - - @model_validator(mode="after") - def validate_audit(self) -> Self: - if self.position_residual_m > _MAX_RUNTIME_POSITION_RESIDUAL_M: - raise ValueError("native endpoint residual exceeds the frozen limit") - if self.relation_before == self.relation_after: - raise ValueError("native audit must change the target relation") - if self.relation_damage_items != tuple(sorted(set(self.relation_damage_items))): - raise ValueError("relation damage items must be unique and canonical") - if self.relation_damage_count != len(self.relation_damage_items): - raise ValueError("relation damage count does not close its roster") - return self - - @model_validator(mode="after") - def validate_fresh_patch(self) -> Self: - if ( - self.fresh_subject_surface_evidence_sha256 - != self.subject_surface_evidence_sha256 - or self.fresh_patch_sha256 != self.patch_sha256 - ): - raise ValueError("fresh native patch does not close frozen patch lineage") - return self - - @model_validator(mode="after") - def validate_camera_replay(self) -> Self: - if type(self.fresh_camera) is not Camera: - raise TypeError("fresh camera replay Camera must be exact") - camera = Camera.model_validate( - self.fresh_camera.model_dump(mode="python"), strict=True - ) - object.__setattr__(self, "fresh_camera", camera) - if ( - self.camera_evidence.policy_sha256 != self.camera_policy.policy_sha256 - or self.camera_evidence.source_capture_sha256 != self.source_capture_sha256 - ): - raise ValueError("camera replay audit frozen lineage is not closed") - verify_competition_native_camera_observation_binding_v2_9_3( - self.camera_evidence.requested_pose, - self.fresh_observed_pose, - self.fresh_observed_native_camera_position, - camera, - ) - return self - - @model_validator(mode="after") - def validate_runtime_collision_authority(self) -> Self: - delegated = self.runtime_collision_delegated_native_object_ids - if delegated != tuple(sorted(set(delegated))): - raise ValueError("runtime collision authority must be canonical") - return self - - @model_validator(mode="after") - def validate_runtime_pose_authority(self) -> Self: - policy = self.runtime_pose_policy - if ( - self.position_residual_m > policy.max_subject_position_residual_m - or self.subject_rotation_residual_degrees - > policy.max_subject_rotation_residual_degrees - or self.subject_obb_corner_residual_m - > policy.max_subject_obb_corner_residual_m - ): - raise ValueError("runtime subject pose exceeds the frozen policy") - return self - - @property - def endpoint_audit_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_AUDIT_HASH_DOMAIN) - - @property - def competition_native_endpoint_audit_sha256(self) -> Sha256Digest: - return self.endpoint_audit_sha256 - - -class _NativeAfterStructureMismatch(ValueError): - """The returned native scene changed facts outside the one allowed edit.""" - - -class EndpointAuditRejected(RuntimeError): - """A solve or native endpoint failed before an audit record was published.""" - - def __init__(self, stage: str, reasons: tuple[str, ...]) -> None: - if type(stage) is not str or not stage or not reasons: - raise ValueError("a native audit rejection requires a stage and reasons") - if type(reasons) is not tuple or any( - type(reason) is not str or not reason for reason in reasons - ): - raise TypeError("native audit rejection reasons must be exact strings") - self.stage = stage - self.reasons = tuple(sorted(set(reasons))) - super().__init__(f"{stage}: {', '.join(self.reasons)}") - - -@dataclass(frozen=True) -class _EndpointExecution: - audit: EndpointAudit | None - application: AI2ThorPoseApplication - capture_bound_audit_payload: dict[str, object] | None = None - - def __post_init__(self) -> None: - if (self.audit is None) == (self.capture_bound_audit_payload is None): - raise ValueError( - "endpoint execution requires exactly one closed audit representation" - ) - - -def execute_endpoint( - adapter: AI2ThorAdapter, - frozen_source_scene: Scene, - fresh_source_scene: Scene, - intervention: InterventionSpec, - proxy_bundle: ProxyBundle, - config: ContinuousYawSolverConfigV2_9, - solve_result: ContinuousYawCertifiedSuccessResultV2_9, - fresh_spawn_map: AI2ThorReceptacleSpawnMap, - *, - capture_bound_spawn_map_source_sha256: Sha256Digest, - max_post_edit_pass_steps: int, - runtime_pose_policy: RuntimePosePolicy, - source_correspondence: CaptureSourceCorrespondence, -) -> _EndpointExecution: - """Execute a frozen solve once against its corresponding fresh baseline.""" - - with warnings.catch_warnings(): - warnings.simplefilter("error", Warning) - frozen = _strict_legacy( - frozen_source_scene, - Scene, - "frozen_source_scene", - ) - fresh = _strict_legacy(fresh_source_scene, Scene, "fresh_source_scene") - spec = _strict_legacy(intervention, InterventionSpec, "intervention") - bundle = _strict_v2(proxy_bundle, ProxyBundle, "proxy_bundle") - checked_config = _strict_v2(config, ContinuousYawSolverConfigV2_9, "config") - result = _strict_v2( - solve_result, - ContinuousYawCertifiedSuccessResultV2_9, - "solve_result", - ) - correspondence = _strict_v2( - source_correspondence, - CaptureSourceCorrespondence, - "source_correspondence", - ) - runtime_pose_policy = _strict_v2( - runtime_pose_policy, RuntimePosePolicy, "runtime_pose_policy" - ) - if correspondence.frozen_source_scene_sha256 != legacy_sha256( - frozen - ) or correspondence.fresh_source_scene_sha256 != legacy_sha256(fresh): - raise EndpointAuditRejected( - "native_precondition", ("capture_source_correspondence_mismatch",) - ) - if type(capture_bound_spawn_map_source_sha256) is not str: - raise TypeError("capture-bound spawn digest must be an exact string") - - binding = bundle.binding - if binding.legacy_scene_sha256 != legacy_sha256(frozen): - raise EndpointAuditRejected("input", ("legacy_scene_sha256_mismatch",)) - if binding.intervention_sha256 != legacy_sha256(spec): - raise EndpointAuditRejected("input", ("intervention_sha256_mismatch",)) - if ( - result.semantic_problem_sha256 - != bundle.semantic_problem.semantic_problem_sha256 - ): - raise EndpointAuditRejected("solve", ("semantic_problem_sha256_mismatch",)) - if type(fresh_spawn_map) is not AI2ThorReceptacleSpawnMap: - raise TypeError("fresh_spawn_map must be an exact AI2ThorReceptacleSpawnMap") - verified = verify_continuous_yaw_solve_result_v2_9( - bundle.semantic_problem, - checked_config, - result, - ) - if verified.kind is not ContinuousYawSolveVerificationKindV2.VERIFIED: - raise EndpointAuditRejected( - "solve", - verified.finding_codes or ("fresh_solve_verification_failed",), - ) - - edit = result.selected_witness.edit - expected_subject_id = f"object:{binding.subject_native_object_id}" - if edit.subject_id != expected_subject_id: - raise RuntimeError("verified edit subject does not match the proxy binding") - frozen_subject = frozen.object_by_id(binding.subject_native_object_id) - _require_native_endpoint_preconditions( - fresh, - frozen_subject.object_id, - fresh_spawn_map, - ) - endpoint_x = frozen_subject.position.x + edit.translation_xy_m.x - endpoint_y = frozen_subject.position.y + edit.translation_xy_m.y - fresh_spawn_map_source_sha256 = fresh_spawn_map.source_sha256 - before_native_event = adapter.latest_native_event(fresh.scene_id) - try: - application = adapter.apply_receptacle_endpoint_settled_observed( - fresh, - fresh_spawn_map, - x=endpoint_x, - y=endpoint_y, - max_pass_steps=max_post_edit_pass_steps, - max_subject_rotation_residual_degrees=( - runtime_pose_policy.max_subject_rotation_residual_degrees - ), - ) - except (RuntimeError, TypeError, ValueError) as error: - if isinstance(error, AI2ThorSettlementTimeout): - raise EndpointAuditRejected( - "native_return", ("post_edit_scene_not_settled",) - ) from error - rejection = _native_action_rejection( - adapter, - fresh.scene_id, - before_native_event, - error, - ) - if rejection is not None: - raise EndpointAuditRejected( - "native_action", (f"native_action_rejected:{rejection}",) - ) from error - returned = _native_return_rejection( - adapter, - fresh.scene_id, - before_native_event, - error, - ) - if returned is not None: - raise EndpointAuditRejected( - "native_return", (f"native_return_rejected:{returned}",) - ) from error - raise - if fresh_spawn_map.source_sha256 != fresh_spawn_map_source_sha256: - raise RuntimeError("fresh spawn map changed during native endpoint audit") - - failures: list[str] = [] - if not application.is_scene_at_rest or not application.observation.is_scene_at_rest: - failures.append("scene_not_at_rest") - if application.subject_is_moving: - failures.append("subject_is_moving") - if application.observation.scene != application.observed_scene: - failures.append("observation_scene_mismatch") - if application.position_residual_m > _MAX_RUNTIME_POSITION_RESIDUAL_M: - failures.append("position_residual_exceeded") - try: - fresh_normalized = _normalized_runtime_pose_observed_scene_v2_9_5( - application.commanded_scene, - application.observed_scene, - binding.subject_native_object_id, - runtime_pose_policy, - ) - except _NativeAfterStructureMismatch as error: - failures.append(f"after_scene_structure:{error}") - frozen_normalized = None - else: - frozen_normalized = ( - _project_runtime_pose_observed_scene_to_frozen_source_v2_9_5( - frozen, - fresh_normalized, - binding.subject_native_object_id, - ) - ) - verification = None - if frozen_normalized is not None: - verification = _verify_minimum_cost_with_proxy_collision_authority( - bundle, - frozen, - frozen_normalized, - spec, - runtime_pose_subject_object_id=binding.subject_native_object_id, - ) - if verification.status is not SolverStatus.SUCCESS: - failures.append(f"verification_status:{verification.status.value}") - if verification.quality not in {QualityTier.PURE, QualityTier.LOW_LEAKAGE}: - failures.append(f"verification_quality:{verification.quality.value}") - failures.extend(f"verification_error:{item}" for item in verification.errors) - if failures: - raise EndpointAuditRejected("native_verification", tuple(sorted(set(failures)))) - if verification is None: - raise RuntimeError("native verification result is missing") - - audit_payload: dict[str, object] = { - "case_id": binding.case_id, - "native_scene_id": binding.native_scene_id, - "subject_native_object_id": binding.subject_native_object_id, - "reference_native_object_id": binding.reference_native_object_id, - "semantic_problem_sha256": (bundle.semantic_problem.semantic_problem_sha256), - "solver_config_sha256": checked_config.config_sha256, - "solve_result_sha256": result.solve_result_sha256, - "edit_sha256": edit.edit_sha256, - "proxy_bundle_sha256": bundle.proxy_bundle_sha256, - "spawn_map_source_sha256": capture_bound_spawn_map_source_sha256, - "fresh_spawn_map_source_sha256": fresh_spawn_map_source_sha256, - "frozen_source_scene_sha256": legacy_sha256(frozen), - "fresh_source_scene_sha256": legacy_sha256(fresh), - "source_correspondence_sha256": ( - correspondence.competition_native_capture_source_correspondence_sha256 - ), - "observed_scene_sha256": legacy_sha256(application.observed_scene), - "after_observation_sha256": observation_sha256(application.observation), - "commanded_position": Vec3V2(**application.commanded_position.model_dump()), - "observed_position": Vec3V2(**application.observed_position.model_dump()), - "position_residual_m": application.position_residual_m, - "relation_before": spec.relation_before.value, - "relation_after": spec.relation_after.value, - "verification_quality": verification.quality.value, - "relation_damage_count": verification.relation_damage_count, - "relation_damage_items": verification.relation_damage_items, - } - commanded_subject = application.commanded_scene.object_by_id( - binding.subject_native_object_id - ) - observed_subject = application.observed_scene.object_by_id( - binding.subject_native_object_id - ) - audit_payload.update( - runtime_pose_policy=runtime_pose_policy, - subject_rotation_residual_degrees=_quaternion_angle_residual_deg( - observed_subject.rotation, - commanded_subject.rotation, - "runtime subject rotation", - ), - subject_obb_corner_residual_m=_obb_corner_hausdorff_residual_m( - observed_subject.obb, - commanded_subject.obb, - "runtime subject OBB", - ), - ) - return _EndpointExecution( - audit=None, - application=application, - capture_bound_audit_payload=audit_payload, - ) - - -def _strict_legacy(value, expected_type, label: str): - if type(value) is not expected_type: - raise TypeError(f"{label} must be an exact {expected_type.__name__}") - return expected_type.model_validate(value.model_dump(mode="python"), strict=True) - - -def _strict_v2(value, expected_type, label: str): - if type(value) is not expected_type: - raise TypeError(f"{label} must be an exact {expected_type.__name__}") - return expected_type.model_validate( - value.model_dump(mode="python", warnings="error"), strict=True - ) - - -def _native_action_rejection( - adapter: AI2ThorAdapter, - scene_id: str, - before_event: object, - error: Exception, -) -> str | None: - """Classify only an explicit failed native action as a request rejection.""" - - if isinstance(error, AI2ThorRuntimeError): - return None - try: - event = adapter.latest_native_event(scene_id) - except (KeyError, RuntimeError, TypeError, ValueError): - return None - metadata = getattr(event, "metadata", None) - if ( - event is before_event - or not isinstance(metadata, dict) - or metadata.get("lastActionSuccess") is not False - ): - return None - message = metadata.get("errorMessage") - if ( - type(message) is not str - or not message.strip() - or message.strip() != str(error).strip() - ): - return None - return " ".join(message.split()) - - -def _native_return_rejection( - adapter: AI2ThorAdapter, - scene_id: str, - before_event: object, - error: Exception, - *, - allow_existing_success_event: bool = False, -) -> str | None: - """Classify a failed parse/validation only after a new successful event.""" - - if not isinstance(error, AI2ThorNativeReturnError): - return None - try: - event = adapter.latest_native_event(scene_id) - except (KeyError, RuntimeError, TypeError, ValueError): - return None - metadata = getattr(event, "metadata", None) - if ( - (event is before_event and not allow_existing_success_event) - or not isinstance(metadata, dict) - or metadata.get("lastActionSuccess") is not True - ): - return None - message = " ".join(str(error).split()) - if not message: - return None - return f"{type(error).__name__}:{message}" - - -def _require_native_endpoint_preconditions( - scene: Scene, - subject_id: str, - spawn_map: AI2ThorReceptacleSpawnMap, -) -> None: - """Reject closed, expected endpoint preconditions before the native action.""" - - subject = scene.object_by_id(subject_id) - if subject.support_object_id is None: - raise EndpointAuditRejected("native_precondition", ("subject_has_no_support",)) - if ( - spawn_map.scene_id != scene.scene_id - or spawn_map.subject_object_id != subject.object_id - or spawn_map.support_object_id != subject.support_object_id - ): - raise EndpointAuditRejected( - "native_precondition", ("spawn_map_source_mismatch",) - ) - positions = spawn_map.positions - if type(positions) is not tuple: - raise TypeError("spawn_map positions must be an exact tuple") - if any(type(item) is not AI2ThorNativePosition for item in positions): - raise TypeError("spawn_map positions must be exact native positions") - if len({item.y for item in positions}) != 1: - raise EndpointAuditRejected( - "native_precondition", ("native_support_height_not_unique",) - ) - - -def _normalized_runtime_pose_observed_scene_v2_9_5( - commanded: Scene, - observed: Scene, - subject_id: str, - policy: RuntimePosePolicy, -) -> Scene: - """Retain the bounded final subject pose/OBB and fresh object views.""" - - checked_policy = RuntimePosePolicy.model_validate( - policy.model_dump(mode="python", warnings="error"), - strict=True, - ) - _require_native_after_structure( - commanded, - observed, - subject_id, - runtime_pose_policy=checked_policy, - ) - observed_by_id = {item.object_id: item for item in observed.objects} - normalized = [] - for expected in commanded.objects: - current = observed_by_id[expected.object_id] - normalized.append( - current - if expected.object_id == subject_id - else expected.model_copy(update={"views": current.views}) - ) - return commanded.model_copy(update={"objects": tuple(normalized)}) - - -def _verify_minimum_cost_with_proxy_collision_authority( - bundle: ProxyBundle, - before: Scene, - after: Scene, - spec: InterventionSpec, - *, - runtime_pose_subject_object_id: str | None = None, -) -> VerificationResult: - verifier = Verifier() - if type(bundle) is not ProxyBundle: - raise TypeError("proxy collision authority requires exact ProxyBundle") - if runtime_pose_subject_object_id is not None: - return verifier.verify_minimum_cost_with_runtime_pose_authority( - before, - after, - spec, - runtime_collision_delegated_object_ids=( - bundle.binding.runtime_collision_delegated_native_object_ids - ), - runtime_pose_subject_object_id=runtime_pose_subject_object_id, - ) - return verifier.verify_minimum_cost_with_runtime_collision_authority( - before, - after, - spec, - runtime_collision_delegated_object_ids=( - bundle.binding.runtime_collision_delegated_native_object_ids - ), - ) - - -def _project_runtime_pose_observed_scene_to_frozen_source_v2_9_5( - frozen_source: Scene, - fresh_after: Scene, - subject_id: str, -) -> Scene: - """Project actual final subject geometry onto frozen source roots.""" - - fresh_by_id = {item.object_id: item for item in fresh_after.objects} - projected = [] - for original in frozen_source.objects: - current = fresh_by_id[original.object_id] - projected.append( - current - if original.object_id == subject_id - else original.model_copy(update={"views": current.views}) - ) - return frozen_source.model_copy(update={"objects": tuple(projected)}) - - -def _require_native_after_structure( - source: Scene, - observed: Scene, - subject_id: str, - *, - runtime_pose_policy: RuntimePosePolicy | None = None, -) -> None: - """Close every returned fact except subject XY and fresh object views.""" - - if ( - observed.scene_id != source.scene_id - or observed.source != source.source - or observed.coordinate_system != source.coordinate_system - or observed.room_polygon_xy != source.room_polygon_xy - or observed.collision_obstacles != source.collision_obstacles - or observed.subject_position_regions != source.subject_position_regions - or observed.pinned_object_ids != source.pinned_object_ids - or observed.generation_seed != source.generation_seed - ): - raise _NativeAfterStructureMismatch("scene root facts changed") - - source_ids = tuple(item.object_id for item in source.objects) - observed_ids = tuple(item.object_id for item in observed.objects) - if ( - len(set(source_ids)) != len(source_ids) - or len(set(observed_ids)) != len(observed_ids) - or observed_ids != source_ids - or subject_id not in set(source_ids) - ): - raise _NativeAfterStructureMismatch("object roster changed") - - for original, current in zip(source.objects, observed.objects, strict=True): - if ( - current.object_id != original.object_id - or current.name != original.name - or current.category != original.category - or current.movable is not original.movable - or current.request_eligible is not original.request_eligible - or current.support_object_id != original.support_object_id - ): - raise _NativeAfterStructureMismatch( - f"object structural facts changed: {original.object_id}" - ) - if original.object_id == subject_id: - if runtime_pose_policy is not None: - if ( - math.dist( - _vec3_values(current.position), - _vec3_values(original.position), - ) - > runtime_pose_policy.max_subject_position_residual_m - ): - raise _NativeAfterStructureMismatch( - "subject runtime position residual exceeded" - ) - if ( - _quaternion_angle_residual_deg( - current.rotation, - original.rotation, - "runtime subject rotation", - ) - > runtime_pose_policy.max_subject_rotation_residual_degrees - ): - raise _NativeAfterStructureMismatch( - "subject runtime rotation residual exceeded" - ) - if ( - _obb_corner_hausdorff_residual_m( - current.obb, - original.obb, - "runtime subject OBB", - ) - > runtime_pose_policy.max_subject_obb_corner_residual_m - ): - raise _NativeAfterStructureMismatch( - "subject runtime OBB residual exceeded" - ) - continue - if not _close_values( - (current.position.z,), - (original.position.z,), - _OBJECT_GEOMETRY_TOLERANCE_M, - ): - raise _NativeAfterStructureMismatch("subject vertical position changed") - expected_center = ( - original.obb.center.x + current.position.x - original.position.x, - original.obb.center.y + current.position.y - original.position.y, - original.obb.center.z, - ) - else: - if not _close_values( - _vec3_values(current.position), - _vec3_values(original.position), - _OBJECT_GEOMETRY_TOLERANCE_M, - ): - raise _NativeAfterStructureMismatch( - f"stationary object position changed: {original.object_id}" - ) - expected_center = _vec3_values(original.obb.center) - if ( - not _close_values( - _vec3_values(current.obb.center), - expected_center, - _OBJECT_GEOMETRY_TOLERANCE_M, - ) - or not _close_values( - _vec3_values(current.obb.extent), - _vec3_values(original.obb.extent), - _OBJECT_GEOMETRY_TOLERANCE_M, - ) - or not _quaternions_close(current.rotation, original.rotation) - or not _quaternions_close(current.obb.rotation, original.obb.rotation) - ): - raise _NativeAfterStructureMismatch( - f"object geometry changed: {original.object_id}" - ) - - source_camera_ids = tuple(item.camera_id for item in source.cameras) - observed_camera_ids = tuple(item.camera_id for item in observed.cameras) - if ( - len(set(source_camera_ids)) != len(source_camera_ids) - or len(set(observed_camera_ids)) != len(observed_camera_ids) - or observed_camera_ids != source_camera_ids - ): - raise _NativeAfterStructureMismatch("camera roster changed") - for original, current in zip(source.cameras, observed.cameras, strict=True): - if ( - current.camera_id != original.camera_id - or current.width != original.width - or current.height != original.height - or not _close_values( - current.intrinsics, - original.intrinsics, - _CAMERA_INTRINSIC_TOLERANCE, - ) - or not _close_values( - current.world_to_camera, - original.world_to_camera, - _CAMERA_EXTRINSIC_TOLERANCE, - ) - ): - raise _NativeAfterStructureMismatch( - f"camera facts changed: {original.camera_id}" - ) - - -def _vec3_values(value) -> tuple[float, float, float]: - return value.x, value.y, value.z - - -def _quaternion_angle_residual_deg(left, right, label: str) -> float: - a = tuple(float(item) for item in (left.x, left.y, left.z, left.w)) - b = tuple(float(item) for item in (right.x, right.y, right.z, right.w)) - if not all(math.isfinite(item) for item in (*a, *b)): - raise ValueError(f"{label} must contain finite values") - a_norm = math.sqrt(sum(item * item for item in a)) - b_norm = math.sqrt(sum(item * item for item in b)) - if a_norm == 0.0 or b_norm == 0.0: - raise ValueError(f"{label} must contain non-zero quaternions") - cosine = abs( - sum( - (a_item / a_norm) * (b_item / b_norm) - for a_item, b_item in zip(a, b, strict=True) - ) - ) - return math.degrees(2.0 * math.acos(min(1.0, max(-1.0, cosine)))) - - -def _obb_corner_coordinates(obb: OBB, label: str) -> tuple[tuple[float, ...], ...]: - values = ( - obb.center.x, - obb.center.y, - obb.center.z, - obb.extent.x, - obb.extent.y, - obb.extent.z, - obb.rotation.x, - obb.rotation.y, - obb.rotation.z, - obb.rotation.w, - ) - if not all(math.isfinite(float(item)) for item in values): - raise ValueError(f"{label} must contain finite values") - if any(float(item) <= 0.0 for item in (obb.extent.x, obb.extent.y, obb.extent.z)): - raise ValueError(f"{label} must contain strictly positive extents") - x, y, z, w = ( - float(obb.rotation.x), - float(obb.rotation.y), - float(obb.rotation.z), - float(obb.rotation.w), - ) - maximum_component = max(abs(x), abs(y), abs(z), abs(w)) - if maximum_component == 0.0: - raise ValueError(f"{label} must contain a non-zero quaternion") - scaled = tuple(component / maximum_component for component in (x, y, z, w)) - scaled_norm = math.sqrt(sum(component * component for component in scaled)) - if not math.isfinite(scaled_norm) or scaled_norm == 0.0: - raise ValueError(f"{label} must contain a normalizable quaternion") - x, y, z, w = (component / scaled_norm for component in scaled) - rotation = ( - (1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w)), - (2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w)), - (2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y)), - ) - center = (float(obb.center.x), float(obb.center.y), float(obb.center.z)) - half_extent = ( - float(obb.extent.x) / 2.0, - float(obb.extent.y) / 2.0, - float(obb.extent.z) / 2.0, - ) - return tuple( - tuple( - center[row] - + sum( - rotation[row][column] * signs[column] * half_extent[column] - for column in range(3) - ) - for row in range(3) - ) - for signs in ( - (dx, dy, dz) - for dx in (-1.0, 1.0) - for dy in (-1.0, 1.0) - for dz in (-1.0, 1.0) - ) - ) - - -def _obb_corner_hausdorff_residual_m(left: OBB, right: OBB, label: str) -> float: - left_corners = _obb_corner_coordinates(left, label) - right_corners = _obb_corner_coordinates(right, label) - - def directed(source, target) -> float: - return max( - min(math.dist(source_corner, target_corner) for target_corner in target) - for source_corner in source - ) - - return max( - directed(left_corners, right_corners), - directed(right_corners, left_corners), - ) - - -def _close_values(left, right, tolerance: float) -> bool: - return len(left) == len(right) and all( - math.isfinite(float(a)) - and math.isfinite(float(b)) - and math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=tolerance) - for a, b in zip(left, right, strict=True) - ) - - -def _quaternions_close(left, right) -> bool: - a = tuple(float(item) for item in (left.x, left.y, left.z, left.w)) - b = tuple(float(item) for item in (right.x, right.y, right.z, right.w)) - if not all(math.isfinite(item) for item in (*a, *b)): - return False - a_norm = math.sqrt(sum(item * item for item in a)) - b_norm = math.sqrt(sum(item * item for item in b)) - if a_norm == 0.0 or b_norm == 0.0: - return False - normalized_a = tuple(item / a_norm for item in a) - normalized_b = tuple(item / b_norm for item in b) - return _close_values( - normalized_a, - normalized_b, - _OBJECT_GEOMETRY_TOLERANCE_M, - ) or _close_values( - normalized_a, - tuple(-item for item in normalized_b), - _OBJECT_GEOMETRY_TOLERANCE_M, - ) - - -__all__ = ( - "EndpointAudit", - "EndpointAuditRejected", - "execute_endpoint", - "observation_sha256", -) diff --git a/src/spatialcf/generation/_internal/planning/__init__.py b/src/spatialcf/generation/_internal/planning/__init__.py deleted file mode 100644 index d0be3fb..0000000 --- a/src/spatialcf/generation/_internal/planning/__init__.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Current proxy and endpoint planning owners.""" - -from spatialcf.generation._internal.planning.endpoint import ( - EndpointPlanRejected, - plan_endpoint, -) -from spatialcf.generation._internal.planning.models import ( - CollisionDelegation, - EndpointPlan, - EndpointWorkspace, - ProxyBinding, - ProxyBundle, - SubjectPlacementFact, -) -from spatialcf.generation._internal.planning.proxy import ( - build_proxy_bundle, - default_planning_workspace, - default_solver_config, -) - -__all__ = ( - "CollisionDelegation", - "EndpointPlan", - "EndpointPlanRejected", - "EndpointWorkspace", - "ProxyBinding", - "ProxyBundle", - "SubjectPlacementFact", - "build_proxy_bundle", - "default_planning_workspace", - "default_solver_config", - "plan_endpoint", -) diff --git a/src/spatialcf/generation/_internal/source_manifest.py b/src/spatialcf/generation/_internal/source_manifest.py index 8146715..b4723b3 100644 --- a/src/spatialcf/generation/_internal/source_manifest.py +++ b/src/spatialcf/generation/_internal/source_manifest.py @@ -1,170 +1,38 @@ -"""Current frozen source-prefix manifest contract and loader.""" +"""Current source-plan parsing and file IO.""" from __future__ import annotations import json -import re from pathlib import Path -from typing import Annotated, Literal, Self - -from pydantic import BaseModel, ConfigDict, Field, model_validator +import spatialcf.domain.source as source_domain from spatialcf.generation._internal.canonical_json import canonical_json_bytes -from spatialcf.solver.certified_models import CertifiedSolverConfig - -_PORTABLE_COMPONENT = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$") - - -class _FrozenModel(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - -class SolverConfig(_FrozenModel): - optimality_tolerance: Annotated[float, Field(strict=True, allow_inf_nan=False)] = ( - 1e-6 - ) - numeric_tolerance: Annotated[float, Field(strict=True, allow_inf_nan=False)] = 1e-9 - target_interior_margin: Annotated[ - float, Field(strict=True, allow_inf_nan=False) - ] = 5e-7 - initial_disk_segments: int = Field(default=128, strict=True) - max_disk_segments: int = Field(default=8192, strict=True) - timeout_seconds: None = None - - @model_validator(mode="after") - def require_frozen_defaults(self) -> Self: - expected = CertifiedSolverConfig() - if self.model_dump() != { - "optimality_tolerance": expected.optimality_tolerance, - "numeric_tolerance": expected.numeric_tolerance, - "target_interior_margin": expected.target_interior_margin, - "initial_disk_segments": expected.initial_disk_segments, - "max_disk_segments": expected.max_disk_segments, - "timeout_seconds": expected.timeout_seconds, - }: - raise ValueError("final pilot requires the frozen default solver config") - return self - - def to_solver_config(self) -> CertifiedSolverConfig: - return CertifiedSolverConfig(**self.model_dump()) - - -class LegacySource(_FrozenModel): - kind: Literal["legacy-ai2thor"] - scene_name: str = Field(min_length=1) - - -class ProceduralSource(_FrozenModel): - kind: Literal["procedural"] - dataset_id: str = Field(min_length=1) - revision: str = Field(pattern=r"^[0-9a-f]{40}$") - split: Literal["train", "val", "test"] - index: int = Field(ge=0, strict=True) - source_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - scene_alias: str = Field(min_length=1) - loader_id: str = Field(min_length=1) - loader_version: str = Field(min_length=1) - - -Source = Annotated[LegacySource | ProceduralSource, Field(discriminator="kind")] -def _source_scene_id(source: Source) -> str: - if isinstance(source, LegacySource): - return source.scene_name - return source.scene_alias - - -def _source_locator(source: Source) -> tuple[object, ...]: - if isinstance(source, LegacySource): - return (source.kind, source.scene_name) - return ( - source.kind, - source.dataset_id, - source.revision, - source.split, - source.index, - ) - - -class SourcePlanEntry(_FrozenModel): - """One exact source in the prefix that must receive a planning outcome.""" - - source_id: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") - scene_id: str = Field(min_length=1) - source: Source - - @model_validator(mode="after") - def validate_source_identity(self) -> Self: - if _PORTABLE_COMPONENT.fullmatch(self.source_id) is None: - raise ValueError("source_id is not a portable component") - if _source_scene_id(self.source) != self.scene_id: - raise ValueError("source scene_id does not match its source identity") - return self - - -class SourcePlanManifest(_FrozenModel): - """Canonical source prefix and frozen request-enumeration policy.""" - - schema_version: Literal["certified-ai2thor-source-plan-manifest-v1"] - plan_version: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") - batch_version: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") - width: int = Field(gt=0, strict=True) - height: int = Field(gt=0, strict=True) - seed: int = Field(strict=True) - camera_policy: Literal["all-observed-source-cameras-v1"] - use_navigation_feasibility: bool = Field(strict=True) - solver_config: SolverConfig - sources: tuple[SourcePlanEntry, ...] - - @model_validator(mode="after") - def validate_source_prefix(self) -> Self: - for label, value in ( - ("plan_version", self.plan_version), - ("batch_version", self.batch_version), - ): - if _PORTABLE_COMPONENT.fullmatch(value) is None: - raise ValueError(f"{label} is not a portable component") - source_ids = tuple(item.source_id for item in self.sources) - if len(set(source_ids)) != len(source_ids): - raise ValueError("source_id values must be unique") - if source_ids != tuple(sorted(source_ids)): - raise ValueError("sources must use canonical source-id order") - scene_ids = tuple(item.scene_id for item in self.sources) - if len(set(scene_ids)) != len(scene_ids): - raise ValueError("source scene_id values must be unique") - locators = tuple(_source_locator(item.source) for item in self.sources) - if len(set(locators)) != len(locators): - raise ValueError("source locators must be unique") - return self - - -def load_source_plan_manifest(path: Path) -> SourcePlanManifest: +def load_source_plan_manifest(path: Path) -> source_domain.SourcePlanManifest: """Strictly parse one canonical source-plan manifest.""" raw = Path(path).read_bytes() try: - json.loads(raw) - manifest = SourcePlanManifest.model_validate_json(raw, strict=True) + decoded = json.loads(raw) except (UnicodeDecodeError, json.JSONDecodeError) as error: raise ValueError("source-plan manifest must be valid UTF-8 JSON") from error + if type(decoded) is not dict: + raise ValueError("source-plan manifest must be a JSON object") + observed_version = decoded.get("schema_version") + expected_version = "certified-ai2thor-source-plan-manifest-v1" + if observed_version != expected_version: + from spatialcf.generation.errors import UnsupportedArtifactVersion + + raise UnsupportedArtifactVersion( + "source-plan manifest", + expected=expected_version, + observed=observed_version, + ) + manifest = source_domain.SourcePlanManifest.model_validate_json(raw, strict=True) if raw != canonical_json_bytes(manifest.model_dump(mode="json"), pretty=True): raise ValueError("source-plan manifest must use canonical pretty JSON") return manifest -# Stored Pydantic type names from the frozen wire family remain available privately. -CertifiedAI2ThorSourcePlanManifest = SourcePlanManifest -CertifiedAI2ThorSourcePlanEntry = SourcePlanEntry -LegacyAI2ThorSource = LegacySource -PilotSolverConfig = SolverConfig -ProcTHORSource = ProceduralSource - -__all__ = ( - "LegacySource", - "ProceduralSource", - "SolverConfig", - "SourcePlanEntry", - "SourcePlanManifest", - "load_source_plan_manifest", -) +__all__ = ("load_source_plan_manifest",) diff --git a/src/spatialcf/generation/_internal/source_observation.py b/src/spatialcf/generation/_internal/source_observation.py deleted file mode 100644 index 3e693eb..0000000 --- a/src/spatialcf/generation/_internal/source_observation.py +++ /dev/null @@ -1,1396 +0,0 @@ -"""Read-only source observation capture for the current roster.""" - -from __future__ import annotations - -from contextlib import ExitStack -from dataclasses import asdict, dataclass - -from pydantic import model_validator - -from spatialcf.adapters.ai2thor import ( - AI2ThorAdapter, - AI2ThorAgentPose, - AI2ThorNativePosition, - AI2ThorNativeReturnError, - AI2ThorObservation, - AI2ThorReceptacleSpawnMap, - AI2ThorSettlementTimeout, - bind_ai2thor_reachable_positions, - build_navigation_feasibility_map, - build_receptacle_support_position_region, - canonicalize_ai2thor_reachable_positions, -) -from spatialcf.domain.models import Scene, SubjectPositionRegion -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.serialization import canonical_sha256_v2 -from spatialcf.generation._internal.evidence.camera import ( - CameraPolicy, - CompetitionNativeCameraPlacementPositionV2_9_4, - CompetitionNativeCameraPlacementRosterEntryV2_9_4, - CompetitionNativeCameraPolicyV2_9_3, - CompetitionNativeCameraPoseV2_9_3, - CompetitionNativeCameraScoreV2_9_3, - CompetitionNativeCameraScoreV2_9_4, - CompetitionNativeSourceCameraEvidenceV2_9_3, - SourceCameraEvidence, - build_competition_native_camera_policy_v2_9_3, - build_competition_native_camera_pose_bank_v2_9_3, - build_competition_native_source_camera_evidence_v2_9_3, - build_competition_native_source_camera_evidence_v2_9_4, - score_competition_native_editable_camera_application_v2_9_4, - score_competition_native_source_camera_application_v2_9_3, - select_competition_native_camera_score_index_v2_9_3, - select_competition_native_camera_score_index_v2_9_4, -) -from spatialcf.generation._internal.evidence.surface import ( - CompetitionNativeSourceSurfaceEvidenceV2_9_2, - SourceSurfaceEvidence, - build_competition_native_source_surface_evidence_v2_9_2, -) -from spatialcf.generation.capture.models import ( - CompetitionNativeFloorEnvelopeV2_9, - CompetitionNativePlacementAvailabilityV2_9, - CompetitionNativePositionV2_9, - CompetitionNativeRuntimeIdentityV2_9, - CompetitionNativeSourceCaptureOutcomeV2_9, - CompetitionNativeSourceCaptureV2_9, - CompetitionNativeSourceRefV2_9, - CompetitionNativeSubjectPlacementFactV2_9, - CompetitionNativeSupportFactV2_9, - CompetitionNativeSupportKindV2_9, - build_competition_native_source_capture_v2_9, - build_competition_native_subject_placement_fact_v2_9, - normalize_competition_native_source_scene_v2_9, - validate_competition_native_floor_envelope_v2_9, - validate_competition_native_runtime_source_lineage_v2_9, -) -from spatialcf.generation.capture.models import ( - CompetitionNativeSourceCaptureOutcomeV2_9 as SourceCaptureOutcome, -) -from spatialcf.generation.capture.models import ( - CompetitionNativeSourceRefV2_9 as SourceRef, -) -from spatialcf.generation.capture.plan import CaptureSettings - -_EXPECTED_SOURCE_ERRORS = ( - AI2ThorNativeReturnError, - AI2ThorSettlementTimeout, - RuntimeError, - ValueError, -) -_RUNTIME_IDENTITY_HASH_DOMAIN = "spatialcf.competition-native-runtime-identity.v2.9.2" -_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain:3" -) -_COLLISION_SAFE_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.2m:4" -) -_CONTACT_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.21m:5" -) -_RESET_PER_POSE_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.21m-reset-per-pose:6" -) -_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.25m:7" -) -_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.25m-physics-paused:8" -) -_SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( - "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" - "movable-clearance-0.25m-physics-paused-final-settle:9" -) - - -class CompetitionNativeSourceCaptureWithSurfaceEvidenceV2_9_2(V2Model): - """The unchanged capture outcome plus evidence from its same native query.""" - - outcome: CompetitionNativeSourceCaptureOutcomeV2_9 - surface_evidence: CompetitionNativeSourceSurfaceEvidenceV2_9_2 | None - - @model_validator(mode="after") - def validate_capture_evidence(self): - if self.outcome.capture is None: - if self.surface_evidence is not None: - raise ValueError( - "rejected source capture cannot carry surface evidence" - ) - elif ( - self.surface_evidence is None - or self.surface_evidence.source_id != self.outcome.source.source_id - or self.surface_evidence.scene_id != self.outcome.source.scene_id - or self.surface_evidence.source_capture_sha256 - != self.outcome.capture.source_capture_sha256 - ): - raise ValueError("accepted source capture surface evidence is not closed") - return self - - -class CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3(V2Model): - """One terminal source outcome with both required sibling evidence rows.""" - - outcome: CompetitionNativeSourceCaptureOutcomeV2_9 - surface_evidence: CompetitionNativeSourceSurfaceEvidenceV2_9_2 | None - camera_evidence: CompetitionNativeSourceCameraEvidenceV2_9_3 | None - - @model_validator(mode="after") - def validate_capture_evidence(self): - capture = self.outcome.capture - if capture is None: - if self.surface_evidence is not None or self.camera_evidence is not None: - raise ValueError("rejected source capture cannot carry evidence") - return self - if self.surface_evidence is None or self.camera_evidence is None: - raise ValueError("accepted source capture requires both evidence rows") - if ( - self.surface_evidence.source_id != self.outcome.source.source_id - or self.surface_evidence.scene_id != self.outcome.source.scene_id - or self.surface_evidence.source_capture_sha256 - != capture.source_capture_sha256 - or self.camera_evidence.source_id != self.outcome.source.source_id - or self.camera_evidence.scene_id != self.outcome.source.scene_id - or self.camera_evidence.source_locator_sha256 - != self.outcome.source.source_locator_sha256 - or self.camera_evidence.source_capture_sha256 - != capture.source_capture_sha256 - or self.camera_evidence.camera != capture.scene.camera_by_id("main") - or self.camera_evidence.rgb_png_sha256 != capture.rgb_png_sha256 - or self.camera_evidence.depth_npy_sha256 != capture.depth_npy_sha256 - or self.camera_evidence.instance_png_sha256 != capture.instance_png_sha256 - or self.camera_evidence.pointcloud_ply_sha256 - != capture.pointcloud_ply_sha256 - or self.camera_evidence.is_scene_at_rest is not capture.is_scene_at_rest - ): - raise ValueError("accepted source capture evidence is not closed") - return self - - -def _rejected( - source: CompetitionNativeSourceRefV2_9, - reason: str, -) -> CompetitionNativeSourceCaptureOutcomeV2_9: - return CompetitionNativeSourceCaptureOutcomeV2_9( - source=source, - status="rejected", - capture=None, - reasons=(reason,), - ) - - -def _support_fact(value) -> CompetitionNativeSupportFactV2_9: - return CompetitionNativeSupportFactV2_9( - scene_id=value.scene_id, - object_id=value.object_id, - object_name=value.object_name, - native_object_id=value.native_object_id, - raw_parent_object_ids=tuple(sorted(set(value.raw_parent_object_ids))), - structural_parent_object_ids=tuple( - sorted(set(value.structural_parent_object_ids)) - ), - domain_parent_object_ids=tuple(sorted(set(value.domain_parent_object_ids))), - support_kind=CompetitionNativeSupportKindV2_9(value.support_kind.value), - support_object_id=value.support_object_id, - floor_object_id=value.floor_object_id, - ) - - -def _positions(values) -> tuple[CompetitionNativePositionV2_9, ...]: - return tuple( - CompetitionNativePositionV2_9(x=item.x, y=item.y, z=item.z) - for item in sorted(values, key=lambda item: (item.x, item.z, item.y)) - ) - - -def _optional_receptacle_position_region( - scene: Scene, - spawn_map: AI2ThorReceptacleSpawnMap, -) -> SubjectPositionRegion | None: - """Attach new trigger-grid evidence without narrowing legacy captures.""" - - if not spawn_map.surface_patches: - return None - region = build_receptacle_support_position_region(scene, spawn_map) - return region if region.components else None - - -def _missing_placement( - object_id: str, - support: CompetitionNativeSupportFactV2_9, - reason: str, -) -> CompetitionNativeSubjectPlacementFactV2_9: - return build_competition_native_subject_placement_fact_v2_9( - object_id=object_id, - availability=CompetitionNativePlacementAvailabilityV2_9.MISSING, - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - reasons=(reason,), - ) - - -def _capture_competition_native_source_with_spawn_maps_v2_9( - adapter: AI2ThorAdapter, - source: CompetitionNativeSourceRefV2_9, - *, - max_settlement_steps: int, - floor_clearance_m: float, - navigation_agent_radius_m: float, - navigation_clearance_m: float, -) -> tuple[ - CompetitionNativeSourceCaptureOutcomeV2_9, - tuple[AI2ThorReceptacleSpawnMap, ...], -]: - """Load, settle, and capture one source without editing an object.""" - - if type(source) is not CompetitionNativeSourceRefV2_9: - raise TypeError("native source ref must be exact") - try: - loaded = adapter.load_scene(source.scene_id) - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:load_failed"), () - try: - settlement = adapter.settle_scene_observed( - loaded, max_pass_steps=max_settlement_steps - ) - except AI2ThorSettlementTimeout: - return _rejected(source, "source_capture:settlement_timeout"), () - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:settlement_failed"), () - scene = settlement.observed_scene - try: - runtime = adapter.runtime_identity() - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:runtime_identity_failed"), () - try: - raw_support_facts = adapter.native_support_facts(scene) - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:support_snapshot_failed"), () - try: - normalized_scene = normalize_competition_native_source_scene_v2_9(scene) - support_facts = tuple( - sorted( - (_support_fact(item) for item in raw_support_facts), - key=lambda item: item.object_id, - ) - ) - object_ids = tuple(item.object_id for item in normalized_scene.objects) - if tuple(item.object_id for item in support_facts) != object_ids: - raise ValueError("support fact roster does not match scene objects") - if any( - item.scene_id != normalized_scene.scene_id - or item.object_name != normalized_scene.object_by_id(item.object_id).name - for item in support_facts - ): - raise ValueError("support facts do not bind the settled scene") - runtime_identity = CompetitionNativeRuntimeIdentityV2_9(**asdict(runtime)) - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:support_snapshot_invalid"), () - try: - validate_competition_native_runtime_source_lineage_v2_9( - source, runtime_identity - ) - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:runtime_lineage_mismatch"), () - - support_by_id = {item.object_id: item for item in support_facts} - floor_subjects = tuple( - item - for item in normalized_scene.objects - if item.movable - and support_by_id[item.object_id].support_kind - is CompetitionNativeSupportKindV2_9.FLOOR - ) - floor = None - floor_reason = None - reachable = () - reachable_reason = None - if floor_subjects: - try: - captured_floor = adapter.conservative_floor_envelope( - scene, clearance_m=floor_clearance_m - ) - except _EXPECTED_SOURCE_ERRORS: - floor_reason = "source_capture:floor_envelope_missing" - else: - try: - floor = CompetitionNativeFloorEnvelopeV2_9( - scene_id=captured_floor.scene_id, - floor_object_id=captured_floor.floor_object_id, - floor_name=captured_floor.floor_name, - native_aabb=captured_floor.native_aabb, - floor_top_z=captured_floor.floor_top_z, - clearance_m=captured_floor.clearance_m, - polygon_xy=captured_floor.polygon_xy, - ) - validate_competition_native_floor_envelope_v2_9( - normalized_scene, - support_facts, - floor, - runtime_identity, - expected_clearance_m=floor_clearance_m, - ) - except _EXPECTED_SOURCE_ERRORS: - floor = None - floor_reason = "source_capture:floor_envelope_mismatch" - try: - raw_reachable = adapter.reachable_agent_positions(scene) - reachable = _positions(raw_reachable) - except _EXPECTED_SOURCE_ERRORS: - reachable_reason = "source_capture:reachable_positions_missing" - - placement_by_id: dict[str, CompetitionNativeSubjectPlacementFactV2_9] = {} - for item in floor_subjects: - support = support_by_id[item.object_id] - missing_reason = floor_reason or reachable_reason - if missing_reason is not None or floor is None or not reachable: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - missing_reason or "source_capture:floor_inner_domain_missing", - ) - continue - try: - navigation = build_navigation_feasibility_map( - normalized_scene, - subject_object_id=item.object_id, - room_polygon_xy=floor.polygon_xy, - reachable_positions=tuple( - AI2ThorNativePosition(x=value.x, y=value.y, z=value.z) - for value in reachable - ), - agent_radius_m=navigation_agent_radius_m, - clearance_m=navigation_clearance_m, - ) - if not navigation.position_region.components: - raise ValueError("navigation inner region is empty") - placement_by_id[item.object_id] = ( - build_competition_native_subject_placement_fact_v2_9( - object_id=item.object_id, - availability=( - CompetitionNativePlacementAvailabilityV2_9.KNOWN_FLOOR_INNER_REGION - ), - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - position_region=navigation.position_region, - ) - ) - except _EXPECTED_SOURCE_ERRORS: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - "source_capture:floor_inner_domain_missing", - ) - - receptacle_subjects = tuple( - item - for item in normalized_scene.objects - if item.movable - and support_by_id[item.object_id].support_kind - is CompetitionNativeSupportKindV2_9.RECEPTACLE - ) - retained_spawn_maps: list[AI2ThorReceptacleSpawnMap] = [] - for item in receptacle_subjects: - support = support_by_id[item.object_id] - try: - spawn_map = adapter.receptacle_spawn_map( - scene, subject_object_id=item.object_id - ) - if ( - spawn_map.scene_id != scene.scene_id - or spawn_map.subject_object_id != item.object_id - or spawn_map.support_object_id != support.support_object_id - or not spawn_map.positions - ): - raise AI2ThorNativeReturnError( - "spawn map does not bind the captured support" - ) - position_region = _optional_receptacle_position_region( - scene, - spawn_map, - ) - placement_by_id[item.object_id] = ( - build_competition_native_subject_placement_fact_v2_9( - object_id=item.object_id, - availability=( - CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN - ), - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - native_positions=_positions(spawn_map.positions), - position_region=position_region, - ) - ) - retained_spawn_maps.append(spawn_map) - except _EXPECTED_SOURCE_ERRORS: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - "source_capture:receptacle_spawn_missing", - ) - - for item in normalized_scene.objects: - if item.object_id in placement_by_id: - continue - support = support_by_id[item.object_id] - if not item.movable: - placement_by_id[item.object_id] = ( - build_competition_native_subject_placement_fact_v2_9( - object_id=item.object_id, - availability=( - CompetitionNativePlacementAvailabilityV2_9.NOT_APPLICABLE - ), - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - ) - ) - else: - reason = { - CompetitionNativeSupportKindV2_9.UNKNOWN: ( - "source_capture:support_unknown" - ), - CompetitionNativeSupportKindV2_9.MULTIPLE_AMBIGUOUS: ( - "source_capture:support_multiple_ambiguous" - ), - CompetitionNativeSupportKindV2_9.CYCLIC: ( - "source_capture:support_cyclic" - ), - }.get( - support.support_kind, - "source_capture:placement_fact_invalid", - ) - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - reason, - ) - - observation = settlement.observation - try: - capture = build_competition_native_source_capture_v2_9( - source=source, - runtime_identity=runtime_identity, - scene=normalized_scene, - rgb_png_sha256=observation.rgb_png_sha256, - depth_npy_sha256=observation.depth_npy_sha256, - instance_png_sha256=observation.instance_png_sha256, - pointcloud_ply_sha256=observation.pointcloud_ply_sha256, - is_scene_at_rest=observation.is_scene_at_rest, - settlement_pass_steps=settlement.pass_steps, - support_facts=support_facts, - floor_envelope=floor, - reachable_positions=reachable, - placement_facts=tuple( - placement_by_id[item.object_id] for item in normalized_scene.objects - ), - ) - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:normalized_capture_invalid"), () - return ( - CompetitionNativeSourceCaptureOutcomeV2_9( - source=source, - status="accepted", - capture=capture, - reasons=(), - ), - tuple(sorted(retained_spawn_maps, key=lambda item: item.subject_object_id)), - ) - - -def capture_competition_native_source_v2_9( - adapter: AI2ThorAdapter, - source: CompetitionNativeSourceRefV2_9, - *, - max_settlement_steps: int, - floor_clearance_m: float, - navigation_agent_radius_m: float, - navigation_clearance_m: float, -) -> CompetitionNativeSourceCaptureOutcomeV2_9: - """Preserve the legacy capture API while discarding sibling evidence.""" - - outcome, _ = _capture_competition_native_source_with_spawn_maps_v2_9( - adapter, - source, - max_settlement_steps=max_settlement_steps, - floor_clearance_m=floor_clearance_m, - navigation_agent_radius_m=navigation_agent_radius_m, - navigation_clearance_m=navigation_clearance_m, - ) - return outcome - - -def capture_competition_native_source_v2_9_2( - adapter: AI2ThorAdapter, - source: CompetitionNativeSourceRefV2_9, - *, - max_settlement_steps: int, - floor_clearance_m: float, - navigation_agent_radius_m: float, - navigation_clearance_m: float, -) -> CompetitionNativeSourceCaptureWithSurfaceEvidenceV2_9_2: - """Capture once and retain the same-query receptacle patch evidence.""" - - outcome, spawn_maps = _capture_competition_native_source_with_spawn_maps_v2_9( - adapter, - source, - max_settlement_steps=max_settlement_steps, - floor_clearance_m=floor_clearance_m, - navigation_agent_radius_m=navigation_agent_radius_m, - navigation_clearance_m=navigation_clearance_m, - ) - evidence = ( - None - if outcome.capture is None - else build_competition_native_source_surface_evidence_v2_9_2( - outcome.capture, - spawn_maps, - ) - ) - return CompetitionNativeSourceCaptureWithSurfaceEvidenceV2_9_2( - outcome=outcome, - surface_evidence=evidence, - ) - - -def _native_camera_pose( - pose: CompetitionNativeCameraPoseV2_9_3, -) -> AI2ThorAgentPose: - return AI2ThorAgentPose( - position=AI2ThorNativePosition(x=pose.x, y=pose.y, z=pose.z), - yaw_degrees=pose.yaw_degrees, - horizon_degrees=pose.horizon_degrees, - standing=pose.standing, - ) - - -def _camera_placement_roster_v2_9_4( - scene: Scene, - pairs: tuple[tuple[str, str], ...], - spawn_maps: tuple[AI2ThorReceptacleSpawnMap, ...], -) -> tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...]: - """Project complete read-only native spawn maps into the camera score wire.""" - - if type(scene) is not Scene or type(pairs) is not tuple: - raise TypeError("camera placement roster inputs must be exact") - if type(spawn_maps) is not tuple or any( - type(item) is not AI2ThorReceptacleSpawnMap for item in spawn_maps - ): - raise TypeError("camera placement roster spawn maps must be exact") - by_subject = {item.subject_object_id: item for item in spawn_maps} - if len(by_subject) != len(spawn_maps) or set(by_subject) != { - subject_id for subject_id, _ in pairs - }: - raise ValueError("camera placement roster does not cover its subject pairs") - entries = [] - for subject_id, support_id in pairs: - subject = scene.object_by_id(subject_id) - scene.object_by_id(support_id) - spawn_map = by_subject[subject_id] - if ( - subject.support_object_id != support_id - or spawn_map.scene_id != scene.scene_id - or spawn_map.subject_object_id != subject_id - or spawn_map.support_object_id != support_id - or not spawn_map.positions - ): - raise ValueError("camera placement roster does not bind the source") - entries.append( - CompetitionNativeCameraPlacementRosterEntryV2_9_4( - subject_object_id=subject_id, - support_object_id=support_id, - positions=tuple( - CompetitionNativeCameraPlacementPositionV2_9_4( - x=item.x, - y=item.y, - z=item.z, - ) - for item in spawn_map.positions - ), - ) - ) - return tuple(sorted(entries, key=lambda item: item.subject_object_id)) - - -def _camera_capture_placement_roster_v2_9_4( - capture: CompetitionNativeSourceCaptureV2_9, -) -> tuple[ - tuple[tuple[str, str], ...], - tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], -]: - """Close every final receptacle pair and every allowed patchless exclusion.""" - - if type(capture) is not CompetitionNativeSourceCaptureV2_9: - raise TypeError("camera placement capture must be exact") - support_by_id = {item.object_id: item for item in capture.support_facts} - placement_by_id = {item.object_id: item for item in capture.placement_facts} - try: - source_pairs = tuple( - sorted( - (item.object_id, support_by_id[item.object_id].support_object_id) - for item in capture.scene.objects - if item.movable - and support_by_id[item.object_id].support_kind - is CompetitionNativeSupportKindV2_9.RECEPTACLE - and support_by_id[item.object_id].support_object_id is not None - ) - ) - certified_pairs = [] - roster_entries = [] - for subject_object_id, support_object_id in source_pairs: - placement = placement_by_id[subject_object_id] - if ( - placement.availability - is CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN - and placement.support_object_id == support_object_id - ): - certified_pairs.append((subject_object_id, support_object_id)) - roster_entries.append( - CompetitionNativeCameraPlacementRosterEntryV2_9_4( - subject_object_id=subject_object_id, - support_object_id=support_object_id, - positions=tuple( - CompetitionNativeCameraPlacementPositionV2_9_4( - x=position.x, - y=position.y, - z=position.z, - ) - for position in placement.native_positions - ), - ) - ) - continue - if not ( - placement.availability - is CompetitionNativePlacementAvailabilityV2_9.MISSING - and placement.support_object_id == support_object_id - and placement.reasons - == ("source_capture:receptacle_surface_patch_missing",) - ): - raise ValueError("final camera placement roster is incomplete") - except KeyError as error: - raise ValueError("final camera placement roster is incomplete") from error - return tuple(certified_pairs), tuple(roster_entries) - - -def _capture_selected_camera_source_v2_9_3( - adapter: AI2ThorAdapter, - source: CompetitionNativeSourceRefV2_9, - *, - scene: Scene, - observation: AI2ThorObservation, - settlement_pass_steps: int, - runtime_identity: CompetitionNativeRuntimeIdentityV2_9, - support_facts: tuple[CompetitionNativeSupportFactV2_9, ...], - reachable: tuple[CompetitionNativePositionV2_9, ...], - floor_clearance_m: float, - navigation_agent_radius_m: float, - navigation_clearance_m: float, -) -> tuple[ - CompetitionNativeSourceCaptureOutcomeV2_9, - tuple[AI2ThorReceptacleSpawnMap, ...], -]: - """Capture placement facts only after the camera winner is fixed.""" - - normalized_scene = normalize_competition_native_source_scene_v2_9(scene) - support_by_id = {item.object_id: item for item in support_facts} - floor_subjects = tuple( - item - for item in normalized_scene.objects - if item.movable - and support_by_id[item.object_id].support_kind - is CompetitionNativeSupportKindV2_9.FLOOR - ) - floor = None - floor_reason = None - if floor_subjects: - try: - captured_floor = adapter.conservative_floor_envelope( - scene, clearance_m=floor_clearance_m - ) - except _EXPECTED_SOURCE_ERRORS: - floor_reason = "source_capture:floor_envelope_missing" - else: - try: - floor = CompetitionNativeFloorEnvelopeV2_9( - scene_id=captured_floor.scene_id, - floor_object_id=captured_floor.floor_object_id, - floor_name=captured_floor.floor_name, - native_aabb=captured_floor.native_aabb, - floor_top_z=captured_floor.floor_top_z, - clearance_m=captured_floor.clearance_m, - polygon_xy=captured_floor.polygon_xy, - ) - validate_competition_native_floor_envelope_v2_9( - normalized_scene, - support_facts, - floor, - runtime_identity, - expected_clearance_m=floor_clearance_m, - ) - except _EXPECTED_SOURCE_ERRORS: - floor = None - floor_reason = "source_capture:floor_envelope_mismatch" - - placement_by_id: dict[str, CompetitionNativeSubjectPlacementFactV2_9] = {} - for item in floor_subjects: - support = support_by_id[item.object_id] - if floor_reason is not None or floor is None or not reachable: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - floor_reason or "source_capture:floor_inner_domain_missing", - ) - continue - try: - navigation = build_navigation_feasibility_map( - normalized_scene, - subject_object_id=item.object_id, - room_polygon_xy=floor.polygon_xy, - reachable_positions=tuple( - AI2ThorNativePosition(x=value.x, y=value.y, z=value.z) - for value in reachable - ), - agent_radius_m=navigation_agent_radius_m, - clearance_m=navigation_clearance_m, - ) - if not navigation.position_region.components: - raise ValueError("navigation inner region is empty") - placement_by_id[item.object_id] = ( - build_competition_native_subject_placement_fact_v2_9( - object_id=item.object_id, - availability=( - CompetitionNativePlacementAvailabilityV2_9.KNOWN_FLOOR_INNER_REGION - ), - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - position_region=navigation.position_region, - ) - ) - except _EXPECTED_SOURCE_ERRORS: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - "source_capture:floor_inner_domain_missing", - ) - - receptacle_subjects = tuple( - item - for item in normalized_scene.objects - if item.movable - and support_by_id[item.object_id].support_kind - is CompetitionNativeSupportKindV2_9.RECEPTACLE - ) - retained_spawn_maps: list[AI2ThorReceptacleSpawnMap] = [] - for item in receptacle_subjects: - support = support_by_id[item.object_id] - try: - spawn_map = adapter.receptacle_spawn_map( - scene, subject_object_id=item.object_id - ) - if ( - spawn_map.scene_id != scene.scene_id - or spawn_map.subject_object_id != item.object_id - or spawn_map.support_object_id != support.support_object_id - or not spawn_map.positions - ): - raise AI2ThorNativeReturnError( - "spawn map does not bind the captured support" - ) - if not spawn_map.surface_patches: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - "source_capture:receptacle_surface_patch_missing", - ) - continue - position_region = _optional_receptacle_position_region(scene, spawn_map) - placement_by_id[item.object_id] = ( - build_competition_native_subject_placement_fact_v2_9( - object_id=item.object_id, - availability=( - CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN - ), - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - native_positions=_positions(spawn_map.positions), - position_region=position_region, - ) - ) - retained_spawn_maps.append(spawn_map) - except _EXPECTED_SOURCE_ERRORS: - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - "source_capture:receptacle_spawn_missing", - ) - - for item in normalized_scene.objects: - if item.object_id in placement_by_id: - continue - support = support_by_id[item.object_id] - if not item.movable: - placement_by_id[item.object_id] = ( - build_competition_native_subject_placement_fact_v2_9( - object_id=item.object_id, - availability=( - CompetitionNativePlacementAvailabilityV2_9.NOT_APPLICABLE - ), - support_kind=support.support_kind, - support_object_id=support.support_object_id, - floor_object_id=support.floor_object_id, - ) - ) - else: - reason = { - CompetitionNativeSupportKindV2_9.UNKNOWN: ( - "source_capture:support_unknown" - ), - CompetitionNativeSupportKindV2_9.MULTIPLE_AMBIGUOUS: ( - "source_capture:support_multiple_ambiguous" - ), - CompetitionNativeSupportKindV2_9.CYCLIC: ( - "source_capture:support_cyclic" - ), - }.get(support.support_kind, "source_capture:placement_fact_invalid") - placement_by_id[item.object_id] = _missing_placement( - item.object_id, - support, - reason, - ) - - try: - capture = build_competition_native_source_capture_v2_9( - source=source, - runtime_identity=runtime_identity, - scene=normalized_scene, - rgb_png_sha256=observation.rgb_png_sha256, - depth_npy_sha256=observation.depth_npy_sha256, - instance_png_sha256=observation.instance_png_sha256, - pointcloud_ply_sha256=observation.pointcloud_ply_sha256, - is_scene_at_rest=observation.is_scene_at_rest, - settlement_pass_steps=settlement_pass_steps, - support_facts=support_facts, - floor_envelope=floor, - reachable_positions=reachable, - placement_facts=tuple( - placement_by_id[item.object_id] for item in normalized_scene.objects - ), - ) - except _EXPECTED_SOURCE_ERRORS: - return _rejected(source, "source_capture:normalized_capture_invalid"), () - return ( - CompetitionNativeSourceCaptureOutcomeV2_9( - source=source, - status="accepted", - capture=capture, - reasons=(), - ), - tuple(sorted(retained_spawn_maps, key=lambda item: item.subject_object_id)), - ) - - -def _capture_competition_native_source_v2_9_3( - adapter: AI2ThorAdapter, - source: CompetitionNativeSourceRefV2_9, - *, - max_settlement_steps: int, - floor_clearance_m: float, - navigation_agent_radius_m: float, - navigation_clearance_m: float, - camera_policy: CompetitionNativeCameraPolicyV2_9_3, -) -> CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3: - """Select one complete source camera before placement capture.""" - - def rejected(reason: str) -> CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3: - return CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3( - outcome=_rejected(source, reason), - surface_evidence=None, - camera_evidence=None, - ) - - if type(source) is not CompetitionNativeSourceRefV2_9: - raise TypeError("native source ref must be exact") - try: - loaded = adapter.load_scene(source.scene_id) - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:load_failed") - try: - settlement = adapter.settle_scene_observed( - loaded, max_pass_steps=max_settlement_steps - ) - except AI2ThorSettlementTimeout: - return rejected("source_capture:settlement_timeout") - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:settlement_failed") - baseline_scene = settlement.observed_scene - try: - runtime = adapter.runtime_identity() - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:runtime_identity_failed") - try: - raw_support_facts = adapter.native_support_facts(baseline_scene) - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:support_snapshot_failed") - try: - normalized_baseline = normalize_competition_native_source_scene_v2_9( - baseline_scene - ) - support_facts = tuple( - sorted( - (_support_fact(item) for item in raw_support_facts), - key=lambda item: item.object_id, - ) - ) - object_ids = tuple(item.object_id for item in normalized_baseline.objects) - if tuple(item.object_id for item in support_facts) != object_ids: - raise ValueError("support fact roster does not match scene objects") - if any( - item.scene_id != normalized_baseline.scene_id - or item.object_name != normalized_baseline.object_by_id(item.object_id).name - for item in support_facts - ): - raise ValueError("support facts do not bind the settled scene") - runtime_identity = CompetitionNativeRuntimeIdentityV2_9(**asdict(runtime)) - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:support_snapshot_invalid") - try: - validate_competition_native_runtime_source_lineage_v2_9( - source, runtime_identity - ) - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:runtime_lineage_mismatch") - - camera_stack = ExitStack() - try: - raw_reachable = adapter.reachable_agent_positions(baseline_scene) - reachable = _positions(raw_reachable) - fallback_pose = adapter.current_agent_pose(baseline_scene) - support_by_id = {item.object_id: item for item in support_facts} - pairs = tuple( - sorted( - ( - item.object_id, - support_by_id[item.object_id].support_object_id, - ) - for item in normalized_baseline.objects - if item.movable - and support_by_id[item.object_id].support_kind - is CompetitionNativeSupportKindV2_9.RECEPTACLE - and support_by_id[item.object_id].support_object_id is not None - ) - ) - policy = CompetitionNativeCameraPolicyV2_9_3.model_validate( - camera_policy.model_dump(mode="python"), strict=True - ) - editable_camera = policy.pose_policy_version in { - _EDITABLE_CAMERA_POLICY_VERSION, - _COLLISION_SAFE_EDITABLE_CAMERA_POLICY_VERSION, - _CONTACT_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, - _RESET_PER_POSE_EDITABLE_CAMERA_POLICY_VERSION, - _GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, - _PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, - _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, - } - reset_per_pose = ( - policy.pose_policy_version == _RESET_PER_POSE_EDITABLE_CAMERA_POLICY_VERSION - ) - pause_physics = policy.pose_policy_version in { - _PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, - _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, - } - settle_after_unpause = ( - policy.pose_policy_version - == _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION - ) - if settle_after_unpause: - reachable = _positions( - canonicalize_ai2thor_reachable_positions(raw_reachable) - ) - frozen_reachable = reachable - paused_observations = ( - adapter.paused_camera_observations_for_settlement - if settle_after_unpause - else adapter.paused_camera_observations - ) - current_scene = ( - camera_stack.enter_context(paused_observations(baseline_scene)) - if pause_physics - else baseline_scene - ) - queried_proposal_spawn_maps = ( - tuple( - adapter.receptacle_spawn_map( - baseline_scene, - subject_object_id=subject_object_id, - ) - for subject_object_id, _support_object_id in pairs - ) - if editable_camera - else () - ) - if editable_camera: - proposal_by_subject = { - item.subject_object_id: item for item in queried_proposal_spawn_maps - } - pairs = tuple( - pair for pair in pairs if proposal_by_subject[pair[0]].surface_patches - ) - proposal_spawn_maps = tuple( - proposal_by_subject[subject_object_id] - for subject_object_id, _support_object_id in pairs - ) - else: - proposal_spawn_maps = () - placement_roster = ( - _camera_placement_roster_v2_9_4( - baseline_scene, - pairs, - proposal_spawn_maps, - ) - if editable_camera - else () - ) - pose_bank = build_competition_native_camera_pose_bank_v2_9_3( - baseline_scene, - pairs, - tuple( - AI2ThorNativePosition(x=item.x, y=item.y, z=item.z) - for item in reachable - ), - fallback_pose, - policy=policy, - ) - pose_scores: list[ - CompetitionNativeCameraScoreV2_9_3 | CompetitionNativeCameraScoreV2_9_4 - ] = [] - for pose in pose_bank: - application = ( - adapter.apply_camera_pose_from_frozen_source_observed( - baseline_scene, - _native_camera_pose(pose), - max_pass_steps=max_settlement_steps, - ) - if reset_per_pose - else adapter.apply_camera_pose_observed( - current_scene, - _native_camera_pose(pose), - ) - ) - current_scene = application.observed_scene - pose_scores.append( - score_competition_native_editable_camera_application_v2_9_4( - source_scene=baseline_scene, - pose=pose, - application=application, - placement_roster=placement_roster, - policy=policy, - ) - if editable_camera - else score_competition_native_source_camera_application_v2_9_3( - source_scene=baseline_scene, - pose=pose, - application=application, - policy=policy, - ) - ) - del application - frozen_scores = tuple(pose_scores) - selected_index = ( - select_competition_native_camera_score_index_v2_9_4(frozen_scores) - if editable_camera - else select_competition_native_camera_score_index_v2_9_3(frozen_scores) - ) - selected_application = ( - adapter.apply_camera_pose_from_frozen_source_observed( - baseline_scene, - _native_camera_pose(pose_bank[selected_index]), - max_pass_steps=max_settlement_steps, - ) - if reset_per_pose - else adapter.apply_camera_pose_observed( - current_scene, - _native_camera_pose(pose_bank[selected_index]), - ) - ) - selected_score = ( - score_competition_native_editable_camera_application_v2_9_4( - source_scene=baseline_scene, - pose=pose_bank[selected_index], - application=selected_application, - placement_roster=placement_roster, - policy=policy, - ) - if editable_camera - else score_competition_native_source_camera_application_v2_9_3( - source_scene=baseline_scene, - pose=pose_bank[selected_index], - application=selected_application, - policy=policy, - ) - ) - if selected_score != frozen_scores[selected_index]: - raise ValueError("camera winner replay score changed") - evidence_source_scene = baseline_scene - capture_settlement_pass_steps = settlement.pass_steps - if settle_after_unpause: - try: - camera_stack.close() - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:camera_physics_resume_failed") - settled_camera = adapter.settle_current_camera_pose_observed( - baseline_scene, - _native_camera_pose(pose_bank[selected_index]), - max_pass_steps=max_settlement_steps, - ) - selected_application = settled_camera.application - capture_settlement_pass_steps = settled_camera.settlement_pass_steps - evidence_source_scene = selected_application.observed_scene - final_support_facts = tuple( - sorted( - ( - _support_fact(item) - for item in adapter.native_support_facts(evidence_source_scene) - ), - key=lambda item: item.object_id, - ) - ) - final_object_ids = tuple( - sorted(item.object_id for item in evidence_source_scene.objects) - ) - if ( - tuple(item.object_id for item in final_support_facts) - != final_object_ids - ): - raise ValueError("final support fact roster does not match scene") - if any( - item.scene_id != evidence_source_scene.scene_id - or item.object_name - != evidence_source_scene.object_by_id(item.object_id).name - for item in final_support_facts - ): - raise ValueError("final support facts do not bind settled scene") - support_facts = final_support_facts - final_raw_reachable = adapter.reachable_agent_positions( - evidence_source_scene - ) - final_reachable = _positions( - bind_ai2thor_reachable_positions( - raw_reachable, - final_raw_reachable, - ) - ) - if final_reachable != frozen_reachable: - raise ValueError("settled camera reachable position roster changed") - reachable = final_reachable - final_pose_bank = build_competition_native_camera_pose_bank_v2_9_3( - evidence_source_scene, - pairs, - tuple( - AI2ThorNativePosition(x=item.x, y=item.y, z=item.z) - for item in reachable - ), - _native_camera_pose(pose_bank[selected_index]), - policy=policy, - ) - if final_pose_bank != pose_bank: - raise ValueError("settled camera pose bank changed") - final_selected_score = ( - score_competition_native_editable_camera_application_v2_9_4( - source_scene=evidence_source_scene, - pose=pose_bank[selected_index], - application=selected_application, - placement_roster=placement_roster, - policy=policy, - ) - if editable_camera - else score_competition_native_source_camera_application_v2_9_3( - source_scene=evidence_source_scene, - pose=pose_bank[selected_index], - application=selected_application, - policy=policy, - ) - ) - if final_selected_score != frozen_scores[selected_index]: - raise ValueError("settled camera winner score changed") - except _EXPECTED_SOURCE_ERRORS: - try: - camera_stack.close() - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:camera_physics_resume_failed") - return rejected("source_capture:camera_selection_failed") - - outcome, spawn_maps = _capture_selected_camera_source_v2_9_3( - adapter, - source, - scene=selected_application.observed_scene, - observation=selected_application.observation, - settlement_pass_steps=capture_settlement_pass_steps, - runtime_identity=runtime_identity, - support_facts=support_facts, - reachable=reachable, - floor_clearance_m=floor_clearance_m, - navigation_agent_radius_m=navigation_agent_radius_m, - navigation_clearance_m=navigation_clearance_m, - ) - capture = outcome.capture - if capture is None: - del selected_application - try: - camera_stack.close() - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:camera_physics_resume_failed") - return CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3( - outcome=outcome, - surface_evidence=None, - camera_evidence=None, - ) - try: - if editable_camera: - if settle_after_unpause: - final_pairs, final_placement_roster = ( - _camera_capture_placement_roster_v2_9_4(capture) - ) - placement_roster_changed = ( - final_pairs != pairs - or final_placement_roster != placement_roster - or _camera_placement_roster_v2_9_4( - selected_application.observed_scene, - final_pairs, - spawn_maps, - ) - != final_placement_roster - ) - else: - placement_roster_changed = ( - _camera_placement_roster_v2_9_4( - selected_application.observed_scene, - pairs, - spawn_maps, - ) - != placement_roster - ) - if placement_roster_changed: - raise ValueError("camera placement roster changed after winner replay") - surface_evidence = build_competition_native_source_surface_evidence_v2_9_2( - capture, - spawn_maps, - ) - evidence_arguments = { - "source_id": source.source_id, - "scene_id": source.scene_id, - "source_locator_sha256": source.source_locator_sha256, - "runtime_identity_sha256": canonical_sha256_v2( - runtime_identity, - domain=_RUNTIME_IDENTITY_HASH_DOMAIN, - ), - "source_capture_sha256": capture.source_capture_sha256, - "source_scene": evidence_source_scene, - "policy": policy, - "pose_bank": pose_bank, - "pose_scores": frozen_scores, - "selected_application": selected_application, - } - camera_evidence = ( - build_competition_native_source_camera_evidence_v2_9_4( - **evidence_arguments, - placement_roster=placement_roster, - ) - if editable_camera - else build_competition_native_source_camera_evidence_v2_9_3( - **evidence_arguments, - ) - ) - result = CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3( - outcome=outcome, - surface_evidence=surface_evidence, - camera_evidence=camera_evidence, - ) - except _EXPECTED_SOURCE_ERRORS: - del selected_application - try: - camera_stack.close() - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:camera_physics_resume_failed") - return rejected("source_capture:evidence_invalid") - del selected_application - try: - camera_stack.close() - except _EXPECTED_SOURCE_ERRORS: - return rejected("source_capture:camera_physics_resume_failed") - return result - - -def capture_competition_native_source_v2_9_3( - adapter: AI2ThorAdapter, - source: CompetitionNativeSourceRefV2_9, - *, - max_settlement_steps: int, - floor_clearance_m: float, - navigation_agent_radius_m: float, - navigation_clearance_m: float, -) -> CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3: - """Capture with the original 2.9.3 camera policy for API compatibility.""" - - return _capture_competition_native_source_v2_9_3( - adapter, - source, - max_settlement_steps=max_settlement_steps, - floor_clearance_m=floor_clearance_m, - navigation_agent_radius_m=navigation_agent_radius_m, - navigation_clearance_m=navigation_clearance_m, - camera_policy=build_competition_native_camera_policy_v2_9_3(), - ) - - -@dataclass(frozen=True, slots=True) -class SourceCaptureResult: - outcome: SourceCaptureOutcome - surface_evidence: SourceSurfaceEvidence | None - camera_evidence: SourceCameraEvidence | None - - -def capture_source_observation( - adapter: AI2ThorAdapter, - *, - source: SourceRef, - settings: CaptureSettings, - camera_policy: CameraPolicy, -) -> SourceCaptureResult: - """Capture one source once; never select or retry based on endpoint success.""" - - if type(settings) is not CaptureSettings: - raise TypeError("capture settings must be exact") - checked_settings = CaptureSettings.model_validate( - settings.model_dump(mode="python", warnings="error"), - strict=True, - ) - result = _capture_competition_native_source_v2_9_3( - adapter, - source, - max_settlement_steps=checked_settings.max_settlement_steps, - floor_clearance_m=checked_settings.floor_clearance_m, - navigation_agent_radius_m=checked_settings.navigation_agent_radius_m, - navigation_clearance_m=checked_settings.navigation_clearance_m, - camera_policy=camera_policy, - ) - return SourceCaptureResult( - outcome=result.outcome, - surface_evidence=result.surface_evidence, - camera_evidence=result.camera_evidence, - ) - - -__all__ = ( - "SourceCaptureResult", - "capture_source_observation", -) diff --git a/src/spatialcf/generation/capture/__init__.py b/src/spatialcf/generation/capture/__init__.py index 0a30a4c..b593923 100644 --- a/src/spatialcf/generation/capture/__init__.py +++ b/src/spatialcf/generation/capture/__init__.py @@ -5,11 +5,9 @@ from collections.abc import Callable from pathlib import Path -from spatialcf.adapters.ai2thor import AI2ThorAdapter -from spatialcf.generation._internal.source_manifest import ( - SourcePlanManifest, - load_source_plan_manifest, -) +from spatialcf.composition import DEFAULT_ENVIRONMENT_ADAPTER_FACTORY as AI2ThorAdapter +from spatialcf.domain.source import SourcePlanManifest +from spatialcf.generation._internal.source_manifest import load_source_plan_manifest from spatialcf.generation.capture.models import RosterCompilation, RosterSummary from spatialcf.generation.capture.plan import ( CapturePlan, @@ -29,10 +27,10 @@ RetainedRosterVerification, load_roster, prepare_roster_verification, - publish_roster, revalidate_roster_verification, verify_roster, ) +from spatialcf.generation.workflows import capture as capture_workflow def load_source_manifest(path: Path) -> SourcePlanManifest: @@ -48,12 +46,12 @@ def capture_and_publish_dataset( adapter_factory: Callable[..., AI2ThorAdapter] = AI2ThorAdapter, dataset_loader: Callable[[str, str], object] = load_prior_dataset, ) -> RosterSummary: - compilation = capture_dataset( + return capture_workflow.capture_and_publish_dataset( plan, + roster_root=roster_root, adapter_factory=adapter_factory, dataset_loader=dataset_loader, ) - return publish_roster(compilation, roster_root) __all__ = ( diff --git a/src/spatialcf/generation/capture/compiler.py b/src/spatialcf/generation/capture/compiler.py index bf441ea..dcbf26a 100644 --- a/src/spatialcf/generation/capture/compiler.py +++ b/src/spatialcf/generation/capture/compiler.py @@ -12,41 +12,20 @@ from pydantic import Field, model_validator -from spatialcf.adapters.ai2thor import AI2ThorAgentPose, AI2ThorNativePosition -from spatialcf.domain.enums import Relation, SolverStatus -from spatialcf.domain.models import Scene -from spatialcf.domain.v2.base import V2Model -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.adapters.base import AdapterPose, AdapterPosition +from spatialcf.domain.base import CanonicalModel +from spatialcf.domain.request import Relation, SolverStatus +from spatialcf.domain.scene import Scene +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, ) -from spatialcf.generation._internal.evidence.camera import ( +from spatialcf.generation.capture.models import ( CameraPolicy, CompetitionNativeCameraPlacementPositionV2_9_4, CompetitionNativeCameraPlacementRosterEntryV2_9_4, CompetitionNativeCameraScoreFamilyV2_9_3, CompetitionNativeCameraScoreV2_9_4, - SourceCameraEvidence, - build_competition_native_camera_pose_bank_v2_9_3, - competition_native_camera_pose_bank_sha256_v2_9_3, - score_competition_native_camera_scene_v2_9_3, - score_competition_native_editable_camera_scene_v2_9_4, - select_competition_native_camera_score_index_v2_9_3, - select_competition_native_camera_score_index_v2_9_4, - verify_competition_native_camera_observation_binding_v2_9_3, - verify_competition_native_solver_camera_binding_v2_9_3, -) -from spatialcf.generation._internal.evidence.reachability import ( - CandidateTargetReachability, - TargetReachabilityStatus, - derive_competition_native_candidate_target_reachability_from_prepared_v2_9_4, - prepare_competition_native_target_reachability_source_v2_9_4, -) -from spatialcf.generation._internal.evidence.surface import ( - SourceSurfaceEvidence, - verify_source_surface_evidence, -) -from spatialcf.generation.capture.models import ( CompetitionNativeCandidateInventoryV2_9, CompetitionNativeCandidateRosterManifestV2_9, CompetitionNativeCandidateStateCountV2_9, @@ -67,7 +46,24 @@ RosterCompilation, RosterPolicy, RosterSummary, + SourceCameraEvidence, + SourceSurfaceEvidence, + build_competition_native_camera_pose_bank_v2_9_3, + competition_native_camera_pose_bank_sha256_v2_9_3, + score_competition_native_camera_scene_v2_9_3, + score_competition_native_editable_camera_scene_v2_9_4, + select_competition_native_camera_score_index_v2_9_3, + select_competition_native_camera_score_index_v2_9_4, validate_competition_native_runtime_source_lineage_v2_9, + verify_competition_native_camera_observation_binding_v2_9_3, + verify_competition_native_solver_camera_binding_v2_9_3, + verify_source_surface_evidence, +) +from spatialcf.generation.capture.reachability import ( + CandidateTargetReachability, + TargetReachabilityStatus, + derive_competition_native_candidate_target_reachability_from_prepared_v2_9_4, + prepare_competition_native_target_reachability_source_v2_9_4, ) from spatialcf.relations.engine import RelationEngine @@ -81,7 +77,7 @@ _MAX_CANDIDATES_TOTAL = 40_000 -class _RosterCore(V2Model): +class _RosterCore(CanonicalModel): policy: RosterPolicy scene_inventory: tuple[CompetitionNativeSourceCaptureOutcomeV2_9, ...] = Field( max_length=_MAX_POLICY_SOURCES @@ -108,7 +104,7 @@ def validate_core(self): def _digest_order(*values: object) -> str: - return hashlib.sha256(canonical_json_bytes_v2(values)).hexdigest() + return hashlib.sha256(canonical_json_bytes(values)).hexdigest() def _rejection( @@ -131,7 +127,7 @@ def _rejection( } return CompetitionNativeRosterRejectionV2_9( rejection_id="rejection-" - + canonical_sha256_v2(payload, domain=_REJECTION_ID_HASH_DOMAIN), + + canonical_sha256(payload, domain=_REJECTION_ID_HASH_DOMAIN), stage=stage, source_id=source_id, scene_id=scene_id, @@ -286,7 +282,7 @@ def _candidate_identity( "subject_id": subject_id, "support_kind": support_kind.value, } - return "candidate-" + canonical_sha256_v2(payload, domain=_CANDIDATE_ID_HASH_DOMAIN) + return "candidate-" + canonical_sha256(payload, domain=_CANDIDATE_ID_HASH_DOMAIN) def _candidate_record( @@ -412,7 +408,7 @@ def _select_candidates( requests: list[CompetitionNativeSelectedRequestV2_9] = [] for item in selected: source = sources[item.source_id] - request_digest = canonical_sha256_v2( + request_digest = canonical_sha256( { "candidate_id": item.candidate_id, "selection_index": item.selection_index, @@ -590,7 +586,7 @@ def _compile_competition_native_candidate_roster( } inventory = CompetitionNativeObjectInventoryV2_9( inventory_id="object-" - + canonical_sha256_v2(identity_payload, domain=_OBJECT_ID_HASH_DOMAIN), + + canonical_sha256(identity_payload, domain=_OBJECT_ID_HASH_DOMAIN), source_id=record.source.source_id, scene_id=scene.scene_id, split=record.source.split, @@ -819,7 +815,7 @@ def _compile_competition_native_candidate_roster( ) -def _editable_camera_capture_roster_v2_9_4( +def _surface_camera_capture_roster( capture: CompetitionNativeSourceCaptureV2_9, ) -> tuple[ tuple[tuple[str, str], ...], @@ -878,7 +874,7 @@ def _editable_camera_capture_roster_v2_9_4( return tuple(certified_pairs), tuple(roster_entries) -def _verify_competition_native_camera_evidence_capture_v2_9_3( +def _verify_camera_evidence_capture( capture: CompetitionNativeSourceCaptureV2_9, evidence: SourceCameraEvidence, policy: CameraPolicy, @@ -902,9 +898,7 @@ def _verify_competition_native_camera_evidence_capture_v2_9_3( ) if type(checked_evidence.score) is CompetitionNativeCameraScoreV2_9_4: - pairs, _placement_roster = _editable_camera_capture_roster_v2_9_4( - checked_capture - ) + pairs, _placement_roster = _surface_camera_capture_roster(checked_capture) else: support_by_id = {item.object_id: item for item in checked_capture.support_facts} pairs = tuple( @@ -917,8 +911,8 @@ def _verify_competition_native_camera_evidence_capture_v2_9_3( and support_by_id[item.object_id].support_object_id is not None ) ) - requested_fallback = AI2ThorAgentPose( - position=AI2ThorNativePosition( + requested_fallback = AdapterPose( + position=AdapterPosition( x=checked_evidence.requested_pose.x, y=checked_evidence.requested_pose.y, z=checked_evidence.requested_pose.z, @@ -930,8 +924,8 @@ def _verify_competition_native_camera_evidence_capture_v2_9_3( fallback = ( requested_fallback if not pairs - else AI2ThorAgentPose( - position=AI2ThorNativePosition( + else AdapterPose( + position=AdapterPosition( x=checked_capture.reachable_positions[0].x, y=checked_capture.reachable_positions[0].y, z=checked_capture.reachable_positions[0].z, @@ -945,7 +939,7 @@ def _verify_competition_native_camera_evidence_capture_v2_9_3( checked_capture.scene, pairs, tuple( - AI2ThorNativePosition(x=item.x, y=item.y, z=item.z) + AdapterPosition(x=item.x, y=item.y, z=item.z) for item in checked_capture.reachable_positions ), fallback, @@ -975,7 +969,7 @@ def _verify_competition_native_camera_evidence_capture_v2_9_3( pose_bank[selected_index], checked_capture.scene.camera_by_id("main"), ) - runtime_identity_sha256 = canonical_sha256_v2( + runtime_identity_sha256 = canonical_sha256( checked_capture.runtime_identity, domain=_RUNTIME_IDENTITY_HASH_DOMAIN, ) @@ -1029,7 +1023,7 @@ def score_competition_native_camera_capture_scene_v2_9_3( if type(checked_evidence.score) is not CompetitionNativeCameraScoreV2_9_4: return score_competition_native_camera_scene_v2_9_3(checked_scene) - _pairs, roster = _editable_camera_capture_roster_v2_9_4(checked_capture) + _pairs, roster = _surface_camera_capture_roster(checked_capture) return score_competition_native_editable_camera_scene_v2_9_4( checked_scene, roster, @@ -1043,7 +1037,7 @@ def verify_competition_native_camera_evidence_capture_v2_9_3( ) -> SourceCameraEvidence: """Public exact verifier for either persisted camera-score generation.""" - return _verify_competition_native_camera_evidence_capture_v2_9_3( + return _verify_camera_evidence_capture( capture, evidence, policy, @@ -1082,7 +1076,7 @@ def compile_roster( for item in surface_evidence ) checked_camera = tuple( - _verify_competition_native_camera_evidence_capture_v2_9_3( + _verify_camera_evidence_capture( capture_by_source_id[item.source_id], item, policy.camera_policy, diff --git a/src/spatialcf/generation/capture/models.py b/src/spatialcf/generation/capture/models.py index 2618d43..f5dfadf 100644 --- a/src/spatialcf/generation/capture/models.py +++ b/src/spatialcf/generation/capture/models.py @@ -6,33 +6,2554 @@ from __future__ import annotations -from enum import StrEnum +# Final Task 3 owners for platform-neutral camera and surface evidence. +import math +from dataclasses import asdict +from hashlib import sha256 from typing import Literal, Self from pydantic import Field, model_validator -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import OBB, Scene, SubjectPositionRegion, Vec2 -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.adapters.base import ( + AdapterCameraApplication, + AdapterObservation, + AdapterPose, + AdapterPosition, + AdapterRuntimeIdentity, + AdapterSpawnMap, + AdapterSurfacePatch, +) +from spatialcf.domain.base import CanonicalModel, FiniteFloat, Sha256Digest +from spatialcf.domain.scene import OBB, Camera, Quaternion, Scene +from spatialcf.domain.serialization import canonical_sha256 +from spatialcf.relations.engine import RelationEngine + +_PAIR_CAMERA_RADIUS_M = 2.0 +_PAIR_CAMERA_DIRECTIONS = ( + (0.0, -1.0), + (1.0, 0.0), + (0.0, 1.0), + (-1.0, 0.0), +) +_PAIR_CAMERA_HORIZONS_DEGREES = (0.0, 30.0) +_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_5 = 0.2 +_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_6 = 0.21 +_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_7 = 0.25 +_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_8 = 0.25 + math.sqrt(2.0) * 0.5e-6 + + +class _CameraConversionError(ValueError): + pass + + +def _expected_camera_world_to_camera( + native_position: tuple[float, float, float], + *, + yaw_degrees: float, + horizon_degrees: float, +) -> tuple[float, ...]: + """Rebuild the persisted Canonical camera matrix from adapter scalars.""" + + yaw = math.radians(yaw_degrees) + pitch = math.radians(horizon_degrees) + right = (math.cos(yaw), -math.sin(yaw), 0.0) + forward = ( + math.sin(yaw) * math.cos(pitch), + math.cos(yaw) * math.cos(pitch), + -math.sin(pitch), + ) + up = ( + right[1] * forward[2] - right[2] * forward[1], + right[2] * forward[0] - right[0] * forward[2], + right[0] * forward[1] - right[1] * forward[0], + ) + # The frozen scene records Canonical right-handed Z-up positions. + position = (native_position[0], native_position[2], native_position[1]) + rows = (right, up, forward) + translation = tuple( + -sum(row[index] * position[index] for index in range(3)) for row in rows + ) + return ( + *right, + translation[0], + *up, + translation[1], + *forward, + translation[2], + 0.0, + 0.0, + 0.0, + 1.0, + ) + + +def _pair_midpoint( + scene: Scene, + subject_object_id: str, + reference_object_id: str, +) -> tuple[float, float]: + subject = scene.object_by_id(subject_object_id) + reference = scene.object_by_id(reference_object_id) + return ( + (subject.obb.center.x + reference.obb.center.x) / 2.0, + (subject.obb.center.y + reference.obb.center.y) / 2.0, + ) + + +def _validate_pair_camera_inputs( + scene: Scene, + subject_object_id: str, + reference_object_id: str, + reachable_positions: tuple[AdapterPosition, ...], +) -> None: + if type(scene) is not Scene: + raise ValueError("scene must be an exact canonical Scene") + if ( + type(subject_object_id) is not str + or not subject_object_id + or type(reference_object_id) is not str + or not reference_object_id + or subject_object_id == reference_object_id + ): + raise ValueError("camera pair must use two distinct non-empty object IDs") + scene.object_by_id(subject_object_id) + scene.object_by_id(reference_object_id) + if ( + type(reachable_positions) is not tuple + or not reachable_positions + or any(type(item) is not AdapterPosition for item in reachable_positions) + ): + raise ValueError("reachable positions must be a non-empty exact tuple") + if len(set(reachable_positions)) != len(reachable_positions): + raise ValueError("reachable positions must be unique") + + +def _ring_positions( + midpoint_x: float, + midpoint_z: float, + directions: tuple[tuple[float, float], ...], + reachable_positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPosition, ...]: + selected: list[AdapterPosition] = [] + selected_set: set[AdapterPosition] = set() + for direction_x, direction_z in directions: + target_x = midpoint_x + _PAIR_CAMERA_RADIUS_M * direction_x + target_z = midpoint_z + _PAIR_CAMERA_RADIUS_M * direction_z + nearest = min( + reachable_positions, + key=lambda item: ( + (item.x - target_x) ** 2 + (item.z - target_z) ** 2, + item.x, + item.z, + item.y, + ), + ) + if nearest not in selected_set: + selected.append(nearest) + selected_set.add(nearest) + return tuple(selected) + + +def deterministic_pair_camera_poses( + scene: Scene, + subject_object_id: str, + reference_object_id: str, + reachable_positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPose, ...]: + """Return at most eight frozen Tier-1 poses around one object pair.""" + + _validate_pair_camera_inputs( + scene, + subject_object_id, + reference_object_id, + reachable_positions, + ) + midpoint_x, midpoint_z = _pair_midpoint( + scene, + subject_object_id, + reference_object_id, + ) + selected = _ring_positions( + midpoint_x, + midpoint_z, + _PAIR_CAMERA_DIRECTIONS, + reachable_positions, + ) + poses: list[AdapterPose] = [] + for position in selected: + yaw = ( + math.degrees(math.atan2(midpoint_x - position.x, midpoint_z - position.z)) + % 360.0 + ) + for horizon in _PAIR_CAMERA_HORIZONS_DEGREES: + poses.append( + AdapterPose( + position=position, + yaw_degrees=yaw, + horizon_degrees=horizon, + standing=True, + ) + ) + return tuple(poses) + + +def _clearance_rotation_matrix( + rotation: Quaternion, +) -> tuple[tuple[float, float, float], ...]: + values = (rotation.x, rotation.y, rotation.z, rotation.w) + if not all(math.isfinite(value) for value in values): + raise ValueError("camera clearance OBB rotation must be finite") + maximum = max(abs(value) for value in values) + if maximum == 0.0: + raise ValueError("camera clearance OBB rotation must be nonzero") + scaled = tuple(value / maximum for value in values) + norm = math.sqrt(sum(value * value for value in scaled)) + x, y, z, w = (value / norm for value in scaled) + return ( + ( + 1.0 - 2.0 * (y * y + z * z), + 2.0 * (x * y - z * w), + 2.0 * (x * z + y * w), + ), + ( + 2.0 * (x * y + z * w), + 1.0 - 2.0 * (x * x + z * z), + 2.0 * (y * z - x * w), + ), + ( + 2.0 * (x * z - y * w), + 2.0 * (y * z + x * w), + 1.0 - 2.0 * (x * x + y * y), + ), + ) + + +def _projected_corners(obb: OBB) -> tuple[tuple[float, float], ...]: + extents = (obb.extent.x, obb.extent.y, obb.extent.z) + centers = (obb.center.x, obb.center.y, obb.center.z) + if not all(math.isfinite(value) and value > 0.0 for value in extents): + raise ValueError("camera clearance OBB extents must be finite and positive") + if not all(math.isfinite(value) for value in centers): + raise ValueError("camera clearance OBB center must be finite") + rotation = _clearance_rotation_matrix(obb.rotation) + points = set() + for x_sign in (-1.0, 1.0): + for y_sign in (-1.0, 1.0): + for z_sign in (-1.0, 1.0): + local = ( + x_sign * extents[0] / 2.0, + y_sign * extents[1] / 2.0, + z_sign * extents[2] / 2.0, + ) + world = tuple( + centers[axis] + + sum(rotation[axis][inner] * local[inner] for inner in range(3)) + for axis in range(3) + ) + points.add((world[0], world[1])) + return tuple(sorted(points)) + + +def _cross( + origin: tuple[float, float], + left: tuple[float, float], + right: tuple[float, float], +) -> float: + return (left[0] - origin[0]) * (right[1] - origin[1]) - (left[1] - origin[1]) * ( + right[0] - origin[0] + ) + + +def _convex_hull( + points: tuple[tuple[float, float], ...], +) -> tuple[tuple[float, float], ...]: + unique = tuple(sorted(set(points))) + if len(unique) < 3: + raise ValueError("camera clearance OBB projection must have positive area") + lower: list[tuple[float, float]] = [] + for point in unique: + while len(lower) >= 2 and _cross(lower[-2], lower[-1], point) <= 0.0: + lower.pop() + lower.append(point) + upper: list[tuple[float, float]] = [] + for point in reversed(unique): + while len(upper) >= 2 and _cross(upper[-2], upper[-1], point) <= 0.0: + upper.pop() + upper.append(point) + hull = tuple(lower[:-1] + upper[:-1]) + if len(hull) < 3: + raise ValueError("camera clearance OBB projection must have positive area") + return hull + + +def _point_segment_distance( + point: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], +) -> float: + delta = (end[0] - start[0], end[1] - start[1]) + length_squared = delta[0] * delta[0] + delta[1] * delta[1] + if length_squared == 0.0: + return math.dist(point, start) + fraction = max( + 0.0, + min( + 1.0, + ((point[0] - start[0]) * delta[0] + (point[1] - start[1]) * delta[1]) + / length_squared, + ), + ) + nearest = ( + start[0] + fraction * delta[0], + start[1] + fraction * delta[1], + ) + return math.dist(point, nearest) + + +def _point_polygon_distance( + point: tuple[float, float], + polygon: tuple[tuple[float, float], ...], +) -> float: + crosses = tuple( + _cross(polygon[index], polygon[(index + 1) % len(polygon)], point) + for index in range(len(polygon)) + ) + if all(value >= 0.0 for value in crosses) or all(value <= 0.0 for value in crosses): + return 0.0 + return min( + _point_segment_distance( + point, + polygon[index], + polygon[(index + 1) % len(polygon)], + ) + for index in range(len(polygon)) + ) + + +def _filter_competition_native_camera_positions( + scene: Scene, + positions: tuple[AdapterPosition, ...], + *, + clearance_radius_m: float, +) -> tuple[AdapterPosition, ...]: + if type(scene) is not Scene: + raise TypeError("camera clearance scene must be an exact Scene") + checked_scene = Scene.model_validate(scene.model_dump(mode="python"), strict=True) + if type(positions) is not tuple or any( + type(position) is not AdapterPosition for position in positions + ): + raise TypeError("camera clearance requires an exact position tuple") + if not positions: + raise TypeError("camera clearance requires a non-empty exact position tuple") + if len(set(positions)) != len(positions): + raise ValueError("camera clearance positions must be unique") + footprints = tuple( + _convex_hull(_projected_corners(item.obb)) + for item in checked_scene.objects + if item.movable + ) + accepted = tuple( + position + for position in positions + if all( + _point_polygon_distance((position.x, position.z), footprint) + > clearance_radius_m + for footprint in footprints + ) + ) + return tuple(sorted(accepted, key=lambda item: (item.x, item.z, item.y))) + + +def filter_competition_native_camera_positions_v2_9_5( + scene: Scene, + positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_5, + ) + + +def filter_competition_native_camera_positions_v2_9_6( + scene: Scene, + positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_6, + ) + + +def filter_competition_native_camera_positions_v2_9_7( + scene: Scene, + positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_7, + ) + + +def filter_competition_native_camera_positions_v2_9_8( + scene: Scene, + positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPosition, ...]: + return _filter_competition_native_camera_positions( + scene, + positions, + clearance_radius_m=_CAMERA_AGENT_CLEARANCE_RADIUS_M_V2_9_8, + ) + + +def _legacy_camera(matrix: tuple[float, ...]) -> tuple[float, dict[str, float]]: + cosine, negative_sine = matrix[0], matrix[1] + sine, second_cosine = matrix[8], matrix[9] + if math.isclose(math.hypot(sine, second_cosine), 0.0, rel_tol=0.0, abs_tol=1e-12): + raise _CameraConversionError("MISSING_FACT:COMPLETE_UPRIGHT_CAMERA_DEPTH_BASIS") + expected = ( + (matrix[2], 0.0), + (matrix[4], 0.0), + (matrix[5], 0.0), + (matrix[6], 1.0), + (matrix[10], 0.0), + (matrix[12], 0.0), + (matrix[13], 0.0), + (matrix[14], 0.0), + (matrix[15], 1.0), + (negative_sine, -sine), + (cosine, second_cosine), + ) + if any( + not math.isclose(actual, wanted, rel_tol=0.0, abs_tol=1e-9) + for actual, wanted in expected + ) or not math.isclose(math.hypot(sine, cosine), 1.0, rel_tol=0.0, abs_tol=1e-9): + raise _CameraConversionError("UNSUPPORTED_MODEL:CAMERA_NOT_EXACT_UPRIGHT") + angle = 0.0 if sine == 0.0 else math.atan2(sine, cosine) + return angle, {"x": matrix[3], "y": -matrix[7], "z": matrix[11]} + + +_MAX_POSE_BANK_MEMBERS = 256 +_MAX_POSITION_RESIDUAL_M = 1e-5 +_MAX_ANGLE_RESIDUAL_DEGREES = 1e-4 +_POLICY_HASH_DOMAIN = "spatialcf.competition-native-camera-policy.v2.9.3" +_POSE_BANK_HASH_DOMAIN = "spatialcf.competition-native-camera-pose-bank.v2.9.3" +_EVIDENCE_HASH_DOMAIN = "spatialcf.competition-native-source-camera-evidence.v2.9.3" +_PLACEMENT_ROSTER_HASH_DOMAIN = ( + "spatialcf.competition-native-camera-placement-roster.v2.9.4" +) +_LEGACY_POSE_POLICY_VERSION = "deterministic-pair-camera-tier-1:1" +_SOLVER_UPRIGHT_POSE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright:2" +) +_EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain:3" +) +_COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.2m:4" +) +_CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.21m:5" +) +_RESET_PER_POSE_EDITABLE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.21m-reset-per-pose:6" +) +_GRID_MARGIN_EDITABLE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.25m:7" +) +_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.25m-physics-paused:8" +) +_SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.25m-physics-paused-final-settle:9" +) + + +def _editable_pose_policy_versions() -> frozenset[str]: + return frozenset( + { + _EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION, + _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION, + _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, + _RESET_PER_POSE_EDITABLE_POLICY_VERSION, + _GRID_MARGIN_EDITABLE_POLICY_VERSION, + _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + } + ) + + +class CompetitionNativeCameraPoseV2_9_3(CanonicalModel): + """One exact native TeleportFull pose on the canonical evidence wire.""" + + pose_version: Literal["competition-native-camera-pose:2.9.3"] = ( + "competition-native-camera-pose:2.9.3" + ) + x: FiniteFloat + y: FiniteFloat + z: FiniteFloat + yaw_degrees: FiniteFloat + horizon_degrees: FiniteFloat + standing: bool + + +def _policy_payload( + pose_policy_version: str = _LEGACY_POSE_POLICY_VERSION, +) -> dict[str, object]: + if pose_policy_version not in { + _LEGACY_POSE_POLICY_VERSION, + _SOLVER_UPRIGHT_POSE_POLICY_VERSION, + _EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION, + _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION, + _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, + _RESET_PER_POSE_EDITABLE_POLICY_VERSION, + _GRID_MARGIN_EDITABLE_POLICY_VERSION, + _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + }: + raise ValueError("camera evidence pose policy version is unsupported") + return { + "camera_id": "main", + "maximum_pose_bank_count": 256, + "maximum_truncated_fraction": 0.5, + "minimum_image_area_fraction": 0.0025, + "minimum_visible_fraction": 0.2, + "policy_version": "competition-native-camera-selection-policy:2.9.3", + "pose_policy_version": pose_policy_version, + } + + +class CompetitionNativeCameraPolicyV2_9_3(CanonicalModel): + """Frozen literal source-camera selection policy and its own digest.""" + + policy_version: Literal["competition-native-camera-selection-policy:2.9.3"] = ( + "competition-native-camera-selection-policy:2.9.3" + ) + pose_policy_version: Literal[ + "deterministic-pair-camera-tier-1:1", + "deterministic-pair-camera-tier-1-solver-upright:2", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain:3", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.2m:4", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.21m:5", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.21m-reset-per-pose:6", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.25m:7", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.25m-physics-paused:8", + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-movable-clearance-0.25m-physics-paused-final-settle:9", + ] = _LEGACY_POSE_POLICY_VERSION + camera_id: Literal["main"] = "main" + maximum_pose_bank_count: Literal[256] = 256 + minimum_visible_fraction: Literal[0.2] = 0.2 + minimum_image_area_fraction: Literal[0.0025] = 0.0025 + maximum_truncated_fraction: Literal[0.5] = 0.5 + policy_sha256: Sha256Digest + + @model_validator(mode="after") + def validate_policy_digest(self) -> Self: + expected = canonical_sha256( + _policy_payload(self.pose_policy_version), + domain=_POLICY_HASH_DOMAIN, + ) + if self.policy_sha256 != expected: + raise ValueError("camera evidence policy digest mismatch") + return self + + +class CompetitionNativeCameraScoreV2_9_3(CanonicalModel): + """The two literal source-only counts used by camera selection.""" + + score_version: Literal["competition-native-camera-score:2.9.3"] = ( + "competition-native-camera-score:2.9.3" + ) + movable_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) + all_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) + + @model_validator(mode="after") + def validate_score_counts(self) -> Self: + if ( + self.movable_scene_unique_category_qualifying_count + > self.all_scene_unique_category_qualifying_count + ): + raise ValueError("camera evidence movable score exceeds total score") + return self + + +class CompetitionNativeCameraPlacementPositionV2_9_4(CanonicalModel): + """One exact native subject anchor considered by camera selection.""" + + position_version: Literal["competition-native-camera-placement-position:2.9.4"] = ( + "competition-native-camera-placement-position:2.9.4" + ) + x: FiniteFloat + y: FiniteFloat + z: FiniteFloat + + +class CompetitionNativeCameraPlacementRosterEntryV2_9_4(CanonicalModel): + """One source subject and its complete canonical native placement roster.""" + + entry_version: Literal["competition-native-camera-placement-roster-entry:2.9.4"] = ( + "competition-native-camera-placement-roster-entry:2.9.4" + ) + subject_object_id: str = Field(strict=True, min_length=1, max_length=512) + support_object_id: str = Field(strict=True, min_length=1, max_length=512) + positions: tuple[CompetitionNativeCameraPlacementPositionV2_9_4, ...] = Field( + min_length=1 + ) + + @model_validator(mode="after") + def validate_positions(self) -> Self: + ordered = tuple( + sorted( + set(self.positions), + key=lambda item: (item.x, item.z, item.y), + ) + ) + if self.positions != ordered: + raise ValueError("camera placement positions must be unique and canonical") + return self + + +class CompetitionNativeCameraScoreV2_9_4(CanonicalModel): + """Source visibility plus the native edit domain visible from one pose.""" + + score_version: Literal["competition-native-camera-score:2.9.4"] = ( + "competition-native-camera-score:2.9.4" + ) + placement_roster_sha256: Sha256Digest + visible_native_placement_subject_count: int = Field(strict=True, ge=0) + visible_native_placement_count: int = Field(strict=True, ge=0) + movable_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) + all_scene_unique_category_qualifying_count: int = Field(strict=True, ge=0) + + @model_validator(mode="after") + def validate_score_counts(self) -> Self: + if ( + self.movable_scene_unique_category_qualifying_count + > self.all_scene_unique_category_qualifying_count + or self.visible_native_placement_subject_count + > self.movable_scene_unique_category_qualifying_count + or self.visible_native_placement_subject_count + > self.visible_native_placement_count + ): + raise ValueError("editable camera score counts are inconsistent") + return self + + +CompetitionNativeCameraScoreFamilyV2_9_3 = ( + CompetitionNativeCameraScoreV2_9_3 | CompetitionNativeCameraScoreV2_9_4 +) + + +def _strict_placement_roster_v2_9_4( + placement_roster: object, +) -> tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...]: + if type(placement_roster) is not tuple or any( + type(item) is not CompetitionNativeCameraPlacementRosterEntryV2_9_4 + for item in placement_roster + ): + raise TypeError("camera placement roster must be an exact entry tuple") + checked = tuple( + CompetitionNativeCameraPlacementRosterEntryV2_9_4.model_validate( + item.model_dump(mode="python"), strict=True + ) + for item in placement_roster + ) + if not checked: + raise ValueError("camera placement roster must not be empty") + if tuple( + sorted(checked, key=lambda item: item.subject_object_id) + ) != checked or len({item.subject_object_id for item in checked}) != len(checked): + raise ValueError("camera placement roster must be unique and canonical") + return checked + + +def competition_native_camera_placement_roster_sha256_v2_9_4( + placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], +) -> Sha256Digest: + """Hash one exact source placement roster in its independent domain.""" + + checked = _strict_placement_roster_v2_9_4(placement_roster) + payload = { + "placement_roster_version": "competition-native-camera-placement-roster:2.9.4", + "subjects": tuple(item.model_dump(mode="json") for item in checked), + } + return canonical_sha256(payload, domain=_PLACEMENT_ROSTER_HASH_DOMAIN) + + +def select_competition_native_camera_score_index_v2_9_4( + pose_scores: tuple[CompetitionNativeCameraScoreV2_9_4, ...], +) -> int: + """Select edit-domain coverage, then source visibility, then bank index.""" + + if ( + type(pose_scores) is not tuple + or not pose_scores + or any( + type(item) is not CompetitionNativeCameraScoreV2_9_4 for item in pose_scores + ) + ): + raise TypeError("editable camera score ledger must be an exact nonempty tuple") + checked = tuple( + CompetitionNativeCameraScoreV2_9_4.model_validate( + item.model_dump(mode="python"), strict=True + ) + for item in pose_scores + ) + if ( + len(checked) > _MAX_POSE_BANK_MEMBERS + or len({item.placement_roster_sha256 for item in checked}) != 1 + ): + raise ValueError("editable camera score ledger is not source-aligned") + return min( + range(len(checked)), + key=lambda index: ( + -checked[index].visible_native_placement_subject_count, + -checked[index].visible_native_placement_count, + -checked[index].movable_scene_unique_category_qualifying_count, + -checked[index].all_scene_unique_category_qualifying_count, + index, + ), + ) + + +def _strict_score_ledger( + pose_scores: object, +) -> tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...]: + if type(pose_scores) is not tuple: + raise TypeError("camera evidence score ledger must be an exact score tuple") + if not pose_scores or len(pose_scores) > _MAX_POSE_BANK_MEMBERS: + raise ValueError( + "camera evidence score ledger must contain 1 through 256 scores" + ) + score_type: type[CompetitionNativeCameraScoreFamilyV2_9_3] + if all(type(item) is CompetitionNativeCameraScoreV2_9_3 for item in pose_scores): + score_type = CompetitionNativeCameraScoreV2_9_3 + elif all(type(item) is CompetitionNativeCameraScoreV2_9_4 for item in pose_scores): + score_type = CompetitionNativeCameraScoreV2_9_4 + else: + raise TypeError("camera evidence score ledger mixes score versions") + return tuple( + score_type.model_validate(item.model_dump(mode="python"), strict=True) + for item in pose_scores + ) + + +def select_competition_native_camera_score_index_v2_9_3( + pose_scores: tuple[CompetitionNativeCameraScoreV2_9_3, ...], +) -> int: + """Select the literal score argmax, breaking complete ties by bank index.""" + + checked = _strict_score_ledger(pose_scores) + if any(type(item) is not CompetitionNativeCameraScoreV2_9_3 for item in checked): + raise TypeError("legacy camera selector requires 2.9.3 scores") + return min( + range(len(checked)), + key=lambda index: ( + -checked[index].movable_scene_unique_category_qualifying_count, + -checked[index].all_scene_unique_category_qualifying_count, + index, + ), + ) + + +def _select_competition_native_camera_score_index( + pose_scores: tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...], +) -> int: + checked = _strict_score_ledger(pose_scores) + if type(checked[0]) is CompetitionNativeCameraScoreV2_9_3: + return select_competition_native_camera_score_index_v2_9_3(checked) # type: ignore[arg-type] + return select_competition_native_camera_score_index_v2_9_4(checked) # type: ignore[arg-type] + + +def verify_competition_native_camera_observation_binding_v2_9_3( + requested_pose: CompetitionNativeCameraPoseV2_9_3, + observed_pose: CompetitionNativeCameraPoseV2_9_3, + observed_native_camera_position: tuple[float, float, float], + camera: Camera, +) -> None: + """Close persisted native observation fields to one requested main Camera.""" + + if ( + type(requested_pose) is not CompetitionNativeCameraPoseV2_9_3 + or type(observed_pose) is not CompetitionNativeCameraPoseV2_9_3 + ): + raise TypeError( + "camera evidence camera observation binding poses must be exact" + ) + if ( + type(observed_native_camera_position) is not tuple + or len(observed_native_camera_position) != 3 + or any(type(item) is not float for item in observed_native_camera_position) + ): + raise TypeError( + "camera evidence camera observation binding native position must be exact" + ) + if type(camera) is not Camera or camera.camera_id != "main": + raise TypeError( + "camera evidence camera observation binding Camera must be exact main" + ) + position_residual_m = math.dist( + (requested_pose.x, requested_pose.y, requested_pose.z), + (observed_pose.x, observed_pose.y, observed_pose.z), + ) + yaw_residual_degrees = abs( + (observed_pose.yaw_degrees - requested_pose.yaw_degrees + 180.0) % 360.0 - 180.0 + ) + horizon_residual_degrees = abs( + observed_pose.horizon_degrees - requested_pose.horizon_degrees + ) + if ( + position_residual_m > _MAX_POSITION_RESIDUAL_M + or yaw_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES + or horizon_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES + or observed_pose.standing is not requested_pose.standing + ): + raise ValueError( + "camera evidence camera observation binding does not close requested pose" + ) + expected_world_to_camera = _expected_camera_world_to_camera( + observed_native_camera_position, + yaw_degrees=observed_pose.yaw_degrees, + horizon_degrees=observed_pose.horizon_degrees, + ) + if camera.world_to_camera != expected_world_to_camera: + raise ValueError( + "camera evidence camera observation binding does not close main Camera" + ) + + +def _evidence_payload( + *, + source_id: str, + scene_id: str, + source_locator_sha256: str, + runtime_identity_sha256: str, + source_capture_sha256: str, + policy_sha256: str, + pose_bank_sha256: str, + pose_bank_count: int, + pose_scores: tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...], + selected_pose_index: int, + requested_pose: CompetitionNativeCameraPoseV2_9_3, + observed_pose: CompetitionNativeCameraPoseV2_9_3, + observed_native_camera_position: tuple[float, float, float], + camera: Camera, + score: CompetitionNativeCameraScoreFamilyV2_9_3, + rgb_png_sha256: str, + depth_npy_sha256: str, + instance_png_sha256: str, + pointcloud_ply_sha256: str, + is_scene_at_rest: bool, +) -> dict[str, object]: + return { + "camera": camera.model_dump(mode="json"), + "depth_npy_sha256": depth_npy_sha256, + "evidence_version": "competition-native-source-camera-evidence:2.9.3", + "instance_png_sha256": instance_png_sha256, + "is_scene_at_rest": is_scene_at_rest, + "observed_native_camera_position": observed_native_camera_position, + "observed_pose": observed_pose.model_dump(mode="json"), + "pointcloud_ply_sha256": pointcloud_ply_sha256, + "policy_sha256": policy_sha256, + "pose_bank_count": pose_bank_count, + "pose_bank_sha256": pose_bank_sha256, + "pose_scores": tuple(item.model_dump(mode="json") for item in pose_scores), + "requested_pose": requested_pose.model_dump(mode="json"), + "rgb_png_sha256": rgb_png_sha256, + "runtime_identity_sha256": runtime_identity_sha256, + "scene_id": scene_id, + "score": score.model_dump(mode="json"), + "selected_pose_index": selected_pose_index, + "source_capture_sha256": source_capture_sha256, + "source_id": source_id, + "source_locator_sha256": source_locator_sha256, + } + + +class CompetitionNativeSourceCameraEvidenceV2_9_3(CanonicalModel): + """One selected source camera with complete immutable capture lineage.""" + + evidence_version: Literal["competition-native-source-camera-evidence:2.9.3"] = ( + "competition-native-source-camera-evidence:2.9.3" + ) + source_id: str = Field(strict=True, min_length=1, max_length=512) + scene_id: str = Field(strict=True, min_length=1, max_length=512) + source_locator_sha256: Sha256Digest + runtime_identity_sha256: Sha256Digest + source_capture_sha256: Sha256Digest + policy_sha256: Sha256Digest + pose_bank_sha256: Sha256Digest + pose_bank_count: int = Field(strict=True, ge=1, le=256) + pose_scores: tuple[CompetitionNativeCameraScoreFamilyV2_9_3, ...] = Field( + min_length=1, max_length=256 + ) + selected_pose_index: int = Field(strict=True, ge=0, le=255) + requested_pose: CompetitionNativeCameraPoseV2_9_3 + observed_pose: CompetitionNativeCameraPoseV2_9_3 + observed_native_camera_position: tuple[FiniteFloat, FiniteFloat, FiniteFloat] + camera: Camera + score: CompetitionNativeCameraScoreFamilyV2_9_3 + rgb_png_sha256: Sha256Digest + depth_npy_sha256: Sha256Digest + instance_png_sha256: Sha256Digest + pointcloud_ply_sha256: Sha256Digest + is_scene_at_rest: bool + camera_evidence_sha256: Sha256Digest + + @model_validator(mode="after") + def validate_evidence(self) -> Self: + checked_scores = _strict_score_ledger(self.pose_scores) + if len(checked_scores) != self.pose_bank_count: + raise ValueError("camera evidence score ledger is not bank-aligned") + if self.selected_pose_index >= self.pose_bank_count: + raise ValueError("camera evidence selected index is outside pose bank") + if ( + self.selected_pose_index + != _select_competition_native_camera_score_index(checked_scores) + or self.score != checked_scores[self.selected_pose_index] + ): + raise ValueError("camera evidence selected index is not the literal argmax") + if self.camera.camera_id != "main": + raise ValueError("camera evidence must persist the main camera") + verify_competition_native_camera_observation_binding_v2_9_3( + self.requested_pose, + self.observed_pose, + self.observed_native_camera_position, + self.camera, + ) + expected_policies = { + canonical_sha256( + _policy_payload(version), + domain=_POLICY_HASH_DOMAIN, + ) + for version in ( + _LEGACY_POSE_POLICY_VERSION, + _SOLVER_UPRIGHT_POSE_POLICY_VERSION, + _EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION, + _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION, + _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, + _RESET_PER_POSE_EDITABLE_POLICY_VERSION, + _GRID_MARGIN_EDITABLE_POLICY_VERSION, + _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + ) + } + if self.policy_sha256 not in expected_policies: + raise ValueError("camera evidence policy digest mismatch") + editable_policy_sha256s = { + canonical_sha256( + _policy_payload(version), + domain=_POLICY_HASH_DOMAIN, + ) + for version in _editable_pose_policy_versions() + } + if (type(checked_scores[0]) is CompetitionNativeCameraScoreV2_9_4) != ( + self.policy_sha256 in editable_policy_sha256s + ): + raise ValueError("camera evidence score version does not bind policy") + expected = canonical_sha256( + _evidence_payload( + source_id=self.source_id, + scene_id=self.scene_id, + source_locator_sha256=self.source_locator_sha256, + runtime_identity_sha256=self.runtime_identity_sha256, + source_capture_sha256=self.source_capture_sha256, + policy_sha256=self.policy_sha256, + pose_bank_sha256=self.pose_bank_sha256, + pose_bank_count=self.pose_bank_count, + pose_scores=checked_scores, + selected_pose_index=self.selected_pose_index, + requested_pose=self.requested_pose, + observed_pose=self.observed_pose, + observed_native_camera_position=self.observed_native_camera_position, + camera=self.camera, + score=self.score, + rgb_png_sha256=self.rgb_png_sha256, + depth_npy_sha256=self.depth_npy_sha256, + instance_png_sha256=self.instance_png_sha256, + pointcloud_ply_sha256=self.pointcloud_ply_sha256, + is_scene_at_rest=self.is_scene_at_rest, + ), + domain=_EVIDENCE_HASH_DOMAIN, + ) + if self.camera_evidence_sha256 != expected: + raise ValueError("camera evidence digest mismatch") + return self + + +def _strict_scene(scene: object) -> Scene: + if type(scene) is not Scene: + raise TypeError("camera evidence scene must be an exact Scene") + return Scene.model_validate(scene.model_dump(mode="python"), strict=True) + + +def _strict_native_pose(pose: object, *, label: str) -> AdapterPose: + if type(pose) is not AdapterPose: + raise TypeError(f"{label} must be an exact AdapterPose") + if type(pose.position) is not AdapterPosition: + raise TypeError(f"{label} position must be exact") + return AdapterPose( + position=AdapterPosition(**asdict(pose.position)), + yaw_degrees=pose.yaw_degrees, + horizon_degrees=pose.horizon_degrees, + standing=pose.standing, + ) + + +def _wire_pose(pose: AdapterPose) -> CompetitionNativeCameraPoseV2_9_3: + return CompetitionNativeCameraPoseV2_9_3( + x=pose.position.x, + y=pose.position.y, + z=pose.position.z, + yaw_degrees=pose.yaw_degrees, + horizon_degrees=pose.horizon_degrees, + standing=pose.standing, + ) + + +def _pose_key( + pose: CompetitionNativeCameraPoseV2_9_3, +) -> tuple[float, float, float, float, float, bool]: + return ( + pose.x, + pose.y, + pose.z, + pose.yaw_degrees, + pose.horizon_degrees, + pose.standing, + ) + + +def build_competition_native_camera_pose_bank_v2_9_3( + scene: Scene, + pairs: tuple[tuple[str, str], ...], + reachable_positions: tuple[AdapterPosition, ...], + fallback_pose: AdapterPose, + *, + policy: CompetitionNativeCameraPolicyV2_9_3 | None = None, +) -> tuple[CompetitionNativeCameraPoseV2_9_3, ...]: + """Build the bounded, permutation-invariant Tier-1 source pose bank.""" + + checked_scene = _strict_scene(scene) + if type(pairs) is not tuple or any( + type(pair) is not tuple + or len(pair) != 2 + or any(type(item) is not str or not item for item in pair) + for pair in pairs + ): + raise TypeError("camera evidence pairs must be an exact tuple of string pairs") + if len(set(pairs)) != len(pairs): + raise ValueError("camera evidence pairs must be unique") + if ( + type(reachable_positions) is not tuple + or not reachable_positions + or any(type(item) is not AdapterPosition for item in reachable_positions) + ): + raise TypeError("reachable positions must be a non-empty exact tuple") + checked_positions = tuple( + AdapterPosition(**asdict(item)) for item in reachable_positions + ) + if len(set(checked_positions)) != len(checked_positions): + raise ValueError("reachable positions must be unique") + checked_fallback = _strict_native_pose(fallback_pose, label="fallback pose") + checked_policy = ( + build_competition_native_camera_policy_v2_9_3() + if policy is None + else _strict_policy(policy) + ) + solver_upright = checked_policy.pose_policy_version in { + _SOLVER_UPRIGHT_POSE_POLICY_VERSION, + *_editable_pose_policy_versions(), + } + if ( + checked_policy.pose_policy_version + == _COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION + ): + checked_positions = filter_competition_native_camera_positions_v2_9_5( + checked_scene, + checked_positions, + ) + if pairs and not checked_positions: + raise ValueError( + "camera evidence has no collision-safe reachable positions" + ) + elif checked_policy.pose_policy_version in { + _CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION, + _RESET_PER_POSE_EDITABLE_POLICY_VERSION, + }: + checked_positions = filter_competition_native_camera_positions_v2_9_6( + checked_scene, + checked_positions, + ) + if pairs and not checked_positions: + raise ValueError( + "camera evidence has no contact-margin-safe reachable positions" + ) + elif checked_policy.pose_policy_version in { + _GRID_MARGIN_EDITABLE_POLICY_VERSION, + _PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION, + }: + checked_positions = filter_competition_native_camera_positions_v2_9_7( + checked_scene, + checked_positions, + ) + if pairs and not checked_positions: + raise ValueError( + "camera evidence has no grid-margin-safe reachable positions" + ) + elif ( + checked_policy.pose_policy_version + == _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION + ): + checked_positions = filter_competition_native_camera_positions_v2_9_8( + checked_scene, + checked_positions, + ) + if pairs and not checked_positions: + raise ValueError( + "camera evidence has no quantization-safe reachable positions" + ) + + poses: set[CompetitionNativeCameraPoseV2_9_3] = set() + for subject_object_id, support_object_id in pairs: + subject = checked_scene.object_by_id(subject_object_id) + checked_scene.object_by_id(support_object_id) + if ( + not subject.movable + or subject.support_object_id != support_object_id + or subject_object_id == support_object_id + ): + raise ValueError("camera evidence pair does not bind movable support") + generated = deterministic_pair_camera_poses( + checked_scene, + subject_object_id, + support_object_id, + checked_positions, + ) + poses.update( + _wire_pose(item) + for item in generated + if not solver_upright or item.horizon_degrees == 0.0 + ) + if len(poses) > _MAX_POSE_BANK_MEMBERS: + raise ValueError("camera evidence pose bank exceeds 256 members") + + if not poses: + if solver_upright: + checked_fallback = AdapterPose( + position=checked_fallback.position, + yaw_degrees=checked_fallback.yaw_degrees, + horizon_degrees=0.0, + standing=checked_fallback.standing, + ) + poses.add(_wire_pose(checked_fallback)) + return tuple(sorted(poses, key=_pose_key)) + + +def build_competition_native_camera_policy_v2_9_3() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the one frozen 2.9.3 source-camera selection policy.""" + + payload = _policy_payload(_LEGACY_POSE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_solver_upright_camera_policy_v2_9_3() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the bounded camera policy supported by the certified solver.""" + + payload = _policy_payload(_SOLVER_UPRIGHT_POSE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_editable_camera_policy_v2_9_3() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the upright policy that ranks source-native edit coverage.""" + + payload = _policy_payload(_EDITABLE_SOLVER_UPRIGHT_POSE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_collision_safe_editable_camera_policy_v2_9_5() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the source-only edit policy with fixed 0.2m movable clearance.""" + + payload = _policy_payload(_COLLISION_SAFE_EDITABLE_POSE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_contact_margin_editable_camera_policy_v2_9_6() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the edit policy with 0.2m agent plus fixed 1cm margin.""" + + payload = _policy_payload(_CONTACT_MARGIN_EDITABLE_POSE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_reset_per_pose_editable_camera_policy_v2_9_7() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the contact-safe policy that resets the source per pose.""" + + payload = _policy_payload(_RESET_PER_POSE_EDITABLE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_grid_margin_editable_camera_policy_v2_9_8() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the sequential source policy with one-grid movable margin.""" + + payload = _policy_payload(_GRID_MARGIN_EDITABLE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_paused_camera_policy_v2_9_9() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the one-grid policy whose observation bank pauses physics.""" + + payload = _policy_payload(_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def build_competition_native_settled_camera_policy_v2_9_10() -> ( + CompetitionNativeCameraPolicyV2_9_3 +): + """Return the paused-bank policy that freezes only after final settlement.""" + + payload = _policy_payload(_SETTLED_PAUSED_GRID_MARGIN_EDITABLE_POLICY_VERSION) + return CompetitionNativeCameraPolicyV2_9_3( + **payload, + policy_sha256=canonical_sha256(payload, domain=_POLICY_HASH_DOMAIN), + ) + + +def _strict_pose_bank( + pose_bank: object, +) -> tuple[CompetitionNativeCameraPoseV2_9_3, ...]: + if type(pose_bank) is not tuple or any( + type(item) is not CompetitionNativeCameraPoseV2_9_3 for item in pose_bank + ): + raise TypeError("camera evidence pose bank must be an exact pose tuple") + checked = tuple( + CompetitionNativeCameraPoseV2_9_3.model_validate( + item.model_dump(mode="python"), strict=True + ) + for item in pose_bank + ) + if not checked or len(checked) > _MAX_POSE_BANK_MEMBERS: + raise ValueError("camera evidence pose bank must contain 1 through 256 poses") + if tuple(sorted(set(checked), key=_pose_key)) != checked: + raise ValueError("camera evidence pose bank is not unique and canonical") + return checked + + +def competition_native_camera_pose_bank_sha256_v2_9_3( + pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], +) -> Sha256Digest: + """Hash one exact canonical pose bank in its independent domain.""" + + checked = _strict_pose_bank(pose_bank) + payload = { + "pose_bank_version": "competition-native-camera-pose-bank:2.9.3", + "poses": tuple(item.model_dump(mode="json") for item in checked), + } + return canonical_sha256(payload, domain=_POSE_BANK_HASH_DOMAIN) + + +def score_competition_native_camera_scene_v2_9_3( + scene: Scene, +) -> CompetitionNativeCameraScoreV2_9_3: + """Count qualifying objects whose category occurs once in the scene.""" + + checked = _strict_scene(scene) + checked.camera_by_id("main") + category_counts: dict[str, int] = {} + for item in checked.objects: + category_counts[item.category] = category_counts.get(item.category, 0) + 1 + + qualifying = [] + for item in checked.objects: + view = item.views.get("main") + if ( + category_counts[item.category] == 1 + and item.request_eligible + and view is not None + and view.visible_fraction >= RelationEngine.MIN_VISIBLE_FRACTION + and view.image_area_fraction >= RelationEngine.MIN_IMAGE_AREA_FRACTION + and view.truncated_fraction <= RelationEngine.MAX_TRUNCATED_FRACTION + ): + qualifying.append(item) + return CompetitionNativeCameraScoreV2_9_3( + movable_scene_unique_category_qualifying_count=sum( + item.movable for item in qualifying + ), + all_scene_unique_category_qualifying_count=len(qualifying), + ) + + +def _rotation_matrix_values(obb: OBB) -> tuple[tuple[float, float, float], ...]: + rotation = obb.rotation + norm = math.sqrt(rotation.x**2 + rotation.y**2 + rotation.z**2 + rotation.w**2) + if not math.isfinite(norm) or norm <= 1e-12: + raise ValueError("camera placement OBB rotation is invalid") + x, y, z, w = ( + rotation.x / norm, + rotation.y / norm, + rotation.z / norm, + rotation.w / norm, + ) + return ( + ( + 1.0 - 2.0 * (y * y + z * z), + 2.0 * (x * y - z * w), + 2.0 * (x * z + y * w), + ), + ( + 2.0 * (x * y + z * w), + 1.0 - 2.0 * (x * x + z * z), + 2.0 * (y * z - x * w), + ), + ( + 2.0 * (x * z - y * w), + 2.0 * (y * z + x * w), + 1.0 - 2.0 * (x * x + y * y), + ), + ) + + +def _translated_obb_fully_visible_v2_9_4( + scene: Scene, + subject_object_id: str, + position: CompetitionNativeCameraPlacementPositionV2_9_4, +) -> bool: + subject = scene.object_by_id(subject_object_id) + camera = scene.camera_by_id("main") + delta = ( + position.x - subject.position.x, + position.z - subject.position.y, + position.y - subject.position.z, + ) + rotation = _rotation_matrix_values(subject.obb) + center = ( + subject.obb.center.x, + subject.obb.center.y, + subject.obb.center.z, + ) + matrix = camera.world_to_camera + fx, fy = camera.intrinsics[0], camera.intrinsics[4] + cx, cy = camera.intrinsics[2], camera.intrinsics[5] + projected: list[tuple[float, float]] = [] + for x_sign in (-1.0, 1.0): + for y_sign in (-1.0, 1.0): + for z_sign in (-1.0, 1.0): + local = ( + x_sign * subject.obb.extent.x / 2.0, + y_sign * subject.obb.extent.y / 2.0, + z_sign * subject.obb.extent.z / 2.0, + ) + world = tuple( + center[axis] + + delta[axis] + + sum(rotation[axis][inner] * local[inner] for inner in range(3)) + for axis in range(3) + ) + homogeneous = tuple( + sum(matrix[row * 4 + column] * world[column] for column in range(3)) + + matrix[row * 4 + 3] + for row in range(4) + ) + if homogeneous[3] == 0.0: + raise ValueError("camera placement projected a point to infinity") + camera_x = homogeneous[0] / homogeneous[3] + camera_y = homogeneous[1] / homogeneous[3] + camera_z = homogeneous[2] / homogeneous[3] + if camera_z <= 1e-12: + return False + projected.append( + ( + fx * camera_x / camera_z + cx, + cy - fy * camera_y / camera_z, + ) + ) + min_x = min(item[0] for item in projected) + max_x = max(item[0] for item in projected) + min_y = min(item[1] for item in projected) + max_y = max(item[1] for item in projected) + if not ( + min_x >= 0.5 + and max_x <= camera.width - 0.5 + and min_y >= 0.5 + and max_y <= camera.height - 0.5 + ): + return False + image_area_fraction = ( + (max_x - min_x) * (max_y - min_y) / (camera.width * camera.height) + ) + return image_area_fraction >= RelationEngine.MIN_IMAGE_AREA_FRACTION + + +def score_competition_native_editable_camera_scene_v2_9_4( + scene: Scene, + placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], +) -> CompetitionNativeCameraScoreV2_9_4: + """Score one frozen scene by complete visible native edit coverage.""" + + observed = _strict_scene(scene) + base = score_competition_native_camera_scene_v2_9_3(observed) + roster = _strict_placement_roster_v2_9_4(placement_roster) + category_counts: dict[str, int] = {} + for item in observed.objects: + category_counts[item.category] = category_counts.get(item.category, 0) + 1 + + visible_subjects = 0 + visible_positions = 0 + for entry in roster: + subject = observed.object_by_id(entry.subject_object_id) + observed.object_by_id(entry.support_object_id) + view = subject.views.get("main") + if ( + not subject.movable + or not subject.request_eligible + or subject.support_object_id != entry.support_object_id + or category_counts[subject.category] != 1 + or view is None + or view.visible_fraction < RelationEngine.MIN_VISIBLE_FRACTION + or view.image_area_fraction < RelationEngine.MIN_IMAGE_AREA_FRACTION + or view.truncated_fraction > RelationEngine.MAX_TRUNCATED_FRACTION + ): + continue + count = sum( + _translated_obb_fully_visible_v2_9_4( + observed, + entry.subject_object_id, + position, + ) + for position in entry.positions + ) + visible_positions += count + visible_subjects += count > 0 + return CompetitionNativeCameraScoreV2_9_4( + placement_roster_sha256=( + competition_native_camera_placement_roster_sha256_v2_9_4(roster) + ), + visible_native_placement_subject_count=visible_subjects, + visible_native_placement_count=visible_positions, + movable_scene_unique_category_qualifying_count=( + base.movable_scene_unique_category_qualifying_count + ), + all_scene_unique_category_qualifying_count=( + base.all_scene_unique_category_qualifying_count + ), + ) + + +def score_competition_native_editable_camera_application_v2_9_4( + *, + source_scene: Scene, + pose: CompetitionNativeCameraPoseV2_9_3, + application: AdapterCameraApplication, + placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], + policy: CompetitionNativeCameraPolicyV2_9_3, +) -> CompetitionNativeCameraScoreV2_9_4: + """Close and score one source observation by native edit coverage.""" + + checked_policy = _strict_policy(policy) + if checked_policy.pose_policy_version not in _editable_pose_policy_versions(): + raise ValueError("editable camera score requires the edit-domain policy") + score_competition_native_source_camera_application_v2_9_3( + source_scene=source_scene, + pose=pose, + application=application, + policy=checked_policy, + ) + checked_application = _strict_application(application) + return score_competition_native_editable_camera_scene_v2_9_4( + checked_application.observed_scene, + placement_roster, + ) + + +def _strict_application(value: object) -> AdapterCameraApplication: + if type(value) is not AdapterCameraApplication: + raise TypeError("camera evidence application must be exact") + requested_pose = _strict_native_pose(value.requested_pose, label="requested pose") + observed_pose = _strict_native_pose(value.observed_pose, label="observed pose") + if type(value.observed_camera_position) is not AdapterPosition: + raise TypeError("camera evidence observed camera position must be exact") + observed_position = AdapterPosition(**asdict(value.observed_camera_position)) + observed_scene = _strict_scene(value.observed_scene) + if type(value.observation) is not AdapterObservation: + raise TypeError("camera evidence observation must be exact") + observation = value.observation + if type(observation.scene) is not Scene: + raise TypeError("camera evidence observation scene must be exact") + if any( + type(blob) is not bytes + for blob in ( + observation.rgb_png, + observation.depth_npy, + observation.instance_png, + observation.pointcloud_ply, + ) + ): + raise TypeError("camera evidence observation assets must be exact bytes") + if type(observation.is_settled) is not bool or any( + type(key) is not str or type(count) is not int or count < 0 + for key, count in observation.instance_pixel_counts + ): + raise TypeError("camera evidence observation metadata must be exact") + rebuilt_observation = AdapterObservation.create( + scene=_strict_scene(observation.scene), + rgb_png=observation.rgb_png, + depth_npy=observation.depth_npy, + instance_png=observation.instance_png, + pointcloud_ply=observation.pointcloud_ply, + instance_pixel_counts=observation.instance_pixel_counts, + is_settled=observation.is_settled, + ) + if ( + rebuilt_observation != observation + or rebuilt_observation.scene != observed_scene + ): + raise ValueError("camera evidence observation binding or asset hash changed") + residuals = ( + value.position_residual_m, + value.yaw_residual_degrees, + value.horizon_residual_degrees, + ) + if any(type(item) is not float or not math.isfinite(item) for item in residuals): + raise TypeError( + "camera evidence application residuals must be exact finite floats" + ) + expected_residuals = ( + math.dist( + ( + requested_pose.position.x, + requested_pose.position.y, + requested_pose.position.z, + ), + ( + observed_pose.position.x, + observed_pose.position.y, + observed_pose.position.z, + ), + ), + abs( + (observed_pose.yaw_degrees - requested_pose.yaw_degrees + 180.0) % 360.0 + - 180.0 + ), + abs(observed_pose.horizon_degrees - requested_pose.horizon_degrees), + ) + if any( + not math.isclose( + actual, + expected, + rel_tol=0.0, + abs_tol=4.0 * max(math.ulp(actual), math.ulp(expected)), + ) + for actual, expected in zip(residuals, expected_residuals, strict=True) + ): + raise ValueError("camera evidence application residual changed") + if ( + value.position_residual_m > _MAX_POSITION_RESIDUAL_M + or value.yaw_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES + or value.horizon_residual_degrees > _MAX_ANGLE_RESIDUAL_DEGREES + ): + raise ValueError("camera evidence application pose drift exceeds tolerance") + if observed_pose.standing is not requested_pose.standing: + raise ValueError("camera evidence application standing state changed") + if not rebuilt_observation.is_settled: + raise ValueError("camera evidence application is not at rest") + return AdapterCameraApplication( + source=value.source, + binding=value.binding, + requested_pose=requested_pose, + observed_pose=observed_pose, + observed_camera_position=observed_position, + observed_scene=observed_scene, + observation=rebuilt_observation, + position_residual_m=value.position_residual_m, + yaw_residual_degrees=value.yaw_residual_degrees, + horizon_residual_degrees=value.horizon_residual_degrees, + ) + + +def _validate_application_source_closure( + source_scene: Scene, + application: AdapterCameraApplication, +) -> None: + observed = application.observed_scene + if ( + observed.scene_id != source_scene.scene_id + or observed.source != source_scene.source + or observed.coordinate_system != source_scene.coordinate_system + or observed.room_polygon_xy != source_scene.room_polygon_xy + or observed.collision_obstacles != source_scene.collision_obstacles + or observed.subject_position_regions != source_scene.subject_position_regions + or observed.pinned_object_ids != source_scene.pinned_object_ids + or observed.generation_seed != source_scene.generation_seed + ): + raise ValueError("camera evidence application source root changed") + + source_object_ids = tuple(item.object_id for item in source_scene.objects) + observed_object_ids = tuple(item.object_id for item in observed.objects) + if source_object_ids != observed_object_ids: + raise ValueError("camera evidence application source object roster changed") + for source_object, observed_object in zip( + source_scene.objects, observed.objects, strict=True + ): + if ( + observed_object.object_id != source_object.object_id + or observed_object.name != source_object.name + or observed_object.category != source_object.category + or observed_object.movable is not source_object.movable + or observed_object.request_eligible is not source_object.request_eligible + or observed_object.support_object_id != source_object.support_object_id + or observed_object.position != source_object.position + or observed_object.rotation != source_object.rotation + or observed_object.obb != source_object.obb + ): + raise ValueError("camera evidence application source object changed") + + source_camera_ids = tuple(item.camera_id for item in source_scene.cameras) + observed_camera_ids = tuple(item.camera_id for item in observed.cameras) + if source_camera_ids != observed_camera_ids: + raise ValueError("camera evidence application source camera roster changed") + for source_camera, observed_camera in zip( + source_scene.cameras, observed.cameras, strict=True + ): + if ( + observed_camera.camera_id != source_camera.camera_id + or observed_camera.width != source_camera.width + or observed_camera.height != source_camera.height + or observed_camera.intrinsics != source_camera.intrinsics + or ( + source_camera.camera_id != "main" + and observed_camera.world_to_camera != source_camera.world_to_camera + ) + ): + raise ValueError("camera evidence application source camera changed") + + +def score_competition_native_source_camera_application_v2_9_3( + *, + source_scene: Scene, + pose: CompetitionNativeCameraPoseV2_9_3, + application: AdapterCameraApplication, + policy: CompetitionNativeCameraPolicyV2_9_3 | None = None, +) -> CompetitionNativeCameraScoreV2_9_3: + """Close and score one bank-aligned application without retaining siblings.""" + + source = _strict_scene(source_scene) + source.camera_by_id("main") + if type(pose) is not CompetitionNativeCameraPoseV2_9_3: + raise TypeError("camera evidence pose must be exact") + checked_pose = CompetitionNativeCameraPoseV2_9_3.model_validate( + pose.model_dump(mode="python"), strict=True + ) + checked_application = _strict_application(application) + if _wire_pose(checked_application.requested_pose) != checked_pose: + raise ValueError("camera evidence application is not bank-aligned") + if checked_application.observed_scene.scene_id != source.scene_id: + raise ValueError("camera evidence application scene identity mismatch") + _validate_application_source_closure(source, checked_application) + observed_camera = checked_application.observed_scene.camera_by_id("main") + if policy is not None: + verify_competition_native_solver_camera_binding_v2_9_3( + policy, + checked_pose, + observed_camera, + ) + return score_competition_native_camera_scene_v2_9_3( + checked_application.observed_scene + ) + + +def _strict_policy( + policy: object, +) -> CompetitionNativeCameraPolicyV2_9_3: + if type(policy) is not CompetitionNativeCameraPolicyV2_9_3: + raise TypeError("camera evidence policy must be exact") + return CompetitionNativeCameraPolicyV2_9_3.model_validate( + policy.model_dump(mode="python"), strict=True + ) + + +def verify_competition_native_solver_camera_binding_v2_9_3( + policy: CompetitionNativeCameraPolicyV2_9_3, + pose: CompetitionNativeCameraPoseV2_9_3, + camera: Camera, +) -> None: + """Require exact upright camera semantics only for the solver policy.""" + + checked_policy = _strict_policy(policy) + if type(pose) is not CompetitionNativeCameraPoseV2_9_3: + raise TypeError("solver camera pose must be exact") + checked_pose = CompetitionNativeCameraPoseV2_9_3.model_validate( + pose.model_dump(mode="python"), strict=True + ) + if type(camera) is not Camera or camera.camera_id != "main": + raise TypeError("solver camera must be exact main") + checked_camera = Camera.model_validate( + camera.model_dump(mode="python"), strict=True + ) + if checked_policy.pose_policy_version == _LEGACY_POSE_POLICY_VERSION: + return + if checked_pose.horizon_degrees != 0.0: + raise ValueError("solver-upright camera pose must use horizon zero") + try: + _legacy_camera(checked_camera.world_to_camera) + except _CameraConversionError as error: + raise ValueError("solver-upright camera matrix is unsupported") from error + + +def build_competition_native_source_camera_evidence_v2_9_3( + *, + source_id: str, + scene_id: str, + source_locator_sha256: str, + runtime_identity_sha256: str, + source_capture_sha256: str, + source_scene: Scene, + policy: CompetitionNativeCameraPolicyV2_9_3, + pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], + pose_scores: tuple[CompetitionNativeCameraScoreV2_9_3, ...], + selected_application: AdapterCameraApplication, +) -> CompetitionNativeSourceCameraEvidenceV2_9_3: + """Build evidence from a complete light ledger and one replayed winner.""" + + checked_source_scene = _strict_scene(source_scene) + if checked_source_scene.scene_id != scene_id: + raise ValueError("camera evidence source scene identity mismatch") + checked_source_scene.camera_by_id("main") + checked_policy = _strict_policy(policy) + checked_bank = _strict_pose_bank(pose_bank) + checked_scores = _strict_score_ledger(pose_scores) + if len(checked_scores) != len(checked_bank): + raise ValueError("camera evidence score ledger is not bank-aligned") + selected_index = select_competition_native_camera_score_index_v2_9_3(checked_scores) + selected_pose = checked_bank[selected_index] + selected = _strict_application(selected_application) + selected_score = score_competition_native_source_camera_application_v2_9_3( + source_scene=checked_source_scene, + pose=selected_pose, + application=selected, + ) + if selected_score != checked_scores[selected_index]: + raise ValueError("camera evidence replay score differs from frozen score") + + observed_pose = _wire_pose(selected.observed_pose) + observed_position = ( + selected.observed_camera_position.x, + selected.observed_camera_position.y, + selected.observed_camera_position.z, + ) + camera = selected.observed_scene.camera_by_id("main") + observation = selected.observation + pose_bank_sha256 = competition_native_camera_pose_bank_sha256_v2_9_3(checked_bank) + payload = _evidence_payload( + source_id=source_id, + scene_id=scene_id, + source_locator_sha256=source_locator_sha256, + runtime_identity_sha256=runtime_identity_sha256, + source_capture_sha256=source_capture_sha256, + policy_sha256=checked_policy.policy_sha256, + pose_bank_sha256=pose_bank_sha256, + pose_bank_count=len(checked_bank), + pose_scores=checked_scores, + selected_pose_index=selected_index, + requested_pose=selected_pose, + observed_pose=observed_pose, + observed_native_camera_position=observed_position, + camera=camera, + score=selected_score, + rgb_png_sha256=observation.rgb_png_sha256, + depth_npy_sha256=observation.depth_npy_sha256, + instance_png_sha256=observation.instance_png_sha256, + pointcloud_ply_sha256=observation.pointcloud_ply_sha256, + is_scene_at_rest=observation.is_settled, + ) + return CompetitionNativeSourceCameraEvidenceV2_9_3( + **payload, + camera_evidence_sha256=canonical_sha256(payload, domain=_EVIDENCE_HASH_DOMAIN), + ) + + +def build_competition_native_source_camera_evidence_v2_9_4( + *, + source_id: str, + scene_id: str, + source_locator_sha256: str, + runtime_identity_sha256: str, + source_capture_sha256: str, + source_scene: Scene, + policy: CompetitionNativeCameraPolicyV2_9_3, + pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], + pose_scores: tuple[CompetitionNativeCameraScoreV2_9_4, ...], + placement_roster: tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...], + selected_application: AdapterCameraApplication, +) -> CompetitionNativeSourceCameraEvidenceV2_9_3: + """Build source evidence whose winner maximizes visible edit coverage.""" + + checked_source_scene = _strict_scene(source_scene) + if checked_source_scene.scene_id != scene_id: + raise ValueError("camera evidence source scene identity mismatch") + checked_source_scene.camera_by_id("main") + checked_policy = _strict_policy(policy) + if checked_policy.pose_policy_version not in _editable_pose_policy_versions(): + raise ValueError("editable camera evidence requires the edit-domain policy") + checked_bank = _strict_pose_bank(pose_bank) + checked_scores = _strict_score_ledger(pose_scores) + if any( + type(item) is not CompetitionNativeCameraScoreV2_9_4 for item in checked_scores + ): + raise TypeError("editable camera evidence requires 2.9.4 scores") + if len(checked_scores) != len(checked_bank): + raise ValueError("camera evidence score ledger is not bank-aligned") + checked_roster = _strict_placement_roster_v2_9_4(placement_roster) + selected_index = select_competition_native_camera_score_index_v2_9_4( + checked_scores # type: ignore[arg-type] + ) + selected_pose = checked_bank[selected_index] + selected = _strict_application(selected_application) + selected_score = score_competition_native_editable_camera_application_v2_9_4( + source_scene=checked_source_scene, + pose=selected_pose, + application=selected, + placement_roster=checked_roster, + policy=checked_policy, + ) + if selected_score != checked_scores[selected_index]: + raise ValueError("camera evidence replay score differs from frozen score") + + observed_pose = _wire_pose(selected.observed_pose) + observed_position = ( + selected.observed_camera_position.x, + selected.observed_camera_position.y, + selected.observed_camera_position.z, + ) + camera = selected.observed_scene.camera_by_id("main") + observation = selected.observation + payload = _evidence_payload( + source_id=source_id, + scene_id=scene_id, + source_locator_sha256=source_locator_sha256, + runtime_identity_sha256=runtime_identity_sha256, + source_capture_sha256=source_capture_sha256, + policy_sha256=checked_policy.policy_sha256, + pose_bank_sha256=competition_native_camera_pose_bank_sha256_v2_9_3( + checked_bank + ), + pose_bank_count=len(checked_bank), + pose_scores=checked_scores, + selected_pose_index=selected_index, + requested_pose=selected_pose, + observed_pose=observed_pose, + observed_native_camera_position=observed_position, + camera=camera, + score=selected_score, + rgb_png_sha256=observation.rgb_png_sha256, + depth_npy_sha256=observation.depth_npy_sha256, + instance_png_sha256=observation.instance_png_sha256, + pointcloud_ply_sha256=observation.pointcloud_ply_sha256, + is_scene_at_rest=observation.is_settled, + ) + return CompetitionNativeSourceCameraEvidenceV2_9_3( + **payload, + camera_evidence_sha256=canonical_sha256(payload, domain=_EVIDENCE_HASH_DOMAIN), + ) + + +def select_competition_native_source_camera_evidence_v2_9_3( + *, + source_id: str, + scene_id: str, + source_locator_sha256: str, + runtime_identity_sha256: str, + source_capture_sha256: str, + source_scene: Scene, + policy: CompetitionNativeCameraPolicyV2_9_3, + pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], + applications: tuple[AdapterCameraApplication, ...], +) -> CompetitionNativeSourceCameraEvidenceV2_9_3: + """Select one complete source observation using literal scene scores only.""" + + checked_source_scene = _strict_scene(source_scene) + checked_bank = _strict_pose_bank(pose_bank) + if type(applications) is not tuple or any( + type(item) is not AdapterCameraApplication for item in applications + ): + raise TypeError("camera evidence applications must be an exact tuple") + if len(applications) != len(checked_bank): + raise ValueError("camera evidence applications are not bank-aligned") + checked_applications = tuple(_strict_application(item) for item in applications) + scores: list[CompetitionNativeCameraScoreV2_9_3] = [] + for index, (pose, application) in enumerate( + zip(checked_bank, checked_applications, strict=True) + ): + try: + score = score_competition_native_source_camera_application_v2_9_3( + source_scene=checked_source_scene, + pose=pose, + application=application, + policy=policy, + ) + except ValueError as error: + if "bank-aligned" in str(error): + raise ValueError( + f"camera evidence application {index} is not bank-aligned" + ) from error + raise + scores.append(score) + + pose_scores = tuple(scores) + selected_index = select_competition_native_camera_score_index_v2_9_3(pose_scores) + return build_competition_native_source_camera_evidence_v2_9_3( + source_id=source_id, + scene_id=scene_id, + source_locator_sha256=source_locator_sha256, + runtime_identity_sha256=runtime_identity_sha256, + source_capture_sha256=source_capture_sha256, + source_scene=checked_source_scene, + policy=policy, + pose_bank=checked_bank, + pose_scores=pose_scores, + selected_application=checked_applications[selected_index], + ) + + +def verify_competition_native_source_camera_evidence_v2_9_3( + evidence: CompetitionNativeSourceCameraEvidenceV2_9_3, + *, + source_id: str, + scene_id: str, + source_locator_sha256: str, + runtime_identity_sha256: str, + source_capture_sha256: str, + policy: CompetitionNativeCameraPolicyV2_9_3, + pose_bank: tuple[CompetitionNativeCameraPoseV2_9_3, ...], + source_scene: Scene, + selected_scene: Scene, + selected_camera: Camera, + selected_application: AdapterCameraApplication, +) -> CompetitionNativeSourceCameraEvidenceV2_9_3: + """Close persisted evidence against independently supplied capture bindings.""" + + if type(evidence) is not CompetitionNativeSourceCameraEvidenceV2_9_3: + raise TypeError("camera evidence must be exact") + checked = CompetitionNativeSourceCameraEvidenceV2_9_3.model_validate( + evidence.model_dump(mode="python"), strict=True + ) + checked_policy = _strict_policy(policy) + checked_bank = _strict_pose_bank(pose_bank) + source = _strict_scene(source_scene) + scene = _strict_scene(selected_scene) + if type(selected_camera) is not Camera: + raise TypeError("camera evidence selected camera must be exact") + camera = Camera.model_validate( + selected_camera.model_dump(mode="python"), strict=True + ) + application = _strict_application(selected_application) + if source.scene_id != scene_id: + raise ValueError("camera evidence source scene identity does not close") + _validate_application_source_closure(source, application) + expected_lineage = ( + source_id, + scene_id, + source_locator_sha256, + runtime_identity_sha256, + source_capture_sha256, + ) + if expected_lineage != ( + checked.source_id, + checked.scene_id, + checked.source_locator_sha256, + checked.runtime_identity_sha256, + checked.source_capture_sha256, + ): + raise ValueError("camera evidence lineage does not close") + if ( + checked.policy_sha256 != checked_policy.policy_sha256 + or checked.pose_bank_sha256 + != competition_native_camera_pose_bank_sha256_v2_9_3(checked_bank) + or checked.pose_bank_count != len(checked_bank) + or checked.selected_pose_index >= len(checked_bank) + or checked_bank[checked.selected_pose_index] != checked.requested_pose + or checked.requested_pose != _wire_pose(application.requested_pose) + ): + raise ValueError("camera evidence policy or pose bank does not close") + if ( + scene.scene_id != scene_id + or application.observed_scene != scene + or application.observation.scene != scene + or scene.camera_by_id("main") != camera + or checked.camera != camera + or checked.observed_pose != _wire_pose(application.observed_pose) + or checked.observed_native_camera_position + != ( + application.observed_camera_position.x, + application.observed_camera_position.y, + application.observed_camera_position.z, + ) + or checked.score != score_competition_native_camera_scene_v2_9_3(scene) + or checked.rgb_png_sha256 != application.observation.rgb_png_sha256 + or checked.depth_npy_sha256 != application.observation.depth_npy_sha256 + or checked.instance_png_sha256 != application.observation.instance_png_sha256 + or checked.pointcloud_ply_sha256 + != application.observation.pointcloud_ply_sha256 + or checked.is_scene_at_rest is not application.observation.is_settled + ): + raise ValueError("camera evidence selected capture does not close") + verify_competition_native_solver_camera_binding_v2_9_3( + checked_policy, + checked.requested_pose, + camera, + ) + return checked + + +CameraPolicy = CompetitionNativeCameraPolicyV2_9_3 +SourceCameraEvidence = CompetitionNativeSourceCameraEvidenceV2_9_3 +build_settled_camera_policy = build_competition_native_settled_camera_policy_v2_9_10 +verify_source_camera_evidence = verify_competition_native_source_camera_evidence_v2_9_3 + +__all__ = ( + "CameraPolicy", + "SourceCameraEvidence", + "build_settled_camera_policy", + "verify_source_camera_evidence", +) + +# Final Task 3 owner for capture-bound surface evidence. + +from pydantic import Field, model_validator + +from spatialcf.domain.base import CanonicalModel + +_PATCH_HASH_DOMAIN = "spatialcf.competition-native-receptacle-surface-patch.v2.9.2" +_SUBJECT_EVIDENCE_HASH_DOMAIN = ( + "spatialcf.competition-native-subject-surface-evidence.v2.9.2" +) +_SOURCE_EVIDENCE_HASH_DOMAIN = ( + "spatialcf.competition-native-source-surface-evidence.v2.9.2" +) +_RUNTIME_IDENTITY_HASH_DOMAIN = "spatialcf.competition-native-runtime-identity.v2.9.2" + + +def _patch_payload( + *, + patch_index: int, + x_min: float, + x_max: float, + native_y: float, + z_min: float, + z_max: float, +) -> dict[str, object]: + return { + "native_y": native_y, + "patch_index": patch_index, + "x_max": x_max, + "x_min": x_min, + "z_max": z_max, + "z_min": z_min, + } + + +class CompetitionNativeReceptacleSurfacePatchV2_9_2(CanonicalModel): + """One ordered raw 21x21 trigger-grid patch and its independent digest.""" + + patch_index: int = Field(strict=True, ge=0) + x_min: FiniteFloat + x_max: FiniteFloat + native_y: FiniteFloat + z_min: FiniteFloat + z_max: FiniteFloat + patch_sha256: Sha256Digest + + @model_validator(mode="after") + def validate_patch(self) -> Self: + if self.x_min >= self.x_max or self.z_min >= self.z_max: + raise ValueError("surface evidence patch must have positive area") + expected = canonical_sha256( + _patch_payload( + patch_index=self.patch_index, + x_min=self.x_min, + x_max=self.x_max, + native_y=self.native_y, + z_min=self.z_min, + z_max=self.z_max, + ), + domain=_PATCH_HASH_DOMAIN, + ) + if self.patch_sha256 != expected: + raise ValueError("surface evidence patch digest mismatch") + return self + + +def _subject_payload( + *, + subject_object_id: str, + support_object_id: str, + native_subject_object_id: str, + native_support_object_id: str, + runtime_identity_sha256: str, + scene_sha256: str, + positions_sha256: str, + spawn_map_source_sha256: str, + placement_sha256: str, + source_capture_sha256: str, + patches: tuple[CompetitionNativeReceptacleSurfacePatchV2_9_2, ...], +) -> dict[str, object]: + return { + "native_support_object_id": native_support_object_id, + "native_subject_object_id": native_subject_object_id, + "patches": tuple(item.model_dump(mode="json") for item in patches), + "placement_sha256": placement_sha256, + "positions_sha256": positions_sha256, + "runtime_identity_sha256": runtime_identity_sha256, + "scene_sha256": scene_sha256, + "source_capture_sha256": source_capture_sha256, + "spawn_map_source_sha256": spawn_map_source_sha256, + "subject_object_id": subject_object_id, + "support_object_id": support_object_id, + } + + +class CompetitionNativeSubjectSurfaceEvidenceV2_9_2(CanonicalModel): + """All source-capture bindings for one receptacle-supported subject.""" + + subject_object_id: str = Field(strict=True, min_length=1, max_length=512) + support_object_id: str = Field(strict=True, min_length=1, max_length=512) + native_subject_object_id: str = Field(strict=True, min_length=1, max_length=512) + native_support_object_id: str = Field(strict=True, min_length=1, max_length=512) + runtime_identity_sha256: Sha256Digest + scene_sha256: Sha256Digest + positions_sha256: Sha256Digest + spawn_map_source_sha256: Sha256Digest + placement_sha256: Sha256Digest + source_capture_sha256: Sha256Digest + patches: tuple[CompetitionNativeReceptacleSurfacePatchV2_9_2, ...] = Field( + min_length=1, + max_length=256, + ) + subject_surface_evidence_sha256: Sha256Digest + + @model_validator(mode="after") + def validate_subject_evidence(self) -> Self: + if self.subject_object_id == self.support_object_id: + raise ValueError("surface evidence subject and support must differ") + if tuple(item.patch_index for item in self.patches) != tuple( + range(len(self.patches)) + ): + raise ValueError("surface evidence patch indexes are not canonical") + patch_keys = tuple( + ( + item.native_y, + item.x_min, + item.z_min, + item.x_max, + item.z_max, + ) + for item in self.patches + ) + if patch_keys != tuple(sorted(set(patch_keys))): + raise ValueError("surface evidence patches are not unique and canonical") + expected = canonical_sha256( + _subject_payload( + subject_object_id=self.subject_object_id, + support_object_id=self.support_object_id, + native_subject_object_id=self.native_subject_object_id, + native_support_object_id=self.native_support_object_id, + runtime_identity_sha256=self.runtime_identity_sha256, + scene_sha256=self.scene_sha256, + positions_sha256=self.positions_sha256, + spawn_map_source_sha256=self.spawn_map_source_sha256, + placement_sha256=self.placement_sha256, + source_capture_sha256=self.source_capture_sha256, + patches=self.patches, + ), + domain=_SUBJECT_EVIDENCE_HASH_DOMAIN, + ) + if self.subject_surface_evidence_sha256 != expected: + raise ValueError("subject surface evidence digest mismatch") + return self + + +def _source_payload( + *, + source_id: str, + scene_id: str, + source_capture_sha256: str, + subjects: tuple[CompetitionNativeSubjectSurfaceEvidenceV2_9_2, ...], +) -> dict[str, object]: + return { + "evidence_version": "competition-native-source-surface-evidence:2.9.2", + "scene_id": scene_id, + "source_capture_sha256": source_capture_sha256, + "source_id": source_id, + "subjects": tuple(item.model_dump(mode="json") for item in subjects), + } + + +class CompetitionNativeSourceSurfaceEvidenceV2_9_2(CanonicalModel): + """One sibling surface-evidence row for an unchanged accepted capture.""" + + evidence_version: Literal["competition-native-source-surface-evidence:2.9.2"] = ( + "competition-native-source-surface-evidence:2.9.2" + ) + source_id: str = Field(strict=True, min_length=1, max_length=512) + scene_id: str = Field(strict=True, min_length=1, max_length=512) + source_capture_sha256: Sha256Digest + subjects: tuple[CompetitionNativeSubjectSurfaceEvidenceV2_9_2, ...] = Field( + max_length=96 + ) + surface_evidence_sha256: Sha256Digest + + @model_validator(mode="after") + def validate_source_evidence(self) -> Self: + subject_ids = tuple(item.subject_object_id for item in self.subjects) + if subject_ids != tuple(sorted(set(subject_ids))): + raise ValueError("source surface evidence subjects are not canonical") + if any( + item.source_capture_sha256 != self.source_capture_sha256 + for item in self.subjects + ): + raise ValueError("source surface evidence capture lineage mismatch") + expected = canonical_sha256( + _source_payload( + source_id=self.source_id, + scene_id=self.scene_id, + source_capture_sha256=self.source_capture_sha256, + subjects=self.subjects, + ), + domain=_SOURCE_EVIDENCE_HASH_DOMAIN, + ) + if self.surface_evidence_sha256 != expected: + raise ValueError("source surface evidence digest mismatch") + return self + + +def _strict_spawn_map(value: object) -> AdapterSpawnMap: + if type(value) is not AdapterSpawnMap: + raise TypeError("surface evidence spawn map must be exact") + if type(value.runtime_identity) is not AdapterRuntimeIdentity: + raise TypeError("surface evidence runtime identity must be exact") + if type(value.surface_patches) is not tuple or any( + type(item) is not AdapterSurfacePatch for item in value.surface_patches + ): + raise TypeError("surface evidence patches must be an exact tuple") + return AdapterSpawnMap( + binding=value.binding, + scene_id=value.scene_id, + subject_object_id=value.subject_object_id, + support_object_id=value.support_object_id, + native_subject_object_id=value.native_subject_object_id, + native_support_object_id=value.native_support_object_id, + runtime_identity=AdapterRuntimeIdentity(**asdict(value.runtime_identity)), + positions=tuple(value.positions), + positions_sha256=value.positions_sha256, + scene_sha256=value.scene_sha256, + source_sha256=value.source_sha256, + surface_patches=tuple( + AdapterSurfacePatch(**asdict(item)) for item in value.surface_patches + ), + position_region=value.position_region, + ) + + +def _build_patch( + patch_index: int, + patch: AdapterSurfacePatch, +) -> CompetitionNativeReceptacleSurfacePatchV2_9_2: + payload = _patch_payload( + patch_index=patch_index, + x_min=patch.x_min, + x_max=patch.x_max, + native_y=patch.native_y, + z_min=patch.z_min, + z_max=patch.z_max, + ) + return CompetitionNativeReceptacleSurfacePatchV2_9_2( + **payload, + patch_sha256=canonical_sha256(payload, domain=_PATCH_HASH_DOMAIN), + ) + + +def _accepted_capture_scene_sha256(scene: Scene) -> str: + """Digest one validated Canonical capture scene in source-roster order.""" + + return sha256( + canonical_json_bytes(normalize_competition_native_source_scene_v2_9(scene)) + ).hexdigest() + + +def verify_competition_native_source_surface_evidence_v2_9_2( + capture: CompetitionNativeSourceCaptureV2_9, + evidence: CompetitionNativeSourceSurfaceEvidenceV2_9_2, +) -> CompetitionNativeSourceSurfaceEvidenceV2_9_2: + """Freshly replay every persisted patch binding against one capture.""" + + if type(capture) is not CompetitionNativeSourceCaptureV2_9: + raise TypeError("surface evidence capture must be exact") + if type(evidence) is not CompetitionNativeSourceSurfaceEvidenceV2_9_2: + raise TypeError("surface evidence must be exact") + checked_capture = CompetitionNativeSourceCaptureV2_9.model_validate( + capture.model_dump(mode="python"), + strict=True, + ) + checked_evidence = CompetitionNativeSourceSurfaceEvidenceV2_9_2.model_validate( + evidence.model_dump(mode="python"), + strict=True, + ) + if ( + checked_evidence.source_id != checked_capture.source.source_id + or checked_evidence.scene_id != checked_capture.scene.scene_id + or checked_evidence.source_capture_sha256 + != checked_capture.source_capture_sha256 + ): + raise ValueError("surface evidence does not bind source capture") + + placements = {item.object_id: item for item in checked_capture.placement_facts} + supports = {item.object_id: item for item in checked_capture.support_facts} + expected_subject_ids = tuple( + sorted( + item.object_id + for item in checked_capture.placement_facts + if item.availability + is CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN + ) + ) + if tuple(item.subject_object_id for item in checked_evidence.subjects) != ( + expected_subject_ids + ): + raise ValueError("surface evidence does not close capture placements") + + runtime = AdapterRuntimeIdentity( + **checked_capture.runtime_identity.model_dump(mode="python") + ) + runtime_digest = canonical_sha256( + CompetitionNativeRuntimeIdentityV2_9(**asdict(runtime)), + domain=_RUNTIME_IDENTITY_HASH_DOMAIN, + ) + capture_scene_sha256 = _accepted_capture_scene_sha256(checked_capture.scene) + for subject in checked_evidence.subjects: + placement = placements[subject.subject_object_id] + support = supports[subject.subject_object_id] + support_object_id = support.support_object_id + if subject.scene_sha256 != capture_scene_sha256: + raise ValueError( + "surface evidence subject scene digest does not bind capture" + ) + if ( + support.support_kind is not CompetitionNativeSupportKindV2_9.RECEPTACLE + or support_object_id is None + or subject.support_object_id != support_object_id + or subject.native_subject_object_id != support.native_object_id + or subject.native_support_object_id + != supports[support_object_id].native_object_id + or subject.runtime_identity_sha256 != runtime_digest + or subject.placement_sha256 != placement.placement_sha256 + or subject.source_capture_sha256 != checked_capture.source_capture_sha256 + or placement.position_region is None + ): + raise ValueError("surface evidence subject does not bind capture facts") + region = placement.position_region + if ( + region.subject_object_id != subject.subject_object_id + or region.source_kind != "ai2thor-receptacle-trigger-grid-v1" + or region.source_sha256 != subject.spawn_map_source_sha256 + ): + raise ValueError("surface evidence placement region changed on replay") + return checked_evidence + + +def build_competition_native_source_surface_evidence_v2_9_2( + capture: CompetitionNativeSourceCaptureV2_9, + spawn_maps: tuple[AdapterSpawnMap, ...], +) -> CompetitionNativeSourceSurfaceEvidenceV2_9_2: + """Close raw patch ownership against one unchanged source capture.""" + + if type(capture) is not CompetitionNativeSourceCaptureV2_9: + raise TypeError("surface evidence capture must be exact") + checked_capture = CompetitionNativeSourceCaptureV2_9.model_validate( + capture.model_dump(mode="python"), + strict=True, + ) + if type(spawn_maps) is not tuple: + raise TypeError("surface evidence spawn_maps must be an exact tuple") + checked_maps = tuple( + sorted( + (_strict_spawn_map(item) for item in spawn_maps), + key=lambda item: item.subject_object_id, + ) + ) + map_subject_ids = tuple(item.subject_object_id for item in checked_maps) + if len(map_subject_ids) != len(set(map_subject_ids)): + raise ValueError("surface evidence spawn-map subjects must be unique") + + placement_by_id = {item.object_id: item for item in checked_capture.placement_facts} + support_by_id = {item.object_id: item for item in checked_capture.support_facts} + expected_subject_ids = tuple( + sorted( + item.object_id + for item in checked_capture.placement_facts + if item.availability + is CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN + ) + ) + if map_subject_ids != expected_subject_ids: + raise ValueError("surface evidence spawn maps do not close capture placements") + + subjects: list[CompetitionNativeSubjectSurfaceEvidenceV2_9_2] = [] + for spawn_map in checked_maps: + placement = placement_by_id[spawn_map.subject_object_id] + support = support_by_id[spawn_map.subject_object_id] + support_object_id = support.support_object_id + if ( + support.support_kind is not CompetitionNativeSupportKindV2_9.RECEPTACLE + or support_object_id is None + or spawn_map.scene_id != checked_capture.scene.scene_id + or spawn_map.support_object_id != support_object_id + or spawn_map.native_subject_object_id != support.native_object_id + or spawn_map.native_support_object_id + != support_by_id[support_object_id].native_object_id + or not spawn_map.surface_patches + or placement.position_region is None + or spawn_map.position_region != placement.position_region + ): + raise ValueError("surface evidence spawn map does not bind capture facts") + expected_runtime = CompetitionNativeRuntimeIdentityV2_9( + **asdict(spawn_map.runtime_identity) + ) + if expected_runtime != checked_capture.runtime_identity: + raise ValueError("surface evidence runtime does not bind source capture") + patches = tuple( + _build_patch(index, patch) + for index, patch in enumerate(spawn_map.surface_patches) + ) + runtime_identity_sha256 = canonical_sha256( + checked_capture.runtime_identity, + domain=_RUNTIME_IDENTITY_HASH_DOMAIN, + ) + payload = _subject_payload( + subject_object_id=spawn_map.subject_object_id, + support_object_id=spawn_map.support_object_id, + native_subject_object_id=spawn_map.native_subject_object_id, + native_support_object_id=spawn_map.native_support_object_id, + runtime_identity_sha256=runtime_identity_sha256, + scene_sha256=spawn_map.scene_sha256, + positions_sha256=spawn_map.positions_sha256, + spawn_map_source_sha256=spawn_map.source_sha256, + placement_sha256=placement.placement_sha256, + source_capture_sha256=checked_capture.source_capture_sha256, + patches=patches, + ) + subjects.append( + CompetitionNativeSubjectSurfaceEvidenceV2_9_2( + **payload, + subject_surface_evidence_sha256=canonical_sha256( + payload, + domain=_SUBJECT_EVIDENCE_HASH_DOMAIN, + ), + ) + ) + + subject_tuple = tuple(subjects) + source_payload = _source_payload( + source_id=checked_capture.source.source_id, + scene_id=checked_capture.scene.scene_id, + source_capture_sha256=checked_capture.source_capture_sha256, + subjects=subject_tuple, + ) + evidence = CompetitionNativeSourceSurfaceEvidenceV2_9_2( + source_id=checked_capture.source.source_id, + scene_id=checked_capture.scene.scene_id, + source_capture_sha256=checked_capture.source_capture_sha256, + subjects=subject_tuple, + surface_evidence_sha256=canonical_sha256( + source_payload, + domain=_SOURCE_EVIDENCE_HASH_DOMAIN, + ), + ) + return verify_competition_native_source_surface_evidence_v2_9_2( + checked_capture, + evidence, + ) + + +ReceptacleSurfacePatch = CompetitionNativeReceptacleSurfacePatchV2_9_2 +SourceSurfaceEvidence = CompetitionNativeSourceSurfaceEvidenceV2_9_2 +SubjectSurfaceEvidence = CompetitionNativeSubjectSurfaceEvidenceV2_9_2 +build_source_surface_evidence = build_competition_native_source_surface_evidence_v2_9_2 +verify_source_surface_evidence = ( + verify_competition_native_source_surface_evidence_v2_9_2 +) + +__all__ = ( + "ReceptacleSurfacePatch", + "SourceSurfaceEvidence", + "SubjectSurfaceEvidence", + "build_source_surface_evidence", + "verify_source_surface_evidence", ) -from spatialcf.generation._internal.evidence.camera import ( - CameraPolicy, - SourceCameraEvidence, + +from enum import StrEnum + +from pydantic import Field, model_validator + +from spatialcf.domain.base import CanonicalModel +from spatialcf.domain.request import Relation +from spatialcf.domain.scene import SubjectPositionRegion, Vec2 +from spatialcf.domain.serialization import ( + canonical_json_bytes, ) -from spatialcf.generation._internal.evidence.reachability import ( +from spatialcf.generation.capture.reachability import ( CandidateTargetReachability, TargetReachabilityStatus, ) -from spatialcf.generation._internal.evidence.surface import ( - SourceSurfaceEvidence, -) DatasetSplitV2_9 = Literal["train", "validation", "test"] -_POLICY_HASH_DOMAIN_V2_9_4 = ( +_CURRENT_POLICY_HASH_DOMAIN = ( "spatialcf.competition-native-candidate-roster-policy.v2.9.4" ) _RUNTIME_IDENTITY_HASH_DOMAIN = "spatialcf.competition-native-runtime-identity.v2.9.2" @@ -95,7 +2616,7 @@ class CompetitionNativeCandidateStateV2_9(StrEnum): REJECTED_TARGET_UNREACHABLE = "REJECTED_TARGET_UNREACHABLE" -class CompetitionNativeSourceRefV2_9(V2Model): +class CompetitionNativeSourceRefV2_9(CanonicalModel): source_id: str = Field(strict=True, min_length=1, max_length=512) scene_id: str = Field(strict=True, min_length=1, max_length=512) split: DatasetSplitV2_9 @@ -107,7 +2628,7 @@ class CompetitionNativeSourceRefV2_9(V2Model): ) -class RosterPolicy(V2Model): +class RosterPolicy(CanonicalModel): """The only supported candidate-roster policy.""" policy_version: Literal["competition-native-candidate-roster-policy:2.9.4"] = ( @@ -152,13 +2673,13 @@ def validate_policy(self) -> Self: @property def policy_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_POLICY_HASH_DOMAIN_V2_9_4) + return canonical_sha256(self, domain=_CURRENT_POLICY_HASH_DOMAIN) CompetitionNativeCandidateRosterPolicyV2_9_4 = RosterPolicy -class CompetitionNativeRuntimeIdentityV2_9(V2Model): +class CompetitionNativeRuntimeIdentityV2_9(CanonicalModel): ai2thor_version: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) unity_commit_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) native_scene_name: str = Field( @@ -220,7 +2741,7 @@ def validate_competition_native_runtime_source_lineage_v2_9( raise ValueError("legacy runtime scene name does not match frozen source") -class CompetitionNativeSupportFactV2_9(V2Model): +class CompetitionNativeSupportFactV2_9(CanonicalModel): scene_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) object_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) object_name: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) @@ -295,13 +2816,13 @@ def validate_support(self) -> Self: return self -class CompetitionNativePositionV2_9(V2Model): +class CompetitionNativePositionV2_9(CanonicalModel): x: float y: float z: float -class CompetitionNativeFloorEnvelopeV2_9(V2Model): +class CompetitionNativeFloorEnvelopeV2_9(CanonicalModel): scene_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) floor_object_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) floor_name: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) @@ -349,7 +2870,7 @@ def _placement_payload( } -class CompetitionNativeSubjectPlacementFactV2_9(V2Model): +class CompetitionNativeSubjectPlacementFactV2_9(CanonicalModel): object_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) availability: CompetitionNativePlacementAvailabilityV2_9 support_kind: CompetitionNativeSupportKindV2_9 @@ -432,7 +2953,7 @@ def validate_placement(self) -> Self: ) if not valid: raise ValueError("subject placement fact is not closed") - expected = canonical_sha256_v2( + expected = canonical_sha256( _placement_payload( object_id=self.object_id, availability=self.availability, @@ -484,7 +3005,7 @@ def build_competition_native_subject_placement_fact_v2_9( native_positions=native_positions, position_region=position_region, reasons=reasons, - placement_sha256=canonical_sha256_v2(payload, domain=_PLACEMENT_HASH_DOMAIN), + placement_sha256=canonical_sha256(payload, domain=_PLACEMENT_HASH_DOMAIN), ) @@ -745,7 +3266,7 @@ def validate_competition_native_floor_envelope_v2_9( ) -class CompetitionNativeSourceCaptureV2_9(V2Model): +class CompetitionNativeSourceCaptureV2_9(CanonicalModel): source: CompetitionNativeSourceRefV2_9 runtime_identity: CompetitionNativeRuntimeIdentityV2_9 scene: Scene @@ -838,11 +3359,11 @@ def validate_capture(self) -> Self: placement_facts=self.placement_facts, ) if ( - len(canonical_json_bytes_v2(capture_payload)) + len(canonical_json_bytes(capture_payload)) > _SOURCE_CAPTURE_PAYLOAD_MAX_BYTES ): raise ValueError("source capture exceeds persisted record byte limit") - expected = canonical_sha256_v2( + expected = canonical_sha256( capture_payload, domain=_SOURCE_CAPTURE_HASH_DOMAIN, ) @@ -902,13 +3423,13 @@ def build_competition_native_source_capture_v2_9( floor_envelope=floor_envelope, reachable_positions=reachable_positions, placement_facts=placement_facts, - source_capture_sha256=canonical_sha256_v2( + source_capture_sha256=canonical_sha256( payload, domain=_SOURCE_CAPTURE_HASH_DOMAIN ), ) -class CompetitionNativeSourceCaptureOutcomeV2_9(V2Model): +class CompetitionNativeSourceCaptureOutcomeV2_9(CanonicalModel): source: CompetitionNativeSourceRefV2_9 status: Literal["accepted", "rejected"] capture: CompetitionNativeSourceCaptureV2_9 | None @@ -932,7 +3453,7 @@ def validate_outcome(self) -> Self: return self -class CompetitionNativeObjectInventoryV2_9(V2Model): +class CompetitionNativeObjectInventoryV2_9(CanonicalModel): inventory_id: str = Field(pattern=r"^object-[0-9a-f]{64}$") source_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) scene_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) @@ -963,7 +3484,7 @@ def validate_inventory(self) -> Self: return self -class CompetitionNativeCandidateInventoryV2_9(V2Model): +class CompetitionNativeCandidateInventoryV2_9(CanonicalModel): candidate_id: str = Field(pattern=r"^candidate-[0-9a-f]{64}$") source_id: str = Field( strict=True, min_length=1, max_length=_MAX_PERSISTED_REQUEST_TEXT_CHARS @@ -1013,7 +3534,7 @@ def validate_candidate(self) -> Self: return self -class CompetitionNativeSelectedRequestV2_9(V2Model): +class CompetitionNativeSelectedRequestV2_9(CanonicalModel): request_id: str = Field(pattern=r"^request-[0-9a-f]{64}$") candidate_id: str = Field(pattern=r"^candidate-[0-9a-f]{64}$") selection_index: int = Field(strict=True, ge=0) @@ -1056,7 +3577,7 @@ def validate_request(self) -> Self: return self -class CompetitionNativeCandidateRosterManifestV2_9(V2Model): +class CompetitionNativeCandidateRosterManifestV2_9(CanonicalModel): manifest_version: Literal["competition-native-candidate-roster-manifest:2.9"] = ( "competition-native-candidate-roster-manifest:2.9" ) @@ -1079,10 +3600,10 @@ def validate_manifest(self) -> Self: @property def manifest_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_MANIFEST_HASH_DOMAIN) + return canonical_sha256(self, domain=_MANIFEST_HASH_DOMAIN) -class CompetitionNativeRosterRejectionV2_9(V2Model): +class CompetitionNativeRosterRejectionV2_9(CanonicalModel): rejection_id: str = Field(pattern=r"^rejection-[0-9a-f]{64}$") stage: Literal["source", "object_subject", "object_reference", "candidate"] source_id: str = Field(strict=True, min_length=1, max_length=_MAX_TEXT_CHARS) @@ -1108,22 +3629,22 @@ def validate_rejection(self) -> Self: return self -class CompetitionNativeStageCountV2_9(V2Model): +class CompetitionNativeStageCountV2_9(CanonicalModel): stage: str = Field(strict=True, min_length=1, max_length=128) count: int = Field(strict=True, gt=0) -class CompetitionNativeCandidateStateCountV2_9(V2Model): +class CompetitionNativeCandidateStateCountV2_9(CanonicalModel): state: CompetitionNativeCandidateStateV2_9 count: int = Field(strict=True, gt=0) -class CompetitionNativeRelationCountV2_9(V2Model): +class CompetitionNativeRelationCountV2_9(CanonicalModel): relation: Relation count: int = Field(strict=True, gt=0) -class RosterSummary(V2Model): +class RosterSummary(CanonicalModel): summary_version: Literal["competition-native-candidate-roster-summary:2.9"] = ( "competition-native-candidate-roster-summary:2.9" ) @@ -1186,13 +3707,13 @@ def validate_summary(self) -> Self: @property def summary_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_SUMMARY_HASH_DOMAIN) + return canonical_sha256(self, domain=_SUMMARY_HASH_DOMAIN) CompetitionNativeCandidateRosterSummaryV2_9 = RosterSummary -class RosterCompilation(V2Model): +class RosterCompilation(CanonicalModel): """The only supported complete roster compilation.""" policy: RosterPolicy diff --git a/src/spatialcf/generation/capture/plan.py b/src/spatialcf/generation/capture/plan.py index 6b87815..a1e9511 100644 --- a/src/spatialcf/generation/capture/plan.py +++ b/src/spatialcf/generation/capture/plan.py @@ -11,21 +11,18 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import ( +from spatialcf.domain.base import ( + CanonicalModel, NonNegativeFiniteFloat, PositiveFiniteFloat, Sha256Digest, - V2Model, ) -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, ) -from spatialcf.generation._internal.evidence.camera import ( - build_settled_camera_policy, -) -from spatialcf.generation._internal.source_manifest import ( - LegacySource, +from spatialcf.domain.source import ( + LegacyAI2ThorSource, ProceduralSource, SolverConfig, SourcePlanEntry, @@ -35,6 +32,7 @@ CompetitionNativeSourceRefV2_9, DatasetSplitV2_9, RosterPolicy, + build_settled_camera_policy, ) from spatialcf.generation.errors import require_wire_version from spatialcf.verification.filesystem import ( @@ -52,7 +50,7 @@ _LOCATOR_DOMAIN = "spatialcf.competition-native-dataset-source-locator.v2.9" _SOURCE_MANIFEST_DOMAIN = "spatialcf.competition-native-source-manifest.v2.9" -_PLAN_DOMAIN_V2_9_3 = "spatialcf.competition-native-dataset-capture-plan.v2.9.3" +_CURRENT_PLAN_HASH_DOMAIN = "spatialcf.competition-native-dataset-capture-plan.v2.9.3" _MAX_SOURCES = 32 _MAX_OBJECTS_PER_SCENE = 96 _MAX_CANDIDATES = 40_000 @@ -62,7 +60,7 @@ _CAPTURE_PLAN_VERIFICATION_CAPABILITY = object() -class CompetitionNativeLegacyDatasetLocatorV2_9(V2Model): +class CompetitionNativeLegacyDatasetLocatorV2_9(CanonicalModel): """One complete iTHOR source locator, independent of runtime results.""" locator_version: Literal["competition-native-dataset-source-locator:2.9"] = ( @@ -81,10 +79,10 @@ def validate_locator(self) -> Self: @property def locator_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_LOCATOR_DOMAIN) + return canonical_sha256(self, domain=_LOCATOR_DOMAIN) -class CompetitionNativeProceduralDatasetLocatorV2_9(V2Model): +class CompetitionNativeProceduralDatasetLocatorV2_9(CanonicalModel): """One complete ProcTHOR locator with frozen source-content identity.""" locator_version: Literal["competition-native-dataset-source-locator:2.9"] = ( @@ -110,7 +108,7 @@ def validate_locator(self) -> Self: @property def locator_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_LOCATOR_DOMAIN) + return canonical_sha256(self, domain=_LOCATOR_DOMAIN) CompetitionNativeDatasetSourceLocatorV2_9 = Annotated[ @@ -120,7 +118,7 @@ def locator_sha256(self) -> Sha256Digest: ] -class CaptureSettings(V2Model): +class CaptureSettings(CanonicalModel): """Read-only runtime settings shared by every source in the prefix.""" settings_version: Literal["competition-native-dataset-capture-settings:2.9"] = ( @@ -145,7 +143,7 @@ def validate_settings(self) -> Self: def _source_manifest_sha256( manifest: SourcePlanManifest, ) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( manifest.model_dump(mode="json"), domain=_SOURCE_MANIFEST_DOMAIN, ) @@ -157,7 +155,7 @@ def _source_locators( locators: list[CompetitionNativeDatasetSourceLocatorV2_9] = [] for entry in manifest.sources: source = entry.source - if type(source) is LegacySource: + if type(source) is LegacyAI2ThorSource: locators.append( CompetitionNativeLegacyDatasetLocatorV2_9( source_id=entry.source_id, @@ -220,7 +218,7 @@ def _source_refs( CompetitionNativeDatasetCaptureSettingsV2_9 = CaptureSettings -class CapturePlan(V2Model): +class CapturePlan(CanonicalModel): """The only supported manifest-to-roster capture plan.""" plan_version: Literal["competition-native-dataset-capture-plan:2.9.3"] = ( @@ -267,7 +265,7 @@ def validate_plan(self) -> Self: @property def plan_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_PLAN_DOMAIN_V2_9_3) + return canonical_sha256(self, domain=_CURRENT_PLAN_HASH_DOMAIN) CompetitionNativeDatasetCapturePlanV2_9_3 = CapturePlan @@ -288,7 +286,7 @@ class RetainedCapturePlanVerification: def _parse_capture_plan(payload: bytes) -> CapturePlan: plan = CapturePlan.model_validate_json(payload, strict=True) - if payload != canonical_json_bytes_v2(plan) + b"\n": + if payload != canonical_json_bytes(plan) + b"\n": raise ValueError("dataset capture plan is not canonical") return plan @@ -482,7 +480,7 @@ def publish_capture_plan(plan: CapturePlan, output_root: Path) -> CapturePlan: plan.model_dump(mode="python", warnings="error"), strict=True, ) - payload = canonical_json_bytes_v2(checked) + b"\n" + payload = canonical_json_bytes(checked) + b"\n" if len(payload) > _MAX_PLAN_BYTES: raise ValueError("dataset capture plan exceeds byte limit") checksum = f"{hashlib.sha256(payload).hexdigest()} plan.json\n".encode("ascii") @@ -670,7 +668,10 @@ def build_legacy_capture_plan( SourcePlanEntry( source_id=f"source-{index:04d}", scene_id=scene_name, - source=LegacySource(kind="legacy-ai2thor", scene_name=scene_name), + source=LegacyAI2ThorSource( + kind="legacy-ai2thor", + scene_name=scene_name, + ), ) for index, scene_name in enumerate(scene_names) ), diff --git a/src/spatialcf/generation/_internal/evidence/reachability.py b/src/spatialcf/generation/capture/reachability.py similarity index 89% rename from src/spatialcf/generation/_internal/evidence/reachability.py rename to src/spatialcf/generation/capture/reachability.py index b8683bd..4545a3d 100644 --- a/src/spatialcf/generation/_internal/evidence/reachability.py +++ b/src/spatialcf/generation/capture/reachability.py @@ -19,71 +19,73 @@ from pydantic import Field, model_validator -from spatialcf.core.v2.continuous_yaw_camera_frame import ( - bound_world_point_in_upright_camera_v2_9, - compile_upright_camera_context_v2_9, - prepare_camera_independent_candidate_problem_v2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - _divide_positive as _divide_positive_interval_v2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - _extract_target as _extract_directional_target_v2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - _scale as _scale_interval_v2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - _subtract as _subtract_intervals_v2_9, -) -from spatialcf.core.v2.continuous_yaw_directional_relation import ( - _UnsupportedDirectionalTargetV2, -) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _expanded_universe as _expanded_target_universe_v2_9, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _extract_target as _extract_shape_gap_target_v2_9, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _inclusive_complement_complex as _inclusive_shape_gap_complement_v2_9, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _offset_and_clip_polygon as _offset_and_clip_target_polygon_v2_9, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _target_complexes as _shape_gap_target_complexes_v2_9, ) -from spatialcf.core.v2.continuous_yaw_target_relation import ( +from spatialcf.core._internal.compilation.target import ( _UnsupportedTargetRelationV2, ) -from spatialcf.core.v2.convex_translation_domain import ( +from spatialcf.core._internal.kernels.convex_translation import ( ConvexTranslationDomainKindV2, RationalPoint2V2, compile_convex_translation_obstacle_v2, ) -from spatialcf.core.v2.rect_kernel import ( +from spatialcf.core._internal.kernels.rect import ( ExactAxisAlignedRectV2, RectCoordinateSpaceV2, ) -from spatialcf.core.v2.so2_interval import ( +from spatialcf.core._internal.kernels.so2 import ( SO2AtomicBudgetExhaustedV2, SO2AtomicBudgetV2, ) -from spatialcf.core.v2.strict_convex_intersection import ( +from spatialcf.core._internal.kernels.strict_convex import ( StrictConvexIntersectionBudgetExhaustedV2, StrictConvexIntersectionBudgetV2, ) -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import InterventionSpec, Scene -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.constraints import ( - MeasurementComparatorV2, - RelationMeasurementV2, - RelationV2, +from spatialcf.core._internal.objective.relation import ( + _divide_positive as _divide_positive_interval_v2_9, +) +from spatialcf.core._internal.objective.relation import ( + _extract_target as _extract_directional_target_v2_9, +) +from spatialcf.core._internal.objective.relation import ( + _scale as _scale_interval_v2_9, +) +from spatialcf.core._internal.objective.relation import ( + _subtract as _subtract_intervals_v2_9, ) -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 +from spatialcf.core._internal.objective.relation import ( + _UnsupportedDirectionalTargetV2, +) +from spatialcf.core.problem import ( + bound_world_point_in_upright_camera, + compile_upright_camera_context, + prepare_camera_independent_candidate_problem, +) +from spatialcf.domain.base import CanonicalModel, Sha256Digest +from spatialcf.domain.constraints import ( + MeasurementComparator, + RelationMeasurement, +) +from spatialcf.domain.constraints import ( + Relation as ConstraintRelation, +) +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.request import InterventionSpec, Relation +from spatialcf.domain.scene import Scene +from spatialcf.domain.serialization import canonical_sha256 _REACHABLE_POSITIONS_HASH_DOMAIN_V2_9_4 = ( "spatialcf.competition-native-reachable-native-positions.v2.9.4" @@ -131,13 +133,13 @@ def competition_native_reachable_native_positions_sha256_v2_9_4( ) -> Sha256Digest: """Hash one canonical set of target-reachable native XY deltas.""" - return canonical_sha256_v2( + return canonical_sha256( _canonical_coordinate_payload_v2_9_4(coordinates), domain=_REACHABLE_POSITIONS_HASH_DOMAIN_V2_9_4, ) -class CompetitionNativeCandidateTargetReachabilityV2_9_4(V2Model): +class CompetitionNativeCandidateTargetReachabilityV2_9_4(CanonicalModel): reachability_version: Literal[ "competition-native-candidate-target-reachability:2.9.4" ] = "competition-native-candidate-target-reachability:2.9.4" @@ -178,7 +180,7 @@ def validate_reachability(self) -> Self: exclude={"target_reachability_sha256"}, warnings="error", ) - if self.target_reachability_sha256 != canonical_sha256_v2( + if self.target_reachability_sha256 != canonical_sha256( payload, domain=_TARGET_REACHABILITY_HASH_DOMAIN_V2_9_4, ): @@ -233,7 +235,7 @@ def build_competition_native_candidate_target_reachability_v2_9_4( "camera_evidence_sha256": camera_evidence_sha256, "native_position_count": native_position_count, "reachable_native_position_count": reachable_count, - "reachable_native_positions_sha256": canonical_sha256_v2( + "reachable_native_positions_sha256": canonical_sha256( coordinate_payload, domain=_REACHABLE_POSITIONS_HASH_DOMAIN_V2_9_4, ), @@ -245,7 +247,7 @@ def build_competition_native_candidate_target_reachability_v2_9_4( } return CompetitionNativeCandidateTargetReachabilityV2_9_4( **payload, - target_reachability_sha256=canonical_sha256_v2( + target_reachability_sha256=canonical_sha256( payload, domain=_TARGET_REACHABILITY_HASH_DOMAIN_V2_9_4, ), @@ -269,15 +271,15 @@ def competition_native_target_only_relation_after_native_coordinates_v2_9_4( target = problem.constraints.target_relation try: if target.relation_after in ( - RelationV2.LEFT, - RelationV2.RIGHT, - RelationV2.FRONT, - RelationV2.BEHIND, + ConstraintRelation.LEFT, + ConstraintRelation.RIGHT, + ConstraintRelation.FRONT, + ConstraintRelation.BEHIND, ): return _target_only_directional_native_coordinates_v2_9_4( problem, native_points ) - if target.relation_after in (RelationV2.NEAR, RelationV2.FAR): + if target.relation_after in (ConstraintRelation.NEAR, ConstraintRelation.FAR): return _target_only_shape_gap_native_coordinates_v2_9_4( problem, native_points ) @@ -316,16 +318,16 @@ def competition_native_target_only_relation_after_native_coordinate_margins_v2_9 target = problem.constraints.target_relation try: if target.relation_after in ( - RelationV2.LEFT, - RelationV2.RIGHT, - RelationV2.FRONT, - RelationV2.BEHIND, + ConstraintRelation.LEFT, + ConstraintRelation.RIGHT, + ConstraintRelation.FRONT, + ConstraintRelation.BEHIND, ): return _target_only_directional_native_coordinate_margins_v2_9_4( problem, native_points, ) - if target.relation_after in (RelationV2.NEAR, RelationV2.FAR): + if target.relation_after in (ConstraintRelation.NEAR, ConstraintRelation.FAR): return tuple( (coordinate, Fraction()) for coordinate in sorted( @@ -366,13 +368,13 @@ def _target_only_directional_native_coordinate_margins_v2_9_4( problem: SemanticProblemV2_3, native_points: tuple[RationalPoint2V2, ...], ) -> tuple[tuple[tuple[Fraction, Fraction], Fraction], ...]: - projected = prepare_camera_independent_candidate_problem_v2_9(problem) + projected = prepare_camera_independent_candidate_problem(problem) atomic = SO2AtomicBudgetV2(limit=10_000 + 100 * len(native_points)) domain = StrictConvexIntersectionBudgetV2( max_domain_operations=10_000 + 20 * len(native_points), max_candidate_cells=1, ) - camera = compile_upright_camera_context_v2_9( + camera = compile_upright_camera_context( problem, atomic_budget=atomic, domain_budget=domain, @@ -383,7 +385,7 @@ def _target_only_directional_native_coordinate_margins_v2_9_4( camera, domain, ) - reference_bounds = bound_world_point_in_upright_camera_v2_9( + reference_bounds = bound_world_point_in_upright_camera( camera, world_xyz=reference, delta_x=(Fraction(), Fraction()), @@ -393,14 +395,14 @@ def _target_only_directional_native_coordinate_margins_v2_9_4( threshold = Fraction.from_float(definition.threshold) selected: dict[tuple[Fraction, Fraction], Fraction] = {} for point in native_points: - subject_bounds = bound_world_point_in_upright_camera_v2_9( + subject_bounds = bound_world_point_in_upright_camera( camera, world_xyz=subject, delta_x=(point.x, point.x), delta_y=(point.y, point.y), atomic_budget=atomic, ) - if definition.measurement is RelationMeasurementV2.PROJECTED_CENTER_DELTA_X: + if definition.measurement is RelationMeasurement.PROJECTED_CENTER_DELTA_X: if not subject_bounds.positive_depth or not reference_bounds.positive_depth: continue if camera.intrinsics[0] <= 0: @@ -420,7 +422,7 @@ def _target_only_directional_native_coordinate_margins_v2_9_4( camera.intrinsics[0], atomic, ) - elif definition.measurement is RelationMeasurementV2.CAMERA_DEPTH_DELTA: + elif definition.measurement is RelationMeasurement.CAMERA_DEPTH_DELTA: measurement = _subtract_intervals_v2_9( subject_bounds.z_camera, reference_bounds.z_camera, @@ -428,7 +430,7 @@ def _target_only_directional_native_coordinate_margins_v2_9_4( ) else: raise RuntimeError("directional target escaped its measurement partition") - if definition.comparator is MeasurementComparatorV2.LESS_THAN: + if definition.comparator is MeasurementComparator.LESS_THAN: margin = threshold - measurement[1] else: margin = measurement[0] - threshold @@ -441,7 +443,7 @@ def _target_only_shape_gap_native_coordinates_v2_9_4( problem: SemanticProblemV2_3, native_points: tuple[RationalPoint2V2, ...], ) -> frozenset[tuple[Fraction, Fraction]]: - projected = prepare_camera_independent_candidate_problem_v2_9(problem) + projected = prepare_camera_independent_candidate_problem(problem) atomic = SO2AtomicBudgetV2(limit=100_000) domain = StrictConvexIntersectionBudgetV2( max_domain_operations=100_000, @@ -484,7 +486,7 @@ def _target_only_shape_gap_native_coordinates_v2_9_4( ) else: return frozenset() - if relation is RelationV2.FAR: + if relation is ConstraintRelation.FAR: inner = _inclusive_shape_gap_complement_v2_9( outer_near, universe, @@ -563,14 +565,10 @@ def prepare_competition_native_target_reachability_source_v2_9_4( ) -> CompetitionNativePreparedTargetReachabilitySourceV2_9_4: """Strictly validate and index one frozen source exactly once.""" - from spatialcf.generation._internal.evidence.camera import ( - SourceCameraEvidence, - ) - from spatialcf.generation._internal.evidence.surface import ( - SourceSurfaceEvidence, - ) from spatialcf.generation.capture.models import ( CompetitionNativeSourceCaptureV2_9, + SourceCameraEvidence, + SourceSurfaceEvidence, ) if type(capture) is not CompetitionNativeSourceCaptureV2_9: @@ -631,13 +629,13 @@ def derive_competition_native_candidate_target_reachability_from_prepared_v2_9_4 target-reachability algorithm and ledger schema. """ - from spatialcf.generation._internal.planning.proxy import ( - build_target_proposal_problem, - default_planning_workspace, - ) from spatialcf.generation.capture.models import ( CompetitionNativePlacementAvailabilityV2_9, ) + from spatialcf.generation.planning.problem import ( + build_target_proposal_problem, + default_planning_workspace, + ) if ( type(prepared_source) diff --git a/src/spatialcf/generation/capture/source.py b/src/spatialcf/generation/capture/source.py index fef3ba4..c01da95 100644 --- a/src/spatialcf/generation/capture/source.py +++ b/src/spatialcf/generation/capture/source.py @@ -1,193 +1,17 @@ -"""Current-only source capture orchestration.""" +"""Schema-2 facade for protocol-only dataset capture.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable -from spatialcf.adapters.ai2thor import AI2ThorAdapter, AI2ThorProceduralScene -from spatialcf.domain.enums import Relation -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 -from spatialcf.generation.capture.compiler import compile_roster -from spatialcf.generation.capture.models import ( - CompetitionNativeSourceCaptureOutcomeV2_9, - CompetitionNativeSourceRefV2_9, - RosterCompilation, - RosterPolicy, -) -from spatialcf.generation.capture.plan import ( - CapturePlan, - CompetitionNativeProceduralDatasetLocatorV2_9, -) - -_PROCTHOR_DATASET_ID = "allenai/procthor-10k" -_PROCTHOR_DATASET_NAME = "procthor-10k" -_PROCTHOR_LOADER_ID = "prior" -_PROCTHOR_LOADER_VERSION = "1.0.3" -_EXPECTED_SOURCE_LIFECYCLE_ERRORS = (OSError, RuntimeError, TypeError, ValueError) +from spatialcf.composition import DEFAULT_ENVIRONMENT_ADAPTER_FACTORY as AI2ThorAdapter +from spatialcf.generation.capture.models import RosterCompilation +from spatialcf.generation.capture.plan import CapturePlan +from spatialcf.generation.workflows import capture as capture_workflow def load_prior_dataset(name: str, revision: str) -> object: - try: - import prior - except ImportError as error: - raise RuntimeError("ProcTHOR capture requires spatialcf[procthor]") from error - try: - return prior.load_dataset(name, revision=revision) - except MemoryError: - raise - except Exception as error: - raise RuntimeError("ProcTHOR dataset loader failed") from error - - -def _dataset_split(dataset: object, split: str): - if isinstance(dataset, Mapping): - try: - values = dataset[split] - except KeyError as error: - raise ValueError("ProcTHOR dataset split is absent") from error - else: - try: - values = getattr(dataset, split) - except AttributeError as error: - raise ValueError("ProcTHOR dataset split is absent") from error - if ( - isinstance(values, (str, bytes)) - or not callable(getattr(values, "__len__", None)) - or not callable(getattr(values, "__getitem__", None)) - ): - raise TypeError("ProcTHOR dataset split must be indexable") - return values - - -def _resolve_procedural_source( - locator: CompetitionNativeProceduralDatasetLocatorV2_9, - datasets: dict[tuple[str, str], object], - dataset_loader: Callable[[str, str], object], -) -> AI2ThorProceduralScene: - if ( - locator.dataset_id != _PROCTHOR_DATASET_ID - or locator.loader_id != _PROCTHOR_LOADER_ID - or locator.loader_version != _PROCTHOR_LOADER_VERSION - ): - raise ValueError("unsupported ProcTHOR source loader identity") - key = (_PROCTHOR_DATASET_NAME, locator.revision) - if key not in datasets: - datasets[key] = dataset_loader(*key) - values = _dataset_split(datasets[key], locator.split) - try: - house = values[locator.index] - except (IndexError, KeyError, TypeError) as error: - raise ValueError("ProcTHOR source locator does not exist") from error - if type(house) is not dict: - raise ValueError("ProcTHOR source house must be an exact dict") - scene = AI2ThorProceduralScene.create( - dataset_id=locator.dataset_id, - revision=locator.revision, - split=locator.split, - index=locator.index, - source_loader_id=locator.loader_id, - source_loader_version=locator.loader_version, - house=house, - ) - if scene.house_sha256 != locator.source_sha256: - raise ValueError("ProcTHOR source content digest changed") - return scene - - -def _rejected_source( - source: CompetitionNativeSourceRefV2_9, - reason: str, -) -> CompetitionNativeSourceCaptureOutcomeV2_9: - return CompetitionNativeSourceCaptureOutcomeV2_9( - source=source, - status="rejected", - capture=None, - reasons=(reason,), - ) - - -def _apply_resource_caps( - policy: RosterPolicy, - outcomes: tuple[CompetitionNativeSourceCaptureOutcomeV2_9, ...], -) -> tuple[CompetitionNativeSourceCaptureOutcomeV2_9, ...]: - bounded = [] - candidate_count = 0 - for outcome in outcomes: - capture = outcome.capture - if outcome.status == "rejected" or capture is None: - bounded.append(outcome) - continue - object_count = len(capture.scene.objects) - source_candidates = object_count * max(0, object_count - 1) * len(Relation) - if object_count > policy.max_objects_per_scene: - bounded.append( - _rejected_source( - outcome.source, - "dataset_capture:object_cap_exceeded", - ) - ) - elif candidate_count + source_candidates > policy.max_candidates_total: - bounded.append( - _rejected_source( - outcome.source, - "dataset_capture:candidate_cap_exceeded", - ) - ) - else: - bounded.append(outcome) - candidate_count += source_candidates - return tuple(bounded) - - -def _capture_source_with_adapter( - source: CompetitionNativeSourceRefV2_9, - procedural: Mapping[str, AI2ThorProceduralScene] | None, - plan: CapturePlan, - adapter_factory: Callable[..., AI2ThorAdapter], -): - from spatialcf.generation._internal.source_observation import ( - capture_source_observation, - ) - - manager = adapter_factory( - [source.scene_id], - width=plan.roster_policy.width, - height=plan.roster_policy.height, - seed=plan.roster_policy.seed, - procedural_scenes=procedural, - ) - try: - adapter = manager.__enter__() - except _EXPECTED_SOURCE_LIFECYCLE_ERRORS: - return ( - _rejected_source(source, "dataset_capture:adapter_lifecycle_failed"), - None, - None, - ) - try: - result = capture_source_observation( - adapter, - source=source, - settings=plan.capture_settings, - camera_policy=plan.roster_policy.camera_policy, - ) - except BaseException as error: - try: - manager.__exit__(type(error), error, error.__traceback__) - except MemoryError: - raise - except Exception as cleanup_error: # noqa: BLE001 - error.add_note(f"AI2-THOR cleanup also failed: {cleanup_error}") - raise - try: - manager.__exit__(None, None, None) - except _EXPECTED_SOURCE_LIFECYCLE_ERRORS: - return ( - _rejected_source(source, "dataset_capture:adapter_lifecycle_failed"), - None, - None, - ) - return result.outcome, result.surface_evidence, result.camera_evidence + return capture_workflow.load_prior_dataset(name, revision) def capture_dataset( @@ -196,70 +20,8 @@ def capture_dataset( adapter_factory: Callable[..., AI2ThorAdapter] = AI2ThorAdapter, dataset_loader: Callable[[str, str], object] = load_prior_dataset, ) -> RosterCompilation: - """Capture every frozen source once, then compile the current roster once.""" - - if type(plan) is not CapturePlan: - raise TypeError("dataset capture plan must be exact") - checked = CapturePlan.model_validate( - plan.model_dump(mode="python", warnings="error"), - strict=True, - ) - datasets: dict[tuple[str, str], object] = {} - outcomes: list[CompetitionNativeSourceCaptureOutcomeV2_9] = [] - surface_evidence = [] - camera_evidence = [] - for source, locator in zip( - checked.roster_policy.sources, - checked.source_locators, - strict=True, - ): - procedural: dict[str, AI2ThorProceduralScene] = {} - if isinstance(locator, CompetitionNativeProceduralDatasetLocatorV2_9): - try: - procedural[source.scene_id] = _resolve_procedural_source( - locator, - datasets, - dataset_loader, - ) - except _EXPECTED_SOURCE_LIFECYCLE_ERRORS: - outcomes.append( - _rejected_source( - source, - "dataset_capture:source_resolution_failed", - ) - ) - continue - outcome, source_surface, source_camera = _capture_source_with_adapter( - source, - procedural or None, - checked, - adapter_factory, - ) - outcomes.append( - CompetitionNativeSourceCaptureOutcomeV2_9.model_validate_json( - canonical_json_bytes_v2(outcome), - strict=True, - ) - ) - if source_surface is not None: - surface_evidence.append(source_surface) - if source_camera is not None: - camera_evidence.append(source_camera) - bounded = _apply_resource_caps(checked.roster_policy, tuple(outcomes)) - accepted_source_ids = { - item.source.source_id for item in bounded if item.status == "accepted" - } - bounded_surface = tuple( - item for item in surface_evidence if item.source_id in accepted_source_ids - ) - bounded_camera = tuple( - item for item in camera_evidence if item.source_id in accepted_source_ids - ) - return compile_roster( - checked.roster_policy, - bounded, - bounded_surface, - bounded_camera, + return capture_workflow.capture_dataset( + plan, adapter_factory=adapter_factory, dataset_loader=dataset_loader ) diff --git a/src/spatialcf/generation/capture/storage.py b/src/spatialcf/generation/capture/storage.py index 8a73690..9b355ef 100644 --- a/src/spatialcf/generation/capture/storage.py +++ b/src/spatialcf/generation/capture/storage.py @@ -9,16 +9,7 @@ from dataclasses import dataclass, field from pathlib import Path -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 -from spatialcf.generation._internal.evidence.camera import ( - SourceCameraEvidence, -) -from spatialcf.generation._internal.evidence.reachability import ( - CandidateTargetReachability, -) -from spatialcf.generation._internal.evidence.surface import ( - SourceSurfaceEvidence, -) +from spatialcf.domain.serialization import canonical_json_bytes from spatialcf.generation.capture.compiler import compile_roster from spatialcf.generation.capture.models import ( CompetitionNativeCandidateInventoryV2_9, @@ -29,6 +20,11 @@ RosterCompilation, RosterPolicy, RosterSummary, + SourceCameraEvidence, + SourceSurfaceEvidence, +) +from spatialcf.generation.capture.reachability import ( + CandidateTargetReachability, ) from spatialcf.generation.errors import require_wire_version from spatialcf.verification.filesystem import ( @@ -99,7 +95,7 @@ def _request_manifest_max_bytes(policy: RosterPolicy) -> int: def _json_line(value: object) -> bytes: - return canonical_json_bytes_v2(value) + b"\n" + return canonical_json_bytes(value) + b"\n" def _publication_payloads(compilation: RosterCompilation) -> dict[str, bytes]: @@ -353,7 +349,7 @@ def _read_canonical_json_fd( return value, hashlib.sha256(payload).hexdigest() -def _stat_fingerprint_v2_9(result: os.stat_result) -> tuple[int, ...]: +def _current_stat_fingerprint(result: os.stat_result) -> tuple[int, ...]: return ( result.st_dev, result.st_ino, @@ -392,7 +388,7 @@ def _read_canonical_jsonl_fd( if ( not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 - or _stat_fingerprint_v2_9(before) != _stat_fingerprint_v2_9(expected) + or _current_stat_fingerprint(before) != _current_stat_fingerprint(expected) ): raise ValueError(f"candidate roster input must be regular: {name}") while True: @@ -425,8 +421,8 @@ def _read_canonical_jsonl_fd( if ( buffer or total != after.st_size - or _stat_fingerprint_v2_9(before) != _stat_fingerprint_v2_9(after) - or _stat_fingerprint_v2_9(after) != _stat_fingerprint_v2_9(current) + or _current_stat_fingerprint(before) != _current_stat_fingerprint(after) + or _current_stat_fingerprint(after) != _current_stat_fingerprint(current) ): raise ValueError(f"candidate roster input changed while read: {name}") finally: diff --git a/src/spatialcf/generation/dataset.py b/src/spatialcf/generation/dataset.py index f8f507d..8af1fa0 100644 --- a/src/spatialcf/generation/dataset.py +++ b/src/spatialcf/generation/dataset.py @@ -2,70 +2,24 @@ from __future__ import annotations -import hashlib -import os -import stat from collections import Counter -from collections.abc import Callable, Iterator, Mapping -from contextlib import ExitStack, contextmanager -from dataclasses import dataclass +from collections.abc import Callable from pathlib import Path, PurePosixPath from typing import Literal, Self from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator -from spatialcf.adapters.ai2thor import AI2ThorAdapter -from spatialcf.domain.enums import Relation -from spatialcf.domain.v2.base import Sha256Digest -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, -) -from spatialcf.generation import capture, execution, planning, publication -from spatialcf.generation.config import GenerationConfig, load_generation_config -from spatialcf.verification.filesystem import ( - CompetitionNativePublicationError, - RenameLocation, - bound_absolute_directory, - bound_child_directory, - directory_identity_fd, - open_directory, - open_native_output_parent, - read_regular_at, - revalidate_entries, - scan_directory, - snapshot_exact_directory, - sync_directory_fd, - write_regular_sync_at, +from spatialcf.composition import DEFAULT_ENVIRONMENT_ADAPTER_FACTORY as AI2ThorAdapter +from spatialcf.domain.base import Sha256Digest +from spatialcf.domain.request import Relation +from spatialcf.domain.serialization import ( + canonical_json_bytes, ) - -_CONFIG_HASH_DOMAIN = "spatialcf.generation-config.v1" -_DATASET_TREE_HASH_DOMAIN = "spatialcf.dataset-tree.v1" -_PUBLIC_FILES = frozenset( - {"manifest.json", "records.jsonl", "report.json", "checksums.sha256"} -) -_PUBLIC_DIRECTORIES = frozenset({"assets", ".spatialcf"}) -_STATE_DIRECTORIES = ("capture-plan", "roster", "source-plan", "batches") -_BUNDLE_FILES = frozenset( - { - "before-rgb.png", - "before-depth.npy", - "before-instance.png", - "before-pointcloud.ply", - "after-rgb.png", - "after-depth.npy", - "after-instance.png", - "after-pointcloud.ply", - "bundle.json", - "checksums.sha256", - } -) -_MAX_METADATA_BYTES = 64 * 1024 * 1024 -_MAX_ASSET_BYTES = 512 * 1024 * 1024 +from spatialcf.generation.config import GenerationConfig def _canonical_model_bytes(model: BaseModel) -> bytes: - return canonical_json_bytes_v2(model.model_dump(mode="json", warnings="error")) + return canonical_json_bytes(model.model_dump(mode="json", warnings="error")) def _safe_relative_path(value: str, *, parts: int | None = None) -> PurePosixPath: @@ -194,1253 +148,7 @@ def validate_asset_roster(self) -> Self: return self -def _config_sha256(config: GenerationConfig) -> Sha256Digest: - return canonical_sha256_v2( - config.model_dump(mode="json", warnings="error"), - domain=_CONFIG_HASH_DOMAIN, - ) - - -def _capture_plan(config: GenerationConfig) -> capture.CapturePlan: - return capture.build_legacy_capture_plan( - config.scene_names, - assigned_split=config.split, - campaign_id=config.campaign_id, - seed=config.seed, - width=config.width, - height=config.height, - max_requests_total=config.max_requests, - ) - - -def _checked_config(config: GenerationConfig | Path) -> GenerationConfig: - if type(config) is GenerationConfig: - return GenerationConfig.model_validate( - config.model_dump(mode="python"), strict=True - ) - if isinstance(config, Path): - return load_generation_config(config) - raise TypeError("config must be an exact GenerationConfig or Path") - - -def _absolute_output(output: Path) -> Path: - if not isinstance(output, Path): - raise TypeError("dataset output must be a Path") - absolute = Path(os.path.abspath(output)) - if absolute == absolute.parent: - raise ValueError("dataset output may not be filesystem root") - return absolute - - -def _initialize_dataset_root( - output: Path, - expected_plan: capture.CapturePlan, -) -> None: - with open_native_output_parent(output) as parent: - parent.ensure_absent(parent.output_name) - with parent.create_staging(label="dataset") as transaction: - transaction.mkdir(".spatialcf") - plan_root = output.parent / transaction.name / ".spatialcf" / "capture-plan" - capture.publish_capture_plan(expected_plan, plan_root) - transaction.adopt_exact_tree( - ".spatialcf/capture-plan", - regular_paths={"plan.json", "checksums.sha256"}, - ) - transaction.fsync() - seal = transaction.seal() - if capture.load_capture_plan(plan_root) != expected_plan: - raise RuntimeError("dataset capture-plan staging verification changed") - transaction.validate_seal(seal) - transaction.publish() - try: - final = capture.load_capture_plan( - output / ".spatialcf" / "capture-plan" - ) - if final != expected_plan: - raise RuntimeError( - "dataset capture-plan final verification changed" - ) - transaction.validate_location(RenameLocation.OUTPUT) - transaction.validate_seal(seal) - except BaseException: - transaction.rollback() - raise - - -def _existing_names(descriptor: int, *, maximum: int) -> dict[str, os.stat_result]: - return scan_directory(descriptor, maximum_entries=maximum) - - -def _validate_generation_root(root: Path) -> None: - allowed = set(_PUBLIC_FILES) | set(_PUBLIC_DIRECTORIES) - with bound_absolute_directory(root) as descriptor: - entries = _existing_names(descriptor, maximum=len(allowed)) - if ".spatialcf" not in entries or not set(entries) <= allowed: - raise ValueError("dataset root file set is not resumable") - for name, item in entries.items(): - if name in _PUBLIC_FILES: - if not stat.S_ISREG(item.st_mode) or item.st_nlink != 1: - raise ValueError("dataset public metadata must be regular") - elif not stat.S_ISDIR(item.st_mode): - raise ValueError("dataset public child must be a real directory") - with bound_child_directory(descriptor, ".spatialcf") as state_fd: - state = _existing_names(state_fd, maximum=len(_STATE_DIRECTORIES)) - names = set(state) - if "capture-plan" not in names or not names <= set(_STATE_DIRECTORIES): - raise ValueError("dataset resumable stage set is invalid") - if "source-plan" in names and "roster" not in names: - raise ValueError("dataset source plan has no roster stage") - if "batches" in names and "source-plan" not in names: - raise ValueError("dataset batches have no source plan stage") - if any(not stat.S_ISDIR(item.st_mode) for item in state.values()): - raise ValueError("dataset stage root must be a real directory") - revalidate_entries(state_fd, state) - revalidate_entries(descriptor, entries) - - -def _load_or_build_roster( - plan: capture.CapturePlan, - root: Path, - *, - adapter_factory: Callable[..., AI2ThorAdapter], -): - if root.exists(): - summary = capture.verify_roster(root) - else: - summary = capture.capture_and_publish_dataset( - plan, - root, - adapter_factory=adapter_factory, - ) - compilation = capture.load_roster(root) - if compilation.summary != summary or compilation.policy != plan.roster_policy: - raise ValueError("dataset roster stage identity differs from config") - return compilation - - -def _load_or_build_source_plan(compilation, root: Path) -> planning.SourcePlan: - expected_policy = planning.build_default_source_policy(compilation) - if root.exists(): - plan = planning.load_source_plan(root) - else: - planned = planning.plan_source_campaign(compilation, expected_policy) - planning.publish_source_plan(planned, root) - plan = planning.load_source_plan(root) - if ( - plan.source_policy != expected_policy - or plan.roster_manifest != compilation.request_manifest - ): - raise ValueError("dataset source-plan stage identity differs from config") - return plan - - -def _ensure_batches_root(root: Path) -> None: - with bound_absolute_directory(root.parent) as descriptor: - try: - item = os.stat(root.name, dir_fd=descriptor, follow_symlinks=False) - except FileNotFoundError: - os.mkdir(root.name, mode=0o700, dir_fd=descriptor) - sync_directory_fd(descriptor) - item = os.stat(root.name, dir_fd=descriptor, follow_symlinks=False) - if not stat.S_ISDIR(item.st_mode): - raise ValueError("dataset batches stage must be a real directory") - revalidate_entries(descriptor, {root.name: item}) - - -def _run_batches( - plan: planning.SourcePlan, - root: Path, - *, - adapter_factory: Callable[..., AI2ThorAdapter], -) -> execution.SourceExecutionSummary: - _ensure_batches_root(root) - - def execute_current_batch( - manifest, - output, - *, - expected_parent_identity, - request_lineage, - ): - return execution.execute_batch( - manifest, - output, - expected_parent_identity=expected_parent_identity, - request_lineage=request_lineage, - adapter_factory=adapter_factory, - ) - - return execution.run_source_campaign( - plan, - root, - execute=True, - batch_executor=execute_current_batch, - ) - - -def _parse_attempts(payload: bytes) -> tuple[execution.BatchAttempt, ...]: - if not payload: - return () - if not payload.endswith(b"\n"): - raise ValueError("dataset batch outcomes require canonical LF") - attempts = tuple( - execution.BatchAttempt.model_validate_json(line, strict=True) - for line in payload.splitlines() - ) - canonical = b"".join(_canonical_model_bytes(item) + b"\n" for item in attempts) - if canonical != payload: - raise ValueError("dataset batch outcomes are not canonical") - return attempts - - -@dataclass(frozen=True, slots=True) -class _VerifiedAttempt: - attempt: execution.BatchAttempt - bundle: publication.AssetBundle | None - source_root: Path | None - - -def _verified_bundle_at_path(source_root: Path) -> publication.AssetBundle: - loaded = publication.load_asset_bundle(source_root) - return publication.verify_asset_bundle(source_root, loaded.native_audit_run) - - -def _verified_bundle_fd(descriptor: int) -> publication.AssetBundle: - entries = snapshot_exact_directory(descriptor, regular_names=_BUNDLE_FILES) - payload = read_regular_at( - descriptor, - "bundle.json", - _MAX_METADATA_BYTES, - expected_stat=entries["bundle.json"], - ) - bundle = publication.AssetBundle.model_validate_json(payload, strict=True) - if payload != canonical_json_bytes_v2(bundle) + b"\n": - raise ValueError("dataset accepted bundle metadata is not canonical") - checked = publication.verify_asset_bundle_fd( - descriptor, - bundle.native_audit_run, - ) - revalidate_entries(descriptor, entries) - return checked - - -def _path_attempts( - plan: planning.SourcePlan, - batches_root: Path, -) -> dict[str, _VerifiedAttempt]: - attempts: dict[str, _VerifiedAttempt] = {} - for batch in plan.batches: - batch_root = batches_root / batch.batch_id - with bound_absolute_directory(batch_root) as descriptor: - entries = _existing_names(descriptor, maximum=5) - item = entries.get("outcomes.jsonl") - if item is None: - raise ValueError("dataset batch outcomes are absent") - payload = read_regular_at( - descriptor, - "outcomes.jsonl", - _MAX_METADATA_BYTES, - expected_stat=item, - ) - parsed = _parse_attempts(payload) - if tuple(item.request_id for item in parsed) != tuple( - item.request_id for item in batch.requests - ): - raise ValueError("dataset batch outcome membership changed") - for attempt in parsed: - if attempt.request_id in attempts: - raise ValueError("dataset batch request outcome is duplicated") - source_root = ( - batch_root / attempt.case_path - if attempt.outcome == "accepted" and attempt.case_path is not None - else None - ) - bundle = ( - _verified_bundle_at_path(source_root) - if source_root is not None - else None - ) - attempts[attempt.request_id] = _VerifiedAttempt( - attempt=attempt, - bundle=bundle, - source_root=source_root, - ) - revalidate_entries(descriptor, entries) - return attempts - - -def _descriptor_attempts( - plan: planning.SourcePlan, - batch_descriptors: Mapping[str, int], -) -> dict[str, _VerifiedAttempt]: - attempts: dict[str, _VerifiedAttempt] = {} - for batch in plan.batches: - descriptor = batch_descriptors[batch.batch_id] - item = os.stat("outcomes.jsonl", dir_fd=descriptor, follow_symlinks=False) - payload = read_regular_at( - descriptor, - "outcomes.jsonl", - _MAX_METADATA_BYTES, - expected_stat=item, - ) - parsed = _parse_attempts(payload) - if tuple(row.request_id for row in parsed) != tuple( - request.request_id for request in batch.requests - ): - raise ValueError("dataset batch outcome membership changed") - accepted = tuple(row for row in parsed if row.outcome == "accepted") - with bound_child_directory(descriptor, "accepted") as accepted_fd: - accepted_entries = snapshot_exact_directory( - accepted_fd, - regular_names=set(), - directory_names={row.request_id for row in accepted}, - ) - for attempt in parsed: - if attempt.request_id in attempts: - raise ValueError("dataset batch request outcome is duplicated") - bundle = None - if attempt.outcome == "accepted": - with bound_child_directory( - accepted_fd, - attempt.request_id, - ) as case_fd: - bundle = _verified_bundle_fd(case_fd) - attempts[attempt.request_id] = _VerifiedAttempt( - attempt=attempt, - bundle=bundle, - source_root=None, - ) - revalidate_entries(accepted_fd, accepted_entries) - revalidate_entries(descriptor, {"outcomes.jsonl": item}) - return attempts - - -def _terminal_key( - prefix: str, reasons: tuple[str, ...], stage: str | None = None -) -> str: - joined = "|".join(reasons) - return f"{prefix}:{joined}" if stage is None else f"{prefix}:{stage}:{joined}" - - -def _dataset_tree_sha256( - config_sha256: Sha256Digest, - records_sha256: Sha256Digest, - records: tuple[DatasetRecord, ...], -) -> Sha256Digest: - return canonical_sha256_v2( - { - "asset_bundles": tuple( - (item.bundle_path, item.bundle_sha256) for item in records - ), - "config_sha256": config_sha256, - "records_sha256": records_sha256, - }, - domain=_DATASET_TREE_HASH_DOMAIN, - ) - - -def _derive_dataset( - config: GenerationConfig, - compilation, - plan: planning.SourcePlan, - execution_summary: execution.SourceExecutionSummary, - attempts: Mapping[str, _VerifiedAttempt], -): - remaining = dict(attempts) - records: list[DatasetRecord] = [] - bundles: dict[str, tuple[publication.AssetBundle, Path]] = {} - seen_bundle_paths: set[str] = set() - reasons: Counter[str] = Counter() - for outcome in compilation.scene_inventory: - if outcome.status == "rejected": - reasons[_terminal_key("capture", outcome.reasons)] += 1 - planned_count = 0 - execution_rejected = 0 - for outcome in plan.request_outcomes: - if outcome.status == "rejected": - reasons[_terminal_key("planning", outcome.reasons)] += 1 - continue - planned_count += 1 - try: - verified = remaining.pop(outcome.request_id) - except KeyError as error: - raise ValueError( - "dataset planned request has no terminal outcome" - ) from error - attempt = verified.attempt - if attempt.outcome == "rejected": - execution_rejected += 1 - reasons[_terminal_key("execution", attempt.reasons, attempt.stage)] += 1 - continue - if attempt.case_path != f"accepted/{outcome.request_id}": - raise ValueError("dataset accepted outcome path changed") - bundle = verified.bundle - if bundle is None: - raise ValueError("dataset accepted outcome has no verified asset bundle") - if ( - attempt.native_asset_bundle_sha256 != bundle.asset_bundle_sha256 - or bundle.native_audit_run.intervention.subject_id != outcome.subject_id - or bundle.native_audit_run.intervention.reference_id != outcome.reference_id - ): - raise ValueError("dataset accepted asset binding changed") - bundle_path = f"assets/{bundle.asset_bundle_sha256}" - before_assets = tuple( - f"{bundle_path}/{item.relative_path}" - for item in bundle.assets - if item.phase is publication.AssetPhase.BEFORE - ) - after_assets = tuple( - f"{bundle_path}/{item.relative_path}" - for item in bundle.assets - if item.phase is publication.AssetPhase.AFTER - ) - record = DatasetRecord( - request_id=outcome.request_id, - scene_id=outcome.scene_id, - subject_id=outcome.subject_id, - reference_id=outcome.reference_id, - relation_before=outcome.relation_before, - relation_after=outcome.relation_before.opposite, - bundle_path=bundle_path, - bundle_sha256=bundle.asset_bundle_sha256, - before_assets=before_assets, - after_assets=after_assets, - ) - if bundle_path in seen_bundle_paths: - raise ValueError("dataset accepted bundle identity is duplicated") - seen_bundle_paths.add(bundle_path) - if verified.source_root is not None: - bundles[bundle_path] = (bundle, verified.source_root) - records.append(record) - if remaining: - raise ValueError("dataset batch outcomes escape the frozen request roster") - records_tuple = tuple(records) - records_payload = b"".join( - _canonical_model_bytes(item) + b"\n" for item in records_tuple - ) - records_sha256 = hashlib.sha256(records_payload).hexdigest() - config_sha256 = _config_sha256(config) - tree_sha256 = _dataset_tree_sha256( - config_sha256, - records_sha256, - records_tuple, - ) - report = GenerationReport( - source_count=compilation.summary.source_count, - source_capture_rejected_count=compilation.summary.rejected_source_count, - frozen_request_count=len(plan.request_outcomes), - planned_request_count=planned_count, - planning_rejected_request_count=len(plan.request_outcomes) - planned_count, - accepted_request_count=len(records_tuple), - execution_rejected_request_count=execution_rejected, - terminal_reasons=dict(sorted(reasons.items())), - dataset_tree_sha256=tree_sha256, - ) - if ( - execution_summary.endpoint_planned_request_count != planned_count - or execution_summary.accepted_request_count != len(records_tuple) - or execution_summary.native_rejected_request_count != execution_rejected - ): - raise ValueError("dataset execution summary counts changed") - report_payload = _canonical_model_bytes(report) + b"\n" - manifest = DatasetManifest( - config_sha256=config_sha256, - record_count=len(records_tuple), - records_sha256=records_sha256, - report_sha256=hashlib.sha256(report_payload).hexdigest(), - asset_bundle_paths=tuple(item.bundle_path for item in records_tuple), - dataset_tree_sha256=tree_sha256, - ) - return ( - records_tuple, - records_payload, - report, - report_payload, - manifest, - bundles, - ) - - -def _checksum_payload(payloads: Mapping[str, bytes]) -> bytes: - return b"".join( - f"{hashlib.sha256(payload).hexdigest()} {name}\n".encode("ascii") - for name, payload in sorted(payloads.items()) - ) - - -def _stage_public_index( - transaction, - records_payload: bytes, - report_payload: bytes, - manifest: DatasetManifest, - bundles: Mapping[str, tuple[publication.AssetBundle, Path]], -) -> dict[str, bytes]: - transaction.mkdir("assets") - payloads: dict[str, bytes] = {} - for bundle_path, (bundle, source_root) in bundles.items(): - transaction.mkdir(bundle_path) - with bound_absolute_directory(source_root) as descriptor: - entries = snapshot_exact_directory( - descriptor, - regular_names=_BUNDLE_FILES, - ) - for name in sorted(_BUNDLE_FILES): - payload = read_regular_at( - descriptor, - name, - _MAX_ASSET_BYTES, - expected_stat=entries[name], - ) - relative = f"{bundle_path}/{name}" - transaction.write(relative, payload) - payloads[relative] = payload - revalidate_entries(descriptor, entries) - with ( - bound_child_directory(transaction.descriptor, "assets") as assets_fd, - bound_child_directory(assets_fd, bundle.asset_bundle_sha256) as bundle_fd, - ): - if ( - publication.verify_asset_bundle_fd( - bundle_fd, - bundle.native_audit_run, - ) - != bundle - ): - raise RuntimeError("dataset staged asset verification changed") - metadata = { - "manifest.json": _canonical_model_bytes(manifest) + b"\n", - "records.jsonl": records_payload, - "report.json": report_payload, - } - for name, payload in metadata.items(): - transaction.write(name, payload) - payloads[name] = payload - checksum = _checksum_payload(payloads) - transaction.write("checksums.sha256", checksum) - payloads["checksums.sha256"] = checksum - return payloads - - -def _read_or_write_exact( - descriptor: int, - name: str, - payload: bytes, - created: dict[Path, tuple[int, int]], - relative: Path, -) -> None: - try: - item = os.stat(name, dir_fd=descriptor, follow_symlinks=False) - except FileNotFoundError: - written = write_regular_sync_at(descriptor, name, payload) - created[relative] = (written.st_dev, written.st_ino) - return - observed = read_regular_at( - descriptor, - name, - max(1, len(payload)), - expected_stat=item, - ) - if observed != payload: - raise FileExistsError(f"dataset public entry differs: {relative.as_posix()}") - - -def _ensure_exact_directory( - descriptor: int, - name: str, - created: dict[Path, tuple[int, int]], - directories: dict[Path, tuple[int, int]], - relative: Path, -) -> None: - try: - item = os.stat(name, dir_fd=descriptor, follow_symlinks=False) - except FileNotFoundError: - os.mkdir(name, mode=0o700, dir_fd=descriptor) - sync_directory_fd(descriptor) - item = os.stat(name, dir_fd=descriptor, follow_symlinks=False) - created[relative] = (item.st_dev, item.st_ino) - if not stat.S_ISDIR(item.st_mode): - raise ValueError(f"dataset public directory is unsafe: {relative.as_posix()}") - identity = (item.st_dev, item.st_ino) - previous = directories.setdefault(relative, identity) - if previous != identity: - raise RuntimeError("dataset public directory identity changed") - - -def _copy_staged_public_index( - root_descriptor: int, - transaction, - payloads: Mapping[str, bytes], - created: dict[Path, tuple[int, int]], - directories: dict[Path, tuple[int, int]], -) -> None: - _ensure_exact_directory( - root_descriptor, - "assets", - created, - directories, - Path("assets"), - ) - with ( - bound_child_directory(root_descriptor, "assets") as final_assets_fd, - bound_child_directory(transaction.descriptor, "assets") as staged_fd, - ): - staged_assets = _existing_names( - staged_fd, - maximum=max(1, len(payloads)), - ) - for digest in sorted(staged_assets): - _ensure_exact_directory( - final_assets_fd, - digest, - created, - directories, - Path("assets") / digest, - ) - with ( - bound_child_directory(staged_fd, digest) as source_fd, - bound_child_directory(final_assets_fd, digest) as target_fd, - ): - source_entries = snapshot_exact_directory( - source_fd, - regular_names=_BUNDLE_FILES, - ) - for name in sorted(_BUNDLE_FILES): - payload = read_regular_at( - source_fd, - name, - _MAX_ASSET_BYTES, - expected_stat=source_entries[name], - ) - _read_or_write_exact( - target_fd, - name, - payload, - created, - Path("assets") / digest / name, - ) - revalidate_entries(source_fd, source_entries) - for name in ("records.jsonl", "report.json", "checksums.sha256"): - _read_or_write_exact( - root_descriptor, - name, - payloads[name], - created, - Path(name), - ) - _read_or_write_exact( - root_descriptor, - "manifest.json", - payloads["manifest.json"], - created, - Path("manifest.json"), - ) - sync_directory_fd(root_descriptor) - - -@contextmanager -def _bound_rollback_parent( - root_descriptor: int, - relative: Path, - directories: Mapping[Path, tuple[int, int]], -) -> Iterator[int]: - with ExitStack() as stack: - descriptor = root_descriptor - opened: list[tuple[int, str, tuple[int, int]]] = [] - prefix = Path() - for component in relative.parts: - prefix /= component - expected = directories.get(prefix) - if expected is None: - raise RuntimeError("dataset rollback parent ownership is unknown") - before = os.stat(component, dir_fd=descriptor, follow_symlinks=False) - if ( - not stat.S_ISDIR(before.st_mode) - or ( - before.st_dev, - before.st_ino, - ) - != expected - ): - raise RuntimeError("dataset rollback parent identity changed") - parent_descriptor = descriptor - descriptor = stack.enter_context( - open_directory(component, dir_fd=parent_descriptor) - ) - if directory_identity_fd(descriptor) != expected: - raise RuntimeError("dataset rollback parent binding changed") - opened.append((parent_descriptor, component, expected)) - yield descriptor - for parent_descriptor, component, expected in reversed(opened): - current = os.stat( - component, - dir_fd=parent_descriptor, - follow_symlinks=False, - ) - if ( - not stat.S_ISDIR(current.st_mode) - or ( - current.st_dev, - current.st_ino, - ) - != expected - ): - raise RuntimeError("dataset rollback parent binding changed") - - -def _rollback_created_fd( - root_descriptor: int, - created: Mapping[Path, tuple[int, int]], - directories: Mapping[Path, tuple[int, int]], -) -> None: - errors: list[BaseException] = [] - for relative, identity in sorted( - created.items(), key=lambda item: len(item[0].parts), reverse=True - ): - try: - with _bound_rollback_parent( - root_descriptor, - relative.parent, - directories, - ) as parent_fd: - item = os.stat( - relative.name, - dir_fd=parent_fd, - follow_symlinks=False, - ) - if (item.st_dev, item.st_ino) != identity: - raise RuntimeError("dataset rollback entry identity changed") - if stat.S_ISDIR(item.st_mode): - os.rmdir(relative.name, dir_fd=parent_fd) - elif stat.S_ISREG(item.st_mode): - os.unlink(relative.name, dir_fd=parent_fd) - else: - raise RuntimeError("dataset rollback entry type changed") - sync_directory_fd(parent_fd) - except BaseException as error: # noqa: BLE001 - errors.append(error) - if errors: - raise RuntimeError("dataset public index rollback was incomplete") from errors[ - 0 - ] - - -def _parse_records(payload: bytes) -> tuple[DatasetRecord, ...]: - if payload and not payload.endswith(b"\n"): - raise ValueError("dataset records require canonical LF") - records = tuple( - DatasetRecord.model_validate_json(line, strict=True) - for line in payload.splitlines() - ) - if len({item.request_id for item in records}) != len(records): - raise ValueError("dataset record request IDs are duplicated") - canonical = b"".join(_canonical_model_bytes(item) + b"\n" for item in records) - if canonical != payload: - raise ValueError("dataset records are not canonical") - return records - - -def _parse_checksum_ledger(payload: bytes) -> dict[str, str]: - if payload and not payload.endswith(b"\n"): - raise ValueError("dataset checksum ledger requires canonical LF") - ledger: dict[str, str] = {} - for line in payload.splitlines(): - try: - digest, encoded_name = line.split(b" ", 1) - name = encoded_name.decode("ascii") - digest_text = digest.decode("ascii") - except (UnicodeDecodeError, ValueError) as error: - raise ValueError("dataset checksum ledger row is malformed") from error - _safe_relative_path(name) - if ( - len(digest_text) != 64 - or any(item not in "0123456789abcdef" for item in digest_text) - or name in ledger - ): - raise ValueError("dataset checksum ledger row is invalid") - ledger[name] = digest_text - if tuple(ledger) != tuple(sorted(ledger)): - raise ValueError("dataset checksum ledger is not canonical") - return ledger - - -def _verify_public_index_fd(descriptor: int, *, with_state: bool): - directories = {"assets"} | ({".spatialcf"} if with_state else set()) - entries = snapshot_exact_directory( - descriptor, - regular_names=_PUBLIC_FILES, - directory_names=directories, - ) - metadata = { - name: read_regular_at( - descriptor, - name, - _MAX_METADATA_BYTES, - expected_stat=entries[name], - ) - for name in _PUBLIC_FILES - } - manifest = DatasetManifest.model_validate_json( - metadata["manifest.json"], strict=True - ) - if metadata["manifest.json"] != _canonical_model_bytes(manifest) + b"\n": - raise ValueError("dataset manifest is not canonical") - report = GenerationReport.model_validate_json(metadata["report.json"], strict=True) - if metadata["report.json"] != _canonical_model_bytes(report) + b"\n": - raise ValueError("dataset report is not canonical") - records = _parse_records(metadata["records.jsonl"]) - if ( - manifest.record_count != len(records) - or manifest.records_sha256 - != hashlib.sha256(metadata["records.jsonl"]).hexdigest() - or manifest.report_sha256 != hashlib.sha256(metadata["report.json"]).hexdigest() - or manifest.asset_bundle_paths != tuple(item.bundle_path for item in records) - or report.accepted_request_count != len(records) - ): - raise ValueError("dataset record/report manifest closure changed") - public_payloads = { - name: payload - for name, payload in metadata.items() - if name != "checksums.sha256" - } - record_by_path = {item.bundle_path: item for item in records} - with bound_child_directory(descriptor, "assets") as assets_fd: - asset_entries = snapshot_exact_directory( - assets_fd, - regular_names=set(), - directory_names={PurePosixPath(item).name for item in record_by_path}, - ) - for bundle_path, record in record_by_path.items(): - digest = PurePosixPath(bundle_path).name - with bound_child_directory(assets_fd, digest) as bundle_fd: - bundle_entries = snapshot_exact_directory( - bundle_fd, - regular_names=_BUNDLE_FILES, - ) - bundle_payload = read_regular_at( - bundle_fd, - "bundle.json", - _MAX_METADATA_BYTES, - expected_stat=bundle_entries["bundle.json"], - ) - bundle = publication.AssetBundle.model_validate_json( - bundle_payload, strict=True - ) - checked = publication.verify_asset_bundle_fd( - bundle_fd, - bundle.native_audit_run, - ) - before = tuple( - f"{bundle_path}/{item.relative_path}" - for item in checked.assets - if item.phase is publication.AssetPhase.BEFORE - ) - after = tuple( - f"{bundle_path}/{item.relative_path}" - for item in checked.assets - if item.phase is publication.AssetPhase.AFTER - ) - if ( - checked.asset_bundle_sha256 != record.bundle_sha256 - or record.bundle_sha256 != digest - or record.before_assets != before - or record.after_assets != after - ): - raise ValueError("dataset public asset binding changed") - for name in sorted(_BUNDLE_FILES): - public_payloads[f"{bundle_path}/{name}"] = read_regular_at( - bundle_fd, - name, - _MAX_ASSET_BYTES, - expected_stat=bundle_entries[name], - ) - revalidate_entries(bundle_fd, bundle_entries) - revalidate_entries(assets_fd, asset_entries) - ledger = _parse_checksum_ledger(metadata["checksums.sha256"]) - expected_ledger = { - name: hashlib.sha256(payload).hexdigest() - for name, payload in public_payloads.items() - } - if ledger != dict(sorted(expected_ledger.items())): - raise ValueError("dataset checksum ledger mismatch") - expected_tree = _dataset_tree_sha256( - manifest.config_sha256, - manifest.records_sha256, - records, - ) - if ( - manifest.dataset_tree_sha256 != expected_tree - or report.dataset_tree_sha256 != expected_tree - ): - raise ValueError("dataset tree digest changed") - revalidate_entries(descriptor, entries) - return manifest, report, records - - -@dataclass(frozen=True, slots=True) -class _RetainedBatchState: - batch_id: str - descriptor: int - verification: execution.RetainedSourceBatchVerification - summary: execution.BatchSummary - - -@dataclass(frozen=True, slots=True) -class _RetainedCampaignState: - entries: Mapping[str, os.stat_result] - batches: tuple[_RetainedBatchState, ...] - - -def _verify_source_campaign_fd( - plan: planning.SourcePlan, - descriptor: int, - stack: ExitStack, -) -> tuple[ - execution.SourceExecutionSummary, - dict[str, _VerifiedAttempt], - _RetainedCampaignState, -]: - entries = snapshot_exact_directory( - descriptor, - regular_names=set(), - directory_names={batch.batch_id for batch in plan.batches}, - ) - retained_batches: list[_RetainedBatchState] = [] - batch_descriptors: dict[str, int] = {} - summaries: dict[str, execution.BatchSummary] = {} - for batch in plan.batches: - batch_fd = stack.enter_context( - bound_child_directory(descriptor, batch.batch_id) - ) - retained = execution.prepare_source_batch_verification( - plan, - batch, - batch_fd, - ) - summary = retained.summary - batch_descriptors[batch.batch_id] = batch_fd - summaries[batch.batch_id] = summary - retained_batches.append( - _RetainedBatchState( - batch_id=batch.batch_id, - descriptor=batch_fd, - verification=retained, - summary=summary, - ) - ) - attempts = _descriptor_attempts(plan, batch_descriptors) - summary = execution.summarize_verified_source_campaign(plan, summaries) - return ( - summary, - attempts, - _RetainedCampaignState( - entries=entries, - batches=tuple(retained_batches), - ), - ) - - -def _revalidate_source_campaign_fd( - descriptor: int, - plan: planning.SourcePlan, - retained: _RetainedCampaignState, -) -> None: - for state in retained.batches: - summary = execution.revalidate_source_batch_verification( - state.descriptor, - state.verification, - ) - if summary != state.summary: - raise ValueError("dataset retained batch summary changed") - revalidate_entries(descriptor, retained.entries) - - -def _publication_root_binding( - parent, - root_name: str, - root_identity: tuple[int, int], -) -> bool | None: - try: - parent.validate() - except BaseException: # noqa: BLE001 - return None - try: - current = os.stat( - root_name, - dir_fd=parent.parent_descriptor, - follow_symlinks=False, - ) - except FileNotFoundError: - return False - except BaseException: # noqa: BLE001 - return None - if ( - stat.S_ISDIR(current.st_mode) - and ( - current.st_dev, - current.st_ino, - ) - == root_identity - ): - return True - return None - - -def _raise_publication_failure( - root: Path, - parent, - root_descriptor: int, - root_identity: tuple[int, int], - created: Mapping[Path, tuple[int, int]], - directories: Mapping[Path, tuple[int, int]], - active_error: BaseException, -) -> None: - rollback_error: BaseException | None = None - try: - _rollback_created_fd(root_descriptor, created, directories) - except BaseException as error: # noqa: BLE001 - rollback_error = error - active_error.add_note(str(error)) - binding = _publication_root_binding(parent, root.name, root_identity) - if rollback_error is None and binding is True: - raise active_error - if rollback_error is None and binding is False: - raise CompetitionNativePublicationError( - root, - published=False, - recovery_name=None, - detail="dataset root moved during public index publication and was rolled back", - ) from active_error - detail = ( - "dataset public index rollback could not prove complete cleanup" - if rollback_error is not None - else "dataset root binding became foreign during public index publication" - ) - raise CompetitionNativePublicationError( - root, - published=None, - recovery_name=None, - detail=detail, - ) from active_error - - -def _publish_dataset_index( - root: Path, - records_payload: bytes, - report_payload: bytes, - manifest: DatasetManifest, - bundles: Mapping[str, tuple[publication.AssetBundle, Path]], -) -> GenerationReport: - stage_target = root.parent / f"{root.name}-dataset-index" - created: dict[Path, tuple[int, int]] = {} - directories: dict[Path, tuple[int, int]] = {} - with ( - open_native_output_parent(stage_target) as parent, - parent.create_staging(label="dataset-index") as transaction, - ): - payloads = _stage_public_index( - transaction, - records_payload, - report_payload, - manifest, - bundles, - ) - transaction.fsync() - seal = transaction.seal() - staged_manifest, staged_report, _ = _verify_public_index_fd( - transaction.descriptor, - with_state=False, - ) - if staged_manifest != manifest: - raise RuntimeError("dataset index staging verification changed") - transaction.validate_seal(seal) - parent.validate() - root_entry = os.stat( - root.name, - dir_fd=parent.parent_descriptor, - follow_symlinks=False, - ) - if not stat.S_ISDIR(root_entry.st_mode): - raise ValueError("dataset publication root must be a real directory") - with open_directory( - root.name, - dir_fd=parent.parent_descriptor, - ) as root_descriptor: - root_identity = directory_identity_fd(root_descriptor) - if root_identity != (root_entry.st_dev, root_entry.st_ino): - raise RuntimeError("dataset publication root binding changed") - try: - _copy_staged_public_index( - root_descriptor, - transaction, - payloads, - created, - directories, - ) - final_report, _ = _verify_dataset_fd(root_descriptor) - if final_report != staged_report: - raise RuntimeError("dataset index final verification changed") - transaction.validate_seal(seal) - if ( - _publication_root_binding(parent, root.name, root_identity) - is not True - ): - raise RuntimeError("dataset publication root binding changed") - return final_report - except BaseException as error: # noqa: BLE001 - _raise_publication_failure( - root, - parent, - root_descriptor, - root_identity, - created, - directories, - error, - ) - raise AssertionError("unreachable") - - -def _config_from_capture_plan(plan: capture.CapturePlan) -> GenerationConfig: - scene_names: list[str] = [] - for locator in plan.source_locators: - if locator.kind != "legacy-ai2thor": - raise ValueError("public dataset contains a non-AI2-THOR legacy source") - scene_names.append(locator.scene_name) - return GenerationConfig( - adapter="ai2thor", - scene_names=tuple(scene_names), - split=plan.assigned_split, - campaign_id=plan.roster_policy.campaign_id, - seed=plan.roster_policy.seed, - width=plan.roster_policy.width, - height=plan.roster_policy.height, - max_requests=plan.roster_policy.max_requests_total, - ) - - -def _verify_dataset_fd( - descriptor: int, -) -> tuple[GenerationReport, tuple[DatasetRecord, ...]]: - root_entries = snapshot_exact_directory( - descriptor, - regular_names=_PUBLIC_FILES, - directory_names=_PUBLIC_DIRECTORIES, - ) - with ExitStack() as stack: - state_fd = stack.enter_context(bound_child_directory(descriptor, ".spatialcf")) - state_entries = snapshot_exact_directory( - state_fd, - regular_names=set(), - directory_names=set(_STATE_DIRECTORIES), - ) - stage_descriptors = { - name: stack.enter_context(bound_child_directory(state_fd, name)) - for name in _STATE_DIRECTORIES - } - for name, stage_fd in stage_descriptors.items(): - expected = state_entries[name] - if directory_identity_fd(stage_fd) != (expected.st_dev, expected.st_ino): - raise RuntimeError("dataset retained stage identity changed") - - manifest, report, records = _verify_public_index_fd( - descriptor, - with_state=True, - ) - - capture_fd = stage_descriptors["capture-plan"] - capture_verification = capture.prepare_capture_plan_verification(capture_fd) - capture_plan = capture_verification.plan - config = _config_from_capture_plan(capture_plan) - if _capture_plan(config) != capture_plan: - raise ValueError("dataset capture plan no longer matches its config") - - roster_fd = stage_descriptors["roster"] - roster_verification = capture.prepare_roster_verification(roster_fd) - compilation = roster_verification.compilation - - source_plan_fd = stage_descriptors["source-plan"] - source_plan_verification = planning.prepare_source_plan_verification( - source_plan_fd - ) - source_plan = source_plan_verification.plan - expected_policy = planning.build_default_source_policy(compilation) - if ( - source_plan.source_policy != expected_policy - or source_plan.roster_manifest != compilation.request_manifest - ): - raise ValueError("dataset source plan binding changed") - - batches_fd = stage_descriptors["batches"] - execution_summary, attempts, retained_campaign = _verify_source_campaign_fd( - source_plan, - batches_fd, - stack, - ) - ( - expected_records, - expected_records_payload, - expected_report, - expected_report_payload, - expected_manifest, - _bundles, - ) = _derive_dataset( - config, - compilation, - source_plan, - execution_summary, - attempts, - ) - if ( - records != expected_records - or manifest != expected_manifest - or report != expected_report - or hashlib.sha256(expected_records_payload).hexdigest() - != manifest.records_sha256 - or hashlib.sha256(expected_report_payload).hexdigest() - != manifest.report_sha256 - ): - raise ValueError("dataset public index differs from verified stages") - - final_manifest, final_report, final_records = _verify_public_index_fd( - descriptor, - with_state=True, - ) - if ( - final_manifest != manifest - or final_report != report - or final_records != records - ): - raise ValueError("dataset public index changed during verification") - - _revalidate_source_campaign_fd( - batches_fd, - source_plan, - retained_campaign, - ) - if ( - capture.revalidate_capture_plan_verification( - capture_fd, - capture_verification, - ) - != capture_plan - or capture.revalidate_roster_verification( - roster_fd, - roster_verification, - ) - != compilation - or planning.revalidate_source_plan_verification( - source_plan_fd, - source_plan_verification, - ) - != source_plan - ): - raise ValueError("dataset retained stage semantics changed") - for name, stage_fd in stage_descriptors.items(): - expected = state_entries[name] - if directory_identity_fd(stage_fd) != (expected.st_dev, expected.st_ino): - raise RuntimeError("dataset retained stage identity changed") - revalidate_entries(state_fd, state_entries) - revalidate_entries(descriptor, root_entries) - return expected_report, expected_records +from spatialcf.generation.workflows import dataset as dataset_workflow def generate_dataset( @@ -1449,96 +157,47 @@ def generate_dataset( *, adapter_factory: Callable[..., AI2ThorAdapter] = AI2ThorAdapter, ) -> GenerationReport: - """Generate or resume one immutable dataset and publish its stable index.""" - - checked = _checked_config(config) - root = _absolute_output(output) - expected_capture = _capture_plan(checked) - if not root.exists(): - _initialize_dataset_root(root, expected_capture) - _validate_generation_root(root) - if (root / "manifest.json").exists(): - raise FileExistsError(root) - loaded_capture = capture.load_capture_plan(root / ".spatialcf" / "capture-plan") - if loaded_capture != expected_capture: - raise ValueError("dataset capture plan identity differs from config") - roster = _load_or_build_roster( - loaded_capture, - root / ".spatialcf" / "roster", - adapter_factory=adapter_factory, - ) - source_plan = _load_or_build_source_plan( - roster, - root / ".spatialcf" / "source-plan", - ) - execution_summary = _run_batches( - source_plan, - root / ".spatialcf" / "batches", - adapter_factory=adapter_factory, - ) - attempts = _path_attempts( - source_plan, - root / ".spatialcf" / "batches", - ) - ( - _records, - records_payload, - _report, - report_payload, - manifest, - bundles, - ) = _derive_dataset( - checked, - roster, - source_plan, - execution_summary, - attempts, - ) - return _publish_dataset_index( - root, - records_payload, - report_payload, - manifest, - bundles, + return dataset_workflow.generate_dataset( + config, output, adapter_factory=adapter_factory ) def verify_dataset(root: Path) -> GenerationReport: """Freshly verify public metadata, assets, stages, and all count closure.""" - absolute = _absolute_output(root) - with bound_absolute_directory(absolute) as descriptor: - report, _ = _verify_dataset_fd(descriptor) + absolute = dataset_workflow._absolute_output(root) + with dataset_workflow.bound_absolute_directory(absolute) as descriptor: + report, _ = dataset_workflow._verify_dataset_fd(descriptor) return report def read_dataset_records(root: Path) -> tuple[DatasetRecord, ...]: """Read the exact canonical JSONL roster under a retained root descriptor.""" - absolute = _absolute_output(root) - with bound_absolute_directory(absolute) as descriptor: - entries = snapshot_exact_directory( + absolute = dataset_workflow._absolute_output(root) + with dataset_workflow.bound_absolute_directory(absolute) as descriptor: + entries = dataset_workflow.snapshot_exact_directory( descriptor, - regular_names=_PUBLIC_FILES, - directory_names=_PUBLIC_DIRECTORIES, + regular_names=dataset_workflow._PUBLIC_FILES, + directory_names=dataset_workflow._PUBLIC_DIRECTORIES, ) - payload = read_regular_at( + payload = dataset_workflow.read_regular_at( descriptor, "records.jsonl", - _MAX_METADATA_BYTES, + dataset_workflow._MAX_METADATA_BYTES, expected_stat=entries["records.jsonl"], ) - records = _parse_records(payload) - revalidate_entries(descriptor, entries) + records = dataset_workflow._parse_records(payload) + dataset_workflow.revalidate_entries(descriptor, entries) return records def inspect_dataset(root: Path) -> dict[str, object]: """Return a compact summary only after full fresh verification.""" - absolute = _absolute_output(root) - with bound_absolute_directory(absolute) as descriptor: - report, records = _verify_dataset_fd(descriptor) + absolute = dataset_workflow._absolute_output(root) + with dataset_workflow.bound_absolute_directory(absolute) as descriptor: + report, records = dataset_workflow._verify_dataset_fd(descriptor) relation_counts = Counter(item.relation_after.value for item in records) return { "dataset_tree_sha256": report.dataset_tree_sha256, diff --git a/src/spatialcf/generation/execution.py b/src/spatialcf/generation/execution/__init__.py similarity index 51% rename from src/spatialcf/generation/execution.py rename to src/spatialcf/generation/execution/__init__.py index 2504a67..cc2c654 100644 --- a/src/spatialcf/generation/execution.py +++ b/src/spatialcf/generation/execution/__init__.py @@ -1,63 +1,37 @@ -"""Current native-execution authority aliases and operations.""" +"""Final public execution package.""" -from __future__ import annotations - -from spatialcf.adapters.ai2thor import AI2ThorAdapter -from spatialcf.domain.models import Scene -from spatialcf.generation._internal.execution import ( +from spatialcf.adapters.base import EnvironmentAdapter as AI2ThorAdapter # noqa: F401 +from spatialcf.generation.execution.audit import ( AuditExecution, AuditRun, EndpointAudit, - RequestLineage, -) -from spatialcf.generation._internal.execution import ( - execute_audit as _execute_current_audit, + execute_audit, + verify_audit_run, ) -from spatialcf.generation._internal.execution import ( - verify_audit_run as _verify_current_audit_run, +from spatialcf.generation.execution.batch import execute_batch +from spatialcf.generation.execution.campaign import ( + RetainedSourceBatchVerification, + SourceExecutionSummary, + prepare_source_batch_verification, + revalidate_source_batch_verification, + run_planned_requests, + run_source_campaign, + summarize_verified_source_campaign, + verify_source_campaign, ) -from spatialcf.generation._internal.execution.batch import ( +from spatialcf.generation.execution.correspondence import RequestLineage +from spatialcf.generation.planning.campaign import BatchManifest, BatchRequest +from spatialcf.generation.planning.models import EndpointPlan, ProxyBundle +from spatialcf.generation.workflows.execution import ( BatchAttempt, - BatchManifest, BatchStageCount, BatchSummary, RetainedBatchVerification, - execute_batch, load_batch_manifest, prepare_batch_verification, revalidate_batch_verification, verify_batch, ) -from spatialcf.generation._internal.execution.campaign import ( - RetainedSourceBatchVerification, - SourceExecutionSummary, - prepare_source_batch_verification, - revalidate_source_batch_verification, - run_planned_requests, - run_source_campaign, - summarize_verified_source_campaign, - verify_source_campaign, -) -from spatialcf.generation._internal.planning.campaign import BatchRequest -from spatialcf.generation._internal.planning.models import EndpointPlan, ProxyBundle - - -def execute_audit( - adapter: AI2ThorAdapter, - request: BatchRequest, - *, - request_lineage: RequestLineage, -) -> AuditExecution: - return _execute_current_audit( - adapter, - request, - request_lineage=request_lineage, - ) - - -def verify_audit_run(run: AuditRun, observed_after_scene: Scene) -> AuditRun: - return _verify_current_audit_run(run, observed_after_scene) - __all__ = ( "AuditExecution", diff --git a/src/spatialcf/generation/_internal/execution/run.py b/src/spatialcf/generation/execution/audit.py similarity index 50% rename from src/spatialcf/generation/_internal/execution/run.py rename to src/spatialcf/generation/execution/audit.py index b9c93da..0aee97f 100644 --- a/src/spatialcf/generation/_internal/execution/run.py +++ b/src/spatialcf/generation/execution/audit.py @@ -1,7 +1,8 @@ -"""Current-only native audit orchestration and replay verification.""" +"""Final one-shot endpoint audit and execution authority.""" from __future__ import annotations +import math import warnings from dataclasses import asdict, dataclass from math import dist @@ -9,93 +10,941 @@ from pydantic import Field, model_validator -from spatialcf.adapters.ai2thor import ( - AI2ThorAdapter, - AI2ThorAgentPose, - AI2ThorNativePosition, - AI2ThorNativeReturnError, - AI2ThorObservation, - AI2ThorSettlementTimeout, - capture_bound_ai2thor_receptacle_spawn_map, +from spatialcf.adapters.base import ( + AdapterActionRejected, + AdapterObservation, + AdapterOperationError, + AdapterPose, + AdapterPosition, + AdapterReturnRejected, + AdapterSettlementTimeout, + AdapterSpawnMap, + CapturedSource, + CaptureRequest, + CertifiedEditApplication, + EnvironmentAdapter, + SettledReadback, + SourceCaptureFacts, + SourceCaptureOptions, + capture_bound_adapter_spawn_map, ) -from spatialcf.core.v2.continuous_yaw_solve_verifier_v2_9 import ( - verify_continuous_yaw_solve_result_v2_9, +from spatialcf.adapters.base import AdapterObservation as AI2ThorObservation +from spatialcf.adapters.base import EnvironmentAdapter as AI2ThorAdapter +from spatialcf.core.solver import solve_minimum_cost +from spatialcf.core.verification import ( + verify_solve_result, ) -from spatialcf.core.v2.continuous_yaw_solver_v2_9 import ( - solve_continuous_yaw_minimum_cost_v2_9, +from spatialcf.domain.base import ( + CanonicalId, + CanonicalModel, + FiniteFloat, + NonNegativeFiniteFloat, + Sha256Digest, + Vec3, ) -from spatialcf.domain.enums import QualityTier, SolverStatus -from spatialcf.domain.models import InterventionSpec, Scene -from spatialcf.domain.v2.base import CanonicalId, Sha256Digest, V2Model -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.request import InterventionSpec, QualityTier, SolverStatus +from spatialcf.domain.scene import OBB, Camera, Scene +from spatialcf.domain.serialization import canonical_sha256 +from spatialcf.domain.solver import ( ContinuousYawCertifiedSuccessResultV2_9, ContinuousYawSolverConfigV2_9, ContinuousYawSolveVerificationKindV2, ) -from spatialcf.domain.v2.serialization import canonical_sha256_v2 -from spatialcf.generation._internal.evidence.camera import ( - CameraPolicy, - SourceCameraEvidence, - build_competition_native_settled_camera_policy_v2_9_10, - verify_competition_native_camera_observation_binding_v2_9_3, -) -from spatialcf.generation._internal.evidence.camera import ( - CompetitionNativeCameraPoseV2_9_3 as CameraPose, +from spatialcf.generation.capture.compiler import ( + score_competition_native_camera_capture_scene_v2_9_3, ) -from spatialcf.generation._internal.evidence.surface import ( +from spatialcf.generation.capture.models import ( _SUBJECT_EVIDENCE_HASH_DOMAIN, + CameraPolicy, + CompetitionNativeRuntimeIdentityV2_9, + CompetitionNativeSourceCaptureV2_9, + SourceCameraEvidence, SourceSurfaceEvidence, SubjectSurfaceEvidence, _build_patch, _subject_payload, + build_competition_native_settled_camera_policy_v2_9_10, + verify_competition_native_camera_observation_binding_v2_9_3, verify_source_surface_evidence, ) -from spatialcf.generation._internal.execution.audit import ( - EndpointAudit, - EndpointAuditRejected, - _native_action_rejection, - _native_return_rejection, - _normalized_runtime_pose_observed_scene_v2_9_5, - _obb_corner_hausdorff_residual_m, - _project_runtime_pose_observed_scene_to_frozen_source_v2_9_5, - _quaternion_angle_residual_deg, - _verify_minimum_cost_with_proxy_collision_authority, - execute_endpoint, - observation_sha256, +from spatialcf.generation.capture.models import ( + CompetitionNativeCameraPoseV2_9_3 as CameraPose, ) -from spatialcf.generation._internal.execution.correspondence import ( +from spatialcf.generation.execution.correspondence import ( CaptureSourceCorrespondence, RequestLineage, legacy_sha256, request_binding_sha256, validate_capture_source, ) -from spatialcf.generation._internal.planning.campaign import ( - BatchRequest, - RuntimePosePolicy, -) -from spatialcf.generation._internal.planning.models import ( +from spatialcf.generation.planning.campaign import BatchRequest, RuntimePosePolicy +from spatialcf.generation.planning.models import ( CollisionDelegation, EndpointWorkspace, ProxyBundle, SubjectPlacementFact, ) -from spatialcf.generation._internal.planning.proxy import build_proxy_bundle -from spatialcf.generation.capture.compiler import ( - score_competition_native_camera_capture_scene_v2_9_3, -) -from spatialcf.generation.capture.models import ( - CompetitionNativeRuntimeIdentityV2_9, - CompetitionNativeSourceCaptureV2_9, +from spatialcf.generation.planning.problem import build_proxy_bundle +from spatialcf.generation.workflows import contracts as workflow_contracts +from spatialcf.verification.integrity import ( + competition_native_observation_payload_sha256, ) - +from spatialcf.verification.verifier import VerificationResult, Verifier + +_AUDIT_HASH_DOMAIN = "spatialcf.competition-native-endpoint-audit.v2.9.6" +_MAX_POSITION_RESIDUAL_M = 1e-5 +_MAX_RUNTIME_POSITION_RESIDUAL_M = 1e-4 +_OBJECT_GEOMETRY_TOLERANCE_M = 1e-5 +_CAMERA_INTRINSIC_TOLERANCE = 1e-8 +_CAMERA_EXTRINSIC_TOLERANCE = 1e-5 _RUN_HASH_DOMAIN = "spatialcf.competition-native-audit-run.v2.9.7" _EXECUTION_HASH_DOMAIN = "spatialcf.competition-native-audit-execution.v2.9.6" _CAMERA_RUNTIME_HASH_DOMAIN = "spatialcf.competition-native-runtime-identity.v2.9.2" _MAX_SETTLEMENT_STEPS = 600 -class AuditRun(V2Model): +def observation_sha256(observation: AdapterObservation) -> str: + if type(observation) is not AdapterObservation: + raise TypeError("adapter observation digest requires an exact observation") + return competition_native_observation_payload_sha256( + scene=observation.scene, + rgb_png=observation.rgb_png, + depth_npy=observation.depth_npy, + instance_png=observation.instance_png, + pointcloud_ply=observation.pointcloud_ply, + instance_pixel_counts=dict(observation.instance_pixel_counts), + is_scene_at_rest=observation.is_settled, + ) + + +class EndpointAudit(CanonicalModel): + """Flat current bbox/runtime-pose endpoint audit.""" + + audit_version: Literal["competition-native-endpoint-audit:2.9.6"] = ( + "competition-native-endpoint-audit:2.9.6" + ) + audit_scope: Literal[ + "ONE_CAMERA_REPLAY_ONE_DELEGATED_BBOX_PATCH_SOLVE_ONE_FRESH_PATCH_" + "ONE_NATIVE_ACTION_ONE_BOUNDED_SUBJECT_POSE" + ] = ( + "ONE_CAMERA_REPLAY_ONE_DELEGATED_BBOX_PATCH_SOLVE_ONE_FRESH_PATCH_" + "ONE_NATIVE_ACTION_ONE_BOUNDED_SUBJECT_POSE" + ) + native_action: Literal["PlaceObjectAtPoint"] = "PlaceObjectAtPoint" + native_audit_status: Literal["PASSED"] = "PASSED" + evidence_eligible: Literal[False] = False + case_id: CanonicalId + native_scene_id: CanonicalId + subject_native_object_id: CanonicalId + reference_native_object_id: CanonicalId + semantic_problem_sha256: Sha256Digest + solver_config_sha256: Sha256Digest + solve_result_sha256: Sha256Digest + edit_sha256: Sha256Digest + proxy_bundle_sha256: Sha256Digest + spawn_map_source_sha256: Sha256Digest + observed_scene_sha256: Sha256Digest + after_observation_sha256: Sha256Digest + commanded_position: Vec3 + observed_position: Vec3 + position_residual_m: NonNegativeFiniteFloat + relation_before: CanonicalId + relation_after: CanonicalId + verification_status: Literal["success"] = "success" + verification_quality: Literal["PURE", "LOW_LEAKAGE"] + relation_damage_count: int = Field(strict=True, ge=0) + relation_damage_items: tuple[CanonicalId, ...] + semantic_normalization_scope: Literal[ + "FRESH_COLLATERAL_THEN_FROZEN_SOLVER_PROJECTION_V1" + ] = "FRESH_COLLATERAL_THEN_FROZEN_SOLVER_PROJECTION_V1" + frozen_source_scene_sha256: Sha256Digest + fresh_source_scene_sha256: Sha256Digest + source_correspondence_sha256: Sha256Digest + fresh_spawn_map_source_sha256: Sha256Digest + source_capture_sha256: Sha256Digest + runtime_identity_sha256: Sha256Digest + placement_sha256: Sha256Digest + surface_evidence_sha256: Sha256Digest + subject_surface_evidence_sha256: Sha256Digest + patch_index: int = Field(strict=True, ge=0) + patch_sha256: Sha256Digest + fresh_subject_surface_evidence_sha256: Sha256Digest + fresh_patch_sha256: Sha256Digest + endpoint_plan_sha256: Sha256Digest + camera_evidence: SourceCameraEvidence + camera_policy: CameraPolicy + fresh_observed_pose: CameraPose + fresh_observed_native_camera_position: tuple[FiniteFloat, FiniteFloat, FiniteFloat] + fresh_camera: Camera + camera_replay_observation_sha256: Sha256Digest + runtime_collision_delegated_native_object_ids: tuple[CanonicalId, ...] + runtime_pose_policy: RuntimePosePolicy + subject_rotation_residual_degrees: NonNegativeFiniteFloat + subject_obb_corner_residual_m: NonNegativeFiniteFloat + visibility_semantics_id: Literal["visibility-semantics:analytic-bbox-v1"] = ( + "visibility-semantics:analytic-bbox-v1" + ) + image_area_metric_definition_id: Literal[ + "visibility:visible-clipped-projected-bounding-box-area-fraction" + ] = "visibility:visible-clipped-projected-bounding-box-area-fraction" + image_area_metric_definition_version: Literal["definition:2"] = "definition:2" + image_area_metric_formula: Literal[ + "VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA" + ] = "VISIBLE_CLIPPED_PROJECTED_BOUNDING_BOX_AREA_OVER_IMAGE_AREA" + + @model_validator(mode="after") + def validate_audit(self) -> Self: + if self.position_residual_m > _MAX_RUNTIME_POSITION_RESIDUAL_M: + raise ValueError("native endpoint residual exceeds the frozen limit") + if self.relation_before == self.relation_after: + raise ValueError("native audit must change the target relation") + if self.relation_damage_items != tuple(sorted(set(self.relation_damage_items))): + raise ValueError("relation damage items must be unique and canonical") + if self.relation_damage_count != len(self.relation_damage_items): + raise ValueError("relation damage count does not close its roster") + return self + + @model_validator(mode="after") + def validate_fresh_patch(self) -> Self: + if ( + self.fresh_subject_surface_evidence_sha256 + != self.subject_surface_evidence_sha256 + or self.fresh_patch_sha256 != self.patch_sha256 + ): + raise ValueError("fresh native patch does not close frozen patch lineage") + return self + + @model_validator(mode="after") + def validate_camera_replay(self) -> Self: + if type(self.fresh_camera) is not Camera: + raise TypeError("fresh camera replay Camera must be exact") + camera = Camera.model_validate( + self.fresh_camera.model_dump(mode="python"), strict=True + ) + object.__setattr__(self, "fresh_camera", camera) + if ( + self.camera_evidence.policy_sha256 != self.camera_policy.policy_sha256 + or self.camera_evidence.source_capture_sha256 != self.source_capture_sha256 + ): + raise ValueError("camera replay audit frozen lineage is not closed") + verify_competition_native_camera_observation_binding_v2_9_3( + self.camera_evidence.requested_pose, + self.fresh_observed_pose, + self.fresh_observed_native_camera_position, + camera, + ) + return self + + @model_validator(mode="after") + def validate_runtime_collision_authority(self) -> Self: + delegated = self.runtime_collision_delegated_native_object_ids + if delegated != tuple(sorted(set(delegated))): + raise ValueError("runtime collision authority must be canonical") + return self + + @model_validator(mode="after") + def validate_runtime_pose_authority(self) -> Self: + policy = self.runtime_pose_policy + if ( + self.position_residual_m > policy.max_subject_position_residual_m + or self.subject_rotation_residual_degrees + > policy.max_subject_rotation_residual_degrees + or self.subject_obb_corner_residual_m + > policy.max_subject_obb_corner_residual_m + ): + raise ValueError("runtime subject pose exceeds the frozen policy") + return self + + @property + def endpoint_audit_sha256(self) -> Sha256Digest: + return canonical_sha256(self, domain=_AUDIT_HASH_DOMAIN) + + @property + def competition_native_endpoint_audit_sha256(self) -> Sha256Digest: + return self.endpoint_audit_sha256 + + +class _NativeAfterStructureMismatch(ValueError): + """The returned native scene changed facts outside the one allowed edit.""" + + +class EndpointAuditRejected(RuntimeError): + """A solve or native endpoint failed before an audit record was published.""" + + def __init__( + self, + stage: str, + reasons: tuple[str, ...], + *, + readback: SettledReadback | None = None, + ) -> None: + if type(stage) is not str or not stage or not reasons: + raise ValueError("a native audit rejection requires a stage and reasons") + if type(reasons) is not tuple or any( + type(reason) is not str or not reason for reason in reasons + ): + raise TypeError("native audit rejection reasons must be exact strings") + if stage == "native_verification": + if type(readback) is not SettledReadback: + raise TypeError( + "native verification rejection requires an exact settled readback" + ) + elif readback is not None: + raise ValueError( + "only native verification rejection may retain a settled readback" + ) + self.stage = stage + self.reasons = tuple(sorted(set(reasons))) + self.readback = readback + super().__init__(f"{stage}: {', '.join(self.reasons)}") + + +@dataclass(frozen=True) +class _EndpointExecution: + audit: EndpointAudit | None + application: SettledReadback + capture_bound_audit_payload: dict[str, object] | None = None + + def __post_init__(self) -> None: + if (self.audit is None) == (self.capture_bound_audit_payload is None): + raise ValueError( + "endpoint execution requires exactly one closed audit representation" + ) + + +def execute_endpoint( + adapter: EnvironmentAdapter, + frozen_source_scene: Scene, + fresh_source: CapturedSource, + intervention: InterventionSpec, + proxy_bundle: ProxyBundle, + config: ContinuousYawSolverConfigV2_9, + solve_result: ContinuousYawCertifiedSuccessResultV2_9, + fresh_spawn_map: AdapterSpawnMap, + *, + capture_bound_spawn_map_source_sha256: Sha256Digest, + max_post_edit_pass_steps: int, + runtime_pose_policy: RuntimePosePolicy, + source_correspondence: CaptureSourceCorrespondence, +) -> _EndpointExecution: + """Execute a frozen solve once against its corresponding fresh baseline.""" + + with warnings.catch_warnings(): + warnings.simplefilter("error", Warning) + frozen = _strict_legacy( + frozen_source_scene, + Scene, + "frozen_source_scene", + ) + if type(fresh_source) is not CapturedSource: + raise TypeError("fresh_source must be an exact CapturedSource") + fresh = _strict_legacy(fresh_source.scene, Scene, "fresh_source_scene") + spec = _strict_legacy(intervention, InterventionSpec, "intervention") + bundle = _strict_v2(proxy_bundle, ProxyBundle, "proxy_bundle") + checked_config = _strict_v2(config, ContinuousYawSolverConfigV2_9, "config") + result = _strict_v2( + solve_result, + ContinuousYawCertifiedSuccessResultV2_9, + "solve_result", + ) + correspondence = _strict_v2( + source_correspondence, + CaptureSourceCorrespondence, + "source_correspondence", + ) + runtime_pose_policy = _strict_v2( + runtime_pose_policy, RuntimePosePolicy, "runtime_pose_policy" + ) + if correspondence.frozen_source_scene_sha256 != legacy_sha256( + frozen + ) or correspondence.fresh_source_scene_sha256 != legacy_sha256(fresh): + raise EndpointAuditRejected( + "native_precondition", ("capture_source_correspondence_mismatch",) + ) + if type(capture_bound_spawn_map_source_sha256) is not str: + raise TypeError("capture-bound spawn digest must be an exact string") + + binding = bundle.binding + if binding.legacy_scene_sha256 != legacy_sha256(frozen): + raise EndpointAuditRejected("input", ("legacy_scene_sha256_mismatch",)) + if binding.intervention_sha256 != legacy_sha256(spec): + raise EndpointAuditRejected("input", ("intervention_sha256_mismatch",)) + if ( + result.semantic_problem_sha256 + != bundle.semantic_problem.semantic_problem_sha256 + ): + raise EndpointAuditRejected("solve", ("semantic_problem_sha256_mismatch",)) + if type(fresh_spawn_map) is not AdapterSpawnMap: + raise TypeError("fresh_spawn_map must be an exact AdapterSpawnMap") + verified = verify_solve_result( + bundle.semantic_problem, + checked_config, + result, + ) + if verified.kind is not ContinuousYawSolveVerificationKindV2.VERIFIED: + raise EndpointAuditRejected( + "solve", + verified.finding_codes or ("fresh_solve_verification_failed",), + ) + + edit = result.selected_witness.edit + expected_subject_id = f"object:{binding.subject_native_object_id}" + if edit.subject_id != expected_subject_id: + raise RuntimeError("verified edit subject does not match the proxy binding") + frozen_subject = frozen.object_by_id(binding.subject_native_object_id) + _require_endpoint_preconditions( + fresh, + frozen_subject.object_id, + fresh_spawn_map, + ) + fresh_spawn_map_source_sha256 = fresh_spawn_map.source_sha256 + adapter_edit = CanonicalEdit( + semantic_problem_sha256=edit.semantic_problem_sha256, + subject_id=frozen_subject.object_id, + translation_xy_m=edit.translation_xy_m, + ) + certified = CertifiedEditApplication( + source=fresh_source, + intervention=spec, + edit=adapter_edit, + spawn_map=fresh_spawn_map, + max_settlement_steps=max_post_edit_pass_steps, + ) + try: + application = adapter.apply_certified_edit(certified) + settled = adapter.settle_readback(application) + except AdapterSettlementTimeout as error: + raise EndpointAuditRejected( + "native_return", ("post_edit_scene_not_settled",) + ) from error + except AdapterActionRejected as error: + raise EndpointAuditRejected( + "native_action", (f"native_action_rejected:{error.reason}",) + ) from error + except AdapterReturnRejected as error: + raise EndpointAuditRejected( + "native_return", (f"native_return_rejected:{error.reason}",) + ) from error + except AdapterOperationError as error: + raise EndpointAuditRejected( + "native_precondition", + (f"certified_edit_failed:{type(error).__name__}:{error}",), + ) from error + if fresh_spawn_map.source_sha256 != fresh_spawn_map_source_sha256: + raise RuntimeError("fresh spawn map changed during native endpoint audit") + + failures: list[str] = [] + if not settled.is_scene_at_rest or not settled.observation.is_settled: + failures.append("scene_not_at_rest") + if settled.subject_is_moving: + failures.append("subject_is_moving") + if settled.observation.scene != settled.observed_scene: + failures.append("observation_scene_mismatch") + if settled.position_residual_m > _MAX_RUNTIME_POSITION_RESIDUAL_M: + failures.append("position_residual_exceeded") + try: + fresh_normalized = _normalized_runtime_pose_observed_scene_v2_9_5( + settled.commanded_scene, + settled.observed_scene, + binding.subject_native_object_id, + runtime_pose_policy, + ) + except _NativeAfterStructureMismatch as error: + failures.append(f"after_scene_structure:{error}") + frozen_normalized = None + else: + frozen_normalized = ( + _project_runtime_pose_observed_scene_to_frozen_source_v2_9_5( + frozen, + fresh_normalized, + binding.subject_native_object_id, + ) + ) + verification = None + if frozen_normalized is not None: + verification = _verify_minimum_cost_with_proxy_collision_authority( + bundle, + frozen, + frozen_normalized, + spec, + runtime_pose_subject_object_id=binding.subject_native_object_id, + ) + if verification.status is not SolverStatus.SUCCESS: + failures.append(f"verification_status:{verification.status.value}") + if verification.quality not in {QualityTier.PURE, QualityTier.LOW_LEAKAGE}: + failures.append(f"verification_quality:{verification.quality.value}") + failures.extend(f"verification_error:{item}" for item in verification.errors) + if failures: + raise EndpointAuditRejected( + "native_verification", + tuple(sorted(set(failures))), + readback=settled, + ) + if verification is None: + raise RuntimeError("native verification result is missing") + + audit_payload: dict[str, object] = { + "case_id": binding.case_id, + "native_scene_id": binding.native_scene_id, + "subject_native_object_id": binding.subject_native_object_id, + "reference_native_object_id": binding.reference_native_object_id, + "semantic_problem_sha256": (bundle.semantic_problem.semantic_problem_sha256), + "solver_config_sha256": checked_config.config_sha256, + "solve_result_sha256": result.solve_result_sha256, + "edit_sha256": edit.edit_sha256, + "proxy_bundle_sha256": bundle.proxy_bundle_sha256, + "spawn_map_source_sha256": capture_bound_spawn_map_source_sha256, + "fresh_spawn_map_source_sha256": fresh_spawn_map_source_sha256, + "frozen_source_scene_sha256": legacy_sha256(frozen), + "fresh_source_scene_sha256": legacy_sha256(fresh), + "source_correspondence_sha256": ( + correspondence.competition_native_capture_source_correspondence_sha256 + ), + "observed_scene_sha256": legacy_sha256(settled.observed_scene), + "after_observation_sha256": observation_sha256(settled.observation), + "commanded_position": Vec3( + x=settled.commanded_position.x, + y=settled.commanded_position.y, + z=settled.commanded_position.z, + ), + "observed_position": Vec3( + x=settled.observed_position.x, + y=settled.observed_position.y, + z=settled.observed_position.z, + ), + "position_residual_m": settled.position_residual_m, + "relation_before": spec.relation_before.value, + "relation_after": spec.relation_after.value, + "verification_quality": verification.quality.value, + "relation_damage_count": verification.relation_damage_count, + "relation_damage_items": verification.relation_damage_items, + } + commanded_subject = settled.commanded_scene.object_by_id( + binding.subject_native_object_id + ) + observed_subject = settled.observed_scene.object_by_id( + binding.subject_native_object_id + ) + audit_payload.update( + runtime_pose_policy=runtime_pose_policy, + subject_rotation_residual_degrees=_quaternion_angle_residual_deg( + observed_subject.rotation, + commanded_subject.rotation, + "runtime subject rotation", + ), + subject_obb_corner_residual_m=_obb_corner_hausdorff_residual_m( + observed_subject.obb, + commanded_subject.obb, + "runtime subject OBB", + ), + ) + return _EndpointExecution( + audit=None, + application=settled, + capture_bound_audit_payload=audit_payload, + ) + + +def _strict_legacy(value, expected_type, label: str): + if type(value) is not expected_type: + raise TypeError(f"{label} must be an exact {expected_type.__name__}") + return expected_type.model_validate(value.model_dump(mode="python"), strict=True) + + +def _strict_v2(value, expected_type, label: str): + if type(value) is not expected_type: + raise TypeError(f"{label} must be an exact {expected_type.__name__}") + return expected_type.model_validate( + value.model_dump(mode="python", warnings="error"), strict=True + ) + + +def _require_endpoint_preconditions( + scene: Scene, + subject_id: str, + spawn_map: AdapterSpawnMap, +) -> None: + """Reject closed, expected endpoint preconditions before the native action.""" + + subject = scene.object_by_id(subject_id) + if subject.support_object_id is None: + raise EndpointAuditRejected("native_precondition", ("subject_has_no_support",)) + if ( + spawn_map.scene_id != scene.scene_id + or spawn_map.subject_object_id != subject.object_id + or spawn_map.support_object_id != subject.support_object_id + ): + raise EndpointAuditRejected( + "native_precondition", ("spawn_map_source_mismatch",) + ) + positions = spawn_map.positions + if type(positions) is not tuple: + raise TypeError("spawn_map positions must be an exact tuple") + if any(type(item) is not AdapterPosition for item in positions): + raise TypeError("spawn_map positions must be exact adapter positions") + if len({item.y for item in positions}) != 1: + raise EndpointAuditRejected( + "native_precondition", ("native_support_height_not_unique",) + ) + + +def _normalized_runtime_pose_observed_scene_v2_9_5( + commanded: Scene, + observed: Scene, + subject_id: str, + policy: RuntimePosePolicy, +) -> Scene: + """Retain the bounded final subject pose/OBB and fresh object views.""" + + checked_policy = RuntimePosePolicy.model_validate( + policy.model_dump(mode="python", warnings="error"), + strict=True, + ) + _require_native_after_structure( + commanded, + observed, + subject_id, + runtime_pose_policy=checked_policy, + ) + observed_by_id = {item.object_id: item for item in observed.objects} + normalized = [] + for expected in commanded.objects: + current = observed_by_id[expected.object_id] + normalized.append( + current + if expected.object_id == subject_id + else expected.model_copy(update={"views": current.views}) + ) + return commanded.model_copy(update={"objects": tuple(normalized)}) + + +def _verify_minimum_cost_with_proxy_collision_authority( + bundle: ProxyBundle, + before: Scene, + after: Scene, + spec: InterventionSpec, + *, + runtime_pose_subject_object_id: str | None = None, +) -> VerificationResult: + verifier = Verifier() + if type(bundle) is not ProxyBundle: + raise TypeError("proxy collision authority requires exact ProxyBundle") + if runtime_pose_subject_object_id is not None: + return verifier.verify_minimum_cost_with_runtime_pose_authority( + before, + after, + spec, + runtime_collision_delegated_object_ids=( + bundle.binding.runtime_collision_delegated_native_object_ids + ), + runtime_pose_subject_object_id=runtime_pose_subject_object_id, + ) + return verifier.verify_minimum_cost_with_runtime_collision_authority( + before, + after, + spec, + runtime_collision_delegated_object_ids=( + bundle.binding.runtime_collision_delegated_native_object_ids + ), + ) + + +def _project_runtime_pose_observed_scene_to_frozen_source_v2_9_5( + frozen_source: Scene, + fresh_after: Scene, + subject_id: str, +) -> Scene: + """Project actual final subject geometry onto frozen source roots.""" + + fresh_by_id = {item.object_id: item for item in fresh_after.objects} + projected = [] + for original in frozen_source.objects: + current = fresh_by_id[original.object_id] + projected.append( + current + if original.object_id == subject_id + else original.model_copy(update={"views": current.views}) + ) + return frozen_source.model_copy(update={"objects": tuple(projected)}) + + +def _require_native_after_structure( + source: Scene, + observed: Scene, + subject_id: str, + *, + runtime_pose_policy: RuntimePosePolicy | None = None, +) -> None: + """Close every returned fact except subject XY and fresh object views.""" + + if ( + observed.scene_id != source.scene_id + or observed.source != source.source + or observed.coordinate_system != source.coordinate_system + or observed.room_polygon_xy != source.room_polygon_xy + or observed.collision_obstacles != source.collision_obstacles + or observed.subject_position_regions != source.subject_position_regions + or observed.pinned_object_ids != source.pinned_object_ids + or observed.generation_seed != source.generation_seed + ): + raise _NativeAfterStructureMismatch("scene root facts changed") + + source_ids = tuple(item.object_id for item in source.objects) + observed_ids = tuple(item.object_id for item in observed.objects) + if ( + len(set(source_ids)) != len(source_ids) + or len(set(observed_ids)) != len(observed_ids) + or observed_ids != source_ids + or subject_id not in set(source_ids) + ): + raise _NativeAfterStructureMismatch("object roster changed") + + for original, current in zip(source.objects, observed.objects, strict=True): + if ( + current.object_id != original.object_id + or current.name != original.name + or current.category != original.category + or current.movable is not original.movable + or current.request_eligible is not original.request_eligible + or current.support_object_id != original.support_object_id + ): + raise _NativeAfterStructureMismatch( + f"object structural facts changed: {original.object_id}" + ) + if original.object_id == subject_id: + if runtime_pose_policy is not None: + if ( + math.dist( + _vec3_values(current.position), + _vec3_values(original.position), + ) + > runtime_pose_policy.max_subject_position_residual_m + ): + raise _NativeAfterStructureMismatch( + "subject runtime position residual exceeded" + ) + if ( + _quaternion_angle_residual_deg( + current.rotation, + original.rotation, + "runtime subject rotation", + ) + > runtime_pose_policy.max_subject_rotation_residual_degrees + ): + raise _NativeAfterStructureMismatch( + "subject runtime rotation residual exceeded" + ) + if ( + _obb_corner_hausdorff_residual_m( + current.obb, + original.obb, + "runtime subject OBB", + ) + > runtime_pose_policy.max_subject_obb_corner_residual_m + ): + raise _NativeAfterStructureMismatch( + "subject runtime OBB residual exceeded" + ) + continue + if not _close_values( + (current.position.z,), + (original.position.z,), + _OBJECT_GEOMETRY_TOLERANCE_M, + ): + raise _NativeAfterStructureMismatch("subject vertical position changed") + expected_center = ( + original.obb.center.x + current.position.x - original.position.x, + original.obb.center.y + current.position.y - original.position.y, + original.obb.center.z, + ) + else: + if not _close_values( + _vec3_values(current.position), + _vec3_values(original.position), + _OBJECT_GEOMETRY_TOLERANCE_M, + ): + raise _NativeAfterStructureMismatch( + f"stationary object position changed: {original.object_id}" + ) + expected_center = _vec3_values(original.obb.center) + if ( + not _close_values( + _vec3_values(current.obb.center), + expected_center, + _OBJECT_GEOMETRY_TOLERANCE_M, + ) + or not _close_values( + _vec3_values(current.obb.extent), + _vec3_values(original.obb.extent), + _OBJECT_GEOMETRY_TOLERANCE_M, + ) + or not _quaternions_close(current.rotation, original.rotation) + or not _quaternions_close(current.obb.rotation, original.obb.rotation) + ): + raise _NativeAfterStructureMismatch( + f"object geometry changed: {original.object_id}" + ) + + source_camera_ids = tuple(item.camera_id for item in source.cameras) + observed_camera_ids = tuple(item.camera_id for item in observed.cameras) + if ( + len(set(source_camera_ids)) != len(source_camera_ids) + or len(set(observed_camera_ids)) != len(observed_camera_ids) + or observed_camera_ids != source_camera_ids + ): + raise _NativeAfterStructureMismatch("camera roster changed") + for original, current in zip(source.cameras, observed.cameras, strict=True): + if ( + current.camera_id != original.camera_id + or current.width != original.width + or current.height != original.height + or not _close_values( + current.intrinsics, + original.intrinsics, + _CAMERA_INTRINSIC_TOLERANCE, + ) + or not _close_values( + current.world_to_camera, + original.world_to_camera, + _CAMERA_EXTRINSIC_TOLERANCE, + ) + ): + raise _NativeAfterStructureMismatch( + f"camera facts changed: {original.camera_id}" + ) + + +def _vec3_values(value) -> tuple[float, float, float]: + return value.x, value.y, value.z + + +def _quaternion_angle_residual_deg(left, right, label: str) -> float: + a = tuple(float(item) for item in (left.x, left.y, left.z, left.w)) + b = tuple(float(item) for item in (right.x, right.y, right.z, right.w)) + if not all(math.isfinite(item) for item in (*a, *b)): + raise ValueError(f"{label} must contain finite values") + a_norm = math.sqrt(sum(item * item for item in a)) + b_norm = math.sqrt(sum(item * item for item in b)) + if a_norm == 0.0 or b_norm == 0.0: + raise ValueError(f"{label} must contain non-zero quaternions") + cosine = abs( + sum( + (a_item / a_norm) * (b_item / b_norm) + for a_item, b_item in zip(a, b, strict=True) + ) + ) + return math.degrees(2.0 * math.acos(min(1.0, max(-1.0, cosine)))) + + +def _obb_corner_coordinates(obb: OBB, label: str) -> tuple[tuple[float, ...], ...]: + values = ( + obb.center.x, + obb.center.y, + obb.center.z, + obb.extent.x, + obb.extent.y, + obb.extent.z, + obb.rotation.x, + obb.rotation.y, + obb.rotation.z, + obb.rotation.w, + ) + if not all(math.isfinite(float(item)) for item in values): + raise ValueError(f"{label} must contain finite values") + if any(float(item) <= 0.0 for item in (obb.extent.x, obb.extent.y, obb.extent.z)): + raise ValueError(f"{label} must contain strictly positive extents") + x, y, z, w = ( + float(obb.rotation.x), + float(obb.rotation.y), + float(obb.rotation.z), + float(obb.rotation.w), + ) + maximum_component = max(abs(x), abs(y), abs(z), abs(w)) + if maximum_component == 0.0: + raise ValueError(f"{label} must contain a non-zero quaternion") + scaled = tuple(component / maximum_component for component in (x, y, z, w)) + scaled_norm = math.sqrt(sum(component * component for component in scaled)) + if not math.isfinite(scaled_norm) or scaled_norm == 0.0: + raise ValueError(f"{label} must contain a normalizable quaternion") + x, y, z, w = (component / scaled_norm for component in scaled) + rotation = ( + (1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - z * w), 2.0 * (x * z + y * w)), + (2.0 * (x * y + z * w), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - x * w)), + (2.0 * (x * z - y * w), 2.0 * (y * z + x * w), 1.0 - 2.0 * (x * x + y * y)), + ) + center = (float(obb.center.x), float(obb.center.y), float(obb.center.z)) + half_extent = ( + float(obb.extent.x) / 2.0, + float(obb.extent.y) / 2.0, + float(obb.extent.z) / 2.0, + ) + return tuple( + tuple( + center[row] + + sum( + rotation[row][column] * signs[column] * half_extent[column] + for column in range(3) + ) + for row in range(3) + ) + for signs in ( + (dx, dy, dz) + for dx in (-1.0, 1.0) + for dy in (-1.0, 1.0) + for dz in (-1.0, 1.0) + ) + ) + + +def _obb_corner_hausdorff_residual_m(left: OBB, right: OBB, label: str) -> float: + left_corners = _obb_corner_coordinates(left, label) + right_corners = _obb_corner_coordinates(right, label) + + def directed(source, target) -> float: + return max( + min(math.dist(source_corner, target_corner) for target_corner in target) + for source_corner in source + ) + + return max( + directed(left_corners, right_corners), + directed(right_corners, left_corners), + ) + + +def _close_values(left, right, tolerance: float) -> bool: + return len(left) == len(right) and all( + math.isfinite(float(a)) + and math.isfinite(float(b)) + and math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=tolerance) + for a, b in zip(left, right, strict=True) + ) + + +def _quaternions_close(left, right) -> bool: + a = tuple(float(item) for item in (left.x, left.y, left.z, left.w)) + b = tuple(float(item) for item in (right.x, right.y, right.z, right.w)) + if not all(math.isfinite(item) for item in (*a, *b)): + return False + a_norm = math.sqrt(sum(item * item for item in a)) + b_norm = math.sqrt(sum(item * item for item in b)) + if a_norm == 0.0 or b_norm == 0.0: + return False + normalized_a = tuple(item / a_norm for item in a) + normalized_b = tuple(item / b_norm for item in b) + return _close_values( + normalized_a, + normalized_b, + _OBJECT_GEOMETRY_TOLERANCE_M, + ) or _close_values( + normalized_a, + tuple(-item for item in normalized_b), + _OBJECT_GEOMETRY_TOLERANCE_M, + ) + + +class AuditRun(CanonicalModel): """Flat current replayable CPU chain plus one native endpoint audit.""" run_version: Literal["competition-native-audit-run:2.9.7"] = ( @@ -272,7 +1121,7 @@ def validate_camera_replay(self) -> Self: or evidence.scene_id != capture.source.scene_id or evidence.source_locator_sha256 != capture.source.source_locator_sha256 or evidence.runtime_identity_sha256 - != canonical_sha256_v2( + != canonical_sha256( capture.runtime_identity, domain=_CAMERA_RUNTIME_HASH_DOMAIN ) or evidence.policy_sha256 != self.camera_policy.policy_sha256 @@ -336,7 +1185,7 @@ def validate_bbox_visibility_authority(self) -> Self: @property def audit_run_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_RUN_HASH_DOMAIN) + return canonical_sha256(self, domain=_RUN_HASH_DOMAIN) @property def competition_native_audit_run_sha256(self) -> Sha256Digest: @@ -398,7 +1247,7 @@ def __post_init__(self) -> None: @property def audit_execution_sha256(self) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( { "execution_version": self.execution_version, "native_audit_run_sha256": self.run.audit_run_sha256, @@ -426,7 +1275,7 @@ def _same_exact_rotation(left, right) -> bool: ) -def _competition_camera_pose_v2_9_3(pose) -> CameraPose: +def _competition_camera_pose_v2_9_3(pose: AdapterPose) -> CameraPose: return CameraPose( x=pose.position.x, y=pose.position.y, @@ -438,13 +1287,10 @@ def _competition_camera_pose_v2_9_3(pose) -> CameraPose: def _fresh_native_support_matches_capture_v2_9_3( - adapter: AI2ThorAdapter, - scene: Scene, + facts: SourceCaptureFacts, capture: CompetitionNativeSourceCaptureV2_9, ) -> bool: - observed = tuple( - sorted(adapter.native_support_facts(scene), key=lambda item: item.object_id) - ) + observed = tuple(sorted(facts.support_facts, key=lambda item: item.object_id)) expected = capture.support_facts def support_key(item): @@ -456,7 +1302,7 @@ def support_key(item): item.raw_parent_object_ids, item.structural_parent_object_ids, item.domain_parent_object_ids, - item.support_kind.value, + item.support_kind, item.support_object_id, item.floor_object_id, ) @@ -512,7 +1358,7 @@ def _rebuild_fresh_subject_surface_evidence_v2_9_2( ) return SubjectSurfaceEvidence( **payload, - subject_surface_evidence_sha256=canonical_sha256_v2( + subject_surface_evidence_sha256=canonical_sha256( payload, domain=_SUBJECT_EVIDENCE_HASH_DOMAIN, ), @@ -575,13 +1421,6 @@ def _require_nonempty_exact_str(value: object, label: str) -> None: raise TypeError(f"{label} must be a non-empty exact string") -def _latest_native_event_or_none(adapter: AI2ThorAdapter, scene_id: str) -> object: - try: - return adapter.latest_native_event(scene_id) - except (KeyError, RuntimeError): - return None - - def _observation_with_scene( observation: AI2ThorObservation, scene: Scene, @@ -593,37 +1432,34 @@ def _observation_with_scene( instance_png=observation.instance_png, pointcloud_ply=observation.pointcloud_ply, instance_pixel_counts=observation.instance_pixel_counts, - is_scene_at_rest=observation.is_scene_at_rest, + is_settled=observation.is_settled, ) -def _observation_asset_identity(observation: AI2ThorObservation) -> tuple[object, ...]: +def _observation_asset_identity( + observation: AI2ThorObservation, +) -> tuple[object, ...]: return ( observation.rgb_png, observation.depth_npy, observation.instance_png, observation.pointcloud_ply, - tuple(sorted(observation.instance_pixel_counts.items())), - observation.is_scene_at_rest, + observation.instance_pixel_counts, + observation.is_settled, ) -def _strict_legacy(value, expected_type, label: str): - if type(value) is not expected_type: - raise TypeError(f"{label} must be an exact {expected_type.__name__}") - return expected_type.model_validate(value.model_dump(mode="python"), strict=True) - - -def execute_audit( +def _execute_audit( adapter: AI2ThorAdapter, request: BatchRequest, *, request_lineage: RequestLineage, + fresh_transitions=None, ) -> AuditExecution: """Execute the single current audit route with no version dispatch.""" - if type(adapter) is not AI2ThorAdapter: - raise TypeError("adapter must be an exact AI2ThorAdapter") + if not isinstance(adapter, EnvironmentAdapter): + raise TypeError("adapter must satisfy the EnvironmentAdapter protocol") if type(request) is not BatchRequest: raise TypeError("batch request must be exact") if type(request_lineage) is not RequestLineage: @@ -646,58 +1482,50 @@ def execute_audit( scene_id = checked_request.scene_id max_settlement_steps = checked_request.max_settlement_steps - before_load_event = _latest_native_event_or_none(adapter, scene_id) try: - loaded_scene = adapter.load_scene(scene_id) - except (RuntimeError, AI2ThorNativeReturnError) as error: - rejection = _native_action_rejection( - adapter, scene_id, before_load_event, error + captured_source = adapter.capture_source( + CaptureRequest(scene_id=scene_id, camera_id="main") ) - if rejection is not None: - raise EndpointAuditRejected( - "native_precondition", (f"load_scene_rejected:{rejection}",) - ) from error - returned = _native_return_rejection(adapter, scene_id, before_load_event, error) - if returned is not None: - raise EndpointAuditRejected( - "native_precondition", (f"load_scene_return_rejected:{returned}",) - ) from error + except AdapterActionRejected as error: + raise EndpointAuditRejected( + "native_precondition", (f"load_scene_rejected:{error.reason}",) + ) from error + except AdapterReturnRejected as error: + raise EndpointAuditRejected( + "native_precondition", (f"load_scene_return_rejected:{error.reason}",) + ) from error + except AdapterOperationError: raise - - before_settlement_event = adapter.latest_native_event(scene_id) try: - settlement = adapter.settle_scene_observed( - loaded_scene, max_pass_steps=max_settlement_steps - ) - except (RuntimeError, AI2ThorNativeReturnError) as error: - rejection = _native_action_rejection( - adapter, scene_id, before_settlement_event, error - ) - if rejection is not None: - raise EndpointAuditRejected( - "native_precondition", (f"settlement_action_rejected:{rejection}",) - ) from error - if isinstance(error, AI2ThorSettlementTimeout): - raise EndpointAuditRejected( - "native_precondition", ("scene_not_settled",) - ) from error - returned = _native_return_rejection( - adapter, - scene_id, - before_settlement_event, - error, - allow_existing_success_event=True, + initial_facts = adapter.observe_source( + captured_source, + options=SourceCaptureOptions( + max_settlement_steps=max_settlement_steps, + floor_clearance_m=0.1, + navigation_agent_radius_m=0.2, + navigation_clearance_m=0.0, + ), + settle=True, ) - if returned is not None: - raise EndpointAuditRejected( - "native_precondition", (f"settlement_return_rejected:{returned}",) - ) from error + except AdapterActionRejected as error: + raise EndpointAuditRejected( + "native_precondition", (f"settlement_action_rejected:{error.reason}",) + ) from error + except AdapterSettlementTimeout as error: + raise EndpointAuditRejected( + "native_precondition", ("scene_not_settled",) + ) from error + except AdapterReturnRejected as error: + raise EndpointAuditRejected( + "native_precondition", (f"settlement_return_rejected:{error.reason}",) + ) from error + except AdapterOperationError: raise - fresh_source = settlement.observed_scene + fresh_source = initial_facts.scene evidence = lineage.camera_evidence - requested_pose = AI2ThorAgentPose( - position=AI2ThorNativePosition( + requested_pose = AdapterPose( + position=AdapterPosition( x=evidence.requested_pose.x, y=evidence.requested_pose.y, z=evidence.requested_pose.z, @@ -706,42 +1534,64 @@ def execute_audit( horizon_degrees=evidence.requested_pose.horizon_degrees, standing=evidence.requested_pose.standing, ) - before_camera_event = adapter.latest_native_event(scene_id) try: - with adapter.paused_camera_observations_for_settlement( - fresh_source - ) as paused_source: - camera_application = adapter.apply_camera_pose_observed( - paused_source, requested_pose + handle = adapter.pause_camera_observations( + initial_facts, + settle_after_resume=True, + ) + try: + adapter.apply_camera_pose( + initial_facts, + requested_pose, + handle=handle, + source_scene=fresh_source, + reset_from_source=False, + max_settlement_steps=max_settlement_steps, ) - camera_application = adapter.settle_current_camera_pose_observed( - fresh_source, + finally: + adapter.resume_camera_observations(handle) + camera_settlement = adapter.settle_camera_pose( + initial_facts, requested_pose, - max_pass_steps=max_settlement_steps, - ).application - except (RuntimeError, AI2ThorNativeReturnError, TypeError, ValueError) as error: - rejection = _native_action_rejection( - adapter, scene_id, before_camera_event, error - ) - if rejection is not None: - raise EndpointAuditRejected( - "native_precondition", - (f"camera_replay_action_rejected:{rejection}",), - ) from error - returned = _native_return_rejection( - adapter, scene_id, before_camera_event, error + source_scene=fresh_source, + max_settlement_steps=max_settlement_steps, ) - if returned is not None: - raise EndpointAuditRejected( - "native_precondition", - (f"camera_replay_return_rejected:{returned}",), - ) from error + camera_application = camera_settlement.application + except AdapterActionRejected as error: + raise EndpointAuditRejected( + "native_precondition", + (f"camera_replay_action_rejected:{error.reason}",), + ) from error + except AdapterReturnRejected as error: + raise EndpointAuditRejected( + "native_precondition", + (f"camera_replay_return_rejected:{error.reason}",), + ) from error + except AdapterOperationError as error: raise EndpointAuditRejected( "native_precondition", (f"camera_replay_failed:{type(error).__name__}:{error}",), ) from error fresh_source = camera_application.observed_scene + fresh_capture = CapturedSource( + request=captured_source.request, + scene=fresh_source, + binding=captured_source.binding, + ) + fresh_facts = SourceCaptureFacts( + source=fresh_capture, + binding=fresh_capture.binding, + scene=fresh_source, + runtime_identity=initial_facts.runtime_identity, + observation=camera_application.observation, + support_facts=initial_facts.support_facts, + floor_envelope=initial_facts.floor_envelope, + floor_position_regions=initial_facts.floor_position_regions, + reachable_positions=initial_facts.reachable_positions, + current_pose=camera_application.observed_pose, + settlement_pass_steps=camera_settlement.settlement_pass_steps, + ) fresh_before_observation = camera_application.observation fresh_observed_pose = _competition_camera_pose_v2_9_3( camera_application.observed_pose @@ -752,9 +1602,8 @@ def execute_audit( camera_application.observed_camera_position.z, ) try: - current_pose = adapter.current_agent_pose(fresh_source) fresh_runtime = CompetitionNativeRuntimeIdentityV2_9( - **asdict(adapter.runtime_identity()) + **asdict(fresh_facts.runtime_identity) ) expected_camera_residuals = ( dist( @@ -785,7 +1634,7 @@ def execute_audit( ) if camera_application.requested_pose != requested_pose: raise ValueError("camera requested pose changed") - if current_pose != camera_application.observed_pose: + if fresh_facts.current_pose != camera_application.observed_pose: raise ValueError("camera current pose changed") if ( camera_application.position_residual_m, @@ -795,12 +1644,12 @@ def execute_audit( raise ValueError("camera application residuals changed") if camera_application.observation.scene != fresh_source: raise ValueError("camera observation scene changed") - if camera_application.observation.is_scene_at_rest is not True: + if camera_application.observation.is_settled is not True: raise ValueError("camera observation is not settled") if fresh_runtime != lineage.source_capture.runtime_identity: raise ValueError("camera replay runtime changed") if not _fresh_native_support_matches_capture_v2_9_3( - adapter, fresh_source, lineage.source_capture + fresh_facts, lineage.source_capture ): raise ValueError("camera replay support facts changed") if ( @@ -903,9 +1752,7 @@ def execute_audit( "native_precondition", ("patch_proxy_lineage_mismatch",) ) - solved = solve_continuous_yaw_minimum_cost_v2_9( - bundle.semantic_problem, checked_request.solver_config - ) + solved = solve_minimum_cost(bundle.semantic_problem, checked_request.solver_config) if type(solved.result) is not ContinuousYawCertifiedSuccessResultV2_9: findings = solved.finding_codes or getattr(solved.result, "finding_codes", ()) raise EndpointAuditRejected( @@ -916,28 +1763,22 @@ def execute_audit( "native_precondition", ("solve_result_lineage_mismatch",) ) - before_spawn_query_event = adapter.latest_native_event(fresh_source.scene_id) try: - spawn_map = adapter.receptacle_spawn_map( - fresh_source, subject_object_id=subject.object_id - ) - except (RuntimeError, TypeError, ValueError) as error: - rejection = _native_action_rejection( - adapter, fresh_source.scene_id, before_spawn_query_event, error + (spawn_map,) = adapter.capture_spawn_maps( + fresh_facts, + subject_object_ids=(subject.object_id,), ) - if rejection is not None: - raise EndpointAuditRejected( - "native_precondition", (f"spawn_map_action_rejected:{rejection}",) - ) from error - returned = _native_return_rejection( - adapter, fresh_source.scene_id, before_spawn_query_event, error - ) - if returned is not None: - raise EndpointAuditRejected( - "native_precondition", (f"spawn_map_return_rejected:{returned}",) - ) from error + except AdapterActionRejected as error: + raise EndpointAuditRejected( + "native_precondition", (f"spawn_map_action_rejected:{error.reason}",) + ) from error + except AdapterReturnRejected as error: + raise EndpointAuditRejected( + "native_precondition", (f"spawn_map_return_rejected:{error.reason}",) + ) from error + except AdapterOperationError: raise - capture_bound_spawn_map = capture_bound_ai2thor_receptacle_spawn_map( + capture_bound_spawn_map = capture_bound_adapter_spawn_map( spawn_map, fresh_scene=fresh_source, frozen_scene=source ) if capture_bound_spawn_map.source_sha256 != lineage.spawn_map_source_sha256: @@ -963,7 +1804,7 @@ def execute_audit( executed = execute_endpoint( adapter, source, - fresh_source, + fresh_capture, spec, bundle, checked_request.solver_config, @@ -974,13 +1815,7 @@ def execute_audit( runtime_pose_policy=lineage.runtime_pose_policy, source_correspondence=source_correspondence, ) - after_observation = adapter.capture_current_observation( - executed.application.observed_scene - ) - if after_observation != executed.application.observation: - raise EndpointAuditRejected( - "native_verification", ("fresh_after_observation_mismatch",) - ) + after_observation = executed.application.observation if executed.audit is not None or executed.capture_bound_audit_payload is None: raise RuntimeError("runtime endpoint audit payload is incomplete") @@ -1041,12 +1876,46 @@ def execute_audit( camera_policy=lineage.camera_policy, runtime_pose_policy=lineage.runtime_pose_policy, ) - return AuditExecution( + result = AuditExecution( run=run, before_observation=normalized_before, fresh_before_observation=fresh_before_observation, after_observation=after_observation, ) + if fresh_transitions is not None: + transition = fresh_transitions.executed( + checked_request.request_id, + executed.application.application.edit, + executed.application, + ) + if type(transition) is not workflow_contracts.ExecutedEdit: + raise TypeError("audit transition execution must be exact") + fresh_transitions.verify(checked_request.request_id) + return result + + +def execute_audit( + adapter: AI2ThorAdapter, + request: BatchRequest, + *, + request_lineage: RequestLineage, +) -> AuditExecution: + return _execute_audit(adapter, request, request_lineage=request_lineage) + + +def _execute_audit_with_transitions( + adapter: AI2ThorAdapter, + request: BatchRequest, + *, + request_lineage: RequestLineage, + fresh_transitions, +) -> AuditExecution: + return _execute_audit( + adapter, + request, + request_lineage=request_lineage, + fresh_transitions=fresh_transitions, + ) def verify_audit_run(run: AuditRun, observed_after_scene: Scene) -> AuditRun: @@ -1093,7 +1962,7 @@ def verify_audit_run(run: AuditRun, observed_after_scene: Scene) -> AuditRun: ) if rebuilt != checked.proxy_bundle: raise ValueError("native audit run proxy replay mismatch") - verified = verify_continuous_yaw_solve_result_v2_9( + verified = verify_solve_result( rebuilt.semantic_problem, checked.solver_config, checked.solve_result, @@ -1188,6 +2057,10 @@ def verify_audit_run(run: AuditRun, observed_after_scene: Scene) -> AuditRun: __all__ = ( "AuditExecution", "AuditRun", + "EndpointAudit", + "EndpointAuditRejected", "execute_audit", + "execute_endpoint", + "observation_sha256", "verify_audit_run", ) diff --git a/src/spatialcf/generation/execution/batch.py b/src/spatialcf/generation/execution/batch.py new file mode 100644 index 0000000..f54281c --- /dev/null +++ b/src/spatialcf/generation/execution/batch.py @@ -0,0 +1,36 @@ +"""Schema-2 composition wrapper for the reusable batch workflow.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from pathlib import Path + +from spatialcf.composition import DEFAULT_ENVIRONMENT_ADAPTER_FACTORY as AI2ThorAdapter +from spatialcf.generation.execution.audit import AuditExecution, execute_audit +from spatialcf.generation.execution.correspondence import RequestLineage +from spatialcf.generation.planning.campaign import BatchManifest +from spatialcf.generation.workflows import execution as execution_workflow +from spatialcf.generation.workflows.execution import BatchSummary +from spatialcf.verification.filesystem import DirectoryIdentity + + +def execute_batch( + manifest: BatchManifest, + output_root: Path, + *, + request_lineage: Mapping[str, RequestLineage], + expected_parent_identity: DirectoryIdentity | None = None, + adapter_factory: Callable[..., AI2ThorAdapter] = AI2ThorAdapter, + runner: Callable[..., AuditExecution] = execute_audit, +) -> BatchSummary: + return execution_workflow.execute_batch( + manifest, + output_root, + request_lineage=request_lineage, + expected_parent_identity=expected_parent_identity, + adapter_factory=adapter_factory, + runner=runner, + ) + + +__all__ = ("execute_batch",) diff --git a/src/spatialcf/generation/_internal/execution/campaign.py b/src/spatialcf/generation/execution/campaign.py similarity index 96% rename from src/spatialcf/generation/_internal/execution/campaign.py rename to src/spatialcf/generation/execution/campaign.py index 1321204..03e8ffa 100644 --- a/src/spatialcf/generation/_internal/execution/campaign.py +++ b/src/spatialcf/generation/execution/campaign.py @@ -1,4 +1,4 @@ -"""Current source-campaign execution over immutable partial batches.""" +"""Final source-campaign execution over immutable partial batches.""" from __future__ import annotations @@ -13,26 +13,26 @@ from pydantic import Field, model_validator -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 -from spatialcf.generation._internal.execution.batch import ( - BatchSummary, - execute_batch, - prepare_batch_verification, - revalidate_batch_verification, - verify_batch, -) -from spatialcf.generation._internal.execution.correspondence import ( +import spatialcf.generation.planning.campaign as planning_campaign +from spatialcf.domain.base import CanonicalModel, Sha256Digest +from spatialcf.domain.serialization import canonical_json_bytes +from spatialcf.generation.execution.correspondence import ( RequestLineage, request_binding_sha256, ) -from spatialcf.generation._internal.planning import campaign as planning_campaign -from spatialcf.generation._internal.planning.campaign import ( +from spatialcf.generation.planning.campaign import ( BatchManifest, RetainedSourcePlan, SourcePlan, ) -from spatialcf.generation._internal.planning.models import EndpointPlan +from spatialcf.generation.planning.models import EndpointPlan +from spatialcf.generation.workflows.execution import ( + BatchSummary, + execute_batch, + prepare_batch_verification, + revalidate_batch_verification, + verify_batch, +) from spatialcf.verification.filesystem import ( bound_absolute_directory, bound_child_directory, @@ -40,7 +40,7 @@ ) -class SourceExecutionSummary(V2Model): +class SourceExecutionSummary(CanonicalModel): """Execution closure for all planned requests in their frozen slots.""" summary_version: Literal["competition-native-source-execution-summary:2.9.3"] = ( @@ -112,7 +112,7 @@ def _checked_source_plan(plan: object) -> SourcePlan: if type(checked) is not SourcePlan: raise TypeError("retained source plan payload must be exact SourcePlan") payload_sha256 = hashlib.sha256( - canonical_json_bytes_v2(checked.model_dump(mode="json", warnings="error")) + canonical_json_bytes(checked.model_dump(mode="json", warnings="error")) + b"\n" ).hexdigest() if payload_sha256 != plan.plan_payload_sha256: @@ -128,7 +128,7 @@ def _checked_source_plan(plan: object) -> SourcePlan: def _batch_manifest_sha256(manifest: BatchManifest) -> Sha256Digest: if type(manifest) is not BatchManifest: raise TypeError("batch manifest digest requires exact BatchManifest") - return hashlib.sha256(canonical_json_bytes_v2(manifest) + b"\n").hexdigest() + return hashlib.sha256(canonical_json_bytes(manifest) + b"\n").hexdigest() def _batch_lineage( diff --git a/src/spatialcf/generation/_internal/execution/correspondence.py b/src/spatialcf/generation/execution/correspondence.py similarity index 89% rename from src/spatialcf/generation/_internal/execution/correspondence.py rename to src/spatialcf/generation/execution/correspondence.py index 5c67df3..9a8c73d 100644 --- a/src/spatialcf/generation/_internal/execution/correspondence.py +++ b/src/spatialcf/generation/execution/correspondence.py @@ -1,37 +1,31 @@ -"""Current capture-to-fresh-source correspondence authority.""" +"""Final capture-to-fresh-source correspondence authority.""" from __future__ import annotations -import hashlib -import json import math -from collections.abc import Mapping, Sequence -from collections.abc import Set as AbstractSet -from enum import Enum from typing import Literal, Self from pydantic import BaseModel, model_validator -from spatialcf.domain.models import OBB, Scene -from spatialcf.domain.v2.base import NonNegativeFiniteFloat, Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import canonical_sha256_v2 -from spatialcf.generation._internal.evidence.camera import ( +from spatialcf.domain.base import CanonicalModel, NonNegativeFiniteFloat, Sha256Digest +from spatialcf.domain.scene import OBB, Scene +from spatialcf.domain.serialization import canonical_sha256 +from spatialcf.generation.capture.compiler import ( + score_competition_native_camera_capture_scene_v2_9_3, +) +from spatialcf.generation.capture.models import ( CameraPolicy, + CompetitionNativeSourceCaptureV2_9, SourceCameraEvidence, -) -from spatialcf.generation._internal.evidence.surface import ( SourceSurfaceEvidence, verify_source_surface_evidence, ) -from spatialcf.generation._internal.planning.campaign import ( +from spatialcf.generation.planning.campaign import ( BatchRequest, RuntimePosePolicy, ) -from spatialcf.generation._internal.planning.models import EndpointPlan -from spatialcf.generation.capture.compiler import ( - score_competition_native_camera_capture_scene_v2_9_3, -) -from spatialcf.generation.capture.models import CompetitionNativeSourceCaptureV2_9 +from spatialcf.generation.planning.models import EndpointPlan +from spatialcf.verification.integrity import competition_legacy_sha256 _CAPTURE_SOURCE_CORRESPONDENCE_HASH_DOMAIN_V2_9_6 = ( "spatialcf.competition-native-capture-source-correspondence.v2.9.6" @@ -55,49 +49,7 @@ def legacy_sha256(value: BaseModel) -> str: """Hash one legacy model under the frozen finite-number convention.""" - - if not isinstance(value, BaseModel): - raise TypeError("competition legacy digest requires a Pydantic model") - payload = json.dumps( - _stable_legacy_value(value.model_dump(mode="json")), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - return hashlib.sha256(payload).hexdigest() - - -def _stable_legacy_value(value: object) -> object: - if isinstance(value, Enum): - return _stable_legacy_value(value.value) - if value is None or type(value) in {str, bool, int}: - return value - if type(value) is float: - if not math.isfinite(value): - raise ValueError("competition legacy digest requires finite floats") - return 0.0 if value == 0.0 else value - if isinstance(value, Mapping): - if any(type(key) is not str for key in value): - raise TypeError("competition legacy digest requires string mapping keys") - return {key: _stable_legacy_value(item) for key, item in sorted(value.items())} - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return [_stable_legacy_value(item) for item in value] - if isinstance(value, AbstractSet): - normalized = [_stable_legacy_value(item) for item in value] - return sorted( - normalized, - key=lambda item: json.dumps( - item, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ), - ) - raise TypeError( - f"unsupported competition legacy digest value {type(value).__name__!r}" - ) + return competition_legacy_sha256(value) def request_binding_sha256(request: BatchRequest) -> Sha256Digest: @@ -108,10 +60,10 @@ def request_binding_sha256(request: BatchRequest) -> Sha256Digest: checked = BatchRequest.model_validate( request.model_dump(mode="python", warnings="error"), strict=True ) - return canonical_sha256_v2(checked, domain=_REQUEST_BINDING_HASH_DOMAIN) + return canonical_sha256(checked, domain=_REQUEST_BINDING_HASH_DOMAIN) -class RequestLineage(V2Model): +class RequestLineage(CanonicalModel): """Flat current camera, patch, collision, pose, and bbox authority.""" lineage_version: Literal[ @@ -205,7 +157,7 @@ def validate_camera_runtime_lineage(self) -> Self: or evidence.scene_id != source.scene_id or evidence.source_locator_sha256 != source.source_locator_sha256 or evidence.runtime_identity_sha256 - != canonical_sha256_v2( + != canonical_sha256( capture.runtime_identity, domain=_CAMERA_RUNTIME_HASH_DOMAIN ) or evidence.source_capture_sha256 != capture.source_capture_sha256 @@ -226,7 +178,7 @@ def validate_camera_runtime_lineage(self) -> Self: @property def request_lineage_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_REQUEST_LINEAGE_HASH_DOMAIN) + return canonical_sha256(self, domain=_REQUEST_LINEAGE_HASH_DOMAIN) @property def competition_native_camera_patch_runtime_request_lineage_sha256( @@ -235,7 +187,7 @@ def competition_native_camera_patch_runtime_request_lineage_sha256( return self.request_lineage_sha256 -class CaptureSourceCorrespondence(V2Model): +class CaptureSourceCorrespondence(CanonicalModel): """Source replay certificate using physical OBB corner-set residuals.""" validation_version: Literal[ @@ -312,7 +264,7 @@ def validate_limits(self) -> Self: def competition_native_capture_source_correspondence_sha256( self, ) -> Sha256Digest: - return canonical_sha256_v2( + return canonical_sha256( self, domain=_CAPTURE_SOURCE_CORRESPONDENCE_HASH_DOMAIN_V2_9_6, ) diff --git a/src/spatialcf/generation/planning.py b/src/spatialcf/generation/planning/__init__.py similarity index 83% rename from src/spatialcf/generation/planning.py rename to src/spatialcf/generation/planning/__init__.py index f5530b8..a79bf47 100644 --- a/src/spatialcf/generation/planning.py +++ b/src/spatialcf/generation/planning/__init__.py @@ -1,19 +1,6 @@ """Current source-planning authority and operations.""" -from spatialcf.generation._internal.planning import ( - CollisionDelegation, - EndpointPlan, - EndpointPlanRejected, - EndpointWorkspace, - ProxyBinding, - ProxyBundle, - SubjectPlacementFact, - build_proxy_bundle, - default_planning_workspace, - default_solver_config, - plan_endpoint, -) -from spatialcf.generation._internal.planning.campaign import ( +from spatialcf.generation.planning.campaign import ( RetainedSourcePlan, RetainedSourcePlanVerification, SourcePlan, @@ -27,6 +14,20 @@ publish_source_plan, revalidate_source_plan_verification, ) +from spatialcf.generation.planning.endpoint import EndpointPlanRejected, plan_endpoint +from spatialcf.generation.planning.models import ( + CollisionDelegation, + EndpointPlan, + EndpointWorkspace, + ProxyBinding, + ProxyBundle, + SubjectPlacementFact, +) +from spatialcf.generation.planning.problem import ( + build_proxy_bundle, + default_planning_workspace, + default_solver_config, +) __all__ = ( "CollisionDelegation", diff --git a/src/spatialcf/generation/_internal/planning/campaign.py b/src/spatialcf/generation/planning/campaign.py similarity index 95% rename from src/spatialcf/generation/_internal/planning/campaign.py rename to src/spatialcf/generation/planning/campaign.py index 111d3fe..49ba8f7 100644 --- a/src/spatialcf/generation/_internal/planning/campaign.py +++ b/src/spatialcf/generation/planning/campaign.py @@ -3,16 +3,12 @@ from __future__ import annotations import hashlib -import json -import math import os import warnings from collections import Counter -from collections.abc import Callable, Mapping, Sequence -from collections.abc import Set as AbstractSet +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field -from enum import Enum from multiprocessing import get_context from multiprocessing.connection import Connection from pathlib import Path @@ -20,64 +16,60 @@ from pydantic import BaseModel, Field, model_validator -from spatialcf.core.v2.continuous_yaw_solver_v2_9 import ( - solve_continuous_yaw_minimum_cost_v2_9, +from spatialcf.core.solver import ( + solve_minimum_cost, ) -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import InterventionSpec, Scene -from spatialcf.domain.v2.base import CanonicalId, Sha256Digest, V2Model -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.base import CanonicalId, CanonicalModel, Sha256Digest +from spatialcf.domain.request import InterventionSpec, Relation +from spatialcf.domain.scene import Scene +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, +) +from spatialcf.domain.solver import ( ContinuousYawCertifiedSuccessResultV2_9, ContinuousYawSolverConfigV2_9, ) -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.generation.capture.compiler import ( + compile_roster, + verify_competition_native_camera_evidence_capture_v2_9_3, ) -from spatialcf.generation._internal.evidence.camera import ( +from spatialcf.generation.capture.models import ( CameraPolicy, + CompetitionNativeCandidateRosterManifestV2_9, + CompetitionNativePlacementAvailabilityV2_9, + CompetitionNativeSelectedRequestV2_9, + CompetitionNativeSourceCaptureV2_9, + CompetitionNativeSubjectPlacementFactV2_9, + CompetitionNativeSupportKindV2_9, + RosterCompilation, SourceCameraEvidence, -) -from spatialcf.generation._internal.evidence.reachability import ( - CandidateTargetReachability, - TargetReachabilityStatus, -) -from spatialcf.generation._internal.evidence.surface import ( SourceSurfaceEvidence, SubjectSurfaceEvidence, verify_source_surface_evidence, ) -from spatialcf.generation._internal.planning.endpoint import ( +from spatialcf.generation.capture.reachability import ( + CandidateTargetReachability, + TargetReachabilityStatus, +) +from spatialcf.generation.errors import require_wire_version +from spatialcf.generation.planning.endpoint import ( EndpointPlanRejected, endpoint_workspace_within_position_region, plan_endpoint, ) -from spatialcf.generation._internal.planning.models import ( +from spatialcf.generation.planning.models import ( CollisionDelegation, EndpointPlan, EndpointWorkspace, ProxyBundle, SubjectPlacementFact, ) -from spatialcf.generation._internal.planning.proxy import ( +from spatialcf.generation.planning.problem import ( build_proxy_bundle, default_planning_workspace, default_solver_config, ) -from spatialcf.generation.capture.compiler import ( - compile_roster, - verify_competition_native_camera_evidence_capture_v2_9_3, -) -from spatialcf.generation.capture.models import ( - CompetitionNativeCandidateRosterManifestV2_9, - CompetitionNativePlacementAvailabilityV2_9, - CompetitionNativeSelectedRequestV2_9, - CompetitionNativeSourceCaptureV2_9, - CompetitionNativeSubjectPlacementFactV2_9, - CompetitionNativeSupportKindV2_9, - RosterCompilation, -) -from spatialcf.generation.errors import require_wire_version from spatialcf.verification.filesystem import ( CompetitionNativePublicationError, DirectoryIdentity, @@ -90,6 +82,7 @@ revalidate_entries, snapshot_exact_directory, ) +from spatialcf.verification.integrity import competition_legacy_sha256 _POLICY_DOMAIN = "spatialcf.competition-native-source-policy.v2.9.13" _PLAN_DOMAIN = "spatialcf.competition-native-source-plan.v2.9.9" @@ -116,54 +109,15 @@ def _manifest_file_sha256(manifest: object) -> str: - return hashlib.sha256(canonical_json_bytes_v2(manifest) + b"\n").hexdigest() + return hashlib.sha256(canonical_json_bytes(manifest) + b"\n").hexdigest() def _runtime_identity_sha256(capture: CompetitionNativeSourceCaptureV2_9) -> str: - return canonical_sha256_v2(capture.runtime_identity, domain=_RUNTIME_DOMAIN) + return canonical_sha256(capture.runtime_identity, domain=_RUNTIME_DOMAIN) def _legacy_sha256(value: BaseModel) -> str: - if not isinstance(value, BaseModel): - raise TypeError("legacy digest requires a Pydantic model") - payload = json.dumps( - _stable_legacy_value(value.model_dump(mode="json")), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - return hashlib.sha256(payload).hexdigest() - - -def _stable_legacy_value(value: object) -> object: - if isinstance(value, Enum): - return _stable_legacy_value(value.value) - if value is None or type(value) in {str, bool, int}: - return value - if type(value) is float: - if not math.isfinite(value): - raise ValueError("legacy digest requires finite floats") - return 0.0 if value == 0.0 else value - if isinstance(value, Mapping): - if any(type(key) is not str for key in value): - raise TypeError("legacy digest requires string mapping keys") - return {key: _stable_legacy_value(item) for key, item in sorted(value.items())} - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return [_stable_legacy_value(item) for item in value] - if isinstance(value, AbstractSet): - normalized = [_stable_legacy_value(item) for item in value] - return sorted( - normalized, - key=lambda item: json.dumps( - item, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ), - ) - raise TypeError(f"unsupported legacy digest value {type(value).__name__!r}") + return competition_legacy_sha256(value) def _accepted_source_capture_roster_sha256( @@ -184,7 +138,7 @@ def _accepted_source_capture_roster_sha256( set(source_ids) ): raise ValueError("accepted source capture roster is not canonical") - return canonical_sha256_v2( + return canonical_sha256( { "roster_version": ( "competition-native-accepted-source-capture-roster:2.9.5" @@ -211,10 +165,10 @@ def _target_reachability_ledger_sha256( candidate_ids = tuple(item.candidate_id for item in rows) if candidate_ids != tuple(sorted(set(candidate_ids))): raise ValueError("target reachability ledger is not canonical") - return canonical_sha256_v2(rows, domain=_TARGET_LEDGER_DOMAIN) + return canonical_sha256(rows, domain=_TARGET_LEDGER_DOMAIN) -class RuntimePosePolicy(V2Model): +class RuntimePosePolicy(CanonicalModel): policy_version: Literal["competition-native-runtime-pose-policy:2.9.5"] = ( "competition-native-runtime-pose-policy:2.9.5" ) @@ -227,10 +181,10 @@ class RuntimePosePolicy(V2Model): @property def runtime_pose_policy_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_RUNTIME_POSE_POLICY_DOMAIN) + return canonical_sha256(self, domain=_RUNTIME_POSE_POLICY_DOMAIN) -class SourcePolicy(V2Model): +class SourcePolicy(CanonicalModel): """The single current bounded source-campaign policy.""" policy_version: Literal[_SOURCE_POLICY_VERSION] = _SOURCE_POLICY_VERSION @@ -273,10 +227,10 @@ def validate_policy(self) -> Self: @property def competition_native_source_policy_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_POLICY_DOMAIN) + return canonical_sha256(self, domain=_POLICY_DOMAIN) -class SourceRequestOutcome(V2Model): +class SourceRequestOutcome(CanonicalModel): request_id: str = Field(pattern=r"^request-[0-9a-f]{64}$") candidate_id: str = Field(pattern=r"^candidate-[0-9a-f]{64}$") selection_index: int = Field(strict=True, ge=0) @@ -374,12 +328,12 @@ def validate_runtime_collision_delegation(self) -> Self: return self -class SourceSlotEntry(V2Model): +class SourceSlotEntry(CanonicalModel): relation: Relation request_id: str | None = Field(default=None, pattern=r"^request-[0-9a-f]{64}$") -class SourceSlotOutcome(V2Model): +class SourceSlotOutcome(CanonicalModel): slot_index: int = Field(strict=True, ge=0) status: Literal["ready", "incomplete"] entries: tuple[SourceSlotEntry, ...] = Field( @@ -410,7 +364,7 @@ def validate_slot(self) -> Self: return self -class BatchRequest(V2Model): +class BatchRequest(CanonicalModel): request_id: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") case_id: CanonicalId scene_id: str = Field(strict=True, min_length=1, max_length=256) @@ -439,7 +393,7 @@ def validate_request(self) -> Self: return self -class BatchManifest(V2Model): +class BatchManifest(CanonicalModel): manifest_version: Literal["competition-native-batch-manifest:2.9.1"] = ( "competition-native-batch-manifest:2.9.1" ) @@ -497,7 +451,7 @@ def _fresh_solve_matches( config: ContinuousYawSolverConfigV2_9, expected_solve_result_sha256: Sha256Digest, ) -> bool: - solved = solve_continuous_yaw_minimum_cost_v2_9(proxy.semantic_problem, config) + solved = solve_minimum_cost(proxy.semantic_problem, config) result = solved.result return bool( type(result) is ContinuousYawCertifiedSuccessResultV2_9 @@ -510,7 +464,7 @@ def _fresh_solve_matches( ) -class SourcePlan(V2Model): +class SourcePlan(CanonicalModel): """The single current, fully replayable source campaign plan.""" plan_version: Literal[_SOURCE_PLAN_VERSION] = _SOURCE_PLAN_VERSION @@ -929,7 +883,7 @@ def validate_target_reachability(self) -> Self: @property def competition_native_source_plan_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_PLAN_DOMAIN) + return canonical_sha256(self, domain=_PLAN_DOMAIN) _SOURCE_PLAN_CAPABILITY = object() @@ -1793,8 +1747,7 @@ def _parse_source_plan(payload: bytes) -> SourcePlan: raise TypeError("source plan must be exact SourcePlan") if ( payload - != canonical_json_bytes_v2(plan.model_dump(mode="json", warnings="error")) - + b"\n" + != canonical_json_bytes(plan.model_dump(mode="json", warnings="error")) + b"\n" ): raise ValueError("native source plan is not canonical") return plan @@ -1817,7 +1770,7 @@ def _load_source_policy_fd( policy = SourcePolicy.model_validate_json(payload, strict=True) if type(policy) is not SourcePolicy: raise TypeError("source policy must be exact SourcePolicy") - if payload != canonical_json_bytes_v2(policy) + b"\n": + if payload != canonical_json_bytes(policy) + b"\n": raise ValueError("native source policy is not canonical") return policy @@ -1913,7 +1866,7 @@ def _retain_checked_source_plan(plan: SourcePlan) -> RetainedSourcePlan: if type(plan) is not SourcePlan: raise TypeError("retained source plan payload must be exact SourcePlan") payload = ( - canonical_json_bytes_v2(plan.model_dump(mode="json", warnings="error")) + b"\n" + canonical_json_bytes(plan.model_dump(mode="json", warnings="error")) + b"\n" ) return RetainedSourcePlan( _capability=_SOURCE_PLAN_CAPABILITY, @@ -2039,7 +1992,7 @@ def publish_source_plan(plan: SourcePlan, output_root: Path) -> SourcePlan: checked = SourcePlan.model_validate( plan.model_dump(mode="python", warnings="error"), strict=True ) - payload = canonical_json_bytes_v2(checked) + b"\n" + payload = canonical_json_bytes(checked) + b"\n" if len(payload) > _MAX_PLAN_BYTES: raise ValueError("native source plan exceeds byte limit") checksum = f"{hashlib.sha256(payload).hexdigest()} plan.json\n".encode("ascii") diff --git a/src/spatialcf/generation/_internal/planning/endpoint.py b/src/spatialcf/generation/planning/endpoint.py similarity index 84% rename from src/spatialcf/generation/_internal/planning/endpoint.py rename to src/spatialcf/generation/planning/endpoint.py index ec812ef..fd79027 100644 --- a/src/spatialcf/generation/_internal/planning/endpoint.py +++ b/src/spatialcf/generation/planning/endpoint.py @@ -11,52 +11,42 @@ from shapely.geometry import GeometryCollection, MultiPolygon, Point, Polygon, box from shapely.geometry.base import BaseGeometry -from spatialcf.adapters.ai2thor import ( - AI2ThorNativePosition, - AI2ThorReceptacleSpawnMap, - AI2ThorReceptacleSurfacePatch, - AI2ThorRuntimeIdentity, - _native_positions_sha256, - _receptacle_scene_sha256, - _receptacle_spawn_source_sha256, - build_receptacle_support_position_region, +from spatialcf.core._internal.kernels.convex_translation import RationalPoint2V2 +from spatialcf.core.feasibility import ( + FeasibleRegionBuilder, + _center_locus, + _relative_vertices, + subject_position_region_geometry, ) -from spatialcf.core.v2.continuous_yaw_solver_v2_9 import ( - solve_continuous_yaw_minimum_cost_v2_9, +from spatialcf.core.solver import ( + solve_minimum_cost, ) -from spatialcf.core.v2.convex_translation_domain import RationalPoint2V2 -from spatialcf.domain.models import ( - InterventionSpec, - Scene, - SceneObject, - SubjectPositionRegion, -) -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.request import InterventionSpec +from spatialcf.domain.scene import Scene, SceneObject, SubjectPositionRegion +from spatialcf.domain.solver import ( ContinuousYawCertifiedSuccessResultV2_9, ContinuousYawSolverConfigV2_9, ) -from spatialcf.generation._internal.evidence.reachability import ( - target_only_relation_after_native_coordinates, -) -from spatialcf.generation._internal.evidence.surface import ( +from spatialcf.generation.capture.models import ( ReceptacleSurfacePatch, SourceSurfaceEvidence, SubjectSurfaceEvidence, ) -from spatialcf.generation._internal.planning.models import ( +from spatialcf.generation.capture.reachability import ( + target_only_relation_after_native_coordinates, +) +from spatialcf.generation.planning.models import ( EndpointPlan, EndpointWorkspace, SubjectPlacementFact, ) -from spatialcf.generation._internal.planning.proxy import ( +from spatialcf.generation.planning.problem import ( _prepare_proxy, _PreparedCurrentProxy, _project_proxy, _project_proxy_problem, ) -from spatialcf.geometry.regions import subject_position_region_geometry -from spatialcf.solver.feasible import FeasibleRegionBuilder _DEFAULT_RADII_M = (0.02, 0.01, 0.005, 0.001) _NATIVE_SPAWN_RADIUS_M = 0.000001 @@ -87,6 +77,10 @@ def _solver_success_mismatch( or result.solver_config.config_sha256 != config.config_sha256 ): return "endpoint_plan:solver_config_mismatch" + try: + ContinuousYawCertifiedSuccessResultV2_9.model_validate(result, strict=True) + except (TypeError, ValueError): + return "endpoint_plan:solver_certificate_mismatch" return None @@ -122,7 +116,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class _PatchLocus: patch: ReceptacleSurfacePatch - position_region: SubjectPositionRegion + position_geometry: BaseGeometry def __post_init__(self) -> None: if type(self.patch) is not ReceptacleSurfacePatch: @@ -131,12 +125,8 @@ def __post_init__(self) -> None: self.patch.model_dump(mode="python"), strict=True, ) - if type(self.position_region) is not SubjectPositionRegion: - raise TypeError("patch locus position region must be exact") - SubjectPositionRegion.model_validate( - self.position_region.model_dump(mode="python"), - strict=True, - ) + if not isinstance(self.position_geometry, BaseGeometry): + raise TypeError("patch locus position geometry must be exact") @dataclass(frozen=True, slots=True) @@ -195,93 +185,22 @@ def _single_patch_loci_from_prepared( or subject_placement.position_region is None ): raise ValueError("patch-bound evidence does not bind placement lineage") - patches = tuple( - AI2ThorReceptacleSurfacePatch( - x_min=item.x_min, - x_max=item.x_max, - native_y=item.native_y, - z_min=item.z_min, - z_max=item.z_max, - ) - for item in checked_evidence.patches - ) - if checked_evidence.scene_sha256 != _receptacle_scene_sha256(scene, patches): - raise ValueError("patch-bound evidence does not bind source geometry") - runtime = AI2ThorRuntimeIdentity( - **capture.runtime_identity.model_dump(mode="python") - ) - positions = tuple( - AI2ThorNativePosition(x=item.x, y=item.y, z=item.z) - for item in subject_placement.native_positions - ) - - def spawn_map( - selected: tuple[AI2ThorReceptacleSurfacePatch, ...], - ) -> AI2ThorReceptacleSpawnMap: - selected_positions = ( - positions - if selected == patches - else tuple( - AI2ThorNativePosition( - x=patch.x_min + (patch.x_max - patch.x_min) * x_index / 20.0, - y=patch.native_y, - z=patch.z_min + (patch.z_max - patch.z_min) * z_index / 20.0, - ) - for patch in selected - for x_index in range(21) - for z_index in range(21) - ) - ) - positions_sha256 = ( - checked_evidence.positions_sha256 - if selected == patches - else _native_positions_sha256(selected_positions) - ) - scene_sha256 = _receptacle_scene_sha256(scene, selected) - source_sha256 = _receptacle_spawn_source_sha256( - scene_id=scene.scene_id, - subject_object_id=checked_evidence.subject_object_id, - support_object_id=checked_evidence.support_object_id, - native_subject_object_id=checked_evidence.native_subject_object_id, - native_support_object_id=checked_evidence.native_support_object_id, - runtime_identity=runtime, - positions_sha256=positions_sha256, - scene_sha256=scene_sha256, - surface_patches=selected, - ) - return AI2ThorReceptacleSpawnMap( - scene_id=scene.scene_id, - subject_object_id=checked_evidence.subject_object_id, - support_object_id=checked_evidence.support_object_id, - native_subject_object_id=checked_evidence.native_subject_object_id, - native_support_object_id=checked_evidence.native_support_object_id, - runtime_identity=runtime, - positions=selected_positions, - positions_sha256=positions_sha256, - scene_sha256=scene_sha256, - source_sha256=source_sha256, - surface_patches=selected, - ) - - full_region = build_receptacle_support_position_region( - scene, - spawn_map(patches), - ) - if full_region != subject_placement.position_region: - raise ValueError("patch-bound evidence changed the captured placement region") + subject = scene.object_by_id(subject_placement.object_id) + relative_vertices = _relative_vertices(subject) return tuple( _PatchLocus( patch=patch, - position_region=build_receptacle_support_position_region( - scene, - spawn_map((native_patch,)), + position_geometry=_center_locus( + box( + patch.x_min, + patch.z_min, + patch.x_max, + patch.z_max, + ), + relative_vertices, ), ) - for patch, native_patch in zip( - checked_evidence.patches, - patches, - strict=True, - ) + for patch in checked_evidence.patches ) @@ -439,7 +358,26 @@ def endpoint_workspace_within_position_region( raise TypeError("workspace must be exact") if region.subject_object_id != subject.object_id: raise ValueError("workspace position region does not bind the subject") - pivot_locus = subject_position_region_geometry(region) + return _endpoint_workspace_within_geometry( + subject, + subject_position_region_geometry(region), + workspace, + ) + + +def _endpoint_workspace_within_geometry( + subject: SceneObject, + pivot_locus: BaseGeometry, + workspace: EndpointWorkspace, +) -> bool: + """Prove one OBB-center workspace lies in a persisted anchor locus.""" + + if type(subject) is not SceneObject: + raise TypeError("workspace subject must be exact") + if not isinstance(pivot_locus, BaseGeometry): + raise TypeError("workspace position geometry must be exact") + if type(workspace) is not EndpointWorkspace: + raise TypeError("workspace must be exact") center_locus = translate( pivot_locus, xoff=subject.obb.center.x - subject.position.x, @@ -529,10 +467,7 @@ def _assign_patch_owned_candidates( indexes = tuple(item.patch.patch_index for item in canonical_loci) if indexes != tuple(range(len(canonical_loci))): raise ValueError("patch loci indexes are not canonical and complete") - geometries = tuple( - (item, subject_position_region_geometry(item.position_region)) - for item in canonical_loci - ) + geometries = tuple((item, item.position_geometry) for item in canonical_loci) owner_by_coordinate: dict[tuple[Fraction, Fraction], _PatchLocus] = {} def freeze_points( @@ -717,6 +652,16 @@ def plan_endpoint( placement, prepared, ) + subject_placement = placement.subject_placement + if subject_placement.position_region is None: + raise ValueError("patch-bound evidence has no persisted placement region") + patch_locus_union: BaseGeometry = GeometryCollection() + for patch_locus in patch_loci: + patch_locus_union = patch_locus_union.union(patch_locus.position_geometry) + if not patch_locus_union.equals( + subject_position_region_geometry(subject_placement.position_region) + ): + raise ValueError("patch loci do not bind persisted placement region") subject = scene.object_by_id(intervention.subject_id) if subject.object_id != subject_surface_evidence.subject_object_id: raise ValueError("patch-bound evidence does not bind intervention subject") @@ -766,9 +711,9 @@ def plan_endpoint( workspace, ) if endpoint_workspace is None or not ( - endpoint_workspace_within_position_region( + _endpoint_workspace_within_geometry( subject, - locus.position_region, + locus.position_geometry, endpoint_workspace, ) ): @@ -786,7 +731,7 @@ def plan_endpoint( ): reasons.add("endpoint_plan:collision_roster_too_large") continue - screened = solve_continuous_yaw_minimum_cost_v2_9( + screened = solve_minimum_cost( proxy.semantic_problem, screening_config, ) @@ -805,7 +750,7 @@ def plan_endpoint( solved = ( screened if screening_config == config - else solve_continuous_yaw_minimum_cost_v2_9( + else solve_minimum_cost( proxy.semantic_problem, config, ) diff --git a/src/spatialcf/generation/_internal/planning/models.py b/src/spatialcf/generation/planning/models.py similarity index 96% rename from src/spatialcf/generation/_internal/planning/models.py rename to src/spatialcf/generation/planning/models.py index cadc831..8498216 100644 --- a/src/spatialcf/generation/_internal/planning/models.py +++ b/src/spatialcf/generation/planning/models.py @@ -9,15 +9,15 @@ from pydantic import model_validator -from spatialcf.domain.v2.base import CanonicalId, FiniteFloat, Sha256Digest, V2Model -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.serialization import canonical_sha256_v2 -from spatialcf.generation._internal.evidence.surface import ReceptacleSurfacePatch +from spatialcf.domain.base import CanonicalId, CanonicalModel, FiniteFloat, Sha256Digest +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.serialization import canonical_sha256 from spatialcf.generation.capture.models import ( CompetitionNativePlacementAvailabilityV2_9, CompetitionNativeSourceCaptureV2_9, CompetitionNativeSubjectPlacementFactV2_9, CompetitionNativeSupportKindV2_9, + ReceptacleSurfacePatch, ) _BINDING_HASH_DOMAIN = "spatialcf.competition-native-proxy-binding.v2.9.4" @@ -43,7 +43,7 @@ def _require_sha256(value: object, label: str) -> str: return value -class EndpointWorkspace(V2Model): +class EndpointWorkspace(CanonicalModel): """Closed absolute world-XY endpoint policy for one native request.""" min_x_m: FiniteFloat @@ -58,7 +58,7 @@ def validate_bounds(self) -> Self: return self -class SubjectPlacementFact(V2Model): +class SubjectPlacementFact(CanonicalModel): """One exact captured receptacle placement supplied without an adapter.""" placement_version: Literal["competition-native-endpoint-placement:2.9.2"] = ( @@ -141,7 +141,7 @@ def __post_init__(self) -> None: raise TypeError("collision delegation patch_index must be non-negative") -class ProxyBinding(V2Model): +class ProxyBinding(CanonicalModel): """Current bbox proxy lineage with an explicit native collision authority.""" binding_version: Literal["competition-native-proxy-binding:2.9.4"] = ( @@ -266,10 +266,10 @@ def validate_runtime_collision_delegation(self) -> Self: @property def proxy_binding_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_BINDING_HASH_DOMAIN) + return canonical_sha256(self, domain=_BINDING_HASH_DOMAIN) -class ProxyBundle(V2Model): +class ProxyBundle(CanonicalModel): """One current Canonical problem plus its non-capability native lineage.""" bundle_version: Literal["competition-native-proxy-bundle:2.9.4"] = ( @@ -360,10 +360,10 @@ def validate_bbox_visibility(self) -> Self: @property def proxy_bundle_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_BUNDLE_HASH_DOMAIN) + return canonical_sha256(self, domain=_BUNDLE_HASH_DOMAIN) -class EndpointPlan(V2Model): +class EndpointPlan(CanonicalModel): """One current solver-certified endpoint wholly owned by a source patch.""" plan_version: Literal["competition-native-endpoint-plan:2.9.4"] = ( @@ -430,7 +430,7 @@ def validate_runtime_collision_delegation(self) -> Self: @property def endpoint_plan_sha256(self) -> str: - return canonical_sha256_v2(self, domain=_ENDPOINT_PLAN_HASH_DOMAIN) + return canonical_sha256(self, domain=_ENDPOINT_PLAN_HASH_DOMAIN) def _direct_support_surface(problem: SemanticProblemV2_3, subject_object_id: str): diff --git a/src/spatialcf/generation/_internal/planning/proxy.py b/src/spatialcf/generation/planning/problem.py similarity index 96% rename from src/spatialcf/generation/_internal/planning/proxy.py rename to src/spatialcf/generation/planning/problem.py index e2d33b1..76b9c9b 100644 --- a/src/spatialcf/generation/_internal/planning/proxy.py +++ b/src/spatialcf/generation/planning/problem.py @@ -6,36 +6,35 @@ import json import math import warnings -from collections.abc import Mapping, Sequence -from collections.abc import Set as AbstractSet from dataclasses import dataclass -from enum import Enum from fractions import Fraction from typing import Literal from pydantic import BaseModel, Field, ValidationError -from spatialcf.adapters.ai2thor import ( - AI2ThorReceptacleSurfacePatch, - _receptacle_scene_sha256, -) -from spatialcf.domain.models import OBB, InterventionSpec, Scene -from spatialcf.domain.v2.base import FactCompletenessV2, UncertaintyBudgetV2, V2Model -from spatialcf.domain.v2.constraints import RelationV2 -from spatialcf.domain.v2.continuous_yaw_camera import SemanticProblemV2_3 -from spatialcf.domain.v2.continuous_yaw_candidate import ( +from spatialcf.domain.artifacts import ( StrictConvexCandidateCompilerConfigV2_7, ) -from spatialcf.domain.v2.continuous_yaw_solver_v2_9 import ( +from spatialcf.domain.base import ( + CanonicalModel, + FactCompletenessV2, + UncertaintyBudgetV2, +) +from spatialcf.domain.constraints import Relation +from spatialcf.domain.problem import SemanticProblemV2_3 +from spatialcf.domain.request import InterventionSpec +from spatialcf.domain.scene import OBB, Scene +from spatialcf.domain.solver import ( ContinuousYawSolverConfigV2_9, ) -from spatialcf.generation._internal.evidence.surface import ( +from spatialcf.generation.capture.models import ( + CompetitionNativeSourceCaptureV2_9, ReceptacleSurfacePatch, SourceSurfaceEvidence, SubjectSurfaceEvidence, verify_source_surface_evidence, ) -from spatialcf.generation._internal.planning.models import ( +from spatialcf.generation.planning.models import ( _PROXY_POLICY_SHA256, CollisionDelegation, EndpointWorkspace, @@ -43,7 +42,7 @@ ProxyBundle, SubjectPlacementFact, ) -from spatialcf.generation.capture.models import CompetitionNativeSourceCaptureV2_9 +from spatialcf.verification.integrity import competition_legacy_sha256 _ZERO_UNCERTAINTY = UncertaintyBudgetV2().model_dump(mode="json") _VISIBILITY_DEFINITIONS = ( @@ -106,7 +105,7 @@ def __init__(self, finding_code: str) -> None: self.finding_code = finding_code -class _ProxyChallenge(V2Model): +class _ProxyChallenge(CanonicalModel): case_id: str direction: str archetype: str @@ -296,8 +295,8 @@ def _convert_proxy_scene( } ) - target_before = RelationV2(spec.relation_before.value.upper()) - target_after = RelationV2(spec.relation_after.value.upper()) + target_before = Relation(spec.relation_before.value.upper()) + target_after = Relation(spec.relation_after.value.upper()) target_axis = target_after.axis.value pair_weights = [] for native_id in sorted(item.object_id for item in scene.objects): @@ -1052,21 +1051,6 @@ def _verified_proxy_inputs( not in {item.object_id for item in checked_scene.objects} ): raise ValueError("subject surface evidence does not bind the native proxy") - native_patches = tuple( - AI2ThorReceptacleSurfacePatch( - x_min=item.x_min, - x_max=item.x_max, - native_y=item.native_y, - z_min=item.z_min, - z_max=item.z_max, - ) - for item in checked_evidence.patches - ) - if checked_evidence.scene_sha256 != _receptacle_scene_sha256( - checked_scene, - native_patches, - ): - raise ValueError("subject surface evidence does not bind source geometry") return ( checked_scene, checked_intervention, @@ -2113,49 +2097,7 @@ def _require_valid_proxy_obb(obb: OBB) -> None: def _legacy_sha256(value: BaseModel) -> str: """Hash one legacy model after applying the v2 finite-number convention.""" - - if not isinstance(value, BaseModel): - raise TypeError("competition legacy digest requires a Pydantic model") - payload = json.dumps( - _stable_value(value.model_dump(mode="json")), - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ).encode("utf-8") - return hashlib.sha256(payload).hexdigest() - - -def _stable_value(value): - if isinstance(value, Enum): - return _stable_value(value.value) - if value is None or type(value) in {str, bool, int}: - return value - if type(value) is float: - if not math.isfinite(value): - raise ValueError("competition legacy digest requires finite floats") - return 0.0 if value == 0.0 else value - if isinstance(value, Mapping): - if any(type(key) is not str for key in value): - raise TypeError("competition legacy digest requires string mapping keys") - return {key: _stable_value(item) for key, item in sorted(value.items())} - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return [_stable_value(item) for item in value] - if isinstance(value, AbstractSet): - normalized = [_stable_value(item) for item in value] - return sorted( - normalized, - key=lambda item: json.dumps( - item, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - allow_nan=False, - ), - ) - raise TypeError( - f"unsupported competition legacy digest value {type(value).__name__!r}" - ) + return competition_legacy_sha256(value) __all__ = ( diff --git a/src/spatialcf/generation/publication.py b/src/spatialcf/generation/publication/__init__.py similarity index 89% rename from src/spatialcf/generation/publication.py rename to src/spatialcf/generation/publication/__init__.py index 5cdab80..04d358c 100644 --- a/src/spatialcf/generation/publication.py +++ b/src/spatialcf/generation/publication/__init__.py @@ -1,6 +1,6 @@ """Current native asset publication authority.""" -from spatialcf.generation._internal.assets import ( +from spatialcf.generation.publication.assets import ( AssetBundle, AssetKind, AssetPhase, diff --git a/src/spatialcf/generation/_internal/assets.py b/src/spatialcf/generation/publication/assets.py similarity index 78% rename from src/spatialcf/generation/_internal/assets.py rename to src/spatialcf/generation/publication/assets.py index bcc5d24..e468a8b 100644 --- a/src/spatialcf/generation/_internal/assets.py +++ b/src/spatialcf/generation/publication/assets.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import math import os import warnings from enum import StrEnum @@ -14,22 +15,29 @@ from PIL import Image, UnidentifiedImageError from pydantic import Field, model_validator -from spatialcf.adapters.ai2thor import AI2ThorObservation -from spatialcf.adapters.ai2thor_validation import observation_contract_errors -from spatialcf.domain.models import Scene -from spatialcf.domain.v2.base import CanonicalId, Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import ( - canonical_json_bytes_v2, - canonical_sha256_v2, +from spatialcf.adapters.base import AdapterObservation +from spatialcf.domain.base import CanonicalId, CanonicalModel, Sha256Digest +from spatialcf.domain.scene import Scene +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, ) -from spatialcf.generation._internal.execution.audit import observation_sha256 -from spatialcf.generation._internal.execution.correspondence import legacy_sha256 -from spatialcf.generation._internal.execution.run import ( +from spatialcf.generation.errors import require_exact_type, require_wire_version +from spatialcf.generation.execution.audit import ( AuditExecution, AuditRun, + observation_sha256, verify_audit_run, ) -from spatialcf.generation.errors import require_exact_type, require_wire_version +from spatialcf.generation.execution.correspondence import legacy_sha256 +from spatialcf.verification.artifacts import ( + canonical_checksum_ledger, + read_retained_artifact, + require_exact_checksum_ledger, + retained_sha256_digests, + revalidate_retained_tree, + snapshot_exact_artifact_tree, +) from spatialcf.verification.filesystem import ( BindingStatus, CompetitionNativePublicationError, @@ -39,10 +47,7 @@ bound_child_directory, directory_identity_fd, open_native_output_parent, - read_regular_at, reconcile_owned_rename_at, - revalidate_entries, - snapshot_exact_directory, ) _BUNDLE_VERSION = "competition-native-asset-bundle:2.9.7" @@ -85,7 +90,7 @@ class AssetKind(StrEnum): } -class ReturnedAssetRef(V2Model): +class ReturnedAssetRef(CanonicalModel): phase: AssetPhase kind: AssetKind relative_path: str = Field( @@ -110,12 +115,12 @@ def validate_path(self) -> Self: return self -class InstancePixelCount(V2Model): +class InstancePixelCount(CanonicalModel): object_id: CanonicalId pixel_count: int = Field(strict=True, ge=0) -class AssetBundle(V2Model): +class AssetBundle(CanonicalModel): """The single current asset bundle wire.""" bundle_version: Literal["competition-native-asset-bundle:2.9.7"] = ( @@ -179,7 +184,7 @@ def validate_bundle(self) -> Self: @property def competition_native_asset_bundle_sha256(self) -> Sha256Digest: - return canonical_sha256_v2(self, domain=_BUNDLE_HASH_DOMAIN) + return canonical_sha256(self, domain=_BUNDLE_HASH_DOMAIN) @property def asset_bundle_sha256(self) -> Sha256Digest: @@ -189,7 +194,7 @@ def asset_bundle_sha256(self) -> Sha256Digest: def asset_bundle_sha256_from_checked(bundle: AssetBundle) -> Sha256Digest: if type(bundle) is not AssetBundle: raise TypeError("checked native asset bundle must be exact") - return canonical_sha256_v2( + return canonical_sha256( bundle.model_dump(mode="json", warnings="error"), domain=_BUNDLE_HASH_DOMAIN, ) @@ -209,13 +214,11 @@ def publish_asset_bundle( if not isinstance(output_root, Path): raise TypeError("output_root must be a Path") output = Path(os.path.abspath(output_root)) + _require_execution_observation_closure(checked) payloads = _observation_payloads( checked.before_observation, checked.after_observation ) _require_payload_limits(payloads) - _require_observation(checked.before_observation, "before") - _require_observation(checked.fresh_before_observation, "fresh before") - _require_observation(checked.after_observation, "after") bundle = AssetBundle( native_audit_run=checked.run, native_audit_run_sha256=(checked.run.competition_native_audit_run_sha256), @@ -234,7 +237,7 @@ def publish_asset_bundle( for (phase, kind), payload in payloads.items() ), ) - bundle_payload = canonical_json_bytes_v2(bundle) + b"\n" + bundle_payload = canonical_json_bytes(bundle) + b"\n" if len(bundle_payload) > _MAX_METADATA_BYTES: raise ValueError("native asset bundle metadata exceeds byte limit") checksum_payload = _checksum_payload(bundle, bundle_payload) @@ -300,7 +303,7 @@ def _publish_bundle_transaction( verified = _verify_bundle_tree_fd( final_descriptor, expected_run ) - if canonical_json_bytes_v2(verified) != canonical_json_bytes_v2( + if canonical_json_bytes(verified) != canonical_json_bytes( bundle ): raise RuntimeError( @@ -507,7 +510,7 @@ def load_asset_bundle(root: Path) -> AssetBundle: warnings.simplefilter("error", Warning) with bound_absolute_directory(root) as descriptor: bundle, _, entries = _read_bundle_metadata_fd(descriptor) - revalidate_entries(descriptor, entries) + revalidate_retained_tree(descriptor, entries) return bundle @@ -542,11 +545,9 @@ def _verify_bundle_tree_fd_with_digests( expected_run: AuditRun, ) -> tuple[AssetBundle, dict[str, str]]: bundle, bundle_payload, entries = _read_bundle_metadata_fd(descriptor) - if canonical_json_bytes_v2( + if canonical_json_bytes( bundle.native_audit_run.model_dump(mode="json", warnings="error") - ) != canonical_json_bytes_v2( - expected_run.model_dump(mode="json", warnings="error") - ): + ) != canonical_json_bytes(expected_run.model_dump(mode="json", warnings="error")): raise ValueError("native asset bundle targets a different audit run") return _verify_bundle_payloads_fd( descriptor, bundle, bundle_payload, entries, expected_run @@ -557,8 +558,8 @@ def _read_bundle_metadata_fd( descriptor: int, ) -> tuple[AssetBundle, bytes, dict[str, os.stat_result]]: expected_names = {"bundle.json", "checksums.sha256", *_ASSET_FILENAMES.values()} - entries = snapshot_exact_directory(descriptor, regular_names=expected_names) - bundle_payload = read_regular_at( + entries = snapshot_exact_artifact_tree(descriptor, regular_names=expected_names) + bundle_payload = read_retained_artifact( descriptor, "bundle.json", _MAX_METADATA_BYTES, @@ -573,7 +574,7 @@ def _read_bundle_metadata_fd( expected=_BUNDLE_VERSION, ) bundle = AssetBundle.model_validate_json(bundle_payload, strict=True) - if bundle_payload != canonical_json_bytes_v2(bundle) + b"\n": + if bundle_payload != canonical_json_bytes(bundle) + b"\n": raise ValueError("native asset bundle metadata is not canonical") return bundle, bundle_payload, entries @@ -595,7 +596,7 @@ def _verify_bundle_payloads_fd( aggregate += reference.byte_length if aggregate > _MAX_AGGREGATE_ASSET_BYTES: raise ValueError("native asset aggregate byte limit exceeded") - payload = read_regular_at( + payload = read_retained_artifact( descriptor, filename, min(_MAX_ASSET_BYTES, reference.byte_length), @@ -607,22 +608,21 @@ def _verify_bundle_payloads_fd( ): raise ValueError(f"native asset content mismatch: {filename}") payloads[key] = payload - checksum_payload = read_regular_at( + checksum_payload = read_retained_artifact( descriptor, "checksums.sha256", _MAX_METADATA_BYTES, expected_stat=entries["checksums.sha256"], ) - if checksum_payload != _checksum_payload(bundle, bundle_payload): - raise ValueError("native asset checksum ledger mismatch") - digests = { - "bundle.json": hashlib.sha256(bundle_payload).hexdigest(), - "checksums.sha256": hashlib.sha256(checksum_payload).hexdigest(), - **{ - _ASSET_FILENAMES[key]: hashlib.sha256(payload).hexdigest() - for key, payload in payloads.items() - }, - } + payload_digests = _bundle_payload_digests(bundle, bundle_payload) + require_exact_checksum_ledger(checksum_payload, payload_digests) + digests = retained_sha256_digests( + { + "bundle.json": bundle_payload, + "checksums.sha256": checksum_payload, + **{_ASSET_FILENAMES[key]: payload for key, payload in payloads.items()}, + } + ) if expected_digests is not None and digests != expected_digests: raise ValueError("native asset outer checksum ledger mismatch") verify_audit_run(expected_run, bundle.observed_after_scene) @@ -643,7 +643,7 @@ def _verify_bundle_payloads_fd( raise ValueError("native fresh before observation digest mismatch") if observation_sha256(after) != expected_run.native_audit.after_observation_sha256: raise ValueError("native after observation digest mismatch") - revalidate_entries(descriptor, entries) + revalidate_retained_tree(descriptor, entries) return bundle, digests @@ -653,7 +653,7 @@ def _observation_from_bundle( payloads: dict[tuple[AssetPhase, AssetKind], bytes], *, scene: Scene | None = None, -) -> AI2ThorObservation: +) -> AdapterObservation: selected_scene = ( scene if scene is not None @@ -666,19 +666,21 @@ def _observation_from_bundle( if phase is AssetPhase.BEFORE else bundle.after_instance_pixel_counts ) - return AI2ThorObservation.create( + return AdapterObservation.create( scene=selected_scene, rgb_png=payloads[(phase, AssetKind.RGB_PNG)], depth_npy=payloads[(phase, AssetKind.DEPTH_NPY)], instance_png=payloads[(phase, AssetKind.INSTANCE_PNG)], pointcloud_ply=payloads[(phase, AssetKind.POINTCLOUD_PLY)], - instance_pixel_counts={item.object_id: item.pixel_count for item in counts}, - is_scene_at_rest=True, + instance_pixel_counts=tuple( + (item.object_id, item.pixel_count) for item in counts + ), + is_settled=True, ) def _observation_payloads( - before: AI2ThorObservation, after: AI2ThorObservation + before: AdapterObservation, after: AdapterObservation ) -> dict[tuple[AssetPhase, AssetKind], bytes]: return { (AssetPhase.BEFORE, AssetKind.RGB_PNG): before.rgb_png, @@ -692,10 +694,46 @@ def _observation_payloads( } -def _pixel_counts(observation: AI2ThorObservation) -> tuple[InstancePixelCount, ...]: +def _require_execution_observation_closure(execution: AuditExecution) -> None: + before = execution.before_observation + fresh_before = execution.fresh_before_observation + after = execution.after_observation + _require_observation(before, "before") + _require_observation(fresh_before, "fresh before") + _require_observation(after, "after") + if before.scene != execution.run.source_scene: + raise ValueError("before observation scene does not close its audit run") + if fresh_before.scene != execution.run.fresh_source_scene: + raise ValueError("fresh before observation scene does not close its audit run") + if ( + before.rgb_png, + before.depth_npy, + before.instance_png, + before.pointcloud_ply, + ) != ( + fresh_before.rgb_png, + fresh_before.depth_npy, + fresh_before.instance_png, + fresh_before.pointcloud_ply, + ): + raise ValueError("before observation assets do not close its audit run") + if observation_sha256(before) != execution.run.before_observation_sha256: + raise ValueError("before observation digest does not close its audit run") + if ( + observation_sha256(fresh_before) + != execution.run.fresh_before_observation_sha256 + ): + raise ValueError("fresh before observation digest does not close its audit run") + if legacy_sha256(after.scene) != execution.run.native_audit.observed_scene_sha256: + raise ValueError("after observation scene does not close its audit run") + if observation_sha256(after) != execution.run.native_audit.after_observation_sha256: + raise ValueError("after observation digest does not close its audit run") + + +def _pixel_counts(observation: AdapterObservation) -> tuple[InstancePixelCount, ...]: return tuple( InstancePixelCount(object_id=object_id, pixel_count=count) - for object_id, count in sorted(observation.instance_pixel_counts.items()) + for object_id, count in observation.instance_pixel_counts ) @@ -709,8 +747,8 @@ def _canonical_counts(values) -> tuple[InstancePixelCount, ...]: return ordered -def _require_observation(observation: AI2ThorObservation, label: str) -> None: - if type(observation) is not AI2ThorObservation: +def _require_observation(observation: AdapterObservation, label: str) -> None: + if type(observation) is not AdapterObservation: raise TypeError(f"{label} observation must be exact") try: camera = observation.scene.camera_by_id("main") @@ -734,13 +772,106 @@ def _require_observation(observation: AI2ThorObservation, label: str) -> None: _require_depth_header( observation.depth_npy, width=camera.width, height=camera.height ) - errors = observation_contract_errors(observation, "main") + errors = _adapter_observation_errors(observation, "main") if errors: raise ValueError(f"invalid {label} native observation: {';'.join(errors)}") - if observation.is_scene_at_rest is not True: + if observation.is_settled is not True: raise ValueError(f"{label} native observation is not at rest") +def _adapter_observation_errors( + observation: AdapterObservation, + camera_id: str, +) -> tuple[str, ...]: + errors: list[str] = [] + try: + camera = observation.scene.camera_by_id(camera_id) + except KeyError: + return ("observation_camera_missing",) + + assets = ( + ("rgb", observation.rgb_png, observation.rgb_png_sha256), + ("depth", observation.depth_npy, observation.depth_npy_sha256), + ("instance", observation.instance_png, observation.instance_png_sha256), + ( + "pointcloud", + observation.pointcloud_ply, + observation.pointcloud_ply_sha256, + ), + ) + for name, payload, expected_digest in assets: + if hashlib.sha256(payload).hexdigest() != expected_digest: + errors.append(f"observation_{name}_digest_mismatch") + + for name, payload in ( + ("rgb", observation.rgb_png), + ("instance", observation.instance_png), + ): + try: + with Image.open(BytesIO(payload)) as image: + image.load() + if image.size != (camera.width, camera.height): + errors.append(f"observation_{name}_dimensions_mismatch") + if image.mode != "RGB": + errors.append(f"observation_{name}_dtype_mismatch") + except (OSError, UnidentifiedImageError, ValueError): + errors.append(f"observation_{name}_invalid") + + try: + depth = np.load(BytesIO(observation.depth_npy), allow_pickle=False) + if depth.shape != (camera.height, camera.width): + errors.append("observation_depth_dimensions_mismatch") + if depth.dtype != np.float32: + errors.append("observation_depth_dtype_mismatch") + if not np.any(np.isfinite(depth) & (depth > 0.0)): + errors.append("observation_depth_has_no_positive_sample") + except (OSError, ValueError, TypeError): + errors.append("observation_depth_invalid") + + try: + pointcloud = observation.pointcloud_ply.decode("ascii") + lines = pointcloud.splitlines() + if ( + len(lines) < 4 + or lines[0] != "ply" + or lines[1] != "format ascii 1.0" + or "end_header" not in lines + ): + raise ValueError + vertex_lines = [line for line in lines if line.startswith("element vertex ")] + if len(vertex_lines) != 1: + raise ValueError + vertex_count = int(vertex_lines[0].removeprefix("element vertex ")) + header_end = lines.index("end_header") + vertices = lines[header_end + 1 :] + if vertex_count <= 0 or len(vertices) != vertex_count: + raise ValueError + for vertex in vertices: + values = vertex.split() + if len(values) != 6: + raise ValueError + coordinates = tuple(float(value) for value in values[:3]) + colors = tuple(int(value) for value in values[3:]) + if not all(math.isfinite(value) for value in coordinates) or not all( + 0 <= value <= 255 for value in colors + ): + raise ValueError + except (UnicodeDecodeError, ValueError): + errors.append("observation_pointcloud_invalid") + + counts = dict(observation.instance_pixel_counts) + expected_ids = {obj.object_id for obj in observation.scene.objects} + if set(counts) != expected_ids: + errors.append("observation_instance_count_ids_mismatch") + max_pixels = camera.width * camera.height + if any( + type(count) is not int or count < 0 or count > max_pixels + for count in counts.values() + ): + errors.append("observation_instance_count_invalid") + return tuple(sorted(set(errors))) + + def _require_payload_limits( payloads: dict[tuple[AssetPhase, AssetKind], bytes], ) -> None: @@ -811,11 +942,16 @@ def _require_png_header( def _checksum_payload(bundle: AssetBundle, bundle_payload: bytes) -> bytes: + return canonical_checksum_ledger(_bundle_payload_digests(bundle, bundle_payload)) + + +def _bundle_payload_digests( + bundle: AssetBundle, + bundle_payload: bytes, +) -> dict[str, str]: entries = {"bundle.json": hashlib.sha256(bundle_payload).hexdigest()} entries.update({item.relative_path: item.content_sha256 for item in bundle.assets}) - return "".join( - f"{digest} {name}\n" for name, digest in sorted(entries.items()) - ).encode("ascii") + return entries __all__ = ( diff --git a/src/spatialcf/generation/workflows/__init__.py b/src/spatialcf/generation/workflows/__init__.py new file mode 100644 index 0000000..e87d006 --- /dev/null +++ b/src/spatialcf/generation/workflows/__init__.py @@ -0,0 +1,3 @@ +"""Reusable protocol-only generation workflows and final state-machine owners.""" + +__all__ = () diff --git a/src/spatialcf/generation/workflows/capture.py b/src/spatialcf/generation/workflows/capture.py new file mode 100644 index 0000000..c0ef99a --- /dev/null +++ b/src/spatialcf/generation/workflows/capture.py @@ -0,0 +1,1231 @@ +"""Protocol-only source capture and deterministic roster compilation.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from dataclasses import asdict, dataclass + +from pydantic import model_validator + +from spatialcf.adapters.base import ( + AdapterCameraApplication, + AdapterOperationError, + AdapterPose, + AdapterPosition, + AdapterProceduralScene, + AdapterSettlementTimeout, + AdapterSpawnMap, + AdapterSupportFact, + CapturedSource, + CaptureRequest, + EnvironmentAdapter, + SourceCaptureFacts, + SourceCaptureOptions, +) +from spatialcf.domain.base import CanonicalModel +from spatialcf.domain.request import Relation +from spatialcf.domain.serialization import canonical_json_bytes, canonical_sha256 +from spatialcf.domain.source import LegacyAI2ThorSource, ProceduralSource +from spatialcf.generation.capture.compiler import compile_roster +from spatialcf.generation.capture.models import ( + CameraPolicy, + CompetitionNativeCameraPlacementPositionV2_9_4, + CompetitionNativeCameraPlacementRosterEntryV2_9_4, + CompetitionNativeCameraPolicyV2_9_3, + CompetitionNativeCameraPoseV2_9_3, + CompetitionNativeCameraScoreV2_9_3, + CompetitionNativeCameraScoreV2_9_4, + CompetitionNativeFloorEnvelopeV2_9, + CompetitionNativePlacementAvailabilityV2_9, + CompetitionNativePositionV2_9, + CompetitionNativeRuntimeIdentityV2_9, + CompetitionNativeSourceCaptureOutcomeV2_9, + CompetitionNativeSourceRefV2_9, + CompetitionNativeSubjectPlacementFactV2_9, + CompetitionNativeSupportFactV2_9, + CompetitionNativeSupportKindV2_9, + SourceCameraEvidence, + SourceSurfaceEvidence, + build_competition_native_camera_policy_v2_9_3, + build_competition_native_camera_pose_bank_v2_9_3, + build_competition_native_source_camera_evidence_v2_9_3, + build_competition_native_source_camera_evidence_v2_9_4, + build_competition_native_source_capture_v2_9, + build_competition_native_source_surface_evidence_v2_9_2, + build_competition_native_subject_placement_fact_v2_9, + normalize_competition_native_source_scene_v2_9, + score_competition_native_editable_camera_application_v2_9_4, + score_competition_native_source_camera_application_v2_9_3, + select_competition_native_camera_score_index_v2_9_3, + select_competition_native_camera_score_index_v2_9_4, + validate_competition_native_floor_envelope_v2_9, + validate_competition_native_runtime_source_lineage_v2_9, +) +from spatialcf.generation.capture.plan import CapturePlan, CaptureSettings +from spatialcf.generation.capture.storage import publish_roster + +_PROCTHOR_DATASET_ID = "allenai/procthor-10k" +_PROCTHOR_DATASET_NAME = "procthor-10k" +_PROCTHOR_LOADER_ID = "prior" +_PROCTHOR_LOADER_VERSION = "1.0.3" +_EXPECTED_SOURCE_LIFECYCLE_ERRORS = (OSError, RuntimeError, TypeError, ValueError) +_EXPECTED_CAPTURE_ERRORS = ( + AdapterOperationError, + AdapterSettlementTimeout, + RuntimeError, + TypeError, + ValueError, + KeyError, +) +_RUNTIME_IDENTITY_HASH_DOMAIN = "spatialcf.competition-native-runtime-identity.v2.9.2" +_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain:3" +) +_COLLISION_SAFE_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.2m:4" +) +_CONTACT_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.21m:5" +) +_RESET_PER_POSE_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.21m-reset-per-pose:6" +) +_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.25m:7" +) +_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.25m-physics-paused:8" +) +_SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION = ( + "deterministic-pair-camera-tier-1-solver-upright-edit-domain-" + "movable-clearance-0.25m-physics-paused-final-settle:9" +) + + +class CompetitionNativeSourceCaptureWithSurfaceEvidenceV2_9_2(CanonicalModel): + outcome: CompetitionNativeSourceCaptureOutcomeV2_9 + surface_evidence: SourceSurfaceEvidence | None + + @model_validator(mode="after") + def validate_capture_evidence(self): + if self.outcome.capture is None: + if self.surface_evidence is not None: + raise ValueError( + "rejected source capture cannot carry surface evidence" + ) + elif ( + self.surface_evidence is None + or self.surface_evidence.source_id != self.outcome.source.source_id + or self.surface_evidence.scene_id != self.outcome.source.scene_id + or self.surface_evidence.source_capture_sha256 + != self.outcome.capture.source_capture_sha256 + ): + raise ValueError("accepted source capture surface evidence is not closed") + return self + + +class CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3(CanonicalModel): + outcome: CompetitionNativeSourceCaptureOutcomeV2_9 + surface_evidence: SourceSurfaceEvidence | None + camera_evidence: SourceCameraEvidence | None + + @model_validator(mode="after") + def validate_capture_evidence(self): + capture = self.outcome.capture + if capture is None: + if self.surface_evidence is not None or self.camera_evidence is not None: + raise ValueError("rejected source capture cannot carry evidence") + return self + if self.surface_evidence is None or self.camera_evidence is None: + raise ValueError("accepted source capture requires both evidence rows") + if ( + self.surface_evidence.source_id != self.outcome.source.source_id + or self.surface_evidence.scene_id != self.outcome.source.scene_id + or self.surface_evidence.source_capture_sha256 + != capture.source_capture_sha256 + or self.camera_evidence.source_id != self.outcome.source.source_id + or self.camera_evidence.scene_id != self.outcome.source.scene_id + or self.camera_evidence.source_locator_sha256 + != self.outcome.source.source_locator_sha256 + or self.camera_evidence.source_capture_sha256 + != capture.source_capture_sha256 + or self.camera_evidence.camera != capture.scene.camera_by_id("main") + or self.camera_evidence.rgb_png_sha256 != capture.rgb_png_sha256 + or self.camera_evidence.depth_npy_sha256 != capture.depth_npy_sha256 + or self.camera_evidence.instance_png_sha256 != capture.instance_png_sha256 + or self.camera_evidence.pointcloud_ply_sha256 + != capture.pointcloud_ply_sha256 + or self.camera_evidence.is_scene_at_rest is not capture.is_scene_at_rest + ): + raise ValueError("accepted source capture evidence is not closed") + return self + + +def load_prior_dataset(name: str, revision: str) -> object: + try: + import prior + except ImportError as error: + raise RuntimeError("ProcTHOR capture requires spatialcf[procthor]") from error + try: + return prior.load_dataset(name, revision=revision) + except MemoryError: + raise + except Exception as error: + raise RuntimeError("ProcTHOR dataset loader failed") from error + + +def _dataset_split(dataset: object, split: str) -> Sequence[dict]: + """Return exactly the named mapping split; no attribute fallback exists.""" + if not isinstance(dataset, Mapping): + raise TypeError("ProcTHOR dataset must expose named mapping splits") + try: + values = dataset[split] + except KeyError as error: + raise ValueError("ProcTHOR dataset split is absent") from error + if isinstance(values, (str, bytes)) or not isinstance(values, Sequence): + raise TypeError("ProcTHOR dataset split must be indexable") + return values + + +def _resolve_procedural_source( + locator: ProceduralSource, + datasets: dict[tuple[str, str], object], + dataset_loader: Callable[[str, str], object], +) -> AdapterProceduralScene: + if ( + locator.dataset_id != _PROCTHOR_DATASET_ID + or locator.loader_id != _PROCTHOR_LOADER_ID + or locator.loader_version != _PROCTHOR_LOADER_VERSION + ): + raise ValueError("unsupported ProcTHOR source loader identity") + key = (_PROCTHOR_DATASET_NAME, locator.revision) + if key not in datasets: + datasets[key] = dataset_loader(*key) + values = _dataset_split(datasets[key], locator.split) + try: + house = values[locator.index] + except (IndexError, KeyError, TypeError) as error: + raise ValueError("ProcTHOR source locator does not exist") from error + if type(house) is not dict: + raise ValueError("ProcTHOR source house must be an exact dict") + scene = AdapterProceduralScene.create( + dataset_id=locator.dataset_id, + revision=locator.revision, + split=locator.split, + index=locator.index, + source_loader_id=locator.loader_id, + source_loader_version=locator.loader_version, + house=house, + ) + if scene.house_sha256 != locator.source_sha256: + raise ValueError("ProcTHOR source content digest changed") + return scene + + +def _rejected_source( + source: CompetitionNativeSourceRefV2_9, + reason: str, +) -> CompetitionNativeSourceCaptureOutcomeV2_9: + return CompetitionNativeSourceCaptureOutcomeV2_9( + source=source, + status="rejected", + capture=None, + reasons=(reason,), + ) + + +def _support_fact(value: AdapterSupportFact) -> CompetitionNativeSupportFactV2_9: + if type(value) is not AdapterSupportFact: + raise TypeError("adapter support fact must be exact") + return CompetitionNativeSupportFactV2_9( + scene_id=value.scene_id, + object_id=value.object_id, + object_name=value.object_name, + native_object_id=value.native_object_id, + raw_parent_object_ids=value.raw_parent_object_ids, + structural_parent_object_ids=value.structural_parent_object_ids, + domain_parent_object_ids=value.domain_parent_object_ids, + support_kind=CompetitionNativeSupportKindV2_9(value.support_kind), + support_object_id=value.support_object_id, + floor_object_id=value.floor_object_id, + ) + + +def _positions( + values: tuple[AdapterPosition, ...], +) -> tuple[CompetitionNativePositionV2_9, ...]: + return tuple( + CompetitionNativePositionV2_9(x=item.x, y=item.y, z=item.z) + for item in sorted(values, key=lambda item: (item.x, item.z, item.y)) + ) + + +def _adapter_pose(pose: CompetitionNativeCameraPoseV2_9_3) -> AdapterPose: + return AdapterPose( + position=AdapterPosition(x=pose.x, y=pose.y, z=pose.z), + yaw_degrees=pose.yaw_degrees, + horizon_degrees=pose.horizon_degrees, + standing=pose.standing, + ) + + +def _canonical_positions( + positions: tuple[AdapterPosition, ...], +) -> tuple[AdapterPosition, ...]: + keyed = { + (round(item.x / 1e-6), round(item.z / 1e-6), round(item.y / 1e-6)) + for item in positions + } + if len(keyed) != len(positions): + raise ValueError("reachable positions contain a duplicate canonical point") + return tuple( + AdapterPosition(x=key[0] * 1e-6, y=key[2] * 1e-6, z=key[1] * 1e-6) + for key in sorted(keyed) + ) + + +def _camera_placement_roster( + scene, + pairs: tuple[tuple[str, str], ...], + spawn_maps: tuple[AdapterSpawnMap, ...], +) -> tuple[CompetitionNativeCameraPlacementRosterEntryV2_9_4, ...]: + if type(spawn_maps) is not tuple or any( + type(item) is not AdapterSpawnMap for item in spawn_maps + ): + raise TypeError("camera placement roster spawn maps must be exact") + by_subject = {item.subject_object_id: item for item in spawn_maps} + if len(by_subject) != len(spawn_maps) or set(by_subject) != { + subject_id for subject_id, _ in pairs + }: + raise ValueError("camera placement roster does not cover its subject pairs") + entries = [] + for subject_id, support_id in pairs: + subject = scene.object_by_id(subject_id) + scene.object_by_id(support_id) + spawn_map = by_subject[subject_id] + if ( + subject.support_object_id != support_id + or spawn_map.scene_id != scene.scene_id + or spawn_map.subject_object_id != subject_id + or spawn_map.support_object_id != support_id + or not spawn_map.positions + ): + raise ValueError("camera placement roster does not bind the source") + entries.append( + CompetitionNativeCameraPlacementRosterEntryV2_9_4( + subject_object_id=subject_id, + support_object_id=support_id, + positions=tuple( + CompetitionNativeCameraPlacementPositionV2_9_4( + x=item.x, + y=item.y, + z=item.z, + ) + for item in spawn_map.positions + ), + ) + ) + return tuple(sorted(entries, key=lambda item: item.subject_object_id)) + + +def _capture_selected_camera_source( + source: CompetitionNativeSourceRefV2_9, + *, + facts: SourceCaptureFacts, + scene, + application: AdapterCameraApplication, + support_facts: tuple[CompetitionNativeSupportFactV2_9, ...], + reachable: tuple[CompetitionNativePositionV2_9, ...], + spawn_maps: tuple[AdapterSpawnMap, ...], + settlement_pass_steps: int, + floor_clearance_m: float, +) -> tuple[CompetitionNativeSourceCaptureOutcomeV2_9, tuple[AdapterSpawnMap, ...]]: + normalized_scene = normalize_competition_native_source_scene_v2_9(scene) + support_by_id = {item.object_id: item for item in support_facts} + runtime_identity = CompetitionNativeRuntimeIdentityV2_9( + **asdict(facts.runtime_identity) + ) + floor = None + floor_reason = None + floor_subjects = tuple( + item + for item in normalized_scene.objects + if item.movable + and support_by_id[item.object_id].support_kind + is CompetitionNativeSupportKindV2_9.FLOOR + ) + if floor_subjects: + if facts.floor_envelope is None: + floor_reason = "source_capture:floor_envelope_missing" + else: + try: + floor = CompetitionNativeFloorEnvelopeV2_9( + scene_id=facts.floor_envelope.scene_id, + floor_object_id=facts.floor_envelope.floor_object_id, + floor_name=facts.floor_envelope.floor_name, + native_aabb=facts.floor_envelope.native_aabb, + floor_top_z=facts.floor_envelope.floor_top_z, + clearance_m=facts.floor_envelope.clearance_m, + polygon_xy=facts.floor_envelope.polygon_xy, + ) + validate_competition_native_floor_envelope_v2_9( + normalized_scene, + support_facts, + floor, + runtime_identity, + expected_clearance_m=floor_clearance_m, + ) + except (TypeError, ValueError): + floor = None + floor_reason = "source_capture:floor_envelope_mismatch" + + placement_by_id: dict[str, CompetitionNativeSubjectPlacementFactV2_9] = {} + floor_regions = dict(facts.floor_position_regions) + for item in floor_subjects: + support = support_by_id[item.object_id] + region = floor_regions.get(item.object_id) + if floor_reason is not None or floor is None or not reachable or region is None: + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.MISSING, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + reasons=( + floor_reason or "source_capture:floor_inner_domain_missing", + ), + ) + ) + else: + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.KNOWN_FLOOR_INNER_REGION, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + position_region=region, + ) + ) + + maps_by_subject = {item.subject_object_id: item for item in spawn_maps} + retained_maps = [] + for item in normalized_scene.objects: + support = support_by_id[item.object_id] + if item.object_id in placement_by_id: + continue + if ( + item.movable + and support.support_kind is CompetitionNativeSupportKindV2_9.RECEPTACLE + ): + spawn_map = maps_by_subject.get(item.object_id) + if ( + spawn_map is None + or spawn_map.scene_id != normalized_scene.scene_id + or spawn_map.support_object_id != support.support_object_id + or not spawn_map.positions + ): + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.MISSING, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + reasons=("source_capture:receptacle_spawn_missing",), + ) + ) + elif not spawn_map.surface_patches or spawn_map.position_region is None: + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.MISSING, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + reasons=("source_capture:receptacle_surface_patch_missing",), + ) + ) + else: + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.KNOWN_RECEPTACLE_SPAWN, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + native_positions=_positions(spawn_map.positions), + position_region=spawn_map.position_region, + ) + ) + retained_maps.append(spawn_map) + continue + if not item.movable: + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.NOT_APPLICABLE, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + ) + ) + continue + reason = { + CompetitionNativeSupportKindV2_9.UNKNOWN: "source_capture:support_unknown", + CompetitionNativeSupportKindV2_9.MULTIPLE_AMBIGUOUS: "source_capture:support_multiple_ambiguous", + CompetitionNativeSupportKindV2_9.CYCLIC: "source_capture:support_cyclic", + }.get(support.support_kind, "source_capture:placement_fact_invalid") + placement_by_id[item.object_id] = ( + build_competition_native_subject_placement_fact_v2_9( + object_id=item.object_id, + availability=CompetitionNativePlacementAvailabilityV2_9.MISSING, + support_kind=support.support_kind, + support_object_id=support.support_object_id, + floor_object_id=support.floor_object_id, + reasons=(reason,), + ) + ) + + try: + capture = build_competition_native_source_capture_v2_9( + source=source, + runtime_identity=runtime_identity, + scene=normalized_scene, + rgb_png_sha256=application.observation.rgb_png_sha256, + depth_npy_sha256=application.observation.depth_npy_sha256, + instance_png_sha256=application.observation.instance_png_sha256, + pointcloud_ply_sha256=application.observation.pointcloud_ply_sha256, + is_scene_at_rest=application.observation.is_settled, + settlement_pass_steps=settlement_pass_steps, + support_facts=support_facts, + floor_envelope=floor, + reachable_positions=reachable, + placement_facts=tuple( + placement_by_id[item.object_id] for item in normalized_scene.objects + ), + ) + except (TypeError, ValueError): + return _rejected_source(source, "source_capture:normalized_capture_invalid"), () + return ( + CompetitionNativeSourceCaptureOutcomeV2_9( + source=source, + status="accepted", + capture=capture, + reasons=(), + ), + tuple(sorted(retained_maps, key=lambda item: item.subject_object_id)), + ) + + +def _capture_competition_native_source_v2_9_3( + adapter: EnvironmentAdapter, + source: CompetitionNativeSourceRefV2_9, + *, + max_settlement_steps: int, + floor_clearance_m: float, + navigation_agent_radius_m: float, + navigation_clearance_m: float, + camera_policy: CompetitionNativeCameraPolicyV2_9_3, + captured_source_consumer: Callable[[CapturedSource], None] | None = None, +) -> CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3: + """Capture one frozen source through six adapter operations, once.""" + + def rejected(reason: str) -> CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3: + return CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3( + outcome=_rejected_source(source, reason), + surface_evidence=None, + camera_evidence=None, + ) + + if type(source) is not CompetitionNativeSourceRefV2_9: + raise TypeError("native source ref must be exact") + options = SourceCaptureOptions( + max_settlement_steps=max_settlement_steps, + floor_clearance_m=floor_clearance_m, + navigation_agent_radius_m=navigation_agent_radius_m, + navigation_clearance_m=navigation_clearance_m, + ) + try: + captured_source = adapter.capture_source( + CaptureRequest(scene_id=source.scene_id, camera_id="main") + ) + except _EXPECTED_CAPTURE_ERRORS: + return rejected("source_capture:load_failed") + if type(captured_source) is not CapturedSource: + return rejected("source_capture:load_failed") + if captured_source_consumer is not None: + captured_source_consumer(captured_source) + try: + facts = adapter.observe_source(captured_source, options=options, settle=True) + except AdapterSettlementTimeout: + return rejected("source_capture:settlement_timeout") + except _EXPECTED_CAPTURE_ERRORS: + return rejected("source_capture:settlement_failed") + if type(facts) is not SourceCaptureFacts: + return rejected("source_capture:support_snapshot_invalid") + try: + normalized_baseline = normalize_competition_native_source_scene_v2_9( + facts.scene + ) + support_facts = tuple( + sorted( + (_support_fact(item) for item in facts.support_facts), + key=lambda item: item.object_id, + ) + ) + object_ids = tuple(item.object_id for item in normalized_baseline.objects) + if tuple(item.object_id for item in support_facts) != object_ids: + raise ValueError("support fact roster does not match scene objects") + runtime_identity = CompetitionNativeRuntimeIdentityV2_9( + **asdict(facts.runtime_identity) + ) + if any( + item.scene_id != normalized_baseline.scene_id + or item.object_name != normalized_baseline.object_by_id(item.object_id).name + for item in support_facts + ): + raise ValueError("support facts do not bind the settled scene") + except (TypeError, ValueError): + return rejected("source_capture:support_snapshot_invalid") + try: + validate_competition_native_runtime_source_lineage_v2_9( + source, + runtime_identity, + ) + except (TypeError, ValueError): + return rejected("source_capture:runtime_lineage_mismatch") + + try: + reachable_values = facts.reachable_positions + policy = CompetitionNativeCameraPolicyV2_9_3.model_validate( + camera_policy.model_dump(mode="python"), + strict=True, + ) + editable_camera = policy.pose_policy_version in { + _EDITABLE_CAMERA_POLICY_VERSION, + _COLLISION_SAFE_EDITABLE_CAMERA_POLICY_VERSION, + _CONTACT_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, + _RESET_PER_POSE_EDITABLE_CAMERA_POLICY_VERSION, + _GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, + _PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, + _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, + } + reset_per_pose = ( + policy.pose_policy_version == _RESET_PER_POSE_EDITABLE_CAMERA_POLICY_VERSION + ) + pause_physics = policy.pose_policy_version in { + _PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, + _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION, + } + settle_after_unpause = ( + policy.pose_policy_version + == _SETTLED_PAUSED_GRID_MARGIN_EDITABLE_CAMERA_POLICY_VERSION + ) + if settle_after_unpause: + reachable_values = _canonical_positions(reachable_values) + reachable = _positions(reachable_values) + support_by_id = {item.object_id: item for item in support_facts} + pairs = tuple( + sorted( + (item.object_id, support_by_id[item.object_id].support_object_id) + for item in normalized_baseline.objects + if item.movable + and support_by_id[item.object_id].support_kind + is CompetitionNativeSupportKindV2_9.RECEPTACLE + and support_by_id[item.object_id].support_object_id is not None + ) + ) + except (TypeError, ValueError, KeyError): + return rejected("source_capture:camera_selection_failed") + + paused_handle = None + camera_selection_failed = False + final_facts = facts + try: + current_scene = facts.scene + if pause_physics: + paused_handle = adapter.pause_camera_observations( + facts, + settle_after_resume=settle_after_unpause, + ) + current_scene = paused_handle.scene + proposal_maps = ( + adapter.capture_spawn_maps( + facts, + subject_object_ids=tuple(subject_id for subject_id, _ in pairs), + ) + if editable_camera + else () + ) + if editable_camera: + proposal_by_subject = { + item.subject_object_id: item for item in proposal_maps + } + if set(proposal_by_subject) != {subject_id for subject_id, _ in pairs}: + raise ValueError("proposal spawn maps do not cover source pairs") + pairs = tuple( + pair for pair in pairs if proposal_by_subject[pair[0]].surface_patches + ) + proposal_maps = tuple( + proposal_by_subject[subject_id] for subject_id, _ in pairs + ) + placement_roster = ( + _camera_placement_roster(facts.scene, pairs, proposal_maps) + if editable_camera + else () + ) + pose_bank = build_competition_native_camera_pose_bank_v2_9_3( + facts.scene, + pairs, + reachable_values, + facts.current_pose, + policy=policy, + ) + pose_scores: list[ + CompetitionNativeCameraScoreV2_9_3 | CompetitionNativeCameraScoreV2_9_4 + ] = [] + for pose in pose_bank: + application = adapter.apply_camera_pose( + facts, + _adapter_pose(pose), + handle=paused_handle, + source_scene=facts.scene if reset_per_pose else current_scene, + reset_from_source=reset_per_pose, + max_settlement_steps=max_settlement_steps, + ) + current_scene = application.observed_scene + pose_scores.append( + score_competition_native_editable_camera_application_v2_9_4( + source_scene=facts.scene, + pose=pose, + application=application, + placement_roster=placement_roster, + policy=policy, + ) + if editable_camera + else score_competition_native_source_camera_application_v2_9_3( + source_scene=facts.scene, + pose=pose, + application=application, + policy=policy, + ) + ) + frozen_scores = tuple(pose_scores) + selected_index = ( + select_competition_native_camera_score_index_v2_9_4(frozen_scores) + if editable_camera + else select_competition_native_camera_score_index_v2_9_3(frozen_scores) + ) + selected_pose = pose_bank[selected_index] + selected_application = adapter.apply_camera_pose( + facts, + _adapter_pose(selected_pose), + handle=paused_handle, + source_scene=facts.scene if reset_per_pose else current_scene, + reset_from_source=reset_per_pose, + max_settlement_steps=max_settlement_steps, + ) + selected_score = ( + score_competition_native_editable_camera_application_v2_9_4( + source_scene=facts.scene, + pose=selected_pose, + application=selected_application, + placement_roster=placement_roster, + policy=policy, + ) + if editable_camera + else score_competition_native_source_camera_application_v2_9_3( + source_scene=facts.scene, + pose=selected_pose, + application=selected_application, + policy=policy, + ) + ) + if selected_score != frozen_scores[selected_index]: + raise ValueError("camera winner replay score changed") + capture_settlement_pass_steps = facts.settlement_pass_steps + evidence_source_scene = facts.scene + if settle_after_unpause: + adapter.resume_camera_observations(paused_handle) + paused_handle = None + settled = adapter.settle_camera_pose( + facts, + _adapter_pose(selected_pose), + source_scene=facts.scene, + max_settlement_steps=max_settlement_steps, + ) + selected_application = settled.application + capture_settlement_pass_steps = settled.settlement_pass_steps + evidence_source_scene = selected_application.observed_scene + final_score = ( + score_competition_native_editable_camera_application_v2_9_4( + source_scene=evidence_source_scene, + pose=selected_pose, + application=selected_application, + placement_roster=placement_roster, + policy=policy, + ) + if editable_camera + else score_competition_native_source_camera_application_v2_9_3( + source_scene=evidence_source_scene, + pose=selected_pose, + application=selected_application, + policy=policy, + ) + ) + if final_score != frozen_scores[selected_index]: + raise ValueError("settled camera winner score changed") + final_support_by_id = {item.object_id: item for item in facts.support_facts} + final_support_facts = tuple( + final_support_by_id[item.object_id] + for item in selected_application.observed_scene.objects + ) + if any( + support.scene_id != selected_application.observed_scene.scene_id + or support.object_name + != selected_application.observed_scene.object_by_id(support.object_id).name + for support in final_support_facts + ): + raise ValueError("final support facts do not bind settled scene") + final_facts = SourceCaptureFacts( + source=facts.source, + binding=facts.binding, + scene=selected_application.observed_scene, + runtime_identity=facts.runtime_identity, + observation=selected_application.observation, + support_facts=final_support_facts, + floor_envelope=facts.floor_envelope, + floor_position_regions=facts.floor_position_regions, + reachable_positions=facts.reachable_positions, + current_pose=selected_application.observed_pose, + settlement_pass_steps=capture_settlement_pass_steps, + ) + except _EXPECTED_CAPTURE_ERRORS: + camera_selection_failed = True + finally: + if paused_handle is not None: + try: + adapter.resume_camera_observations(paused_handle) + except _EXPECTED_CAPTURE_ERRORS: + camera_selection_failed = True + + if camera_selection_failed: + return rejected("source_capture:camera_selection_failed") + + try: + final_maps = adapter.capture_spawn_maps( + final_facts, + subject_object_ids=tuple( + item.object_id + for item in normalized_baseline.objects + if item.movable + and support_by_id[item.object_id].support_kind + is CompetitionNativeSupportKindV2_9.RECEPTACLE + ), + ) + outcome, retained_maps = _capture_selected_camera_source( + source, + facts=facts, + scene=selected_application.observed_scene, + application=selected_application, + support_facts=support_facts, + reachable=reachable, + spawn_maps=final_maps, + settlement_pass_steps=capture_settlement_pass_steps, + floor_clearance_m=floor_clearance_m, + ) + capture = outcome.capture + if capture is None: + return CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3( + outcome=outcome, + surface_evidence=None, + camera_evidence=None, + ) + if ( + editable_camera + and _camera_placement_roster( + selected_application.observed_scene, + pairs, + retained_maps, + ) + != placement_roster + ): + raise ValueError("camera placement roster changed after winner replay") + surface_evidence = build_competition_native_source_surface_evidence_v2_9_2( + capture, + retained_maps, + ) + evidence_arguments = { + "source_id": source.source_id, + "scene_id": source.scene_id, + "source_locator_sha256": source.source_locator_sha256, + "runtime_identity_sha256": canonical_sha256( + runtime_identity, + domain=_RUNTIME_IDENTITY_HASH_DOMAIN, + ), + "source_capture_sha256": capture.source_capture_sha256, + "source_scene": evidence_source_scene, + "policy": policy, + "pose_bank": pose_bank, + "pose_scores": frozen_scores, + "selected_application": selected_application, + } + camera_evidence = ( + build_competition_native_source_camera_evidence_v2_9_4( + **evidence_arguments, + placement_roster=placement_roster, + ) + if editable_camera + else build_competition_native_source_camera_evidence_v2_9_3( + **evidence_arguments, + ) + ) + return CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3( + outcome=outcome, + surface_evidence=surface_evidence, + camera_evidence=camera_evidence, + ) + except _EXPECTED_CAPTURE_ERRORS: + return rejected("source_capture:evidence_invalid") + + +def capture_competition_native_source_v2_9( + adapter: EnvironmentAdapter, + source: CompetitionNativeSourceRefV2_9, + *, + max_settlement_steps: int, + floor_clearance_m: float, + navigation_agent_radius_m: float, + navigation_clearance_m: float, +) -> CompetitionNativeSourceCaptureOutcomeV2_9: + return _capture_competition_native_source_v2_9_3( + adapter, + source, + max_settlement_steps=max_settlement_steps, + floor_clearance_m=floor_clearance_m, + navigation_agent_radius_m=navigation_agent_radius_m, + navigation_clearance_m=navigation_clearance_m, + camera_policy=build_competition_native_camera_policy_v2_9_3(), + ).outcome + + +def capture_competition_native_source_v2_9_2( + adapter: EnvironmentAdapter, + source: CompetitionNativeSourceRefV2_9, + *, + max_settlement_steps: int, + floor_clearance_m: float, + navigation_agent_radius_m: float, + navigation_clearance_m: float, +) -> CompetitionNativeSourceCaptureWithSurfaceEvidenceV2_9_2: + result = _capture_competition_native_source_v2_9_3( + adapter, + source, + max_settlement_steps=max_settlement_steps, + floor_clearance_m=floor_clearance_m, + navigation_agent_radius_m=navigation_agent_radius_m, + navigation_clearance_m=navigation_clearance_m, + camera_policy=build_competition_native_camera_policy_v2_9_3(), + ) + return CompetitionNativeSourceCaptureWithSurfaceEvidenceV2_9_2( + outcome=result.outcome, + surface_evidence=result.surface_evidence, + ) + + +def capture_competition_native_source_v2_9_3( + adapter: EnvironmentAdapter, + source: CompetitionNativeSourceRefV2_9, + *, + max_settlement_steps: int, + floor_clearance_m: float, + navigation_agent_radius_m: float, + navigation_clearance_m: float, +) -> CompetitionNativeSourceCaptureWithCameraEvidenceV2_9_3: + return _capture_competition_native_source_v2_9_3( + adapter, + source, + max_settlement_steps=max_settlement_steps, + floor_clearance_m=floor_clearance_m, + navigation_agent_radius_m=navigation_agent_radius_m, + navigation_clearance_m=navigation_clearance_m, + camera_policy=build_competition_native_camera_policy_v2_9_3(), + ) + + +@dataclass(frozen=True, slots=True) +class SourceCaptureResult: + outcome: CompetitionNativeSourceCaptureOutcomeV2_9 + surface_evidence: SourceSurfaceEvidence | None + camera_evidence: SourceCameraEvidence | None + + +def capture_source_observation( + adapter: EnvironmentAdapter, + *, + source: CompetitionNativeSourceRefV2_9, + settings: CaptureSettings, + camera_policy: CameraPolicy, +) -> SourceCaptureResult: + if type(settings) is not CaptureSettings: + raise TypeError("capture settings must be exact") + checked_settings = CaptureSettings.model_validate( + settings.model_dump(mode="python", warnings="error"), + strict=True, + ) + result = _capture_competition_native_source_v2_9_3( + adapter, + source, + max_settlement_steps=checked_settings.max_settlement_steps, + floor_clearance_m=checked_settings.floor_clearance_m, + navigation_agent_radius_m=checked_settings.navigation_agent_radius_m, + navigation_clearance_m=checked_settings.navigation_clearance_m, + camera_policy=camera_policy, + ) + return SourceCaptureResult( + outcome=result.outcome, + surface_evidence=result.surface_evidence, + camera_evidence=result.camera_evidence, + ) + + +def _apply_resource_caps( + policy, + outcomes: tuple[CompetitionNativeSourceCaptureOutcomeV2_9, ...], +) -> tuple[CompetitionNativeSourceCaptureOutcomeV2_9, ...]: + bounded = [] + candidate_count = 0 + for outcome in outcomes: + capture = outcome.capture + if outcome.status == "rejected" or capture is None: + bounded.append(outcome) + continue + object_count = len(capture.scene.objects) + source_candidates = object_count * max(0, object_count - 1) * len(Relation) + if object_count > policy.max_objects_per_scene: + bounded.append( + _rejected_source(outcome.source, "dataset_capture:object_cap_exceeded") + ) + elif candidate_count + source_candidates > policy.max_candidates_total: + bounded.append( + _rejected_source( + outcome.source, "dataset_capture:candidate_cap_exceeded" + ) + ) + else: + bounded.append(outcome) + candidate_count += source_candidates + return tuple(bounded) + + +def _capture_source_with_adapter( + source: CompetitionNativeSourceRefV2_9, + procedural: Mapping[str, AdapterProceduralScene] | None, + plan: CapturePlan, + adapter_factory: Callable[..., EnvironmentAdapter], + fresh_transitions=None, +) -> tuple[ + CompetitionNativeSourceCaptureOutcomeV2_9, + SourceSurfaceEvidence | None, + SourceCameraEvidence | None, +]: + manager = adapter_factory( + [source.scene_id], + width=plan.roster_policy.width, + height=plan.roster_policy.height, + seed=plan.roster_policy.seed, + procedural_scenes=procedural, + ) + try: + adapter = manager.__enter__() + except _EXPECTED_SOURCE_LIFECYCLE_ERRORS: + return ( + _rejected_source(source, "dataset_capture:adapter_lifecycle_failed"), + None, + None, + ) + try: + result = _capture_competition_native_source_v2_9_3( + adapter, + source, + max_settlement_steps=plan.capture_settings.max_settlement_steps, + floor_clearance_m=plan.capture_settings.floor_clearance_m, + navigation_agent_radius_m=plan.capture_settings.navigation_agent_radius_m, + navigation_clearance_m=plan.capture_settings.navigation_clearance_m, + camera_policy=plan.roster_policy.camera_policy, + captured_source_consumer=( + (lambda captured: fresh_transitions.capture(source.source_id, captured)) + if fresh_transitions is not None + else None + ), + ) + except BaseException as error: + try: + manager.__exit__(type(error), error, error.__traceback__) + except MemoryError: + raise + except Exception as cleanup_error: # noqa: BLE001 + error.add_note(f"adapter cleanup also failed: {cleanup_error}") + raise + try: + manager.__exit__(None, None, None) + except _EXPECTED_SOURCE_LIFECYCLE_ERRORS: + return ( + _rejected_source(source, "dataset_capture:adapter_lifecycle_failed"), + None, + None, + ) + return result.outcome, result.surface_evidence, result.camera_evidence + + +def _capture_dataset( + plan: CapturePlan, + *, + adapter_factory: Callable[..., EnvironmentAdapter], + dataset_loader: Callable[[str, str], object], + fresh_transitions=None, +): + if type(plan) is not CapturePlan: + raise TypeError("dataset capture plan must be exact") + checked = CapturePlan.model_validate( + plan.model_dump(mode="python", warnings="error"), + strict=True, + ) + datasets: dict[tuple[str, str], object] = {} + outcomes: list[CompetitionNativeSourceCaptureOutcomeV2_9] = [] + surface_evidence = [] + camera_evidence = [] + source_locators = { + entry.source_id: entry.source for entry in checked.source_manifest.sources + } + for source in checked.roster_policy.sources: + locator = source_locators[source.source_id] + procedural: dict[str, AdapterProceduralScene] = {} + if type(locator) is ProceduralSource: + try: + procedural[source.scene_id] = _resolve_procedural_source( + locator, + datasets, + dataset_loader, + ) + except _EXPECTED_SOURCE_LIFECYCLE_ERRORS: + outcomes.append( + _rejected_source(source, "dataset_capture:source_resolution_failed") + ) + continue + elif type(locator) is not LegacyAI2ThorSource: + raise TypeError("dataset source manifest contains an unsupported source") + outcome, source_surface, source_camera = _capture_source_with_adapter( + source, + procedural or None, + checked, + adapter_factory, + fresh_transitions=fresh_transitions, + ) + outcomes.append( + CompetitionNativeSourceCaptureOutcomeV2_9.model_validate_json( + canonical_json_bytes(outcome), + strict=True, + ) + ) + if source_surface is not None: + surface_evidence.append(source_surface) + if source_camera is not None: + camera_evidence.append(source_camera) + bounded = _apply_resource_caps(checked.roster_policy, tuple(outcomes)) + accepted_source_ids = { + item.source.source_id for item in bounded if item.status == "accepted" + } + return compile_roster( + checked.roster_policy, + bounded, + tuple( + item for item in surface_evidence if item.source_id in accepted_source_ids + ), + tuple( + item for item in camera_evidence if item.source_id in accepted_source_ids + ), + ) + + +def capture_dataset( + plan: CapturePlan, + *, + adapter_factory: Callable[..., EnvironmentAdapter], + dataset_loader: Callable[[str, str], object], +): + return _capture_dataset( + plan, + adapter_factory=adapter_factory, + dataset_loader=dataset_loader, + ) + + +def _capture_dataset_with_transitions( + plan: CapturePlan, + *, + adapter_factory: Callable[..., EnvironmentAdapter], + dataset_loader: Callable[[str, str], object], + fresh_transitions, +): + return _capture_dataset( + plan, + adapter_factory=adapter_factory, + dataset_loader=dataset_loader, + fresh_transitions=fresh_transitions, + ) + + +def _capture_and_publish_dataset_with_transitions( + plan: CapturePlan, + roster_root, + *, + adapter_factory: Callable[..., EnvironmentAdapter], + dataset_loader: Callable[[str, str], object], + fresh_transitions, +): + return publish_roster( + _capture_dataset( + plan, + adapter_factory=adapter_factory, + dataset_loader=dataset_loader, + fresh_transitions=fresh_transitions, + ), + roster_root, + ) + + +def capture_and_publish_dataset( + plan: CapturePlan, + roster_root, + *, + adapter_factory: Callable[..., EnvironmentAdapter], + dataset_loader: Callable[[str, str], object], +): + return publish_roster( + capture_dataset( + plan, + adapter_factory=adapter_factory, + dataset_loader=dataset_loader, + ), + roster_root, + ) + + +__all__ = ( + "SourceCaptureResult", + "capture_and_publish_dataset", + "capture_competition_native_source_v2_9", + "capture_competition_native_source_v2_9_2", + "capture_competition_native_source_v2_9_3", + "capture_dataset", + "capture_source_observation", + "load_prior_dataset", +) diff --git a/src/spatialcf/generation/workflows/contracts.py b/src/spatialcf/generation/workflows/contracts.py new file mode 100644 index 0000000..7a3842b --- /dev/null +++ b/src/spatialcf/generation/workflows/contracts.py @@ -0,0 +1,243 @@ +"""Unique generation transition values between protocol workflow stages.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from spatialcf.adapters.base import CapturedSource, SettledReadback +from spatialcf.domain.edit import CanonicalEdit +from spatialcf.domain.request import InterventionSpec + + +@dataclass(frozen=True) +class CounterfactualRequest: + source: CapturedSource + intervention: InterventionSpec + + def __post_init__(self) -> None: + if type(self.source) is not CapturedSource: + raise TypeError("counterfactual request source must be exact") + if type(self.intervention) is not InterventionSpec: + raise TypeError("counterfactual request intervention must be exact") + + +@dataclass(frozen=True) +class CertifiedPlan: + request: CounterfactualRequest + edit: CanonicalEdit + + +@dataclass(frozen=True) +class PlanningRejection: + request: CounterfactualRequest + reason: str + + +@dataclass(frozen=True) +class ExecutedEdit: + plan: CertifiedPlan + readback: SettledReadback + + def __post_init__(self) -> None: + if type(self.plan) is not CertifiedPlan: + raise TypeError("executed edit plan must be exact") + if type(self.readback) is not SettledReadback: + raise TypeError("executed edit readback must be exact") + if ( + self.readback.application.intervention != self.plan.request.intervention + or self.readback.application.edit != self.plan.edit + ): + raise ValueError("executed edit does not bind its certified plan") + + +@dataclass(frozen=True) +class ExecutionRejection: + request: CounterfactualRequest + reason: str + plan: CertifiedPlan | None = None + + def __post_init__(self) -> None: + if type(self.request) is not CounterfactualRequest: + raise TypeError("execution rejection request must be exact") + if self.plan is not None and ( + type(self.plan) is not CertifiedPlan + or self.plan.request is not self.request + ): + raise ValueError("execution rejection plan does not bind its request") + + +@dataclass(frozen=True) +class VerifiedExample: + execution: ExecutedEdit + + +@dataclass(frozen=True) +class VerificationRejection: + execution: ExecutedEdit + reason: str + + +class _FreshTransitions: + """Explicit, process-local transition carrier for one fresh dataset root.""" + + def __init__(self) -> None: + self._sources: dict[str, CapturedSource] = {} + self._requests: dict[str, CounterfactualRequest] = {} + self._plans: dict[str, CertifiedPlan] = {} + self._executed: dict[str, ExecutedEdit] = {} + self._verified: dict[str, VerifiedExample] = {} + self._terminals: dict[ + str, + PlanningRejection + | ExecutionRejection + | VerificationRejection + | VerifiedExample, + ] = {} + + def capture(self, source_id: str, source: CapturedSource) -> None: + if type(source_id) is not str or type(source) is not CapturedSource: + raise TypeError("fresh capture carrier requires exact source values") + if source_id in self._sources: + raise ValueError("fresh capture carrier reused a source") + self._sources[source_id] = source + + def request( + self, request_id: str, source_id: str, intervention: InterventionSpec + ) -> CounterfactualRequest: + if request_id in self._requests or request_id in self._terminals: + raise ValueError("fresh transition request was reused") + try: + source = self._sources[source_id] + except KeyError as error: + raise ValueError( + "fresh transition request has no captured source" + ) from error + value = CounterfactualRequest(source=source, intervention=intervention) + self._requests[request_id] = value + return value + + def reject_planning(self, request_id: str, reason: str) -> PlanningRejection: + value = PlanningRejection(request=self._request(request_id), reason=reason) + self._terminal_once(request_id, value) + return value + + def executed( + self, request_id: str, edit: CanonicalEdit, readback: SettledReadback + ) -> ExecutedEdit: + request = self._request(request_id) + if request_id in self._plans or request_id in self._terminals: + raise ValueError("fresh transition plan was reused") + plan = CertifiedPlan(request=request, edit=edit) + value = ExecutedEdit(plan=plan, readback=readback) + self._plans[request_id] = plan + self._executed[request_id] = value + return value + + def reject_execution(self, request_id: str, reason: str) -> ExecutionRejection: + request = self._request(request_id) + value = ExecutionRejection( + request=request, plan=self._plans.get(request_id), reason=reason + ) + self._terminal_once(request_id, value) + return value + + def verify(self, request_id: str) -> VerifiedExample: + if request_id in self._terminals or request_id in self._verified: + raise ValueError("fresh transition verification was reused") + try: + execution = self._executed[request_id] + except KeyError as error: + raise ValueError("verification has no executed edit") from error + value = VerifiedExample(execution=execution) + self._verified[request_id] = value + self._terminal_once(request_id, value) + return value + + def reject_verification( + self, request_id: str, reason: str + ) -> VerificationRejection: + try: + execution = self._executed[request_id] + except KeyError as error: + raise ValueError("verification rejection has no executed edit") from error + value = VerificationRejection(execution=execution, reason=reason) + self._terminal_once(request_id, value) + return value + + def require_verified(self, request_id: str) -> VerifiedExample: + try: + value = self._verified[request_id] + except KeyError as error: + raise ValueError( + "accepted publication bypassed verified example" + ) from error + if self._terminal_for(request_id) is not value: + raise ValueError("accepted publication bypassed verified example") + return value + + def _terminal_for( + self, request_id: str + ) -> ( + PlanningRejection | ExecutionRejection | VerificationRejection | VerifiedExample + ): + try: + return self._terminals[request_id] + except KeyError as error: + raise ValueError("fresh transition has no terminal") from error + + def _validate_terminal_closure( + self, request_ids: tuple[str, ...] + ) -> tuple[ + PlanningRejection + | ExecutionRejection + | VerificationRejection + | VerifiedExample, + ..., + ]: + if type(request_ids) is not tuple or any( + type(request_id) is not str for request_id in request_ids + ): + raise TypeError("fresh transition terminal closure requires exact IDs") + if len(request_ids) != len(set(request_ids)): + raise ValueError("fresh transition terminal closure has duplicate requests") + if set(request_ids) != set(self._terminals): + raise ValueError( + "fresh transition terminal closure does not cover requests" + ) + return tuple(self._terminal_for(request_id) for request_id in request_ids) + + def _request(self, request_id: str) -> CounterfactualRequest: + try: + return self._requests[request_id] + except KeyError as error: + raise ValueError("fresh transition has no request") from error + + def _terminal_once( + self, + request_id: str, + value: PlanningRejection + | ExecutionRejection + | VerificationRejection + | VerifiedExample, + ) -> None: + if type(value) not in { + PlanningRejection, + ExecutionRejection, + VerificationRejection, + VerifiedExample, + }: + raise TypeError("fresh transition terminal must be exact") + if request_id in self._terminals: + raise ValueError("fresh transition has duplicate terminal") + self._terminals[request_id] = value + + +__all__ = ( + "CertifiedPlan", + "CounterfactualRequest", + "ExecutedEdit", + "ExecutionRejection", + "PlanningRejection", + "VerificationRejection", + "VerifiedExample", +) diff --git a/src/spatialcf/generation/workflows/dataset.py b/src/spatialcf/generation/workflows/dataset.py new file mode 100644 index 0000000..ccd87b9 --- /dev/null +++ b/src/spatialcf/generation/workflows/dataset.py @@ -0,0 +1,1568 @@ +"""Protocol-only dataset transaction and publication workflow.""" + +from __future__ import annotations + +import hashlib +import os +import stat +from collections import Counter +from collections.abc import Callable, Iterator, Mapping +from contextlib import ExitStack, contextmanager +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from spatialcf.adapters.base import EnvironmentAdapter as AI2ThorAdapter +from spatialcf.adapters.base import SettledReadback +from spatialcf.domain.base import Sha256Digest +from spatialcf.domain.request import InterventionSpec +from spatialcf.domain.serialization import ( + canonical_json_bytes, + canonical_sha256, +) +from spatialcf.generation import capture, execution, planning, publication +from spatialcf.generation.config import GenerationConfig, load_generation_config +from spatialcf.generation.dataset import ( + DatasetManifest, + DatasetRecord, + GenerationReport, + _canonical_model_bytes, + _safe_relative_path, +) +from spatialcf.generation.execution.audit import ( + EndpointAuditRejected, + _execute_audit_with_transitions, +) +from spatialcf.generation.workflows.capture import ( + _capture_and_publish_dataset_with_transitions, +) +from spatialcf.generation.workflows.contracts import ( + CounterfactualRequest, + ExecutedEdit, + ExecutionRejection, + PlanningRejection, + VerificationRejection, + VerifiedExample, + _FreshTransitions, +) +from spatialcf.verification.filesystem import ( + CompetitionNativePublicationError, + RenameLocation, + bound_absolute_directory, + bound_child_directory, + directory_identity_fd, + open_directory, + open_native_output_parent, + read_regular_at, + revalidate_entries, + scan_directory, + snapshot_exact_directory, + sync_directory_fd, + write_regular_sync_at, +) + +_CONFIG_HASH_DOMAIN = "spatialcf.generation-config.v1" +_DATASET_TREE_HASH_DOMAIN = "spatialcf.dataset-tree.v1" +_PUBLIC_FILES = frozenset( + {"manifest.json", "records.jsonl", "report.json", "checksums.sha256"} +) +_PUBLIC_DIRECTORIES = frozenset({"assets", ".spatialcf"}) +_STATE_DIRECTORIES = ("capture-plan", "roster", "source-plan", "batches") +_BUNDLE_FILES = frozenset( + { + "before-rgb.png", + "before-depth.npy", + "before-instance.png", + "before-pointcloud.ply", + "after-rgb.png", + "after-depth.npy", + "after-instance.png", + "after-pointcloud.ply", + "bundle.json", + "checksums.sha256", + } +) +_MAX_METADATA_BYTES = 64 * 1024 * 1024 +_MAX_ASSET_BYTES = 512 * 1024 * 1024 + + +def _config_sha256(config: GenerationConfig) -> Sha256Digest: + return canonical_sha256( + config.model_dump(mode="json", warnings="error"), + domain=_CONFIG_HASH_DOMAIN, + ) + + +def _capture_plan(config: GenerationConfig) -> capture.CapturePlan: + return capture.build_legacy_capture_plan( + config.scene_names, + assigned_split=config.split, + campaign_id=config.campaign_id, + seed=config.seed, + width=config.width, + height=config.height, + max_requests_total=config.max_requests, + ) + + +def _checked_config(config: GenerationConfig | Path) -> GenerationConfig: + if type(config) is GenerationConfig: + return GenerationConfig.model_validate( + config.model_dump(mode="python"), strict=True + ) + if isinstance(config, Path): + return load_generation_config(config) + raise TypeError("config must be an exact GenerationConfig or Path") + + +def _absolute_output(output: Path) -> Path: + if not isinstance(output, Path): + raise TypeError("dataset output must be a Path") + absolute = Path(os.path.abspath(output)) + if absolute == absolute.parent: + raise ValueError("dataset output may not be filesystem root") + return absolute + + +def _initialize_dataset_root( + output: Path, + expected_plan: capture.CapturePlan, +) -> None: + with open_native_output_parent(output) as parent: + parent.ensure_absent(parent.output_name) + with parent.create_staging(label="dataset") as transaction: + transaction.mkdir(".spatialcf") + plan_root = output.parent / transaction.name / ".spatialcf" / "capture-plan" + capture.publish_capture_plan(expected_plan, plan_root) + transaction.adopt_exact_tree( + ".spatialcf/capture-plan", + regular_paths={"plan.json", "checksums.sha256"}, + ) + transaction.fsync() + seal = transaction.seal() + if capture.load_capture_plan(plan_root) != expected_plan: + raise RuntimeError("dataset capture-plan staging verification changed") + transaction.validate_seal(seal) + transaction.publish() + try: + final = capture.load_capture_plan( + output / ".spatialcf" / "capture-plan" + ) + if final != expected_plan: + raise RuntimeError( + "dataset capture-plan final verification changed" + ) + transaction.validate_location(RenameLocation.OUTPUT) + transaction.validate_seal(seal) + except BaseException: + transaction.rollback() + raise + + +def _existing_names(descriptor: int, *, maximum: int) -> dict[str, os.stat_result]: + return scan_directory(descriptor, maximum_entries=maximum) + + +def _validate_generation_root(root: Path) -> None: + allowed = set(_PUBLIC_FILES) | set(_PUBLIC_DIRECTORIES) + with bound_absolute_directory(root) as descriptor: + entries = _existing_names(descriptor, maximum=len(allowed)) + if ".spatialcf" not in entries or not set(entries) <= allowed: + raise ValueError("dataset root file set is not resumable") + for name, item in entries.items(): + if name in _PUBLIC_FILES: + if not stat.S_ISREG(item.st_mode) or item.st_nlink != 1: + raise ValueError("dataset public metadata must be regular") + elif not stat.S_ISDIR(item.st_mode): + raise ValueError("dataset public child must be a real directory") + with bound_child_directory(descriptor, ".spatialcf") as state_fd: + state = _existing_names(state_fd, maximum=len(_STATE_DIRECTORIES)) + names = set(state) + if "capture-plan" not in names or not names <= set(_STATE_DIRECTORIES): + raise ValueError("dataset resumable stage set is invalid") + if "source-plan" in names and "roster" not in names: + raise ValueError("dataset source plan has no roster stage") + if "batches" in names and "source-plan" not in names: + raise ValueError("dataset batches have no source plan stage") + if any(not stat.S_ISDIR(item.st_mode) for item in state.values()): + raise ValueError("dataset stage root must be a real directory") + revalidate_entries(state_fd, state) + revalidate_entries(descriptor, entries) + + +def _load_or_build_roster( + plan: capture.CapturePlan, + root: Path, + *, + adapter_factory: Callable[..., AI2ThorAdapter], + fresh_transitions: _FreshTransitions | None = None, +): + if root.exists(): + summary = capture.verify_roster(root) + else: + if fresh_transitions is None: + summary = capture.capture_and_publish_dataset( + plan, + root, + adapter_factory=adapter_factory, + ) + else: + summary = _capture_and_publish_dataset_with_transitions( + plan, + root, + adapter_factory=adapter_factory, + dataset_loader=capture.load_prior_dataset, + fresh_transitions=fresh_transitions, + ) + compilation = capture.load_roster(root) + if compilation.summary != summary or compilation.policy != plan.roster_policy: + raise ValueError("dataset roster stage identity differs from config") + return compilation + + +def _load_or_build_source_plan( + compilation, + root: Path, + *, + fresh_transitions: _FreshTransitions | None = None, +) -> planning.SourcePlan: + expected_policy = planning.build_default_source_policy(compilation) + if root.exists(): + plan = planning.load_source_plan(root) + else: + planned = _plan_source_campaign_with_transitions( + compilation, + expected_policy, + fresh_transitions=fresh_transitions, + ) + planning.publish_source_plan(planned, root) + plan = planning.load_source_plan(root) + if ( + plan.source_policy != expected_policy + or plan.roster_manifest != compilation.request_manifest + ): + raise ValueError("dataset source-plan stage identity differs from config") + return plan + + +def _plan_source_campaign_with_transitions( + compilation, + policy, + *, + fresh_transitions: _FreshTransitions | None, +) -> planning.SourcePlan: + plan = planning.plan_source_campaign(compilation, policy) + if fresh_transitions is None: + return plan + for request, outcome in zip( + plan.roster_manifest.requests, plan.request_outcomes, strict=True + ): + intervention = InterventionSpec( + subject_id=request.subject_id, + reference_id=request.reference_id, + relation_before=request.relation_before, + relation_after=request.relation_after, + camera_id=request.camera_id, + ) + value = fresh_transitions.request( + request.request_id, request.source_id, intervention + ) + if type(value) is not CounterfactualRequest: + raise TypeError("planning transition request must be exact") + if outcome.status == "rejected": + fresh_transitions.reject_planning( + request.request_id, "|".join(outcome.reasons) + ) + return plan + + +def _ensure_batches_root(root: Path) -> None: + with bound_absolute_directory(root.parent) as descriptor: + try: + item = os.stat(root.name, dir_fd=descriptor, follow_symlinks=False) + except FileNotFoundError: + os.mkdir(root.name, mode=0o700, dir_fd=descriptor) + sync_directory_fd(descriptor) + item = os.stat(root.name, dir_fd=descriptor, follow_symlinks=False) + if not stat.S_ISDIR(item.st_mode): + raise ValueError("dataset batches stage must be a real directory") + revalidate_entries(descriptor, {root.name: item}) + + +def _run_batches( + plan: planning.SourcePlan, + root: Path, + *, + adapter_factory: Callable[..., AI2ThorAdapter], + fresh_transitions: _FreshTransitions | None = None, +) -> execution.SourceExecutionSummary: + _ensure_batches_root(root) + + class _FreshAuditRunner: + def __call__(self, adapter, request, *, request_lineage): + try: + return _execute_audit_with_transitions( + adapter, + request, + request_lineage=request_lineage, + fresh_transitions=fresh_transitions, + ) + except EndpointAuditRejected as error: + if error.stage == "native_verification": + readback = error.readback + if type(readback) is not SettledReadback: + raise TypeError( + "native verification rejection must retain exact readback" + ) + executed = fresh_transitions.executed( + request.request_id, + readback.application.edit, + readback, + ) + if type(executed) is not ExecutedEdit: + raise TypeError( + "verification transition execution must be exact" + ) + rejection = fresh_transitions.reject_verification( + request.request_id, "|".join(error.reasons) + ) + if type(rejection) is not VerificationRejection: + raise TypeError( + "verification rejection transition must be exact" + ) + else: + rejection = fresh_transitions.reject_execution( + request.request_id, "|".join(error.reasons) + ) + if type(rejection) is not ExecutionRejection: + raise TypeError("execution rejection transition must be exact") + raise + + def require_verified(self, request_id: str) -> None: + fresh_transitions.require_verified(request_id) + + runner = _FreshAuditRunner() if fresh_transitions is not None else None + + def execute_current_batch( + manifest, + output, + *, + expected_parent_identity, + request_lineage, + ): + arguments = { + "expected_parent_identity": expected_parent_identity, + "request_lineage": request_lineage, + "adapter_factory": adapter_factory, + } + if runner is not None: + arguments["runner"] = runner + return execution.execute_batch( + manifest, + output, + **arguments, + ) + + return execution.run_source_campaign( + plan, + root, + execute=True, + batch_executor=execute_current_batch, + ) + + +def _parse_attempts(payload: bytes) -> tuple[execution.BatchAttempt, ...]: + if not payload: + return () + if not payload.endswith(b"\n"): + raise ValueError("dataset batch outcomes require canonical LF") + attempts = tuple( + execution.BatchAttempt.model_validate_json(line, strict=True) + for line in payload.splitlines() + ) + canonical = b"".join(_canonical_model_bytes(item) + b"\n" for item in attempts) + if canonical != payload: + raise ValueError("dataset batch outcomes are not canonical") + return attempts + + +@dataclass(frozen=True, slots=True) +class _VerifiedAttempt: + attempt: execution.BatchAttempt + bundle: publication.AssetBundle | None + source_root: Path | None + + +def _verified_bundle_at_path(source_root: Path) -> publication.AssetBundle: + loaded = publication.load_asset_bundle(source_root) + return publication.verify_asset_bundle(source_root, loaded.native_audit_run) + + +def _verified_bundle_fd(descriptor: int) -> publication.AssetBundle: + entries = snapshot_exact_directory(descriptor, regular_names=_BUNDLE_FILES) + payload = read_regular_at( + descriptor, + "bundle.json", + _MAX_METADATA_BYTES, + expected_stat=entries["bundle.json"], + ) + bundle = publication.AssetBundle.model_validate_json(payload, strict=True) + if payload != canonical_json_bytes(bundle) + b"\n": + raise ValueError("dataset accepted bundle metadata is not canonical") + checked = publication.verify_asset_bundle_fd( + descriptor, + bundle.native_audit_run, + ) + revalidate_entries(descriptor, entries) + return checked + + +def _path_attempts( + plan: planning.SourcePlan, + batches_root: Path, +) -> dict[str, _VerifiedAttempt]: + attempts: dict[str, _VerifiedAttempt] = {} + for batch in plan.batches: + batch_root = batches_root / batch.batch_id + with bound_absolute_directory(batch_root) as descriptor: + entries = _existing_names(descriptor, maximum=5) + item = entries.get("outcomes.jsonl") + if item is None: + raise ValueError("dataset batch outcomes are absent") + payload = read_regular_at( + descriptor, + "outcomes.jsonl", + _MAX_METADATA_BYTES, + expected_stat=item, + ) + parsed = _parse_attempts(payload) + if tuple(item.request_id for item in parsed) != tuple( + item.request_id for item in batch.requests + ): + raise ValueError("dataset batch outcome membership changed") + for attempt in parsed: + if attempt.request_id in attempts: + raise ValueError("dataset batch request outcome is duplicated") + source_root = ( + batch_root / attempt.case_path + if attempt.outcome == "accepted" and attempt.case_path is not None + else None + ) + bundle = ( + _verified_bundle_at_path(source_root) + if source_root is not None + else None + ) + attempts[attempt.request_id] = _VerifiedAttempt( + attempt=attempt, + bundle=bundle, + source_root=source_root, + ) + revalidate_entries(descriptor, entries) + return attempts + + +def _descriptor_attempts( + plan: planning.SourcePlan, + batch_descriptors: Mapping[str, int], +) -> dict[str, _VerifiedAttempt]: + attempts: dict[str, _VerifiedAttempt] = {} + for batch in plan.batches: + descriptor = batch_descriptors[batch.batch_id] + item = os.stat("outcomes.jsonl", dir_fd=descriptor, follow_symlinks=False) + payload = read_regular_at( + descriptor, + "outcomes.jsonl", + _MAX_METADATA_BYTES, + expected_stat=item, + ) + parsed = _parse_attempts(payload) + if tuple(row.request_id for row in parsed) != tuple( + request.request_id for request in batch.requests + ): + raise ValueError("dataset batch outcome membership changed") + accepted = tuple(row for row in parsed if row.outcome == "accepted") + with bound_child_directory(descriptor, "accepted") as accepted_fd: + accepted_entries = snapshot_exact_directory( + accepted_fd, + regular_names=set(), + directory_names={row.request_id for row in accepted}, + ) + for attempt in parsed: + if attempt.request_id in attempts: + raise ValueError("dataset batch request outcome is duplicated") + bundle = None + if attempt.outcome == "accepted": + with bound_child_directory( + accepted_fd, + attempt.request_id, + ) as case_fd: + bundle = _verified_bundle_fd(case_fd) + attempts[attempt.request_id] = _VerifiedAttempt( + attempt=attempt, + bundle=bundle, + source_root=None, + ) + revalidate_entries(accepted_fd, accepted_entries) + revalidate_entries(descriptor, {"outcomes.jsonl": item}) + return attempts + + +def _terminal_key( + prefix: str, reasons: tuple[str, ...], stage: str | None = None +) -> str: + joined = "|".join(reasons) + return f"{prefix}:{joined}" if stage is None else f"{prefix}:{stage}:{joined}" + + +def _dataset_tree_sha256( + config_sha256: Sha256Digest, + records_sha256: Sha256Digest, + records: tuple[DatasetRecord, ...], +) -> Sha256Digest: + return canonical_sha256( + { + "asset_bundles": tuple( + (item.bundle_path, item.bundle_sha256) for item in records + ), + "config_sha256": config_sha256, + "records_sha256": records_sha256, + }, + domain=_DATASET_TREE_HASH_DOMAIN, + ) + + +def _derive_dataset( + config: GenerationConfig, + compilation, + plan: planning.SourcePlan, + execution_summary: execution.SourceExecutionSummary, + attempts: Mapping[str, _VerifiedAttempt], + *, + fresh_transitions: _FreshTransitions | None = None, +): + remaining = dict(attempts) + request_ids = tuple(item.request_id for item in plan.request_outcomes) + if fresh_transitions is not None: + fresh_transitions._validate_terminal_closure(request_ids) + records: list[DatasetRecord] = [] + bundles: dict[str, tuple[publication.AssetBundle, Path]] = {} + seen_bundle_paths: set[str] = set() + reasons: Counter[str] = Counter() + for outcome in compilation.scene_inventory: + if outcome.status == "rejected": + reasons[_terminal_key("capture", outcome.reasons)] += 1 + planned_count = 0 + execution_rejected = 0 + for outcome in plan.request_outcomes: + terminal = ( + fresh_transitions._terminal_for(outcome.request_id) + if fresh_transitions is not None + else None + ) + if outcome.status == "rejected": + if fresh_transitions is not None: + request = fresh_transitions._request(outcome.request_id) + if ( + type(terminal) is not PlanningRejection + or terminal.request is not request + or terminal.reason != "|".join(outcome.reasons) + ): + raise ValueError("dataset planning terminal binding changed") + reasons[_terminal_key("planning", outcome.reasons)] += 1 + continue + planned_count += 1 + try: + verified = remaining.pop(outcome.request_id) + except KeyError as error: + raise ValueError( + "dataset planned request has no terminal outcome" + ) from error + attempt = verified.attempt + if attempt.outcome == "rejected": + if fresh_transitions is not None: + request = fresh_transitions._request(outcome.request_id) + reason = "|".join(attempt.reasons) + if attempt.stage == "native_verification": + if ( + type(terminal) is not VerificationRejection + or terminal.execution.plan.request is not request + or terminal.reason != reason + ): + raise ValueError( + "dataset verification terminal binding changed" + ) + elif ( + type(terminal) is not ExecutionRejection + or terminal.request is not request + or terminal.reason != reason + or ( + terminal.plan is not None + and terminal.plan.request is not request + ) + ): + raise ValueError("dataset execution terminal binding changed") + execution_rejected += 1 + reasons[_terminal_key("execution", attempt.reasons, attempt.stage)] += 1 + continue + if attempt.case_path != f"accepted/{outcome.request_id}": + raise ValueError("dataset accepted outcome path changed") + bundle = verified.bundle + if bundle is None: + raise ValueError("dataset accepted outcome has no verified asset bundle") + if ( + attempt.native_asset_bundle_sha256 != bundle.asset_bundle_sha256 + or bundle.native_audit_run.intervention.subject_id != outcome.subject_id + or bundle.native_audit_run.intervention.reference_id != outcome.reference_id + ): + raise ValueError("dataset accepted asset binding changed") + if fresh_transitions is not None: + if type(terminal) is not VerifiedExample: + raise ValueError("dataset accepted terminal is not verified") + request = terminal.execution.plan.request + intervention = request.intervention + audit_edit = bundle.native_audit_run.solve_result.selected_witness.edit + if ( + request.source.request.scene_id != outcome.scene_id + or intervention.subject_id != outcome.subject_id + or intervention.reference_id != outcome.reference_id + or intervention.relation_before is not outcome.relation_before + or intervention.relation_after is not outcome.relation_before.opposite + or terminal.execution.readback.application.edit + != terminal.execution.plan.edit + or bundle.native_audit_run.intervention != intervention + or audit_edit.semantic_problem_sha256 + != terminal.execution.plan.edit.semantic_problem_sha256 + or audit_edit.translation_xy_m + != terminal.execution.plan.edit.translation_xy_m + or audit_edit.subject_id + != f"object:{terminal.execution.plan.edit.subject_id}" + or fresh_transitions.require_verified(outcome.request_id) + is not terminal + ): + raise ValueError("dataset accepted terminal binding changed") + bundle_path = f"assets/{bundle.asset_bundle_sha256}" + before_assets = tuple( + f"{bundle_path}/{item.relative_path}" + for item in bundle.assets + if item.phase is publication.AssetPhase.BEFORE + ) + after_assets = tuple( + f"{bundle_path}/{item.relative_path}" + for item in bundle.assets + if item.phase is publication.AssetPhase.AFTER + ) + record = DatasetRecord( + request_id=outcome.request_id, + scene_id=outcome.scene_id, + subject_id=outcome.subject_id, + reference_id=outcome.reference_id, + relation_before=outcome.relation_before, + relation_after=outcome.relation_before.opposite, + bundle_path=bundle_path, + bundle_sha256=bundle.asset_bundle_sha256, + before_assets=before_assets, + after_assets=after_assets, + ) + if bundle_path in seen_bundle_paths: + raise ValueError("dataset accepted bundle identity is duplicated") + seen_bundle_paths.add(bundle_path) + if verified.source_root is not None: + bundles[bundle_path] = (bundle, verified.source_root) + records.append(record) + if remaining: + raise ValueError("dataset batch outcomes escape the frozen request roster") + records_tuple = tuple(records) + records_payload = b"".join( + _canonical_model_bytes(item) + b"\n" for item in records_tuple + ) + records_sha256 = hashlib.sha256(records_payload).hexdigest() + config_sha256 = _config_sha256(config) + tree_sha256 = _dataset_tree_sha256( + config_sha256, + records_sha256, + records_tuple, + ) + report = GenerationReport( + source_count=compilation.summary.source_count, + source_capture_rejected_count=compilation.summary.rejected_source_count, + frozen_request_count=len(plan.request_outcomes), + planned_request_count=planned_count, + planning_rejected_request_count=len(plan.request_outcomes) - planned_count, + accepted_request_count=len(records_tuple), + execution_rejected_request_count=execution_rejected, + terminal_reasons=dict(sorted(reasons.items())), + dataset_tree_sha256=tree_sha256, + ) + if ( + execution_summary.endpoint_planned_request_count != planned_count + or execution_summary.accepted_request_count != len(records_tuple) + or execution_summary.native_rejected_request_count != execution_rejected + ): + raise ValueError("dataset execution summary counts changed") + report_payload = _canonical_model_bytes(report) + b"\n" + manifest = DatasetManifest( + config_sha256=config_sha256, + record_count=len(records_tuple), + records_sha256=records_sha256, + report_sha256=hashlib.sha256(report_payload).hexdigest(), + asset_bundle_paths=tuple(item.bundle_path for item in records_tuple), + dataset_tree_sha256=tree_sha256, + ) + return ( + records_tuple, + records_payload, + report, + report_payload, + manifest, + bundles, + ) + + +def _checksum_payload(payloads: Mapping[str, bytes]) -> bytes: + return b"".join( + f"{hashlib.sha256(payload).hexdigest()} {name}\n".encode("ascii") + for name, payload in sorted(payloads.items()) + ) + + +def _stage_public_index( + transaction, + records_payload: bytes, + report_payload: bytes, + manifest: DatasetManifest, + bundles: Mapping[str, tuple[publication.AssetBundle, Path]], +) -> dict[str, bytes]: + transaction.mkdir("assets") + payloads: dict[str, bytes] = {} + for bundle_path, (bundle, source_root) in bundles.items(): + transaction.mkdir(bundle_path) + with bound_absolute_directory(source_root) as descriptor: + entries = snapshot_exact_directory( + descriptor, + regular_names=_BUNDLE_FILES, + ) + for name in sorted(_BUNDLE_FILES): + payload = read_regular_at( + descriptor, + name, + _MAX_ASSET_BYTES, + expected_stat=entries[name], + ) + relative = f"{bundle_path}/{name}" + transaction.write(relative, payload) + payloads[relative] = payload + revalidate_entries(descriptor, entries) + with ( + bound_child_directory(transaction.descriptor, "assets") as assets_fd, + bound_child_directory(assets_fd, bundle.asset_bundle_sha256) as bundle_fd, + ): + if ( + publication.verify_asset_bundle_fd( + bundle_fd, + bundle.native_audit_run, + ) + != bundle + ): + raise RuntimeError("dataset staged asset verification changed") + metadata = { + "manifest.json": _canonical_model_bytes(manifest) + b"\n", + "records.jsonl": records_payload, + "report.json": report_payload, + } + for name, payload in metadata.items(): + transaction.write(name, payload) + payloads[name] = payload + checksum = _checksum_payload(payloads) + transaction.write("checksums.sha256", checksum) + payloads["checksums.sha256"] = checksum + return payloads + + +def _read_or_write_exact( + descriptor: int, + name: str, + payload: bytes, + created: dict[Path, tuple[int, int]], + relative: Path, +) -> None: + try: + item = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + except FileNotFoundError: + written = write_regular_sync_at(descriptor, name, payload) + created[relative] = (written.st_dev, written.st_ino) + return + observed = read_regular_at( + descriptor, + name, + max(1, len(payload)), + expected_stat=item, + ) + if observed != payload: + raise FileExistsError(f"dataset public entry differs: {relative.as_posix()}") + + +def _ensure_exact_directory( + descriptor: int, + name: str, + created: dict[Path, tuple[int, int]], + directories: dict[Path, tuple[int, int]], + relative: Path, +) -> None: + try: + item = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + except FileNotFoundError: + os.mkdir(name, mode=0o700, dir_fd=descriptor) + sync_directory_fd(descriptor) + item = os.stat(name, dir_fd=descriptor, follow_symlinks=False) + created[relative] = (item.st_dev, item.st_ino) + if not stat.S_ISDIR(item.st_mode): + raise ValueError(f"dataset public directory is unsafe: {relative.as_posix()}") + identity = (item.st_dev, item.st_ino) + previous = directories.setdefault(relative, identity) + if previous != identity: + raise RuntimeError("dataset public directory identity changed") + + +def _copy_staged_public_index( + root_descriptor: int, + transaction, + payloads: Mapping[str, bytes], + created: dict[Path, tuple[int, int]], + directories: dict[Path, tuple[int, int]], +) -> None: + _ensure_exact_directory( + root_descriptor, + "assets", + created, + directories, + Path("assets"), + ) + with ( + bound_child_directory(root_descriptor, "assets") as final_assets_fd, + bound_child_directory(transaction.descriptor, "assets") as staged_fd, + ): + staged_assets = _existing_names( + staged_fd, + maximum=max(1, len(payloads)), + ) + for digest in sorted(staged_assets): + _ensure_exact_directory( + final_assets_fd, + digest, + created, + directories, + Path("assets") / digest, + ) + with ( + bound_child_directory(staged_fd, digest) as source_fd, + bound_child_directory(final_assets_fd, digest) as target_fd, + ): + source_entries = snapshot_exact_directory( + source_fd, + regular_names=_BUNDLE_FILES, + ) + for name in sorted(_BUNDLE_FILES): + payload = read_regular_at( + source_fd, + name, + _MAX_ASSET_BYTES, + expected_stat=source_entries[name], + ) + _read_or_write_exact( + target_fd, + name, + payload, + created, + Path("assets") / digest / name, + ) + revalidate_entries(source_fd, source_entries) + for name in ("records.jsonl", "report.json", "checksums.sha256"): + _read_or_write_exact( + root_descriptor, + name, + payloads[name], + created, + Path(name), + ) + _read_or_write_exact( + root_descriptor, + "manifest.json", + payloads["manifest.json"], + created, + Path("manifest.json"), + ) + sync_directory_fd(root_descriptor) + + +@contextmanager +def _bound_rollback_parent( + root_descriptor: int, + relative: Path, + directories: Mapping[Path, tuple[int, int]], +) -> Iterator[int]: + with ExitStack() as stack: + descriptor = root_descriptor + opened: list[tuple[int, str, tuple[int, int]]] = [] + prefix = Path() + for component in relative.parts: + prefix /= component + expected = directories.get(prefix) + if expected is None: + raise RuntimeError("dataset rollback parent ownership is unknown") + before = os.stat(component, dir_fd=descriptor, follow_symlinks=False) + if ( + not stat.S_ISDIR(before.st_mode) + or ( + before.st_dev, + before.st_ino, + ) + != expected + ): + raise RuntimeError("dataset rollback parent identity changed") + parent_descriptor = descriptor + descriptor = stack.enter_context( + open_directory(component, dir_fd=parent_descriptor) + ) + if directory_identity_fd(descriptor) != expected: + raise RuntimeError("dataset rollback parent binding changed") + opened.append((parent_descriptor, component, expected)) + yield descriptor + for parent_descriptor, component, expected in reversed(opened): + current = os.stat( + component, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + if ( + not stat.S_ISDIR(current.st_mode) + or ( + current.st_dev, + current.st_ino, + ) + != expected + ): + raise RuntimeError("dataset rollback parent binding changed") + + +def _rollback_created_fd( + root_descriptor: int, + created: Mapping[Path, tuple[int, int]], + directories: Mapping[Path, tuple[int, int]], +) -> None: + errors: list[BaseException] = [] + for relative, identity in sorted( + created.items(), key=lambda item: len(item[0].parts), reverse=True + ): + try: + with _bound_rollback_parent( + root_descriptor, + relative.parent, + directories, + ) as parent_fd: + item = os.stat( + relative.name, + dir_fd=parent_fd, + follow_symlinks=False, + ) + if (item.st_dev, item.st_ino) != identity: + raise RuntimeError("dataset rollback entry identity changed") + if stat.S_ISDIR(item.st_mode): + os.rmdir(relative.name, dir_fd=parent_fd) + elif stat.S_ISREG(item.st_mode): + os.unlink(relative.name, dir_fd=parent_fd) + else: + raise RuntimeError("dataset rollback entry type changed") + sync_directory_fd(parent_fd) + except BaseException as error: # noqa: BLE001 + errors.append(error) + if errors: + raise RuntimeError("dataset public index rollback was incomplete") from errors[ + 0 + ] + + +def _parse_records(payload: bytes) -> tuple[DatasetRecord, ...]: + if payload and not payload.endswith(b"\n"): + raise ValueError("dataset records require canonical LF") + records = tuple( + DatasetRecord.model_validate_json(line, strict=True) + for line in payload.splitlines() + ) + if len({item.request_id for item in records}) != len(records): + raise ValueError("dataset record request IDs are duplicated") + canonical = b"".join(_canonical_model_bytes(item) + b"\n" for item in records) + if canonical != payload: + raise ValueError("dataset records are not canonical") + return records + + +def _parse_checksum_ledger(payload: bytes) -> dict[str, str]: + if payload and not payload.endswith(b"\n"): + raise ValueError("dataset checksum ledger requires canonical LF") + ledger: dict[str, str] = {} + for line in payload.splitlines(): + try: + digest, encoded_name = line.split(b" ", 1) + name = encoded_name.decode("ascii") + digest_text = digest.decode("ascii") + except (UnicodeDecodeError, ValueError) as error: + raise ValueError("dataset checksum ledger row is malformed") from error + _safe_relative_path(name) + if ( + len(digest_text) != 64 + or any(item not in "0123456789abcdef" for item in digest_text) + or name in ledger + ): + raise ValueError("dataset checksum ledger row is invalid") + ledger[name] = digest_text + if tuple(ledger) != tuple(sorted(ledger)): + raise ValueError("dataset checksum ledger is not canonical") + return ledger + + +def _verify_public_index_fd(descriptor: int, *, with_state: bool): + directories = {"assets"} | ({".spatialcf"} if with_state else set()) + entries = snapshot_exact_directory( + descriptor, + regular_names=_PUBLIC_FILES, + directory_names=directories, + ) + metadata = { + name: read_regular_at( + descriptor, + name, + _MAX_METADATA_BYTES, + expected_stat=entries[name], + ) + for name in _PUBLIC_FILES + } + manifest = DatasetManifest.model_validate_json( + metadata["manifest.json"], strict=True + ) + if metadata["manifest.json"] != _canonical_model_bytes(manifest) + b"\n": + raise ValueError("dataset manifest is not canonical") + report = GenerationReport.model_validate_json(metadata["report.json"], strict=True) + if metadata["report.json"] != _canonical_model_bytes(report) + b"\n": + raise ValueError("dataset report is not canonical") + records = _parse_records(metadata["records.jsonl"]) + if ( + manifest.record_count != len(records) + or manifest.records_sha256 + != hashlib.sha256(metadata["records.jsonl"]).hexdigest() + or manifest.report_sha256 != hashlib.sha256(metadata["report.json"]).hexdigest() + or manifest.asset_bundle_paths != tuple(item.bundle_path for item in records) + or report.accepted_request_count != len(records) + ): + raise ValueError("dataset record/report manifest closure changed") + public_payloads = { + name: payload + for name, payload in metadata.items() + if name != "checksums.sha256" + } + record_by_path = {item.bundle_path: item for item in records} + with bound_child_directory(descriptor, "assets") as assets_fd: + asset_entries = snapshot_exact_directory( + assets_fd, + regular_names=set(), + directory_names={PurePosixPath(item).name for item in record_by_path}, + ) + for bundle_path, record in record_by_path.items(): + digest = PurePosixPath(bundle_path).name + with bound_child_directory(assets_fd, digest) as bundle_fd: + bundle_entries = snapshot_exact_directory( + bundle_fd, + regular_names=_BUNDLE_FILES, + ) + bundle_payload = read_regular_at( + bundle_fd, + "bundle.json", + _MAX_METADATA_BYTES, + expected_stat=bundle_entries["bundle.json"], + ) + bundle = publication.AssetBundle.model_validate_json( + bundle_payload, strict=True + ) + checked = publication.verify_asset_bundle_fd( + bundle_fd, + bundle.native_audit_run, + ) + before = tuple( + f"{bundle_path}/{item.relative_path}" + for item in checked.assets + if item.phase is publication.AssetPhase.BEFORE + ) + after = tuple( + f"{bundle_path}/{item.relative_path}" + for item in checked.assets + if item.phase is publication.AssetPhase.AFTER + ) + if ( + checked.asset_bundle_sha256 != record.bundle_sha256 + or record.bundle_sha256 != digest + or record.before_assets != before + or record.after_assets != after + ): + raise ValueError("dataset public asset binding changed") + for name in sorted(_BUNDLE_FILES): + public_payloads[f"{bundle_path}/{name}"] = read_regular_at( + bundle_fd, + name, + _MAX_ASSET_BYTES, + expected_stat=bundle_entries[name], + ) + revalidate_entries(bundle_fd, bundle_entries) + revalidate_entries(assets_fd, asset_entries) + ledger = _parse_checksum_ledger(metadata["checksums.sha256"]) + expected_ledger = { + name: hashlib.sha256(payload).hexdigest() + for name, payload in public_payloads.items() + } + if ledger != dict(sorted(expected_ledger.items())): + raise ValueError("dataset checksum ledger mismatch") + expected_tree = _dataset_tree_sha256( + manifest.config_sha256, + manifest.records_sha256, + records, + ) + if ( + manifest.dataset_tree_sha256 != expected_tree + or report.dataset_tree_sha256 != expected_tree + ): + raise ValueError("dataset tree digest changed") + revalidate_entries(descriptor, entries) + return manifest, report, records + + +@dataclass(frozen=True, slots=True) +class _RetainedBatchState: + batch_id: str + descriptor: int + verification: execution.RetainedSourceBatchVerification + summary: execution.BatchSummary + + +@dataclass(frozen=True, slots=True) +class _RetainedCampaignState: + entries: Mapping[str, os.stat_result] + batches: tuple[_RetainedBatchState, ...] + + +def _verify_source_campaign_fd( + plan: planning.SourcePlan, + descriptor: int, + stack: ExitStack, +) -> tuple[ + execution.SourceExecutionSummary, + dict[str, _VerifiedAttempt], + _RetainedCampaignState, +]: + entries = snapshot_exact_directory( + descriptor, + regular_names=set(), + directory_names={batch.batch_id for batch in plan.batches}, + ) + retained_batches: list[_RetainedBatchState] = [] + batch_descriptors: dict[str, int] = {} + summaries: dict[str, execution.BatchSummary] = {} + for batch in plan.batches: + batch_fd = stack.enter_context( + bound_child_directory(descriptor, batch.batch_id) + ) + retained = execution.prepare_source_batch_verification( + plan, + batch, + batch_fd, + ) + summary = retained.summary + batch_descriptors[batch.batch_id] = batch_fd + summaries[batch.batch_id] = summary + retained_batches.append( + _RetainedBatchState( + batch_id=batch.batch_id, + descriptor=batch_fd, + verification=retained, + summary=summary, + ) + ) + attempts = _descriptor_attempts(plan, batch_descriptors) + summary = execution.summarize_verified_source_campaign(plan, summaries) + return ( + summary, + attempts, + _RetainedCampaignState( + entries=entries, + batches=tuple(retained_batches), + ), + ) + + +def _revalidate_source_campaign_fd( + descriptor: int, + plan: planning.SourcePlan, + retained: _RetainedCampaignState, +) -> None: + for state in retained.batches: + summary = execution.revalidate_source_batch_verification( + state.descriptor, + state.verification, + ) + if summary != state.summary: + raise ValueError("dataset retained batch summary changed") + revalidate_entries(descriptor, retained.entries) + + +def _publication_root_binding( + parent, + root_name: str, + root_identity: tuple[int, int], +) -> bool | None: + try: + parent.validate() + except BaseException: # noqa: BLE001 + return None + try: + current = os.stat( + root_name, + dir_fd=parent.parent_descriptor, + follow_symlinks=False, + ) + except FileNotFoundError: + return False + except BaseException: # noqa: BLE001 + return None + if ( + stat.S_ISDIR(current.st_mode) + and ( + current.st_dev, + current.st_ino, + ) + == root_identity + ): + return True + return None + + +def _raise_publication_failure( + root: Path, + parent, + root_descriptor: int, + root_identity: tuple[int, int], + created: Mapping[Path, tuple[int, int]], + directories: Mapping[Path, tuple[int, int]], + active_error: BaseException, +) -> None: + rollback_error: BaseException | None = None + try: + _rollback_created_fd(root_descriptor, created, directories) + except BaseException as error: # noqa: BLE001 + rollback_error = error + active_error.add_note(str(error)) + binding = _publication_root_binding(parent, root.name, root_identity) + if rollback_error is None and binding is True: + raise active_error + if rollback_error is None and binding is False: + raise CompetitionNativePublicationError( + root, + published=False, + recovery_name=None, + detail="dataset root moved during public index publication and was rolled back", + ) from active_error + detail = ( + "dataset public index rollback could not prove complete cleanup" + if rollback_error is not None + else "dataset root binding became foreign during public index publication" + ) + raise CompetitionNativePublicationError( + root, + published=None, + recovery_name=None, + detail=detail, + ) from active_error + + +def _publish_dataset_index( + root: Path, + records_payload: bytes, + report_payload: bytes, + manifest: DatasetManifest, + bundles: Mapping[str, tuple[publication.AssetBundle, Path]], +) -> GenerationReport: + stage_target = root.parent / f"{root.name}-dataset-index" + created: dict[Path, tuple[int, int]] = {} + directories: dict[Path, tuple[int, int]] = {} + with ( + open_native_output_parent(stage_target) as parent, + parent.create_staging(label="dataset-index") as transaction, + ): + payloads = _stage_public_index( + transaction, + records_payload, + report_payload, + manifest, + bundles, + ) + transaction.fsync() + seal = transaction.seal() + staged_manifest, staged_report, _ = _verify_public_index_fd( + transaction.descriptor, + with_state=False, + ) + if staged_manifest != manifest: + raise RuntimeError("dataset index staging verification changed") + transaction.validate_seal(seal) + parent.validate() + root_entry = os.stat( + root.name, + dir_fd=parent.parent_descriptor, + follow_symlinks=False, + ) + if not stat.S_ISDIR(root_entry.st_mode): + raise ValueError("dataset publication root must be a real directory") + with open_directory( + root.name, + dir_fd=parent.parent_descriptor, + ) as root_descriptor: + root_identity = directory_identity_fd(root_descriptor) + if root_identity != (root_entry.st_dev, root_entry.st_ino): + raise RuntimeError("dataset publication root binding changed") + try: + _copy_staged_public_index( + root_descriptor, + transaction, + payloads, + created, + directories, + ) + final_report, _ = _verify_dataset_fd(root_descriptor) + if final_report != staged_report: + raise RuntimeError("dataset index final verification changed") + transaction.validate_seal(seal) + if ( + _publication_root_binding(parent, root.name, root_identity) + is not True + ): + raise RuntimeError("dataset publication root binding changed") + return final_report + except BaseException as error: # noqa: BLE001 + _raise_publication_failure( + root, + parent, + root_descriptor, + root_identity, + created, + directories, + error, + ) + raise AssertionError("unreachable") + + +def _config_from_capture_plan(plan: capture.CapturePlan) -> GenerationConfig: + scene_names: list[str] = [] + for locator in plan.source_locators: + if locator.kind != "legacy-ai2thor": + raise ValueError("public dataset contains a non-AI2-THOR legacy source") + scene_names.append(locator.scene_name) + return GenerationConfig( + adapter="ai2thor", + scene_names=tuple(scene_names), + split=plan.assigned_split, + campaign_id=plan.roster_policy.campaign_id, + seed=plan.roster_policy.seed, + width=plan.roster_policy.width, + height=plan.roster_policy.height, + max_requests=plan.roster_policy.max_requests_total, + ) + + +def _verify_dataset_fd( + descriptor: int, +) -> tuple[GenerationReport, tuple[DatasetRecord, ...]]: + root_entries = snapshot_exact_directory( + descriptor, + regular_names=_PUBLIC_FILES, + directory_names=_PUBLIC_DIRECTORIES, + ) + with ExitStack() as stack: + state_fd = stack.enter_context(bound_child_directory(descriptor, ".spatialcf")) + state_entries = snapshot_exact_directory( + state_fd, + regular_names=set(), + directory_names=set(_STATE_DIRECTORIES), + ) + stage_descriptors = { + name: stack.enter_context(bound_child_directory(state_fd, name)) + for name in _STATE_DIRECTORIES + } + for name, stage_fd in stage_descriptors.items(): + expected = state_entries[name] + if directory_identity_fd(stage_fd) != (expected.st_dev, expected.st_ino): + raise RuntimeError("dataset retained stage identity changed") + + manifest, report, records = _verify_public_index_fd( + descriptor, + with_state=True, + ) + + capture_fd = stage_descriptors["capture-plan"] + capture_verification = capture.prepare_capture_plan_verification(capture_fd) + capture_plan = capture_verification.plan + config = _config_from_capture_plan(capture_plan) + if _capture_plan(config) != capture_plan: + raise ValueError("dataset capture plan no longer matches its config") + + roster_fd = stage_descriptors["roster"] + roster_verification = capture.prepare_roster_verification(roster_fd) + compilation = roster_verification.compilation + + source_plan_fd = stage_descriptors["source-plan"] + source_plan_verification = planning.prepare_source_plan_verification( + source_plan_fd + ) + source_plan = source_plan_verification.plan + expected_policy = planning.build_default_source_policy(compilation) + if ( + source_plan.source_policy != expected_policy + or source_plan.roster_manifest != compilation.request_manifest + ): + raise ValueError("dataset source plan binding changed") + + batches_fd = stage_descriptors["batches"] + execution_summary, attempts, retained_campaign = _verify_source_campaign_fd( + source_plan, + batches_fd, + stack, + ) + ( + expected_records, + expected_records_payload, + expected_report, + expected_report_payload, + expected_manifest, + _bundles, + ) = _derive_dataset( + config, + compilation, + source_plan, + execution_summary, + attempts, + ) + if ( + records != expected_records + or manifest != expected_manifest + or report != expected_report + or hashlib.sha256(expected_records_payload).hexdigest() + != manifest.records_sha256 + or hashlib.sha256(expected_report_payload).hexdigest() + != manifest.report_sha256 + ): + raise ValueError("dataset public index differs from verified stages") + + final_manifest, final_report, final_records = _verify_public_index_fd( + descriptor, + with_state=True, + ) + if ( + final_manifest != manifest + or final_report != report + or final_records != records + ): + raise ValueError("dataset public index changed during verification") + + _revalidate_source_campaign_fd( + batches_fd, + source_plan, + retained_campaign, + ) + if ( + capture.revalidate_capture_plan_verification( + capture_fd, + capture_verification, + ) + != capture_plan + or capture.revalidate_roster_verification( + roster_fd, + roster_verification, + ) + != compilation + or planning.revalidate_source_plan_verification( + source_plan_fd, + source_plan_verification, + ) + != source_plan + ): + raise ValueError("dataset retained stage semantics changed") + for name, stage_fd in stage_descriptors.items(): + expected = state_entries[name] + if directory_identity_fd(stage_fd) != (expected.st_dev, expected.st_ino): + raise RuntimeError("dataset retained stage identity changed") + revalidate_entries(state_fd, state_entries) + revalidate_entries(descriptor, root_entries) + return expected_report, expected_records + + +def generate_dataset( + config: GenerationConfig | Path, + output: Path, + *, + adapter_factory: Callable[..., AI2ThorAdapter], +) -> GenerationReport: + """Generate or resume one immutable dataset and publish its stable index.""" + + checked = _checked_config(config) + root = _absolute_output(output) + expected_capture = _capture_plan(checked) + fresh_transitions = _FreshTransitions() if not root.exists() else None + if not root.exists(): + _initialize_dataset_root(root, expected_capture) + _validate_generation_root(root) + if (root / "manifest.json").exists(): + raise FileExistsError(root) + loaded_capture = capture.load_capture_plan(root / ".spatialcf" / "capture-plan") + if loaded_capture != expected_capture: + raise ValueError("dataset capture plan identity differs from config") + roster = _load_or_build_roster( + loaded_capture, + root / ".spatialcf" / "roster", + adapter_factory=adapter_factory, + fresh_transitions=fresh_transitions, + ) + source_plan = _load_or_build_source_plan( + roster, + root / ".spatialcf" / "source-plan", + fresh_transitions=fresh_transitions, + ) + execution_summary = _run_batches( + source_plan, + root / ".spatialcf" / "batches", + adapter_factory=adapter_factory, + fresh_transitions=fresh_transitions, + ) + attempts = _path_attempts( + source_plan, + root / ".spatialcf" / "batches", + ) + ( + _records, + records_payload, + _report, + report_payload, + manifest, + bundles, + ) = _derive_dataset( + checked, + roster, + source_plan, + execution_summary, + attempts, + fresh_transitions=fresh_transitions, + ) + return _publish_dataset_index( + root, + records_payload, + report_payload, + manifest, + bundles, + ) + + +__all__ = ("generate_dataset",) diff --git a/src/spatialcf/generation/_internal/execution/batch.py b/src/spatialcf/generation/workflows/execution.py similarity index 96% rename from src/spatialcf/generation/_internal/execution/batch.py rename to src/spatialcf/generation/workflows/execution.py index b57db2b..aca3bf6 100644 --- a/src/spatialcf/generation/_internal/execution/batch.py +++ b/src/spatialcf/generation/workflows/execution.py @@ -1,4 +1,4 @@ -"""Current partial-batch execution and immutable publication.""" +"""Final reusable partial-batch execution and immutable publication.""" from __future__ import annotations @@ -14,32 +14,28 @@ from pydantic import Field, model_validator -from spatialcf.adapters.ai2thor import AI2ThorAdapter -from spatialcf.domain.enums import Relation -from spatialcf.domain.v2.base import Sha256Digest, V2Model -from spatialcf.domain.v2.serialization import canonical_json_bytes_v2 -from spatialcf.generation._internal.assets import ( - AssetBundle, - _read_bundle_metadata_fd, - _verify_bundle_payloads_fd, - asset_bundle_sha256_from_checked, - publish_asset_bundle, -) -from spatialcf.generation._internal.execution.audit import EndpointAuditRejected -from spatialcf.generation._internal.execution.correspondence import ( +from spatialcf.adapters.base import EnvironmentAdapter +from spatialcf.domain.base import CanonicalModel, Sha256Digest +from spatialcf.domain.request import Relation +from spatialcf.domain.serialization import canonical_json_bytes +from spatialcf.generation.errors import require_wire_version +from spatialcf.generation.execution.audit import AuditExecution, EndpointAuditRejected +from spatialcf.generation.execution.correspondence import ( RequestLineage, legacy_sha256, request_binding_sha256, ) -from spatialcf.generation._internal.execution.run import ( - AuditExecution, - execute_audit, -) -from spatialcf.generation._internal.planning.campaign import ( +from spatialcf.generation.planning.campaign import ( BatchManifest, BatchRequest, ) -from spatialcf.generation.errors import require_wire_version +from spatialcf.generation.publication.assets import ( + AssetBundle, + _read_bundle_metadata_fd, + _verify_bundle_payloads_fd, + asset_bundle_sha256_from_checked, + publish_asset_bundle, +) from spatialcf.verification.filesystem import ( BindingStatus, CompetitionNativePublicationError, @@ -83,7 +79,7 @@ _BATCH_VERIFICATION_CAPABILITY = object() -class BatchAttempt(V2Model): +class BatchAttempt(CanonicalModel): attempt_index: int = Field(strict=True, ge=0) request_id: str = Field(pattern=r"^[a-z0-9][a-z0-9._-]{0,127}$") outcome: Literal["accepted", "rejected"] @@ -127,12 +123,12 @@ def validate_outcome(self) -> Self: return self -class BatchStageCount(V2Model): +class BatchStageCount(CanonicalModel): stage: str = Field(strict=True, min_length=1, max_length=128) count: int = Field(strict=True, gt=0) -class BatchSummary(V2Model): +class BatchSummary(CanonicalModel): """Closed current summary for one partial frozen-slot shard.""" summary_version: Literal["competition-native-batch-summary:2.9.1"] = ( @@ -246,7 +242,7 @@ def load_batch_manifest(path: Path) -> BatchManifest: expected=_MANIFEST_VERSION, ) manifest = BatchManifest.model_validate_json(payload, strict=True) - if payload != canonical_json_bytes_v2(manifest) + b"\n": + if payload != canonical_json_bytes(manifest) + b"\n": raise ValueError("native batch manifest is not canonical") return manifest @@ -257,8 +253,8 @@ def execute_batch( *, request_lineage: Mapping[str, RequestLineage], expected_parent_identity: DirectoryIdentity | None = None, - adapter_factory: Callable[..., AI2ThorAdapter] = AI2ThorAdapter, - runner: Callable[..., AuditExecution] = execute_audit, + adapter_factory: Callable[..., EnvironmentAdapter], + runner: Callable[..., AuditExecution], ) -> BatchSummary: """Execute every declared request once and atomically publish all outcomes.""" @@ -270,7 +266,7 @@ def execute_batch( manifest.model_dump(mode="python", warnings="error"), strict=True ) lineage = _normalize_request_lineage(checked, request_lineage) - manifest_payload = canonical_json_bytes_v2(checked) + b"\n" + manifest_payload = canonical_json_bytes(checked) + b"\n" if len(manifest_payload) > _MAX_METADATA_BYTES: raise ValueError("native batch request manifest exceeds byte limit") if not isinstance(output_root, Path): @@ -380,6 +376,9 @@ def _populate_batch_transaction( raise TypeError("native batch runner returned an invalid execution") _require_execution_request_binding(manifest, request, execution) _require_request_lineage(request, execution, lineage) + require_verified = getattr(runner, "require_verified", None) + if require_verified is not None: + require_verified(request.request_id) relative_case = f"accepted/{request.request_id}" case_root = output.parent / transaction.name / relative_case with bound_child_directory(transaction.descriptor, "accepted") as accepted_fd: @@ -404,15 +403,13 @@ def _populate_batch_transaction( native_asset_bundle_sha256=bundle.asset_bundle_sha256, ) ) - manifest_payload = canonical_json_bytes_v2(manifest) + b"\n" - outcomes_payload = b"".join( - canonical_json_bytes_v2(item) + b"\n" for item in attempts - ) + manifest_payload = canonical_json_bytes(manifest) + b"\n" + outcomes_payload = b"".join(canonical_json_bytes(item) + b"\n" for item in attempts) summary = _summary(manifest, tuple(attempts), manifest_payload, outcomes_payload) metadata = { "request-manifest.json": manifest_payload, "outcomes.jsonl": outcomes_payload, - "summary.json": canonical_json_bytes_v2(summary) + b"\n", + "summary.json": canonical_json_bytes(summary) + b"\n", } if any(len(payload) > _MAX_METADATA_BYTES for payload in metadata.values()): raise ValueError("native batch root metadata exceeds byte limit") @@ -441,7 +438,7 @@ def _populate_batch_transaction( final_descriptor, request_lineage=request_lineage, ) - if canonical_json_bytes_v2(final_summary) != canonical_json_bytes_v2(summary): + if canonical_json_bytes(final_summary) != canonical_json_bytes(summary): raise RuntimeError("native batch final verification changed") transaction.validate_location(RenameLocation.OUTPUT) transaction.validate_seal(seal) @@ -657,7 +654,7 @@ def _verify_batch_fd( expected=_MANIFEST_VERSION, ) manifest = BatchManifest.model_validate_json(manifest_payload, strict=True) - if manifest_payload != canonical_json_bytes_v2(manifest) + b"\n": + if manifest_payload != canonical_json_bytes(manifest) + b"\n": raise ValueError("native batch request manifest is not canonical") lineage = _normalize_request_lineage(manifest, request_lineage) outcomes_payload = read_regular_at( @@ -687,7 +684,7 @@ def _verify_batch_fd( expected=_SUMMARY_VERSION, ) summary = BatchSummary.model_validate_json(summary_payload, strict=True) - if summary_payload != canonical_json_bytes_v2(summary) + b"\n": + if summary_payload != canonical_json_bytes(summary) + b"\n": raise ValueError("native batch summary is not canonical") expected_summary = _summary(manifest, attempts, manifest_payload, outcomes_payload) if summary != expected_summary: @@ -947,7 +944,7 @@ def _parse_attempts(payload: bytes) -> tuple[BatchAttempt, ...]: attempts = [] for line in payload.splitlines(): item = BatchAttempt.model_validate_json(line, strict=True) - if line != canonical_json_bytes_v2(item): + if line != canonical_json_bytes(item): raise ValueError("native batch outcome is not canonical") attempts.append(item) return tuple(attempts) diff --git a/src/spatialcf/geometry/obb.py b/src/spatialcf/geometry/obb.py index 79e1074..392a406 100644 --- a/src/spatialcf/geometry/obb.py +++ b/src/spatialcf/geometry/obb.py @@ -3,7 +3,7 @@ from shapely.affinity import rotate from shapely.geometry import Polygon -from spatialcf.domain.models import OBB +from spatialcf.domain.scene import OBB OBB_INTERSECTION_XY_AREA_TOLERANCE = 1e-9 OBB_INTERSECTION_Z_OVERLAP_TOLERANCE = 1e-9 @@ -18,12 +18,14 @@ def _yaw_degrees(obb: OBB) -> float: def obb_footprint(obb: OBB) -> Polygon: hx = obb.extent.x / 2.0 hy = obb.extent.y / 2.0 - polygon = Polygon([ - (obb.center.x - hx, obb.center.y - hy), - (obb.center.x + hx, obb.center.y - hy), - (obb.center.x + hx, obb.center.y + hy), - (obb.center.x - hx, obb.center.y + hy), - ]) + polygon = Polygon( + [ + (obb.center.x - hx, obb.center.y - hy), + (obb.center.x + hx, obb.center.y - hy), + (obb.center.x + hx, obb.center.y + hy), + (obb.center.x - hx, obb.center.y + hy), + ] + ) return rotate(polygon, _yaw_degrees(obb), origin=(obb.center.x, obb.center.y)) diff --git a/src/spatialcf/geometry/regions.py b/src/spatialcf/geometry/regions.py index affa5e0..1899bb9 100644 --- a/src/spatialcf/geometry/regions.py +++ b/src/spatialcf/geometry/regions.py @@ -11,7 +11,7 @@ from shapely.geometry.base import BaseGeometry from shapely.ops import unary_union -from spatialcf.domain.models import ( +from spatialcf.domain.scene import ( PlanarPolygon, SceneObject, SubjectPositionRegion, @@ -37,10 +37,7 @@ def planar_polygon_geometry(component: PlanarPolygon) -> Polygon: """Parse one strict positive-area polygon component.""" polygon = Polygon( [(point.x, point.y) for point in component.exterior], - [ - [(point.x, point.y) for point in hole] - for hole in component.holes - ], + [[(point.x, point.y) for point in hole] for hole in component.holes], ) polygon = _finite_geometry(polygon, "planar polygon") if not isinstance(polygon, Polygon) or polygon.is_empty or polygon.area <= 0.0: @@ -83,8 +80,7 @@ def planar_polygon_payloads(geometry: BaseGeometry) -> tuple[PlanarPolygon, ...] ), holes=tuple( tuple( - Vec2(x=float(x), y=float(y)) - for x, y in tuple(ring.coords)[:-1] + Vec2(x=float(x), y=float(y)) for x, y in tuple(ring.coords)[:-1] ) for ring in polygon.interiors ), @@ -122,9 +118,7 @@ def _convex_center_locus( ) -> BaseGeometry: locus: BaseGeometry = container for x, y in relative_vertices: - locus = _polygonal( - locus.intersection(translate(container, xoff=-x, yoff=-y)) - ) + locus = _polygonal(locus.intersection(translate(container, xoff=-x, yoff=-y))) if locus.is_empty: break return locus @@ -159,9 +153,7 @@ def conservative_receptacle_position_geometry( raise ValueError(f"receptacle patch {index} bounds must be finite") if xmin >= xmax or ymin >= ymax: raise ValueError(f"receptacle patch {index} must have positive area") - patch = Polygon( - [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)] - ) + patch = Polygon([(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax)]) locus = _convex_center_locus(patch, relative_vertices) if not locus.is_empty: loci.append(locus) diff --git a/src/spatialcf/geometry/transforms.py b/src/spatialcf/geometry/transforms.py index e07d984..86ede60 100644 --- a/src/spatialcf/geometry/transforms.py +++ b/src/spatialcf/geometry/transforms.py @@ -2,7 +2,7 @@ import numpy as np -from spatialcf.domain.models import Quaternion, Vec3 +from spatialcf.domain.scene import Quaternion, Vec3 def ai2thor_position_to_world(value: Vec3) -> Vec3: @@ -43,4 +43,6 @@ def transform_point(matrix: np.ndarray, point: Vec3) -> Vec3: if transformed[3] == 0: raise ValueError("point transformed to infinity") transformed = transformed / transformed[3] - return Vec3(x=float(transformed[0]), y=float(transformed[1]), z=float(transformed[2])) + return Vec3( + x=float(transformed[0]), y=float(transformed[1]), z=float(transformed[2]) + ) diff --git a/src/spatialcf/relations/engine.py b/src/spatialcf/relations/engine.py index dbc02c8..6779866 100644 --- a/src/spatialcf/relations/engine.py +++ b/src/spatialcf/relations/engine.py @@ -1,8 +1,8 @@ import math from dataclasses import dataclass -from spatialcf.domain.enums import Relation, SolverStatus -from spatialcf.domain.models import ObjectView, Scene +from spatialcf.domain.request import Relation, SolverStatus +from spatialcf.domain.scene import ObjectView, Scene from spatialcf.geometry.obb import ground_gap @@ -61,7 +61,9 @@ def observe( return RelationResult(relation, False, 0.0, SolverStatus.NOT_VISIBLE) if relation in {Relation.LEFT, Relation.RIGHT, Relation.FRONT, Relation.BEHIND}: if relation in {Relation.LEFT, Relation.RIGHT}: - threshold = scene.camera_by_id(camera_id).width * self.LEFT_RIGHT_FRACTION + threshold = ( + scene.camera_by_id(camera_id).width * self.LEFT_RIGHT_FRACTION + ) delta = reference_view.bbox.center_x - subject_view.bbox.center_x signed = delta if relation is Relation.LEFT else -delta else: @@ -76,7 +78,9 @@ def observe( abs_tol=self.COMPARISON_TOLERANCE, ) if distance < threshold and not at_threshold: - return RelationResult(relation, False, distance - threshold, SolverStatus.AMBIGUOUS) + return RelationResult( + relation, False, distance - threshold, SolverStatus.AMBIGUOUS + ) return RelationResult( relation=relation, satisfied=signed >= threshold or (at_threshold and signed > 0.0), @@ -86,8 +90,16 @@ def observe( gap = ground_gap(subject.obb, reference.obb) if self.NEAR_METERS < gap < self.FAR_METERS: return RelationResult(relation, False, 0.0, SolverStatus.AMBIGUOUS) - satisfied = gap <= self.NEAR_METERS if relation is Relation.NEAR else gap >= self.FAR_METERS - margin = self.NEAR_METERS - gap if relation is Relation.NEAR else gap - self.FAR_METERS + satisfied = ( + gap <= self.NEAR_METERS + if relation is Relation.NEAR + else gap >= self.FAR_METERS + ) + margin = ( + self.NEAR_METERS - gap + if relation is Relation.NEAR + else gap - self.FAR_METERS + ) return RelationResult(relation, satisfied, abs(margin), SolverStatus.SUCCESS) def pair_labels( diff --git a/src/spatialcf/solver/__init__.py b/src/spatialcf/solver/__init__.py deleted file mode 100644 index 93c7107..0000000 --- a/src/spatialcf/solver/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Analytic candidate-region construction for spatial counterfactuals.""" - -from spatialcf.solver.certified_constraints import CertifiedConstraintBuilder -from spatialcf.solver.certified_models import ( - CertifiedSolverConfig, - CertifiedSolveResult, - ConstraintBracketSummary, - ConstraintBuildDiagnostics, - ConstraintDiagnosticStep, - ConstraintRegionSummary, - OptimalityCertificate, -) -from spatialcf.solver.continuous import CertifiedSpatialCFSolver -from spatialcf.solver.execution import ( - CandidateExecution, - CandidateExecutionStatus, - CandidateExecutor, - ExecutionResidual, -) -from spatialcf.solver.objective import ObjectiveBreakdown, ObjectiveWeights -from spatialcf.solver.search import ( - GroundedCandidate, - GroundedCandidateAttempt, - GroundedSolveResult, - MinimumCostSpatialCFSolver, - SearchConfig, - SolveResult, - SpatialCFSolver, -) - -__all__ = ( - "CandidateExecution", - "CandidateExecutionStatus", - "CandidateExecutor", - "CertifiedConstraintBuilder", - "CertifiedSolveResult", - "CertifiedSolverConfig", - "CertifiedSpatialCFSolver", - "ConstraintBracketSummary", - "ConstraintBuildDiagnostics", - "ConstraintDiagnosticStep", - "ConstraintRegionSummary", - "ExecutionResidual", - "GroundedCandidate", - "GroundedCandidateAttempt", - "GroundedSolveResult", - "MinimumCostSpatialCFSolver", - "ObjectiveBreakdown", - "ObjectiveWeights", - "OptimalityCertificate", - "SearchConfig", - "SolveResult", - "SpatialCFSolver", -) diff --git a/src/spatialcf/solver/analytic_motion.py b/src/spatialcf/solver/analytic_motion.py deleted file mode 100644 index d62aab9..0000000 --- a/src/spatialcf/solver/analytic_motion.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Calibrated analytic camera projection for continuous XY subject motion.""" - -from dataclasses import dataclass -import math - -from spatialcf.domain.models import BBox2D, Camera, ObjectView, Scene, Vec3 - - -class CandidateProjectionError(ValueError): - """A proposed XY point cannot be projected into a valid subject view.""" - - -def _camera_coordinate(camera: Camera, row: int, point: Vec3) -> float: - """Return one homogeneous world-to-camera row evaluated at ``point``.""" - start = row * 4 - matrix = camera.world_to_camera - return ( - matrix[start] * point.x - + matrix[start + 1] * point.y - + matrix[start + 2] * point.z - + matrix[start + 3] - ) - - -@dataclass(frozen=True) -class ProjectionCalibration: - """Affine camera coordinates and residuals fitted to one observed view.""" - - camera: Camera - subject_z: float - camera_x_coefficients: tuple[float, float, float] - camera_y_coefficients: tuple[float, float, float] - depth_coefficients: tuple[float, float, float] - horizontal_residual: float - vertical_residual: float - depth_residual: float - - def camera_x(self, x: float, y: float) -> float: - return _evaluate_affine(self.camera_x_coefficients, x, y) - - def camera_y(self, x: float, y: float) -> float: - return _evaluate_affine(self.camera_y_coefficients, x, y) - - def depth(self, x: float, y: float) -> float: - return _evaluate_affine(self.depth_coefficients, x, y) - - def projected_center(self, x: float, y: float) -> tuple[float, float]: - depth = self.depth(x, y) - if depth <= 0.0: - raise CandidateProjectionError("positive camera depth is required") - fx, _, cx, _, fy, cy, _, _, _ = self.camera.intrinsics - return ( - fx * self.camera_x(x, y) / depth + cx, - cy - fy * self.camera_y(x, y) / depth, - ) - - def calibrated_depth(self, x: float, y: float) -> float: - return self.depth(x, y) + self.depth_residual - - -def _evaluate_affine(coefficients: tuple[float, float, float], x: float, y: float) -> float: - x_coefficient, y_coefficient, constant = coefficients - return x_coefficient * x + y_coefficient * y + constant - - -def _camera_affine_coefficients(camera: Camera, row: int, z: float) -> tuple[float, float, float]: - start = row * 4 - matrix = camera.world_to_camera - return ( - matrix[start], - matrix[start + 1], - matrix[start + 2] * z + matrix[start + 3], - ) - - -class AnalyticMotionModel: - """Create independently-verifiable candidate scenes from XY subject motion.""" - - def calibration(self, scene: Scene, object_id: str, camera_id: str) -> ProjectionCalibration: - subject = scene.object_by_id(object_id) - camera = scene.camera_by_id(camera_id) - observed = subject.views.get(camera_id) - if observed is None: - raise ValueError("subject has no view for camera") - - point = subject.position - camera_x = _camera_coordinate(camera, 0, point) - camera_y = _camera_coordinate(camera, 1, point) - depth = _camera_coordinate(camera, 2, point) - if depth <= 0.0: - raise CandidateProjectionError("positive camera depth is required") - fx, _, cx, _, fy, cy, _, _, _ = camera.intrinsics - projected_x = fx * camera_x / depth + cx - projected_y = cy - fy * camera_y / depth - observed_center_y = (observed.bbox.ymin + observed.bbox.ymax) / 2.0 - camera_x_coefficients = _camera_affine_coefficients(camera, 0, point.z) - camera_y_coefficients = _camera_affine_coefficients(camera, 1, point.z) - depth_coefficients = _camera_affine_coefficients(camera, 2, point.z) - horizontal_residual = observed.bbox.center_x - projected_x - vertical_residual = observed_center_y - projected_y - depth_residual = observed.camera_depth - depth - values = ( - point.x, - point.y, - point.z, - camera_x, - camera_y, - depth, - projected_x, - projected_y, - observed.bbox.center_x, - observed_center_y, - observed.camera_depth, - *camera_x_coefficients, - *camera_y_coefficients, - *depth_coefficients, - horizontal_residual, - vertical_residual, - depth_residual, - ) - if not all(math.isfinite(value) for value in values): - raise CandidateProjectionError( - "projection calibration values must be finite" - ) - return ProjectionCalibration( - camera=camera, - subject_z=point.z, - camera_x_coefficients=camera_x_coefficients, - camera_y_coefficients=camera_y_coefficients, - depth_coefficients=depth_coefficients, - horizontal_residual=horizontal_residual, - vertical_residual=vertical_residual, - depth_residual=depth_residual, - ) - - def projected_view( - self, scene: Scene, object_id: str, camera_id: str, x: float, y: float - ) -> ObjectView: - self._require_finite_xy(x, y) - subject = scene.object_by_id(object_id) - observed = subject.views.get(camera_id) - if observed is None: - raise ValueError("subject has no view for camera") - calibration = self.calibration(scene, object_id, camera_id) - projected_x, projected_y = calibration.projected_center(x, y) - depth = calibration.calibrated_depth(x, y) - if not math.isfinite(depth) or depth <= 0.0: - raise CandidateProjectionError("positive camera depth is required") - - bbox = _bbox_at_center(observed.bbox, projected_x + calibration.horizontal_residual, - projected_y + calibration.vertical_residual) - return observed.model_copy(update={ - "bbox": bbox, - "camera_depth": depth, - "truncated_fraction": _truncated_fraction(bbox, calibration.camera), - }) - - def with_object_xy(self, scene: Scene, object_id: str, x: float, y: float) -> Scene: - self._require_finite_xy(x, y) - subject = scene.object_by_id(object_id) - delta_x = x - subject.position.x - delta_y = y - subject.position.y - new_position = subject.position.model_copy(update={"x": x, "y": y}) - new_center = subject.obb.center.model_copy(update={ - "x": subject.obb.center.x + delta_x, - "y": subject.obb.center.y + delta_y, - }) - new_views = { - camera_id: self.projected_view(scene, object_id, camera_id, x, y) - for camera_id in subject.views - } - moved = subject.model_copy(update={ - "position": new_position, - "obb": subject.obb.model_copy(update={"center": new_center}), - "views": new_views, - }) - return scene.model_copy(update={ - "objects": tuple(moved if obj.object_id == object_id else obj for obj in scene.objects), - }) - - @staticmethod - def _require_finite_xy(x: float, y: float) -> None: - if not math.isfinite(x) or not math.isfinite(y): - raise ValueError("candidate coordinates must be finite") - - -def _bbox_at_center(bbox: BBox2D, center_x: float, center_y: float) -> BBox2D: - half_width = (bbox.xmax - bbox.xmin) / 2.0 - half_height = (bbox.ymax - bbox.ymin) / 2.0 - return BBox2D( - xmin=center_x - half_width, - ymin=center_y - half_height, - xmax=center_x + half_width, - ymax=center_y + half_height, - ) - - -def _truncated_fraction(bbox: BBox2D, camera: Camera) -> float: - full_area = bbox.area - if full_area <= 0.0: - raise CandidateProjectionError("projected bbox must have positive area") - clipped_width = max(0.0, min(bbox.xmax, camera.width) - max(bbox.xmin, 0.0)) - clipped_height = max(0.0, min(bbox.ymax, camera.height) - max(bbox.ymin, 0.0)) - return 1.0 - clipped_width * clipped_height / full_area diff --git a/src/spatialcf/solver/certified_constraints.py b/src/spatialcf/solver/certified_constraints.py deleted file mode 100644 index f200700..0000000 --- a/src/spatialcf/solver/certified_constraints.py +++ /dev/null @@ -1,1043 +0,0 @@ -"""Complete conservative constraints for certified analytic XY motion.""" - -import math - -import shapely -from shapely.affinity import translate -from shapely.geometry import Polygon, box -from shapely.geometry.base import BaseGeometry - -from spatialcf.domain.enums import Relation, RelationAxis -from spatialcf.domain.models import InterventionSpec, ObjectView, Scene, SceneObject -from spatialcf.geometry.obb import ( - OBB_INTERSECTION_Z_OVERLAP_TOLERANCE, - obb_footprint, - obb_z_overlap_depth, -) -from spatialcf.geometry.regions import subject_position_region_geometry -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.analytic_motion import AnalyticMotionModel, ProjectionCalibration -from spatialcf.solver.certified_geometry import ( - complement_bracket, - far_bracket, - intersect_brackets, - near_bracket, - union_brackets, -) -from spatialcf.solver.certified_models import ( - CertifiedGeometryError, - ConstraintBracketSummary, - ConstraintBuildDiagnostics, - ConstraintDiagnosticStep, - ConstraintRegionSummary, - FeasibleRegionBracket, -) -from spatialcf.solver.feasible import ( - _configuration_obstacle, - _relative_vertices, - clip_half_plane, -) - -_AXIS_RELATIONS = { - RelationAxis.HORIZONTAL: (Relation.LEFT, Relation.RIGHT), - RelationAxis.DEPTH: (Relation.FRONT, Relation.BEHIND), - RelationAxis.DISTANCE: (Relation.NEAR, Relation.FAR), -} - - -def _normalize_geometry(geometry: BaseGeometry) -> BaseGeometry: - """Return deterministic valid finite geometry without dropping dimensions.""" - coordinates = shapely.get_coordinates(geometry) - if any(not math.isfinite(float(value)) for row in coordinates for value in row): - raise ArithmeticError("GEOS overlay produced non-finite geometry") - if not geometry.is_valid: - raise ArithmeticError("GEOS overlay produced invalid geometry") - geometry = shapely.normalize(geometry) - coordinates = shapely.get_coordinates(geometry) - if any(not math.isfinite(float(value)) for row in coordinates for value in row): - raise ArithmeticError("GEOS overlay produced non-finite geometry") - if not geometry.is_valid: - raise ArithmeticError("GEOS normalization produced invalid geometry") - return geometry - - -def _make_bracket( - *, - inner: BaseGeometry, - outer: BaseGeometry, - radial_geometry_error: float, - disk_segments: int, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - return FeasibleRegionBracket.create( - inner=_normalize_geometry(inner), - outer=_normalize_geometry(outer), - radial_geometry_error=radial_geometry_error, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def _exact_bracket( - geometry: BaseGeometry, - disk_segments: int, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - geometry = _normalize_geometry(geometry) - return _make_bracket( - inner=geometry, - outer=geometry, - radial_geometry_error=0.0, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def _empty_bracket( - disk_segments: int, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - return _exact_bracket(Polygon(), disk_segments, numeric_tolerance) - - -def _intersect( - left: FeasibleRegionBracket, - right: FeasibleRegionBracket, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - result = intersect_brackets(left, right) - return _make_bracket( - inner=result.inner, - outer=result.outer, - radial_geometry_error=result.radial_geometry_error, - disk_segments=result.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def _union( - left: FeasibleRegionBracket, - right: FeasibleRegionBracket, - universe: BaseGeometry, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - result = union_brackets(left, right, universe) - return _make_bracket( - inner=result.inner, - outer=result.outer, - radial_geometry_error=result.radial_geometry_error, - disk_segments=result.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def _complement( - value: FeasibleRegionBracket, - universe: BaseGeometry, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - result = complement_bracket(value, universe) - return _make_bracket( - inner=result.inner, - outer=result.outer, - radial_geometry_error=result.radial_geometry_error, - disk_segments=result.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def _center_locus( - container: BaseGeometry, - relative_vertices: list[tuple[float, float]], -) -> BaseGeometry: - """Intersect translated containers while retaining line and point results.""" - if not relative_vertices: - return _normalize_geometry(container) - first_x, first_y = relative_vertices[0] - locus = _normalize_geometry( - translate(container, xoff=-first_x, yoff=-first_y) - ) - for x, y in relative_vertices[1:]: - locus = _normalize_geometry( - locus.intersection(translate(container, xoff=-x, yoff=-y)) - ) - if locus.is_empty: - break - return locus - - -def _half_plane_region( - universe: BaseGeometry, - nx: float, - ny: float, - constant: float, - *, - keep_greater: bool, -) -> BaseGeometry: - """Intersect ``universe`` with one inclusive affine half-plane.""" - if universe.is_empty: - return _normalize_geometry(universe) - if not all(math.isfinite(value) for value in (nx, ny, constant)): - raise CertifiedGeometryError("half-plane coefficients must be finite") - min_x, min_y, max_x, max_y = universe.bounds - if not all(math.isfinite(value) for value in (min_x, min_y, max_x, max_y)): - raise ArithmeticError("half-plane universe has non-finite bounds") - corners = ( - (min_x, min_y), - (min_x, max_y), - (max_x, min_y), - (max_x, max_y), - ) - values = tuple(nx * x + ny * y + constant for x, y in corners) - oriented = values if keep_greater else tuple(-value for value in values) - if min(oriented) >= 0.0: - return _normalize_geometry(universe) - if max(oriented) < 0.0: - return Polygon() - span = max(max_x - min_x, max_y - min_y, 1.0) - mask_box = box(min_x - span, min_y - span, max_x + span, max_y + span) - mask = clip_half_plane(mask_box, nx, ny, -constant, keep_greater) - return _normalize_geometry(universe.intersection(mask)) - - -def _linear_bracket( - universe: BaseGeometry, - inner_coefficients: tuple[float, float, float], - outer_coefficients: tuple[float, float, float], - *, - keep_greater: bool, - include_inner_in_outer: bool = False, - disk_segments: int, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - inner = _half_plane_region( - universe, *inner_coefficients, keep_greater=keep_greater - ) - outer = _half_plane_region( - universe, *outer_coefficients, keep_greater=keep_greater - ) - if include_inner_in_outer: - outer = _normalize_geometry(outer.union(inner)) - return _make_bracket( - inner=inner, - outer=outer, - radial_geometry_error=0.0, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def _guard_inner_coefficients( - coefficients: tuple[float, float, float], - *, - keep_greater: bool, - margin: float, -) -> tuple[float, float, float]: - """Move one half-plane boundary ``margin`` metres into its kept side.""" - if margin == 0.0: - return coefficients - nx, ny, constant = coefficients - norm = math.hypot(nx, ny) - if not math.isfinite(norm) or norm == 0.0: - raise CertifiedGeometryError( - "target half-plane must have a finite non-zero candidate-space normal" - ) - guarded_constant = constant + (-norm * margin if keep_greater else norm * margin) - return nx, ny, guarded_constant - - -def _exclude_interior(geometry: BaseGeometry, obstacle: BaseGeometry) -> BaseGeometry: - """Remove positive-area collision states but restore allowed contact.""" - return _normalize_geometry( - geometry.difference(obstacle).union(geometry.intersection(obstacle.boundary)) - ) - - -def _component_count(geometry: BaseGeometry) -> int: - if geometry.is_empty: - return 0 - parts = getattr(geometry, "geoms", None) - if parts is None: - return 1 - return sum(_component_count(part) for part in parts) - - -def _region_summary(geometry: BaseGeometry) -> ConstraintRegionSummary: - is_empty = bool(geometry.is_empty) - return ConstraintRegionSummary( - area_m2=float(geometry.area), - component_count=_component_count(geometry), - dimension=-1 if is_empty else int(shapely.get_dimensions(geometry)), - is_empty=is_empty, - ) - - -def _bracket_summary(region: FeasibleRegionBracket) -> ConstraintBracketSummary: - return ConstraintBracketSummary( - inner=_region_summary(region.inner), - outer=_region_summary(region.outer), - ) - - -def _record_diagnostic_step( - steps: list[ConstraintDiagnosticStep] | None, - *, - stage: str, - constraint_id: str, - applied: bool, - outer_was_empty: bool, - region: FeasibleRegionBracket, -) -> None: - if steps is None: - return - steps.append( - ConstraintDiagnosticStep( - index=len(steps), - stage=stage, - constraint_id=constraint_id, - applied=applied, - emptied_outer=( - applied and not outer_was_empty and bool(region.outer.is_empty) - ), - inner=_region_summary(region.inner), - outer=_region_summary(region.outer), - ) - ) - - -class CertifiedConstraintBuilder: - """Compile the target and complete original relation graph into a bracket.""" - - def __init__( - self, - *, - relation_engine: RelationEngine | None = None, - motion_model: AnalyticMotionModel | None = None, - ) -> None: - self.engine = relation_engine or RelationEngine() - self.motion_model = motion_model or AnalyticMotionModel() - - def build( - self, - scene: Scene, - spec: InterventionSpec, - disk_segments: int, - numeric_tolerance: float, - target_interior_margin: float = 0.0, - ) -> FeasibleRegionBracket: - return self._build( - scene, - spec, - disk_segments, - numeric_tolerance, - target_interior_margin, - diagnostic_steps=None, - diagnostic_regions=None, - ) - - def build_with_diagnostics( - self, - scene: Scene, - spec: InterventionSpec, - disk_segments: int, - numeric_tolerance: float, - target_interior_margin: float = 0.0, - ) -> tuple[FeasibleRegionBracket, ConstraintBuildDiagnostics]: - """Build the exact bracket and report each canonical constraint prefix. - - Diagnostics observe the same private construction path used by - :meth:`build`; they neither add nor remove a feasibility constraint. - """ - steps: list[ConstraintDiagnosticStep] = [] - regions: dict[str, FeasibleRegionBracket] = {} - bracket = self._build( - scene, - spec, - disk_segments, - numeric_tolerance, - target_interior_margin, - diagnostic_steps=steps, - diagnostic_regions=regions, - ) - first_empty = next( - (step.index for step in steps if step.emptied_outer), - None, - ) - structural = regions["structural"] - target_with_structural = _intersect( - structural, - regions["target_constraint"], - numeric_tolerance, - ) - if structural.outer.is_empty: - outer_empty_group = "structural" - elif target_with_structural.outer.is_empty: - outer_empty_group = "target_relation" - elif bracket.outer.is_empty: - outer_empty_group = "preserved_relation" - else: - outer_empty_group = None - return bracket, ConstraintBuildDiagnostics( - steps=tuple(steps), - first_outer_empty_step_index=first_empty, - structural=_bracket_summary(structural), - target_with_structural=_bracket_summary(target_with_structural), - final=_bracket_summary(bracket), - outer_empty_group=outer_empty_group, - ) - - def _build( - self, - scene: Scene, - spec: InterventionSpec, - disk_segments: int, - numeric_tolerance: float, - target_interior_margin: float, - *, - diagnostic_steps: list[ConstraintDiagnosticStep] | None, - diagnostic_regions: dict[str, FeasibleRegionBracket] | None, - ) -> FeasibleRegionBracket: - self._validate_inputs( - scene, - spec, - disk_segments, - numeric_tolerance, - target_interior_margin, - ) - subject = scene.object_by_id(spec.subject_id) - relative_vertices = _relative_vertices(subject) - room = self._room(scene) - room_locus = _center_locus(room, relative_vertices) - room_is_convex = room.equals(room.convex_hull) - region = _make_bracket( - inner=room_locus if room_is_convex else Polygon(), - outer=room_locus, - radial_geometry_error=0.0, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - universe = region.outer - _record_diagnostic_step( - diagnostic_steps, - stage="room_boundary", - constraint_id="room", - applied=True, - outer_was_empty=False, - region=region, - ) - for position_region in sorted( - scene.subject_position_regions, - key=lambda item: item.region_id, - ): - if position_region.subject_object_id != subject.object_id: - continue - allowed = subject_position_region_geometry(position_region) - outer_was_empty = bool(region.outer.is_empty) - region = _intersect( - region, - _exact_bracket(allowed, disk_segments, numeric_tolerance), - numeric_tolerance, - ) - _record_diagnostic_step( - diagnostic_steps, - stage="subject_position_region", - constraint_id=f"position-region:{position_region.region_id}", - applied=True, - outer_was_empty=outer_was_empty, - region=region, - ) - - support_id = subject.support_object_id - if support_id is not None: - support = scene.object_by_id(support_id) - support_locus = _center_locus( - obb_footprint(support.obb), - relative_vertices, - ) - outer_was_empty = bool(region.outer.is_empty) - region = _intersect( - region, - _exact_bracket(support_locus, disk_segments, numeric_tolerance), - numeric_tolerance, - ) - _record_diagnostic_step( - diagnostic_steps, - stage="support_locus", - constraint_id=f"support:{support_id}", - applied=True, - outer_was_empty=outer_was_empty, - region=region, - ) - - for obstacle in sorted(scene.objects, key=lambda obj: obj.object_id): - if obstacle.object_id in {subject.object_id, support_id}: - continue - if ( - obb_z_overlap_depth(subject.obb, obstacle.obb) - <= OBB_INTERSECTION_Z_OVERLAP_TOLERANCE - ): - continue - configuration = _configuration_obstacle( - obb_footprint(obstacle.obb), relative_vertices - ) - outer_was_empty = bool(region.outer.is_empty) - region = _make_bracket( - inner=_exclude_interior(region.inner, configuration), - outer=_exclude_interior(region.outer, configuration), - radial_geometry_error=region.radial_geometry_error, - disk_segments=region.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - _record_diagnostic_step( - diagnostic_steps, - stage="object_collision", - constraint_id=f"object-collision:{obstacle.object_id}", - applied=True, - outer_was_empty=outer_was_empty, - region=region, - ) - - for obstacle in sorted( - scene.collision_obstacles, - key=lambda item: item.obstacle_id, - ): - conservative_obb = obstacle.conservative_obb() - if ( - obb_z_overlap_depth(subject.obb, conservative_obb) - <= OBB_INTERSECTION_Z_OVERLAP_TOLERANCE - ): - continue - configuration = _configuration_obstacle( - obb_footprint(conservative_obb), - relative_vertices, - ) - outer_was_empty = bool(region.outer.is_empty) - region = _make_bracket( - inner=_exclude_interior(region.inner, configuration), - outer=_exclude_interior(region.outer, configuration), - radial_geometry_error=region.radial_geometry_error, - disk_segments=region.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - _record_diagnostic_step( - diagnostic_steps, - stage="collision_obstacle", - constraint_id=f"collision-obstacle:{obstacle.obstacle_id}", - applied=True, - outer_was_empty=outer_was_empty, - region=region, - ) - - visibility = self._visibility_bracket( - scene, - spec, - universe, - disk_segments, - numeric_tolerance, - ) - outer_was_empty = bool(region.outer.is_empty) - region = _intersect(region, visibility, numeric_tolerance) - _record_diagnostic_step( - diagnostic_steps, - stage="visibility", - constraint_id=f"visibility:{spec.camera_id}", - applied=True, - outer_was_empty=outer_was_empty, - region=region, - ) - if diagnostic_regions is not None: - diagnostic_regions["structural"] = region - - for stationary in sorted(scene.objects, key=lambda obj: obj.object_id): - if stationary.object_id == subject.object_id: - continue - labels = self.engine.pair_labels( - scene, - subject.object_id, - stationary.object_id, - spec.camera_id, - ) - permanently_invisible = self._pair_is_permanently_invisible( - subject, stationary, spec.camera_id - ) - for axis in RelationAxis: - if stationary.object_id == spec.reference_id and axis is spec.relation_after.axis: - constraint = self._relation_bracket( - scene, - spec, - stationary, - spec.relation_after, - relative_vertices, - universe, - disk_segments, - numeric_tolerance, - target_interior_margin, - ) - stage = "target_relation" - constraint_id = ( - f"target:{subject.object_id}:" - f"{spec.relation_after.value}:{stationary.object_id}" - ) - if diagnostic_regions is not None: - diagnostic_regions["target_constraint"] = constraint - else: - constraint = self._preserved_axis_bracket( - scene, - spec, - stationary, - axis, - labels, - permanently_invisible, - relative_vertices, - universe, - disk_segments, - numeric_tolerance, - ) - stage = "preserved_relation" - axis_labels = ",".join( - relation.value - for relation in _AXIS_RELATIONS[axis] - if relation in labels - ) - constraint_id = ( - f"preserve:{subject.object_id}:{stationary.object_id}:" - f"{axis.value}:{axis_labels or 'none'}" - ) - outer_was_empty = bool(region.outer.is_empty) - if constraint is not None: - region = _intersect(region, constraint, numeric_tolerance) - _record_diagnostic_step( - diagnostic_steps, - stage=stage, - constraint_id=constraint_id, - applied=constraint is not None, - outer_was_empty=outer_was_empty, - region=region, - ) - return region - - @staticmethod - def _validate_inputs( - scene: Scene, - spec: InterventionSpec, - disk_segments: int, - numeric_tolerance: float, - target_interior_margin: float, - ) -> None: - if ( - type(disk_segments) is not int - or disk_segments < 4 - or disk_segments & (disk_segments - 1) - ): - raise ValueError("disk_segments must be a power of two at least four") - if ( - isinstance(numeric_tolerance, bool) - or not isinstance(numeric_tolerance, (int, float)) - or not math.isfinite(float(numeric_tolerance)) - or numeric_tolerance <= 0.0 - ): - raise ValueError("numeric_tolerance must be finite and positive") - if ( - isinstance(target_interior_margin, bool) - or not isinstance(target_interior_margin, (int, float)) - or not math.isfinite(float(target_interior_margin)) - or target_interior_margin < 0.0 - ): - raise ValueError( - "target_interior_margin must be finite and non-negative" - ) - subject = scene.object_by_id(spec.subject_id) - scene.object_by_id(spec.reference_id) - scene.camera_by_id(spec.camera_id) - if scene.children_by_support().get(subject.object_id): - raise ValueError("subject has supported objects") - if subject.support_object_id is not None: - scene.object_by_id(subject.support_object_id) - - @staticmethod - def _room(scene: Scene) -> Polygon: - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - coordinates = [ - value - for point in scene.room_polygon_xy - for value in (point.x, point.y) - ] - if not all(math.isfinite(value) for value in coordinates): - raise ValueError("room polygon coordinates must be finite") - if not room.is_valid or room.is_empty or room.area <= 0.0: - raise ValueError("room polygon must be valid with positive area") - return shapely.normalize(room) - - def _visibility_bracket( - self, - scene: Scene, - spec: InterventionSpec, - universe: BaseGeometry, - disk_segments: int, - numeric_tolerance: float, - ) -> FeasibleRegionBracket: - subject = scene.object_by_id(spec.subject_id) - reference = scene.object_by_id(spec.reference_id) - subject_view = subject.views.get(spec.camera_id) - reference_view = reference.views.get(spec.camera_id) - if subject_view is None: - raise ValueError("subject has no view for camera") - if ( - subject_view.visible_fraction < self.engine.MIN_VISIBLE_FRACTION - or subject_view.image_area_fraction < self.engine.MIN_IMAGE_AREA_FRACTION - or reference_view is None - or not self._fixed_view_is_visible(reference_view) - ): - return _empty_bracket(disk_segments, numeric_tolerance) - - calibration = self.motion_model.calibration( - scene, subject.object_id, spec.camera_id - ) - raw_nx, raw_ny, raw_constant = calibration.depth_coefficients - calibrated_constant = raw_constant + calibration.depth_residual - outer = _half_plane_region( - universe, raw_nx, raw_ny, raw_constant, keep_greater=True - ) - outer = _half_plane_region( - outer, raw_nx, raw_ny, calibrated_constant, keep_greater=True - ) - inner = _half_plane_region( - universe, - raw_nx, - raw_ny, - raw_constant - numeric_tolerance, - keep_greater=True, - ) - inner = _half_plane_region( - inner, - raw_nx, - raw_ny, - calibrated_constant - numeric_tolerance, - keep_greater=True, - ) - inner = self._full_bbox_inside_image(inner, calibration, subject_view) - return _make_bracket( - inner=inner, - outer=outer, - radial_geometry_error=0.0, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - @staticmethod - def _full_bbox_inside_image( - universe: BaseGeometry, - calibration: ProjectionCalibration, - subject_view: ObjectView, - ) -> BaseGeometry: - camera = calibration.camera - fx, _, cx, _, fy, cy, _, _, _ = camera.intrinsics - x_nx, x_ny, x_constant = calibration.camera_x_coefficients - y_nx, y_ny, y_constant = calibration.camera_y_coefficients - d_nx, d_ny, d_constant = calibration.depth_coefficients - half_width = (subject_view.bbox.xmax - subject_view.bbox.xmin) / 2.0 - half_height = (subject_view.bbox.ymax - subject_view.bbox.ymin) / 2.0 - if half_width <= 0.0 or half_height <= 0.0: - raise ValueError("subject projected bbox must have positive area") - - constraints = ( - ( - fx * x_nx - + (cx + calibration.horizontal_residual - half_width) * d_nx, - fx * x_ny - + (cx + calibration.horizontal_residual - half_width) * d_ny, - fx * x_constant - + (cx + calibration.horizontal_residual - half_width) * d_constant, - True, - ), - ( - fx * x_nx - + ( - cx - + calibration.horizontal_residual - - (camera.width - half_width) - ) - * d_nx, - fx * x_ny - + ( - cx - + calibration.horizontal_residual - - (camera.width - half_width) - ) - * d_ny, - fx * x_constant - + ( - cx - + calibration.horizontal_residual - - (camera.width - half_width) - ) - * d_constant, - False, - ), - ( - -fy * y_nx - + (cy + calibration.vertical_residual - half_height) * d_nx, - -fy * y_ny - + (cy + calibration.vertical_residual - half_height) * d_ny, - -fy * y_constant - + (cy + calibration.vertical_residual - half_height) * d_constant, - True, - ), - ( - -fy * y_nx - + ( - cy - + calibration.vertical_residual - - (camera.height - half_height) - ) - * d_nx, - -fy * y_ny - + ( - cy - + calibration.vertical_residual - - (camera.height - half_height) - ) - * d_ny, - -fy * y_constant - + ( - cy - + calibration.vertical_residual - - (camera.height - half_height) - ) - * d_constant, - False, - ), - ) - result = universe - for nx, ny, constant, keep_greater in constraints: - result = _half_plane_region( - result, nx, ny, constant, keep_greater=keep_greater - ) - return result - - def _preserved_axis_bracket( - self, - scene: Scene, - spec: InterventionSpec, - stationary: SceneObject, - axis: RelationAxis, - labels: frozenset[Relation], - permanently_invisible: bool, - relative_vertices: list[tuple[float, float]], - universe: BaseGeometry, - disk_segments: int, - numeric_tolerance: float, - ) -> FeasibleRegionBracket | None: - axis_labels = tuple( - relation for relation in _AXIS_RELATIONS[axis] if relation in labels - ) - if len(axis_labels) == 1: - return self._relation_bracket( - scene, - spec, - stationary, - axis_labels[0], - relative_vertices, - universe, - disk_segments, - numeric_tolerance, - ) - if len(axis_labels) > 1: - raise ValueError("original relation axis has contradictory labels") - if permanently_invisible: - return None - first_relation, second_relation = _AXIS_RELATIONS[axis] - first = self._relation_bracket( - scene, - spec, - stationary, - first_relation, - relative_vertices, - universe, - disk_segments, - numeric_tolerance, - ) - second = self._relation_bracket( - scene, - spec, - stationary, - second_relation, - relative_vertices, - universe, - disk_segments, - numeric_tolerance, - ) - excluded = _union(first, second, universe, numeric_tolerance) - dead_zone = _complement( - excluded, - universe, - numeric_tolerance, - ) - guarded_first = _normalize_geometry( - first.outer.buffer(numeric_tolerance).intersection(universe) - ) - guarded_second = _normalize_geometry( - second.outer.buffer(numeric_tolerance).intersection(universe) - ) - guarded_excluded = _normalize_geometry( - guarded_first.union(guarded_second) - ) - return _make_bracket( - inner=_normalize_geometry(universe.difference(guarded_excluded)), - outer=dead_zone.outer, - radial_geometry_error=dead_zone.radial_geometry_error, - disk_segments=dead_zone.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - def _relation_bracket( - self, - scene: Scene, - spec: InterventionSpec, - stationary: SceneObject, - relation: Relation, - relative_vertices: list[tuple[float, float]], - universe: BaseGeometry, - disk_segments: int, - numeric_tolerance: float, - inner_margin: float = 0.0, - ) -> FeasibleRegionBracket: - subject = scene.object_by_id(spec.subject_id) - if relation.axis in {RelationAxis.HORIZONTAL, RelationAxis.DEPTH}: - reference_view = stationary.views.get(spec.camera_id) - if reference_view is None: - return _empty_bracket(disk_segments, numeric_tolerance) - calibration = self.motion_model.calibration( - scene, subject.object_id, spec.camera_id - ) - if relation.axis is RelationAxis.HORIZONTAL: - camera = calibration.camera - nominal_target = reference_view.bbox.center_x + ( - camera.width * self.engine.LEFT_RIGHT_FRACTION - if relation is Relation.RIGHT - else -camera.width * self.engine.LEFT_RIGHT_FRACTION - ) - fx, _, cx, _, _, _, _, _, _ = camera.intrinsics - x_nx, x_ny, x_constant = calibration.camera_x_coefficients - d_nx, d_ny, d_constant = calibration.depth_coefficients - - def coefficients(target: float) -> tuple[float, float, float]: - scale = cx + calibration.horizontal_residual - target - return ( - fx * x_nx + scale * d_nx, - fx * x_ny + scale * d_ny, - fx * x_constant + scale * d_constant, - ) - - outer_target = nominal_target + ( - -self.engine.COMPARISON_TOLERANCE - if relation is Relation.RIGHT - else self.engine.COMPARISON_TOLERANCE - ) - inner_coefficients = _guard_inner_coefficients( - coefficients(nominal_target), - keep_greater=relation is Relation.RIGHT, - margin=inner_margin, - ) - return _linear_bracket( - universe, - inner_coefficients, - coefficients(outer_target), - keep_greater=relation is Relation.RIGHT, - include_inner_in_outer=True, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - d_nx, d_ny, d_constant = calibration.depth_coefficients - calibrated_constant = d_constant + calibration.depth_residual - nominal_target = reference_view.camera_depth + ( - self.engine.FRONT_BEHIND_METERS - if relation is Relation.BEHIND - else -self.engine.FRONT_BEHIND_METERS - ) - outer_target = nominal_target + ( - -self.engine.COMPARISON_TOLERANCE - if relation is Relation.BEHIND - else self.engine.COMPARISON_TOLERANCE - ) - keep_greater = relation is Relation.BEHIND - inner_coefficients = _guard_inner_coefficients( - (d_nx, d_ny, calibrated_constant - nominal_target), - keep_greater=keep_greater, - margin=inner_margin, - ) - return _linear_bracket( - universe, - inner_coefficients, - (d_nx, d_ny, calibrated_constant - outer_target), - keep_greater=keep_greater, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - configuration = _configuration_obstacle( - obb_footprint(stationary.obb), relative_vertices - ) - if relation is Relation.NEAR: - result = near_bracket( - configuration, - self.engine.NEAR_METERS, - disk_segments, - numeric_tolerance, - ) - else: - result = far_bracket( - configuration, - self.engine.FAR_METERS, - disk_segments, - numeric_tolerance, - universe, - ) - if inner_margin > 0.0: - guarded = far_bracket( - configuration, - self.engine.FAR_METERS + inner_margin, - disk_segments, - numeric_tolerance, - universe, - ) - return _make_bracket( - inner=guarded.inner, - outer=result.outer, - radial_geometry_error=max( - guarded.radial_geometry_error, - result.radial_geometry_error, - ), - disk_segments=result.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - return _make_bracket( - inner=result.inner, - outer=result.outer, - radial_geometry_error=result.radial_geometry_error, - disk_segments=result.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - def _pair_is_permanently_invisible( - self, - subject: SceneObject, - stationary: SceneObject, - camera_id: str, - ) -> bool: - subject_view = subject.views.get(camera_id) - stationary_view = stationary.views.get(camera_id) - return ( - subject_view is None - or subject_view.visible_fraction < self.engine.MIN_VISIBLE_FRACTION - or subject_view.image_area_fraction < self.engine.MIN_IMAGE_AREA_FRACTION - or stationary_view is None - or not self._fixed_view_is_visible(stationary_view) - ) - - def _fixed_view_is_visible(self, view: ObjectView) -> bool: - return ( - view.visible_fraction >= self.engine.MIN_VISIBLE_FRACTION - and view.image_area_fraction >= self.engine.MIN_IMAGE_AREA_FRACTION - and view.truncated_fraction <= self.engine.MAX_TRUNCATED_FRACTION - ) diff --git a/src/spatialcf/solver/certified_geometry.py b/src/spatialcf/solver/certified_geometry.py deleted file mode 100644 index 5751879..0000000 --- a/src/spatialcf/solver/certified_geometry.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Conservative inner and outer geometry for distance constraints.""" - -import math - -from shapely.geometry import Polygon -from shapely.geometry.base import BaseGeometry - -from spatialcf.solver.certified_models import FeasibleRegionBracket -from spatialcf.solver.feasible import ( - GEOMETRY_EPS, - _convex_minkowski_sum, - _minimum_side_apothem, - _regular_disk_polygon, -) - - -def disk_bracket( - radius: float, - disk_segments: int, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - """Bracket a true origin-centred disk with safe regular polygons.""" - inner = _regular_disk_polygon( - radius, - circumscribed=False, - sides=disk_segments, - ) - outer = _regular_disk_polygon( - radius, - circumscribed=True, - sides=disk_segments, - ) - inner_vertices = list(inner.exterior.coords)[:-1] - outer_vertices = list(outer.exterior.coords)[:-1] - radial_geometry_error = max( - radius - _minimum_side_apothem(inner_vertices), - max(math.hypot(x, y) for x, y in outer_vertices) - radius, - 0.0, - ) - return FeasibleRegionBracket.create( - inner=inner, - outer=outer, - radial_geometry_error=radial_geometry_error, - disk_segments=disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def near_bracket( - configuration_obstacle: Polygon, - radius: float, - disk_segments: int, - numeric_tolerance: float, -) -> FeasibleRegionBracket: - """Bracket points within ``radius`` of a convex configuration obstacle.""" - disk = disk_bracket(radius, disk_segments, numeric_tolerance) - inner = _convex_minkowski_sum( - configuration_obstacle, - list(disk.inner.exterior.coords)[:-1], - ) - outer = _convex_minkowski_sum( - configuration_obstacle, - list(disk.outer.exterior.coords)[:-1], - ) - return FeasibleRegionBracket.create( - inner=inner, - outer=outer, - radial_geometry_error=disk.radial_geometry_error, - disk_segments=disk.disk_segments, - numeric_tolerance=numeric_tolerance, - ) - - -def far_bracket( - configuration_obstacle: Polygon, - radius: float, - disk_segments: int, - numeric_tolerance: float, - universe: BaseGeometry, -) -> FeasibleRegionBracket: - """Bracket points at least ``radius`` from a configuration obstacle.""" - forbidden = near_bracket( - configuration_obstacle, - radius, - disk_segments, - numeric_tolerance, - ) - return complement_bracket(forbidden, universe) - - -def intersect_brackets( - left: FeasibleRegionBracket, - right: FeasibleRegionBracket, -) -> FeasibleRegionBracket: - """Conservatively intersect two region brackets.""" - return FeasibleRegionBracket.create( - inner=left.inner.intersection(right.inner), - outer=left.outer.intersection(right.outer), - radial_geometry_error=max( - left.radial_geometry_error, - right.radial_geometry_error, - ), - disk_segments=min(left.disk_segments, right.disk_segments), - numeric_tolerance=GEOMETRY_EPS, - ) - - -def union_brackets( - left: FeasibleRegionBracket, - right: FeasibleRegionBracket, - universe: BaseGeometry | None = None, -) -> FeasibleRegionBracket: - """Conservatively unite two brackets; accept a compositional universe.""" - inner = left.inner.union(right.inner) - outer = left.outer.union(right.outer) - return FeasibleRegionBracket.create( - inner=inner, - outer=outer, - radial_geometry_error=max( - left.radial_geometry_error, - right.radial_geometry_error, - ), - disk_segments=min(left.disk_segments, right.disk_segments), - numeric_tolerance=GEOMETRY_EPS, - ) - - -def complement_bracket( - value: FeasibleRegionBracket, - universe: BaseGeometry, -) -> FeasibleRegionBracket: - """Complement a bracket within ``universe``, reversing its polarity.""" - return FeasibleRegionBracket.create( - inner=universe.difference(value.outer), - outer=universe.difference(value.inner), - radial_geometry_error=value.radial_geometry_error, - disk_segments=value.disk_segments, - numeric_tolerance=GEOMETRY_EPS, - ) diff --git a/src/spatialcf/solver/certified_models.py b/src/spatialcf/solver/certified_models.py deleted file mode 100644 index e34cfb0..0000000 --- a/src/spatialcf/solver/certified_models.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Immutable public contracts for certified continuous solver results.""" - -import math -from dataclasses import dataclass - -from shapely.geometry.base import BaseGeometry - -from spatialcf.domain.enums import QualityTier, Relation, SolverStatus -from spatialcf.domain.models import InterventionSpec, Vec3 -from spatialcf.solver.objective import ObjectiveBreakdown - -SUPPORTED_DIRECTIONS = frozenset({ - (Relation.LEFT, Relation.RIGHT), - (Relation.FRONT, Relation.BEHIND), - (Relation.NEAR, Relation.FAR), -}) - -_MAX_CERTIFIED_OPTIMALITY_TOLERANCE = 1e-6 - - -class CertifiedGeometryError(ArithmeticError, ValueError): - """A numerical geometry result cannot support a certified proof.""" - - -def _is_finite_number(value: object) -> bool: - return ( - not isinstance(value, bool) - and isinstance(value, (int, float)) - and math.isfinite(float(value)) - ) - - -def _is_power_of_two_at_least_four(value: object) -> bool: - return type(value) is int and value >= 4 and value & (value - 1) == 0 - - -@dataclass(frozen=True) -class ConstraintRegionSummary: - """Serialization-safe summary of one inner or outer feasible geometry.""" - - area_m2: float - component_count: int - dimension: int - is_empty: bool - - def __post_init__(self) -> None: - if not _is_finite_number(self.area_m2) or self.area_m2 < 0.0: - raise ValueError("constraint region area must be finite and non-negative") - if type(self.component_count) is not int or self.component_count < 0: - raise ValueError("constraint region component count must be non-negative") - if type(self.dimension) is not int or self.dimension not in {-1, 0, 1, 2}: - raise ValueError("constraint region dimension must be -1, 0, 1, or 2") - if type(self.is_empty) is not bool: - raise ValueError("constraint region empty flag must be a boolean") - if self.is_empty != (self.component_count == 0): - raise ValueError("empty constraint regions must have zero components") - if self.is_empty != (self.dimension == -1): - raise ValueError("empty constraint regions must have dimension -1") - - -@dataclass(frozen=True) -class ConstraintBracketSummary: - """Serialization-safe summaries for both conservative bracket sides.""" - - inner: ConstraintRegionSummary - outer: ConstraintRegionSummary - - def __post_init__(self) -> None: - if ( - type(self.inner) is not ConstraintRegionSummary - or type(self.outer) is not ConstraintRegionSummary - ): - raise ValueError("constraint bracket summaries must be exact summaries") - - -@dataclass(frozen=True) -class ConstraintDiagnosticStep: - """One deterministic constraint application in canonical builder order.""" - - index: int - stage: str - constraint_id: str - applied: bool - emptied_outer: bool - inner: ConstraintRegionSummary - outer: ConstraintRegionSummary - - def __post_init__(self) -> None: - if type(self.index) is not int or self.index < 0: - raise ValueError("constraint diagnostic index must be non-negative") - if type(self.stage) is not str or not self.stage: - raise ValueError("constraint diagnostic stage must be non-empty") - if type(self.constraint_id) is not str or not self.constraint_id: - raise ValueError("constraint diagnostic ID must be non-empty") - if type(self.applied) is not bool or type(self.emptied_outer) is not bool: - raise ValueError("constraint diagnostic flags must be booleans") - if self.emptied_outer and (not self.applied or not self.outer.is_empty): - raise ValueError("only an applied empty constraint can empty the outer region") - - -@dataclass(frozen=True) -class ConstraintBuildDiagnostics: - """Ordered evidence from the exact certified constraint construction path. - - ``first_outer_empty_step`` is deliberately order-dependent. It identifies - the first empty prefix in the builder's canonical order; it is not a - minimal unsatisfiable core. - """ - - steps: tuple[ConstraintDiagnosticStep, ...] - first_outer_empty_step_index: int | None - structural: ConstraintBracketSummary - target_with_structural: ConstraintBracketSummary - final: ConstraintBracketSummary - outer_empty_group: str | None - - def __post_init__(self) -> None: - if ( - type(self.steps) is not tuple - or not self.steps - or any(type(step) is not ConstraintDiagnosticStep for step in self.steps) - ): - raise ValueError("constraint diagnostics must contain at least one step") - if tuple(step.index for step in self.steps) != tuple(range(len(self.steps))): - raise ValueError("constraint diagnostic indices must be contiguous") - emptied = tuple(step.index for step in self.steps if step.emptied_outer) - expected = emptied[0] if emptied else None - if self.first_outer_empty_step_index is not None and ( - type(self.first_outer_empty_step_index) is not int - or not 0 <= self.first_outer_empty_step_index < len(self.steps) - ): - raise ValueError("first outer-empty diagnostic index is invalid") - if self.first_outer_empty_step_index != expected: - raise ValueError("first outer-empty diagnostic index is inconsistent") - if any( - type(summary) is not ConstraintBracketSummary - for summary in ( - self.structural, - self.target_with_structural, - self.final, - ) - ): - raise ValueError("constraint group summaries must be exact summaries") - if self.final != ConstraintBracketSummary( - inner=self.steps[-1].inner, - outer=self.steps[-1].outer, - ): - raise ValueError("final constraint group summary is inconsistent") - if self.structural.outer.is_empty: - expected_group = "structural" - elif self.target_with_structural.outer.is_empty: - expected_group = "target_relation" - elif self.final.outer.is_empty: - expected_group = "preserved_relation" - else: - expected_group = None - if self.outer_empty_group != expected_group: - raise ValueError("outer-empty constraint group is inconsistent") - - @property - def first_outer_empty_step(self) -> ConstraintDiagnosticStep | None: - if self.first_outer_empty_step_index is None: - return None - return self.steps[self.first_outer_empty_step_index] - - -def expected_target_diff(spec: InterventionSpec) -> tuple[str, ...]: - """Return the complete canonical target-pair relation change tuple.""" - return tuple(sorted(( - f"-{spec.subject_id}:{spec.relation_before.value}:{spec.reference_id}", - f"+{spec.subject_id}:{spec.relation_after.value}:{spec.reference_id}", - ( - f"-{spec.reference_id}:{spec.relation_before.converse.value}:" - f"{spec.subject_id}" - ), - ( - f"+{spec.reference_id}:{spec.relation_after.converse.value}:" - f"{spec.subject_id}" - ), - ))) - - -@dataclass(frozen=True) -class CertifiedSolverConfig: - optimality_tolerance: float = 1e-6 - numeric_tolerance: float = 1e-9 - target_interior_margin: float = 5e-7 - initial_disk_segments: int = 128 - max_disk_segments: int = 8192 - timeout_seconds: float | None = None - - def __post_init__(self) -> None: - if not _is_finite_number(self.optimality_tolerance) or self.optimality_tolerance <= 0: - raise ValueError("optimality_tolerance must be finite and positive") - if self.optimality_tolerance > _MAX_CERTIFIED_OPTIMALITY_TOLERANCE: - raise ValueError("optimality_tolerance must be at most 1e-6 metres") - if not _is_finite_number(self.numeric_tolerance) or self.numeric_tolerance <= 0: - raise ValueError("numeric_tolerance must be finite and positive") - if self.numeric_tolerance >= self.optimality_tolerance: - raise ValueError("numeric_tolerance must be below optimality_tolerance") - if ( - not _is_finite_number(self.target_interior_margin) - or self.target_interior_margin < 0 - ): - raise ValueError("target_interior_margin must be finite and non-negative") - if ( - self.target_interior_margin + 2 * self.numeric_tolerance - > self.optimality_tolerance - ): - raise ValueError( - "target_interior_margin and numeric guard exceed optimality tolerance" - ) - if not _is_power_of_two_at_least_four(self.initial_disk_segments): - raise ValueError("initial_disk_segments must be a power of two at least four") - if not _is_power_of_two_at_least_four(self.max_disk_segments): - raise ValueError("max_disk_segments must be a power of two at least four") - if self.initial_disk_segments > self.max_disk_segments: - raise ValueError("disk segment limits must be ordered") - if self.timeout_seconds is not None and ( - not _is_finite_number(self.timeout_seconds) or self.timeout_seconds < 0 - ): - raise ValueError("timeout_seconds must be None or finite and non-negative") - - -@dataclass(frozen=True) -class OptimalityCertificate: - distance_lower_bound: float - distance_upper_bound: float - optimality_gap: float - radial_geometry_error: float - numeric_error_bound: float - disk_segments: int - infimum_only: bool - - @classmethod - def create( - cls, - *, - distance_lower_bound: float, - distance_upper_bound: float, - radial_geometry_error: float, - numeric_error_bound: float, - disk_segments: int, - infimum_only: bool, - ) -> "OptimalityCertificate": - if ( - not _is_finite_number(distance_lower_bound) - or not _is_finite_number(distance_upper_bound) - or distance_lower_bound < 0 - or distance_upper_bound < distance_lower_bound - ): - raise ValueError("certificate distance bounds are invalid") - if ( - not _is_finite_number(radial_geometry_error) - or radial_geometry_error < 0 - ): - raise ValueError("radial_geometry_error must be finite and non-negative") - if not _is_finite_number(numeric_error_bound) or numeric_error_bound < 0: - raise ValueError("numeric_error_bound must be finite and non-negative") - if not _is_power_of_two_at_least_four(disk_segments): - raise ValueError("disk_segments must be a power of two at least four") - gap = distance_upper_bound - distance_lower_bound + 2 * numeric_error_bound - return cls( - distance_lower_bound, - distance_upper_bound, - gap, - radial_geometry_error, - numeric_error_bound, - disk_segments, - infimum_only, - ) - - -@dataclass(frozen=True) -class FeasibleRegionBracket: - inner: BaseGeometry - outer: BaseGeometry - radial_geometry_error: float - disk_segments: int - - @classmethod - def create( - cls, - *, - inner: BaseGeometry, - outer: BaseGeometry, - radial_geometry_error: float, - disk_segments: int, - numeric_tolerance: float, - ) -> "FeasibleRegionBracket": - if ( - not _is_finite_number(radial_geometry_error) - or radial_geometry_error < 0 - ): - raise ValueError("radial_geometry_error must be finite and non-negative") - if not inner.is_empty and not outer.buffer(numeric_tolerance).covers(inner): - raise CertifiedGeometryError("outer region must cover inner region") - return cls(inner, outer, radial_geometry_error, disk_segments) - - -@dataclass(frozen=True) -class CertifiedSolveResult: - status: SolverStatus - subject_position: Vec3 | None - score: ObjectiveBreakdown | None - quality: QualityTier - evaluated_candidates: int - reason: str | None - certificate: OptimalityCertificate | None - - @classmethod - def success( - cls, - *, - subject_position: Vec3 | None, - score: ObjectiveBreakdown | None, - quality: QualityTier, - evaluated_candidates: int, - certificate: OptimalityCertificate | None, - tolerance: float, - leakage_count: int, - relation_diff: tuple[str, ...], - spec: InterventionSpec, - ) -> "CertifiedSolveResult": - if ( - subject_position is None - or score is None - or quality is not QualityTier.PURE - or certificate is None - or not _is_finite_number(tolerance) - or tolerance <= 0.0 - or tolerance > _MAX_CERTIFIED_OPTIMALITY_TOLERANCE - or certificate.optimality_gap > tolerance - or certificate.optimality_gap > _MAX_CERTIFIED_OPTIMALITY_TOLERANCE - or score.leakage != 0.0 - or type(leakage_count) is not int - or leakage_count != 0 - or type(relation_diff) is not tuple - or len(relation_diff) != 4 - or any(type(change) is not str for change in relation_diff) - or relation_diff != expected_target_diff(spec) - ): - return cls( - SolverStatus.INVALID_SCENE, - None, - None, - QualityTier.REJECTED, - evaluated_candidates, - "invalid_success_contract", - None, - ) - return cls( - SolverStatus.SUCCESS, - subject_position, - score, - quality, - evaluated_candidates, - None, - certificate, - ) diff --git a/src/spatialcf/solver/challenge_validation.py b/src/spatialcf/solver/challenge_validation.py deleted file mode 100644 index 2ed7382..0000000 --- a/src/spatialcf/solver/challenge_validation.py +++ /dev/null @@ -1,1054 +0,0 @@ -"""Dataset-independent challenge contracts for the certified solver.""" - -from __future__ import annotations - -from dataclasses import dataclass -import hashlib -import json -import math -from pathlib import Path -from typing import Annotated, Literal - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - ValidationError, - model_validator, -) -from shapely.geometry import Point, Polygon, box - -from spatialcf.domain.enums import QualityTier, Relation, SolverStatus -from spatialcf.domain.models import InterventionSpec, Scene, SceneObject, Vec2 -from spatialcf.geometry.obb import ( - footprints_overlap, - inside_room, - obb_footprint, -) -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.analytic_motion import ( - AnalyticMotionModel, - CandidateProjectionError, -) -from spatialcf.solver.certified_models import ( - CertifiedSolveResult, - CertifiedSolverConfig, - expected_target_diff, -) -from spatialcf.solver.continuous import CertifiedSpatialCFSolver -from spatialcf.solver.validation import CertificateRecord -from spatialcf.verification.verifier import Verifier - - -_CASE_IDS = ( - "obstacle-final-placement", - "finite-support-surface", - "bounded-far-unsat", -) -_DIRECTIONS = ( - (Relation.LEFT, Relation.RIGHT), - (Relation.FRONT, Relation.BEHIND), - (Relation.NEAR, Relation.FAR), -) -_SOURCE = "core-solver-challenges-v1" -_IDENTITY = (0.0, 0.0, 0.0, 1.0) -_SAT_COMMON_CHECKS = ( - "source_relation_satisfied", - "solver_success", - "certificate_gap_closed", - "exact_infimum_bracketed", - "realized_within_tolerance", - "verifier_success", - "relation_diff_exact", - "only_subject_xy_changed", - "subject_z_unchanged", - "subject_rotation_unchanged", - "subject_extent_unchanged", - "subject_identity_unchanged", - "support_assignment_unchanged", - "stationary_objects_unchanged", - "camera_unchanged", - "room_unchanged", - "subject_position_matches_result", - "subject_view_matches_analytic_motion", - "inside_room", - "collision_free", -) -_SAT_CASE_CHECKS = { - "obstacle-final-placement": ( - "at_one_tied_infimum", - "obstacle_final_contact_only", - "floor_contact", - ), - "finite-support-surface": ( - "at_one_tied_infimum", - "support_footprint_covers_subject", - "support_vertical_contact", - ), -} -_UNSAT_CHECKS = ( - "source_relation_satisfied", - "analytic_bound_below_requirement", - "solver_proved_unsat", - "quality_rejected", - "reason_exact", - "position_absent", - "score_absent", - "certificate_absent", -) - - -class ChallengeValidationError(ValueError): - """The challenge input or result cannot support an acceptance claim.""" - - def __init__(self, message: str, *, failed_checks: tuple[str, ...] = ()) -> None: - super().__init__(message) - self.failed_checks = failed_checks - - -class SatChallengeSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+$") - scene_file: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+\.json$") - expected_outcome: Literal["SAT"] - intervention: InterventionSpec - exact_infimum_points: tuple[Vec2, ...] = Field(min_length=1) - exact_infimum_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - derivation: str = Field(min_length=1) - expected_relation_diff: tuple[str, str, str, str] - - @model_validator(mode="after") - def validate_points_are_finite(self) -> "SatChallengeSpec": - if not all( - math.isfinite(value) - for point in self.exact_infimum_points - for value in (point.x, point.y) - ): - raise ValueError("exact_infimum_points must be finite") - return self - - -class UnsatChallengeSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+$") - scene_file: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+\.json$") - expected_outcome: Literal["UNSAT"] - intervention: InterventionSpec - maximum_possible_gap_m: float = Field( - ge=0.0, - allow_inf_nan=False, - strict=True, - ) - required_gap_m: float = Field(gt=0.0, allow_inf_nan=False, strict=True) - expected_reason: Literal["empty_outer_region"] - derivation: str = Field(min_length=1) - - -ChallengeCaseSpec = Annotated[ - SatChallengeSpec | UnsatChallengeSpec, - Field(discriminator="expected_outcome"), -] - - -class ChallengeManifest(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: Literal[1] - cases: tuple[ChallengeCaseSpec, ...] - - -@dataclass(frozen=True) -class LoadedChallengeCase: - spec: SatChallengeSpec | UnsatChallengeSpec - scene: Scene - scene_sha256: str - - -class SatChallengeRecord(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str - expected_outcome: Literal["SAT"] = "SAT" - scene_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - derivation: str = Field(min_length=1) - relation_before: Relation - relation_after: Relation - before_xy: Vec2 - after_xy: Vec2 - exact_infimum_points: tuple[Vec2, ...] - exact_infimum_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - realized_displacement_m: float = Field( - ge=0.0, - allow_inf_nan=False, - strict=True, - ) - realized_error_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - solver_status: SolverStatus - quality: QualityTier - verifier_status: SolverStatus - leakage_count: int = Field(ge=0, strict=True) - changed_relations: tuple[str, ...] - certificate: CertificateRecord - checks: dict[str, bool] - - @model_validator(mode="after") - def validate_checks(self) -> "SatChallengeRecord": - expected = set(_SAT_COMMON_CHECKS + _SAT_CASE_CHECKS.get(self.case_id, ())) - if set(self.checks) != expected or not all(self.checks.values()): - raise ValueError("SAT challenge record requires every canonical check") - return self - - -class UnsatChallengeRecord(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str - expected_outcome: Literal["UNSAT"] = "UNSAT" - scene_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - derivation: str = Field(min_length=1) - relation_before: Relation - relation_after: Relation - maximum_possible_gap_m: float = Field( - ge=0.0, - allow_inf_nan=False, - strict=True, - ) - required_gap_m: float = Field(gt=0.0, allow_inf_nan=False, strict=True) - solver_status: SolverStatus - quality: QualityTier - reason: str - checks: dict[str, bool] - - @model_validator(mode="after") - def validate_checks(self) -> "UnsatChallengeRecord": - if set(self.checks) != set(_UNSAT_CHECKS) or not all(self.checks.values()): - raise ValueError("UNSAT challenge record requires every canonical check") - return self - - -ChallengeRecord = Annotated[ - SatChallengeRecord | UnsatChallengeRecord, - Field(discriminator="expected_outcome"), -] -ChallengeScenes = dict[str, tuple[Scene, Scene | None]] - - -class ChallengeValidationReport(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: Literal[1] = 1 - status: Literal["PASS"] = "PASS" - optimality_tolerance_m: Literal[1e-6] = 1e-6 - cases: tuple[ChallengeRecord, ChallengeRecord, ChallengeRecord] - - @model_validator(mode="after") - def validate_case_order(self) -> "ChallengeValidationReport": - if tuple(record.case_id for record in self.cases) != _CASE_IDS: - raise ValueError("challenge report case order mismatch") - if tuple(record.expected_outcome for record in self.cases) != ( - "SAT", - "SAT", - "UNSAT", - ): - raise ValueError("challenge report outcome order mismatch") - return self - - -def _read_regular_file(path: Path, label: str) -> bytes: - if path.is_symlink() or not path.is_file(): - raise ChallengeValidationError(f"{label} must be a regular file") - try: - return path.read_bytes() - except OSError as exc: - raise ChallengeValidationError(f"cannot read {label}: {exc}") from exc - - -def _parse_json(payload: bytes, label: str) -> object: - try: - return json.loads(payload.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise ChallengeValidationError(f"invalid UTF-8 JSON in {label}: {exc}") from exc - - -def _require_close(actual: float, expected: float, label: str) -> None: - if not math.isclose(actual, expected, rel_tol=0.0, abs_tol=1e-12): - raise ChallengeValidationError(f"{label} mismatch") - - -def _require_values( - actual: tuple[float, ...], - expected: tuple[float, ...], - label: str, -) -> None: - if len(actual) != len(expected) or any( - not math.isclose(left, right, rel_tol=0.0, abs_tol=1e-12) - for left, right in zip(actual, expected, strict=True) - ): - raise ChallengeValidationError(f"{label} mismatch") - - -def _vec3(obj: SceneObject, *, extent: bool = False) -> tuple[float, float, float]: - value = obj.obb.extent if extent else obj.position - return value.x, value.y, value.z - - -def _require_object_geometry( - obj: SceneObject, - *, - position: tuple[float, float, float], - extent: tuple[float, float, float], - label: str, -) -> None: - _require_values(_vec3(obj), position, f"{label} position") - _require_values( - (obj.obb.center.x, obj.obb.center.y, obj.obb.center.z), - position, - f"{label} OBB centre", - ) - _require_values(_vec3(obj, extent=True), extent, f"{label} extent") - _require_values( - (obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w), - _IDENTITY, - f"{label} rotation", - ) - _require_values( - ( - obj.obb.rotation.x, - obj.obb.rotation.y, - obj.obb.rotation.z, - obj.obb.rotation.w, - ), - _IDENTITY, - f"{label} OBB rotation", - ) - - -def _require_bounds( - geometry: Polygon, - expected: tuple[float, float, float, float], - label: str, -) -> None: - _require_values(tuple(float(value) for value in geometry.bounds), expected, label) - - -def _require_finite_scene(scene: Scene, case_id: str) -> None: - def visit(value: object) -> None: - if isinstance(value, float) and not math.isfinite(value): - raise ChallengeValidationError(f"{case_id}: scene geometry must be finite") - if isinstance(value, dict): - for item in value.values(): - visit(item) - elif isinstance(value, (list, tuple, set, frozenset)): - for item in value: - visit(item) - - visit(scene.model_dump(mode="python")) - - -def _validate_common_scene( - case: SatChallengeSpec | UnsatChallengeSpec, - scene: Scene, -) -> None: - if scene.scene_id != case.case_id: - raise ChallengeValidationError(f"{case.case_id}: scene_id mismatch") - if scene.source != _SOURCE: - raise ChallengeValidationError(f"{case.case_id}: source mismatch") - if scene.coordinate_system != "RH_METERS_Z_UP": - raise ChallengeValidationError(f"{case.case_id}: coordinate system mismatch") - if scene.generation_seed != 20260723: - raise ChallengeValidationError(f"{case.case_id}: generation seed mismatch") - if scene.pinned_object_ids: - raise ChallengeValidationError(f"{case.case_id}: pinned objects mismatch") - object_ids = tuple(obj.object_id for obj in scene.objects) - if len(object_ids) != len(set(object_ids)): - raise ChallengeValidationError(f"{case.case_id}: object ids must be unique") - camera_ids = tuple(camera.camera_id for camera in scene.cameras) - if camera_ids != ("camera",): - raise ChallengeValidationError(f"{case.case_id}: camera set mismatch") - _require_finite_scene(scene, case.case_id) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - if not room.is_valid or room.is_empty or room.area <= 0.0: - raise ChallengeValidationError(f"{case.case_id}: room polygon must be valid") - - try: - subject = scene.object_by_id(case.intervention.subject_id) - reference = scene.object_by_id(case.intervention.reference_id) - scene.camera_by_id(case.intervention.camera_id) - except KeyError as exc: - raise ChallengeValidationError( - f"{case.case_id}: intervention endpoint missing" - ) from exc - if not subject.movable or reference.movable: - raise ChallengeValidationError(f"{case.case_id}: movability mismatch") - if any(obj.movable for obj in scene.objects if obj.object_id != subject.object_id): - raise ChallengeValidationError( - f"{case.case_id}: only the subject may be movable" - ) - source = RelationEngine().observe( - scene, - case.intervention.subject_id, - case.intervention.reference_id, - case.intervention.relation_before, - case.intervention.camera_id, - ) - if source.status is not SolverStatus.SUCCESS or not source.satisfied: - raise ChallengeValidationError( - f"{case.case_id}: source relation is not satisfied" - ) - - -def _validate_obstacle_case(case: SatChallengeSpec, scene: Scene) -> None: - if {obj.object_id for obj in scene.objects} != { - "subject", - "reference", - "obstacle", - }: - raise ChallengeValidationError("obstacle-final-placement: object set mismatch") - subject = scene.object_by_id("subject") - reference = scene.object_by_id("reference") - obstacle = scene.object_by_id("obstacle") - _require_object_geometry( - subject, - position=(0.0, 0.0, 0.5), - extent=(0.2, 0.2, 1.0), - label="obstacle subject", - ) - _require_object_geometry( - reference, - position=(2.2, 0.0, 0.5), - extent=(0.2, 0.2, 1.0), - label="obstacle reference", - ) - _require_object_geometry( - obstacle, - position=(0.448, 0.0, 0.5), - extent=(0.4, 0.8, 1.0), - label="obstacle", - ) - if obstacle.views or obstacle.support_object_id is not None: - raise ChallengeValidationError("obstacle geometry mismatch") - camera = scene.camera_by_id("camera") - _require_values(camera.intrinsics, (500.0, 0.0, 320.0, 0.0, 500.0, 240.0, 0.0, 0.0, 1.0), "obstacle camera intrinsics") - _require_values( - camera.world_to_camera, - (1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, -0.5, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 1.0), - "obstacle camera transform", - ) - subject_view = subject.views["camera"] - reference_view = reference.views["camera"] - target_x = subject.position.x + ( - reference_view.bbox.center_x - + camera.width * RelationEngine.LEFT_RIGHT_FRACTION - - subject_view.bbox.center_x - ) * subject_view.camera_depth / camera.intrinsics[0] - _require_close(target_x, 0.448, "obstacle target boundary") - footprint = obb_footprint(obstacle.obb) - _require_bounds(footprint, (0.248, -0.4, 0.648, 0.4), "obstacle footprint") - half_x = subject.obb.extent.x / 2.0 - half_y = subject.obb.extent.y / 2.0 - min_x, min_y, max_x, max_y = footprint.bounds - configuration = box( - min_x - half_x, - min_y - half_y, - max_x + half_x, - max_y + half_y, - ) - _require_bounds( - configuration, - (0.148, -0.5, 0.748, 0.5), - "obstacle configuration", - ) - expected_points = ( - (target_x, float(configuration.bounds[1])), - (target_x, float(configuration.bounds[3])), - ) - actual_points = tuple((point.x, point.y) for point in case.exact_infimum_points) - if actual_points != expected_points: - raise ChallengeValidationError("obstacle infimum points mismatch") - infimum = math.hypot(target_x - subject.position.x, 0.5) - if not math.isclose(case.exact_infimum_m, infimum, rel_tol=0.0, abs_tol=1e-12): - raise ChallengeValidationError("obstacle infimum mismatch") - - -def _validate_support_case(case: SatChallengeSpec, scene: Scene) -> None: - if {obj.object_id for obj in scene.objects} != { - "subject", - "reference", - "support", - }: - raise ChallengeValidationError("finite-support-surface: object set mismatch") - subject = scene.object_by_id("subject") - reference = scene.object_by_id("reference") - support = scene.object_by_id("support") - _require_object_geometry( - subject, - position=(0.0, 1.0, 0.3), - extent=(0.2, 0.2, 0.2), - label="support subject", - ) - _require_object_geometry( - reference, - position=(4.0, 2.0, 0.5), - extent=(0.2, 0.2, 1.0), - label="support reference", - ) - if subject.support_object_id != "support" or support.views: - raise ChallengeValidationError("support assignment mismatch") - subject_bottom = subject.obb.center.z - subject.obb.extent.z / 2.0 - support_top = support.obb.center.z + support.obb.extent.z / 2.0 - if not math.isclose(subject_bottom, support_top, rel_tol=0.0, abs_tol=1e-12): - raise ChallengeValidationError("support contact mismatch") - _require_object_geometry( - support, - position=(0.0, 1.5, 0.1), - extent=(2.0, 2.0, 0.2), - label="support", - ) - support_footprint = obb_footprint(support.obb) - _require_bounds( - support_footprint, - (-1.0, 0.5, 1.0, 2.5), - "support footprint", - ) - half_x = subject.obb.extent.x / 2.0 - half_y = subject.obb.extent.y / 2.0 - min_x, min_y, max_x, max_y = support_footprint.bounds - locus = box(min_x + half_x, min_y + half_y, max_x - half_x, max_y - half_y) - _require_bounds(locus, (-0.9, 0.6, 0.9, 2.4), "support centre locus") - subject_view = subject.views["camera"] - reference_view = reference.views["camera"] - world_span = reference.position.y - subject.position.y - depth_span = reference_view.camera_depth - subject_view.camera_depth - _require_close(world_span, 1.0, "support calibration world span") - _require_close(depth_span, 1.0, "support calibration depth span") - target_depth = reference_view.camera_depth + RelationEngine.FRONT_BEHIND_METERS - target_y = subject.position.y + (target_depth - subject_view.camera_depth) * ( - world_span / depth_span - ) - target = Point(subject.position.x, target_y) - if not locus.contains(target): - raise ChallengeValidationError("support target is not in finite locus") - actual_points = tuple((point.x, point.y) for point in case.exact_infimum_points) - if actual_points != ((0.0, target_y),): - raise ChallengeValidationError("support infimum points mismatch") - infimum = math.hypot(target.x - subject.position.x, target.y - subject.position.y) - if not math.isclose(case.exact_infimum_m, infimum, rel_tol=0.0, abs_tol=1e-12): - raise ChallengeValidationError("support infimum mismatch") - - -def _validate_unsat_case(case: UnsatChallengeSpec, scene: Scene) -> None: - if {obj.object_id for obj in scene.objects} != {"subject", "reference"}: - raise ChallengeValidationError("bounded-far-unsat: object set mismatch") - subject = scene.object_by_id("subject") - reference = scene.object_by_id("reference") - _require_object_geometry( - subject, - position=(0.0, 0.0, 0.5), - extent=(0.2, 0.2, 1.0), - label="unsat subject", - ) - _require_object_geometry( - reference, - position=(0.4, 0.0, 0.5), - extent=(0.2, 0.2, 1.0), - label="unsat reference", - ) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - _require_bounds(room, (-0.6, -0.6, 1.0, 0.6), "unsat room") - half_x = subject.obb.extent.x / 2.0 - half_y = subject.obb.extent.y / 2.0 - min_x, min_y, max_x, max_y = room.bounds - room_locus = box( - min_x + half_x, - min_y + half_y, - max_x - half_x, - max_y - half_y, - ) - _require_bounds(room_locus, (-0.5, -0.5, 0.9, 0.5), "unsat room locus") - reference_footprint = obb_footprint(reference.obb) - ref_min_x, ref_min_y, ref_max_x, ref_max_y = reference_footprint.bounds - configuration = box( - ref_min_x - half_x, - ref_min_y - half_y, - ref_max_x + half_x, - ref_max_y + half_y, - ) - _require_bounds( - configuration, - (0.2, -0.2, 0.6, 0.2), - "unsat expanded reference", - ) - locus_min_x, locus_min_y, locus_max_x, locus_max_y = room_locus.bounds - maximum = max( - Point(x, y).distance(configuration) - for x in (locus_min_x, locus_max_x) - for y in (locus_min_y, locus_max_y) - ) - if not math.isclose( - case.maximum_possible_gap_m, - maximum, - rel_tol=0.0, - abs_tol=1e-12, - ): - raise ChallengeValidationError("maximum possible gap mismatch") - _require_close( - case.required_gap_m, - RelationEngine.FAR_METERS, - "required FAR gap", - ) - if not maximum < case.required_gap_m: - raise ChallengeValidationError("UNSAT analytic bound does not close") - - -def _validate_case_geometry( - case: SatChallengeSpec | UnsatChallengeSpec, - scene: Scene, -) -> None: - _validate_common_scene(case, scene) - if case.case_id == "obstacle-final-placement" and isinstance(case, SatChallengeSpec): - _validate_obstacle_case(case, scene) - elif case.case_id == "finite-support-surface" and isinstance(case, SatChallengeSpec): - _validate_support_case(case, scene) - elif case.case_id == "bounded-far-unsat" and isinstance(case, UnsatChallengeSpec): - _validate_unsat_case(case, scene) - else: - raise ChallengeValidationError(f"{case.case_id}: outcome type mismatch") - - -def load_challenge_cases(root: Path) -> tuple[LoadedChallengeCase, ...]: - """Load the exact closed challenge corpus and reject any drift.""" - if root.is_symlink() or not root.is_dir(): - raise ChallengeValidationError("challenge root must be a real directory") - manifest_payload = _read_regular_file(root / "cases.json", "cases.json") - try: - manifest = ChallengeManifest.model_validate( - _parse_json(manifest_payload, "cases.json") - ) - except ValidationError as exc: - raise ChallengeValidationError(f"invalid challenge manifest: {exc}") from exc - - case_ids = tuple(case.case_id for case in manifest.cases) - if len(case_ids) != len(set(case_ids)): - raise ChallengeValidationError("challenge case ids must be unique") - if set(case_ids) != set(_CASE_IDS): - raise ChallengeValidationError("manifest must contain the canonical case ids") - if case_ids != _CASE_IDS: - raise ChallengeValidationError("challenge cases must use canonical order") - directions = tuple( - (case.intervention.relation_before, case.intervention.relation_after) - for case in manifest.cases - ) - if directions != _DIRECTIONS: - raise ChallengeValidationError("challenge directions mismatch") - - resolved_root = root.resolve() - scene_names: list[str] = [] - for case in manifest.cases: - scene_name = Path(case.scene_file) - if ( - scene_name.name != case.scene_file - or scene_name.suffix != ".json" - or case.scene_file == "cases.json" - ): - raise ChallengeValidationError(f"{case.case_id}: invalid scene_file") - if (root / scene_name).resolve().parent != resolved_root: - raise ChallengeValidationError(f"{case.case_id}: scene_file escapes root") - if isinstance(case, SatChallengeSpec) and ( - case.expected_relation_diff != expected_target_diff(case.intervention) - ): - raise ChallengeValidationError( - f"{case.case_id}: expected_relation_diff mismatch" - ) - scene_names.append(case.scene_file) - - expected_files = {"cases.json", *scene_names} - actual_files = {entry.name for entry in root.iterdir()} - if actual_files != expected_files: - raise ChallengeValidationError("challenge fixture file set mismatch") - - loaded: list[LoadedChallengeCase] = [] - for case in manifest.cases: - scene_payload = _read_regular_file(root / case.scene_file, case.scene_file) - try: - scene = Scene.model_validate(_parse_json(scene_payload, case.scene_file)) - except ValidationError as exc: - raise ChallengeValidationError( - f"{case.case_id}: invalid scene: {exc}" - ) from exc - _validate_case_geometry(case, scene) - loaded.append( - LoadedChallengeCase( - spec=case, - scene=scene, - scene_sha256=hashlib.sha256(scene_payload).hexdigest(), - ) - ) - return tuple(loaded) - - -def _sat_failed(checks: dict[str, bool], case_id: str) -> ChallengeValidationError: - order = _SAT_COMMON_CHECKS + _SAT_CASE_CHECKS.get(case_id, ()) - failed_checks = tuple(name for name in order if not checks.get(name, False)) - return ChallengeValidationError( - f"{case_id}: failed checks: {', '.join(failed_checks)}", - failed_checks=failed_checks, - ) - - -def _bottom_z(obj: SceneObject) -> float: - return obj.obb.center.z - obj.obb.extent.z / 2.0 - - -def _top_z(obj: SceneObject) -> float: - return obj.obb.center.z + obj.obb.extent.z / 2.0 - - -def validate_sat_challenge( - case: LoadedChallengeCase, - solve_result: CertifiedSolveResult, - after: Scene, -) -> SatChallengeRecord: - """Independently validate one expected-SAT challenge result.""" - if not isinstance(case.spec, SatChallengeSpec): - raise ChallengeValidationError( - f"{case.spec.case_id}: expected a SAT challenge specification" - ) - before = case.scene - spec = case.spec.intervention - tolerance = CertifiedSolverConfig().optimality_tolerance - before_subject = before.object_by_id(spec.subject_id) - try: - after_subject = after.object_by_id(spec.subject_id) - except KeyError as exc: - raise ChallengeValidationError( - f"{case.spec.case_id}: subject missing", - failed_checks=("only_subject_xy_changed",), - ) from exc - - source = RelationEngine().observe( - before, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - certificate = solve_result.certificate - result_position = solve_result.subject_position - solver_success = ( - solve_result.status is SolverStatus.SUCCESS - and solve_result.quality is QualityTier.PURE - and solve_result.score is not None - and result_position is not None - and certificate is not None - ) - realized_displacement = math.hypot( - after_subject.position.x - before_subject.position.x, - after_subject.position.y - before_subject.position.y, - ) - realized_error = abs(realized_displacement - case.spec.exact_infimum_m) - exact_position_error = min( - math.hypot( - after_subject.position.x - point.x, - after_subject.position.y - point.y, - ) - for point in case.spec.exact_infimum_points - ) - - expected_after_subject = None - if result_position is not None: - try: - expected_after_subject = AnalyticMotionModel().with_object_xy( - before, - spec.subject_id, - result_position.x, - result_position.y, - ).object_by_id(spec.subject_id) - except CandidateProjectionError: - expected_after_subject = None - - verification = Verifier().verify(before, after, spec) - before_ids = tuple(obj.object_id for obj in before.objects) - after_ids = tuple(obj.object_id for obj in after.objects) - position_dx = after_subject.position.x - before_subject.position.x - position_dy = after_subject.position.y - before_subject.position.y - obb_dx = after_subject.obb.center.x - before_subject.obb.center.x - obb_dy = after_subject.obb.center.y - before_subject.obb.center.y - only_subject_xy = ( - before_ids == after_ids - and math.isclose(position_dx, obb_dx, rel_tol=0.0, abs_tol=1e-12) - and math.isclose(position_dy, obb_dy, rel_tol=0.0, abs_tol=1e-12) - ) - stationary_unchanged = before_ids == after_ids and all( - before.object_by_id(object_id) == after.object_by_id(object_id) - for object_id in before_ids - if object_id != spec.subject_id - ) - room = Polygon([(point.x, point.y) for point in before.room_polygon_xy]) - support_id = before_subject.support_object_id - collision_free = all( - not footprints_overlap(after_subject.obb, obj.obb) - for obj in after.objects - if obj.object_id not in {spec.subject_id, support_id} - ) - - checks = { - "source_relation_satisfied": ( - source.status is SolverStatus.SUCCESS and source.satisfied - ), - "solver_success": solver_success, - "certificate_gap_closed": ( - certificate is not None and certificate.optimality_gap <= tolerance - ), - "exact_infimum_bracketed": ( - certificate is not None - and certificate.distance_lower_bound - certificate.numeric_error_bound - <= case.spec.exact_infimum_m - <= certificate.distance_upper_bound + certificate.numeric_error_bound - ), - "realized_within_tolerance": realized_error <= tolerance, - "verifier_success": ( - verification.status is SolverStatus.SUCCESS - and verification.quality is QualityTier.PURE - and verification.leakage_count == 0 - ), - "relation_diff_exact": ( - verification.changed_relations == case.spec.expected_relation_diff - ), - "only_subject_xy_changed": only_subject_xy, - "subject_z_unchanged": ( - after_subject.position.z == before_subject.position.z - and after_subject.obb.center.z == before_subject.obb.center.z - ), - "subject_rotation_unchanged": ( - after_subject.rotation == before_subject.rotation - and after_subject.obb.rotation == before_subject.obb.rotation - ), - "subject_extent_unchanged": ( - after_subject.obb.extent == before_subject.obb.extent - ), - "subject_identity_unchanged": ( - after_subject.object_id == before_subject.object_id - and after_subject.name == before_subject.name - and after_subject.category == before_subject.category - and after_subject.movable == before_subject.movable - and after_subject.request_eligible == before_subject.request_eligible - ), - "support_assignment_unchanged": ( - after_subject.support_object_id == before_subject.support_object_id - ), - "stationary_objects_unchanged": stationary_unchanged, - "camera_unchanged": after.cameras == before.cameras, - "room_unchanged": after.room_polygon_xy == before.room_polygon_xy, - "subject_position_matches_result": ( - result_position is not None and after_subject.position == result_position - ), - "subject_view_matches_analytic_motion": ( - expected_after_subject is not None - and after_subject == expected_after_subject - ), - "inside_room": inside_room(after_subject.obb, room), - "collision_free": collision_free, - } - - if case.spec.case_id == "obstacle-final-placement": - obstacle = after.object_by_id("obstacle") - checks.update( - { - "at_one_tied_infimum": exact_position_error <= tolerance, - "obstacle_final_contact_only": math.isclose( - obb_footprint(after_subject.obb) - .intersection(obb_footprint(obstacle.obb)) - .area, - 0.0, - rel_tol=0.0, - abs_tol=1e-12, - ), - "floor_contact": ( - math.isclose( - _bottom_z(before_subject), - 0.0, - rel_tol=0.0, - abs_tol=1e-12, - ) - and math.isclose( - _bottom_z(after_subject), - 0.0, - rel_tol=0.0, - abs_tol=1e-12, - ) - ), - } - ) - elif case.spec.case_id == "finite-support-surface": - before_support = before.object_by_id("support") - after_support = after.object_by_id("support") - checks.update( - { - "at_one_tied_infimum": exact_position_error <= tolerance, - "support_footprint_covers_subject": ( - obb_footprint(before_support.obb).covers( - obb_footprint(before_subject.obb) - ) - and obb_footprint(after_support.obb).covers( - obb_footprint(after_subject.obb) - ) - ), - "support_vertical_contact": ( - math.isclose( - _bottom_z(before_subject), - _top_z(before_support), - rel_tol=0.0, - abs_tol=1e-12, - ) - and math.isclose( - _bottom_z(after_subject), - _top_z(after_support), - rel_tol=0.0, - abs_tol=1e-12, - ) - ), - } - ) - else: - raise ChallengeValidationError( - f"{case.spec.case_id}: unsupported SAT challenge" - ) - - if not all(checks.values()): - raise _sat_failed(checks, case.spec.case_id) - - assert certificate is not None - return SatChallengeRecord( - case_id=case.spec.case_id, - scene_sha256=case.scene_sha256, - derivation=case.spec.derivation, - relation_before=spec.relation_before, - relation_after=spec.relation_after, - before_xy=Vec2(x=before_subject.position.x, y=before_subject.position.y), - after_xy=Vec2(x=after_subject.position.x, y=after_subject.position.y), - exact_infimum_points=case.spec.exact_infimum_points, - exact_infimum_m=case.spec.exact_infimum_m, - realized_displacement_m=realized_displacement, - realized_error_m=realized_error, - solver_status=solve_result.status, - quality=solve_result.quality, - verifier_status=verification.status, - leakage_count=verification.leakage_count, - changed_relations=verification.changed_relations, - certificate=CertificateRecord( - distance_lower_bound=certificate.distance_lower_bound, - distance_upper_bound=certificate.distance_upper_bound, - optimality_gap=certificate.optimality_gap, - radial_geometry_error=certificate.radial_geometry_error, - numeric_error_bound=certificate.numeric_error_bound, - disk_segments=certificate.disk_segments, - infimum_only=certificate.infimum_only, - ), - checks=checks, - ) - - -def validate_unsat_challenge( - case: LoadedChallengeCase, - solve_result: CertifiedSolveResult, -) -> UnsatChallengeRecord: - """Accept only the exact proof contract for one expected-UNSAT challenge.""" - if not isinstance(case.spec, UnsatChallengeSpec): - raise ChallengeValidationError( - f"{case.spec.case_id}: expected an UNSAT challenge specification" - ) - spec = case.spec - source = RelationEngine().observe( - case.scene, - spec.intervention.subject_id, - spec.intervention.reference_id, - spec.intervention.relation_before, - spec.intervention.camera_id, - ) - checks = { - "source_relation_satisfied": ( - source.status is SolverStatus.SUCCESS and source.satisfied - ), - "analytic_bound_below_requirement": ( - spec.maximum_possible_gap_m < spec.required_gap_m - ), - "solver_proved_unsat": ( - solve_result.status is SolverStatus.UNSATISFIABLE - ), - "quality_rejected": solve_result.quality is QualityTier.REJECTED, - "reason_exact": solve_result.reason == spec.expected_reason, - "position_absent": solve_result.subject_position is None, - "score_absent": solve_result.score is None, - "certificate_absent": solve_result.certificate is None, - } - if not all(checks.values()): - failed_checks = tuple(name for name in _UNSAT_CHECKS if not checks[name]) - raise ChallengeValidationError( - f"{spec.case_id}: failed checks: {', '.join(failed_checks)}", - failed_checks=failed_checks, - ) - return UnsatChallengeRecord( - case_id=spec.case_id, - scene_sha256=case.scene_sha256, - derivation=spec.derivation, - relation_before=spec.intervention.relation_before, - relation_after=spec.intervention.relation_after, - maximum_possible_gap_m=spec.maximum_possible_gap_m, - required_gap_m=spec.required_gap_m, - solver_status=solve_result.status, - quality=solve_result.quality, - reason=solve_result.reason, - checks=checks, - ) - - -def run_challenge_case( - case: LoadedChallengeCase, -) -> tuple[SatChallengeRecord | UnsatChallengeRecord, Scene | None]: - """Solve and independently validate one committed challenge case.""" - result = CertifiedSpatialCFSolver().solve(case.scene, case.spec.intervention) - if isinstance(case.spec, UnsatChallengeSpec): - return validate_unsat_challenge(case, result), None - if result.subject_position is None: - raise ChallengeValidationError( - f"{case.spec.case_id}: solver did not return a SAT position", - failed_checks=("solver_success",), - ) - try: - after = AnalyticMotionModel().with_object_xy( - case.scene, - case.spec.intervention.subject_id, - result.subject_position.x, - result.subject_position.y, - ) - except CandidateProjectionError as exc: - raise ChallengeValidationError( - f"{case.spec.case_id}: analytic replay failed", - failed_checks=("subject_view_matches_analytic_motion",), - ) from exc - return validate_sat_challenge(case, result, after), after - - -def run_challenge_suite( - root: Path, -) -> tuple[ChallengeValidationReport, ChallengeScenes]: - """Run all challenges twice and return only byte-repeatable evidence.""" - records: list[SatChallengeRecord | UnsatChallengeRecord] = [] - scenes: ChallengeScenes = {} - for case in load_challenge_cases(root): - first_record, first_after = run_challenge_case(case) - second_record, second_after = run_challenge_case(case) - if first_record != second_record or first_after != second_after: - raise ChallengeValidationError( - f"{case.spec.case_id}: challenge result is not repeatable" - ) - records.append(first_record) - scenes[case.spec.case_id] = (case.scene, first_after) - if len(records) != 3: - raise ChallengeValidationError("challenge suite did not produce three cases") - report = ChallengeValidationReport( - cases=(records[0], records[1], records[2]) - ) - return report, scenes diff --git a/src/spatialcf/solver/continuous.py b/src/spatialcf/solver/continuous.py deleted file mode 100644 index 9f361ec..0000000 --- a/src/spatialcf/solver/continuous.py +++ /dev/null @@ -1,1297 +0,0 @@ -"""Certified continuous XY counterfactual solving by distance-bound closure.""" - -import math -import time -from dataclasses import dataclass -from itertools import pairwise - -from shapely.errors import GEOSException -from shapely.geometry import LinearRing, LineString, Point, Polygon -from shapely.geometry.base import BaseGeometry -from shapely.ops import nearest_points - -from spatialcf.domain.enums import QualityTier, Relation, SolverStatus -from spatialcf.domain.models import ( - InterventionSpec, - ObjectView, - Scene, - SceneObject, - Vec3, -) -from spatialcf.geometry.obb import obb_footprint -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.analytic_motion import ( - AnalyticMotionModel, - CandidateProjectionError, -) -from spatialcf.solver.certified_constraints import CertifiedConstraintBuilder -from spatialcf.solver.certified_models import ( - SUPPORTED_DIRECTIONS, - CertifiedSolverConfig, - CertifiedSolveResult, - OptimalityCertificate, - expected_target_diff, -) -from spatialcf.solver.feasible import _configuration_obstacle -from spatialcf.solver.objective import ObjectiveBreakdown, score_candidate -from spatialcf.verification.verifier import VerificationResult, Verifier - -_ATOMIC_GEOMETRY_TYPES = (Polygon, LineString, LinearRing, Point) -_STRICT_PRESERVATION_ERRORS = frozenset( - {"target_pair_collateral_change", "non_target_relation_changed"} -) - - -def _atomic_components(region: BaseGeometry) -> tuple[BaseGeometry, ...]: - """Return every non-empty polygon, line, or point component.""" - if region.is_empty: - return () - if isinstance(region, _ATOMIC_GEOMETRY_TYPES): - return (region,) - return tuple( - component for part in region.geoms for component in _atomic_components(part) - ) - - -@dataclass(frozen=True) -class _NearestPointCandidate: - point: Point - component: BaseGeometry - distance: float - - -def _nearest_on_segment( - origin: Point, - start: tuple[float, float], - end: tuple[float, float], -) -> Point: - dx = end[0] - start[0] - dy = end[1] - start[1] - squared_span = dx * dx + dy * dy - if squared_span == 0.0: - return Point(start) - fraction = ((origin.x - start[0]) * dx + (origin.y - start[1]) * dy) / squared_span - fraction = min(1.0, max(0.0, fraction)) - return Point(start[0] + fraction * dx, start[1] + fraction * dy) - - -def _segment_candidates( - line: LineString | LinearRing, - origin: Point, -) -> tuple[Point, ...]: - coordinates = tuple((float(row[0]), float(row[1])) for row in line.coords) - return tuple( - _nearest_on_segment(origin, start, end) for start, end in pairwise(coordinates) - ) - - -def _component_nearest_candidates( - component: BaseGeometry, - origin: Point, -) -> tuple[Point, ...]: - if isinstance(component, Point): - return (component,) - if isinstance(component, Polygon): - if component.covers(origin): - return (origin,) - candidates = list(_segment_candidates(component.exterior, origin)) - for ring in component.interiors: - candidates.extend(_segment_candidates(ring, origin)) - elif isinstance(component, (LineString, LinearRing)): - candidates = list(_segment_candidates(component, origin)) - else: - raise TypeError(f"unsupported atomic geometry: {component.geom_type}") - if not candidates: - return () - distances = tuple(origin.distance(candidate) for candidate in candidates) - minimum = min(distances) - return tuple( - candidate - for candidate, distance in zip(candidates, distances, strict=True) - if math.isclose( - distance, - minimum, - rel_tol=0.0, - abs_tol=max(math.ulp(distance), math.ulp(minimum)), - ) - ) - - -def _nearest_candidate_entries( - region: BaseGeometry, - origin: Point, -) -> tuple[_NearestPointCandidate, ...]: - ranked = [ - _NearestPointCandidate( - point=point, - component=component, - distance=origin.distance(point), - ) - for component in sorted(_atomic_components(region), key=lambda item: item.wkb) - for point in _component_nearest_candidates(component, origin) - ] - unique: list[_NearestPointCandidate] = [] - seen_coordinates: set[tuple[float, float]] = set() - for candidate in sorted( - ranked, - key=lambda item: ( - item.distance, - item.point.x, - item.point.y, - item.component.wkb, - ), - ): - coordinates = (candidate.point.x, candidate.point.y) - if coordinates in seen_coordinates: - continue - seen_coordinates.add(coordinates) - unique.append(candidate) - return tuple(unique) - - -def _nearest_candidates( - region: BaseGeometry, - origin: Point, - numeric_tolerance: float = 1e-9, -) -> tuple[Point, ...]: - """Return every deterministic tied nearest coordinate per component.""" - del numeric_tolerance - return tuple( - candidate.point for candidate in _nearest_candidate_entries(region, origin) - ) - - -def _interpolate(start: Point, end: Point, fraction: float) -> Point: - return Point( - start.x + fraction * (end.x - start.x), - start.y + fraction * (end.y - start.y), - ) - - -def _polygon_component( - region: BaseGeometry, - point: Point, - numeric_tolerance: float, -) -> Polygon | None: - components = tuple( - component - for component in _atomic_components(region) - if isinstance(component, Polygon) - ) - exact = tuple(component for component in components if component.covers(point)) - if exact: - return min(exact, key=lambda component: component.wkb) - nearby = tuple( - component - for component in components - if component.distance(point) <= numeric_tolerance - ) - if not nearby: - return None - return min( - nearby, - key=lambda component: (component.distance(point), component.wkb), - ) - - -@dataclass(frozen=True) -class _VerifiedCandidate: - distance: float - point: Point - position: Vec3 - score: ObjectiveBreakdown - verification: VerificationResult - - @property - def key(self) -> tuple[float, float, float]: - return (self.distance, self.point.x, self.point.y) - - -class CertifiedSpatialCFSolver: - """Close analytic inner/outer distance bounds around a strict solution.""" - - def __init__(self, config: CertifiedSolverConfig | None = None) -> None: - self.config = config if config is not None else CertifiedSolverConfig() - - @staticmethod - def _failure( - status: SolverStatus, - evaluated_candidates: int, - reason: str, - ) -> CertifiedSolveResult: - return CertifiedSolveResult( - status=status, - subject_position=None, - score=None, - quality=QualityTier.REJECTED, - evaluated_candidates=evaluated_candidates, - reason=reason, - certificate=None, - ) - - @staticmethod - def _object_geometry_values(obj: SceneObject) -> tuple[float, ...]: - values = ( - obj.position.x, - obj.position.y, - obj.position.z, - obj.rotation.x, - obj.rotation.y, - obj.rotation.z, - obj.rotation.w, - obj.obb.center.x, - obj.obb.center.y, - obj.obb.center.z, - obj.obb.extent.x, - obj.obb.extent.y, - obj.obb.extent.z, - obj.obb.rotation.x, - obj.obb.rotation.y, - obj.obb.rotation.z, - obj.obb.rotation.w, - ) - view_values = tuple( - value - for camera_id in sorted(obj.views) - for value in ( - obj.views[camera_id].bbox.xmin, - obj.views[camera_id].bbox.ymin, - obj.views[camera_id].bbox.xmax, - obj.views[camera_id].bbox.ymax, - obj.views[camera_id].camera_depth, - obj.views[camera_id].visible_fraction, - obj.views[camera_id].image_area_fraction, - obj.views[camera_id].truncated_fraction, - ) - ) - return values + view_values - - @classmethod - def _scene_geometry_is_finite(cls, scene: Scene) -> bool: - values = [ - value for point in scene.room_polygon_xy for value in (point.x, point.y) - ] - for camera in scene.cameras: - values.extend(camera.intrinsics) - values.extend(camera.world_to_camera) - for obj in scene.objects: - values.extend(cls._object_geometry_values(obj)) - for obstacle in scene.collision_obstacles: - obb = obstacle.obb - values.extend( - ( - obstacle.clearance_m, - obb.center.x, - obb.center.y, - obb.center.z, - obb.extent.x, - obb.extent.y, - obb.extent.z, - obb.rotation.x, - obb.rotation.y, - obb.rotation.z, - obb.rotation.w, - ) - ) - return all(math.isfinite(float(value)) for value in values) - - @staticmethod - def _scene_graph_failure_reason(scene: Scene) -> str | None: - object_ids = tuple(obj.object_id for obj in scene.objects) - if len(set(object_ids)) != len(object_ids): - return "duplicate_object_ids" - - camera_ids = tuple(camera.camera_id for camera in scene.cameras) - if len(set(camera_ids)) != len(camera_ids): - return "duplicate_camera_ids" - - object_id_set = set(object_ids) - obstacle_ids = tuple( - obstacle.obstacle_id for obstacle in scene.collision_obstacles - ) - if len(set(obstacle_ids)) != len(obstacle_ids): - return "duplicate_collision_obstacle_ids" - if object_id_set.intersection(obstacle_ids): - return "collision_obstacle_id_conflict" - camera_id_set = set(camera_ids) - if not scene.pinned_object_ids.issubset(object_id_set): - return "pinned_object_missing" - - support_parents: dict[str, str | None] = {} - for obj in sorted(scene.objects, key=lambda item: item.object_id): - for camera_id, view in sorted(obj.views.items()): - if camera_id not in camera_id_set: - return "view_camera_missing" - if view.camera_id != camera_id: - return "view_camera_mismatch" - support_id = obj.support_object_id - if support_id is not None and support_id not in object_id_set: - return "support_object_missing" - support_parents[obj.object_id] = support_id - - for object_id in sorted(object_ids): - trail: set[str] = set() - current: str | None = object_id - while current is not None: - if current in trail: - return "invalid_support_graph" - trail.add(current) - current = support_parents[current] - return None - - @staticmethod - def _view_geometry_is_valid(view: ObjectView) -> bool: - bbox = view.bbox - width = bbox.xmax - bbox.xmin - height = bbox.ymax - bbox.ymin - center_x = (bbox.xmin + bbox.xmax) / 2.0 - center_y = (bbox.ymin + bbox.ymax) / 2.0 - return ( - all( - math.isfinite(value) - for value in (width, height, bbox.area, center_x, center_y) - ) - and width > 0.0 - and height > 0.0 - and bbox.area > 0.0 - and view.camera_depth > 0.0 - ) - - def _validate_scene( - self, - scene: Scene, - spec: InterventionSpec, - ) -> CertifiedSolveResult | None: - graph_failure = self._scene_graph_failure_reason(scene) - if graph_failure is not None: - return self._failure(SolverStatus.INVALID_SCENE, 0, graph_failure) - - try: - subject = scene.object_by_id(spec.subject_id) - except KeyError: - return self._failure(SolverStatus.INVALID_SCENE, 0, "unknown_subject") - try: - reference = scene.object_by_id(spec.reference_id) - except KeyError: - return self._failure(SolverStatus.INVALID_SCENE, 0, "unknown_reference") - try: - scene.camera_by_id(spec.camera_id) - except KeyError: - return self._failure(SolverStatus.INVALID_SCENE, 0, "unknown_camera") - - if not subject.request_eligible or not reference.request_eligible: - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "request_endpoint_ineligible", - ) - if not subject.movable or spec.subject_id in scene.pinned_object_ids: - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "subject_not_movable", - ) - if scene.children_by_support().get(spec.subject_id): - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "subject_has_supported_objects", - ) - if not self._scene_geometry_is_finite(scene): - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "non_finite_geometry", - ) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - if not math.isfinite(room.area): - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "non_finite_geometry", - ) - if not room.is_valid or room.is_empty or room.area <= 0.0: - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "invalid_room_geometry", - ) - if any( - extent <= 0.0 - for obj in scene.objects - for extent in (obj.obb.extent.x, obj.obb.extent.y, obj.obb.extent.z) - ) or any( - extent <= 0.0 - for obstacle in scene.collision_obstacles - for extent in ( - obstacle.obb.extent.x, - obstacle.obb.extent.y, - obstacle.obb.extent.z, - ) - ): - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - "invalid_object_geometry", - ) - - query_endpoint_ids = {spec.subject_id, spec.reference_id} - for obj in sorted(scene.objects, key=lambda item: item.object_id): - for camera_id, view in sorted(obj.views.items()): - if not self._view_geometry_is_valid(view): - reason = ( - "invalid_query_view_geometry" - if obj.object_id in query_endpoint_ids - and camera_id == spec.camera_id - else "invalid_object_view_geometry" - ) - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - reason, - ) - - motion_model = AnalyticMotionModel() - for camera_id in sorted(subject.views): - try: - motion_model.calibration(scene, subject.object_id, camera_id) - except CandidateProjectionError: - reason = ( - "invalid_query_view_geometry" - if camera_id == spec.camera_id - else "invalid_subject_view_calibration" - ) - return self._failure( - SolverStatus.INVALID_SCENE, - 0, - reason, - ) - - source = RelationEngine().observe( - scene, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - if source.status is SolverStatus.NOT_VISIBLE: - return self._failure( - SolverStatus.NOT_VISIBLE, - 0, - "source_not_visible", - ) - if not source.satisfied: - return self._failure( - SolverStatus.AMBIGUOUS, - 0, - "source_relation_not_satisfied", - ) - return None - - @staticmethod - def _deadline(config: CertifiedSolverConfig) -> float | None: - if config.timeout_seconds is None: - return None - return time.monotonic() + config.timeout_seconds - - @staticmethod - def _timed_out(deadline: float | None) -> bool: - return deadline is not None and time.monotonic() >= deadline - - @staticmethod - def _evaluate_candidate( - scene: Scene, - spec: InterventionSpec, - point: Point, - origin: Point, - ) -> tuple[_VerifiedCandidate | None, VerificationResult]: - after = AnalyticMotionModel().with_object_xy( - scene, - spec.subject_id, - point.x, - point.y, - ) - verifier = Verifier() - verification = verifier.verify(scene, after, spec) - if not ( - verification.status is SolverStatus.SUCCESS - and verification.quality is QualityTier.PURE - and verification.leakage_count == 0 - and verification.changed_relations == expected_target_diff(spec) - ): - return None, verification - score = score_candidate( - scene, - after, - spec, - verification.leakage_count, - verifier.engine, - ) - return ( - _VerifiedCandidate( - distance=origin.distance(point), - point=point, - position=after.object_by_id(spec.subject_id).position, - score=score, - verification=verification, - ), - verification, - ) - - @staticmethod - def _is_strict_preservation_failure( - verification: VerificationResult, - ) -> bool: - return ( - verification.status is not SolverStatus.SUCCESS - and bool(verification.errors) - and set(verification.errors).issubset(_STRICT_PRESERVATION_ERRORS) - ) - - @staticmethod - def _is_target_boundary_failure( - verification: VerificationResult, - ) -> bool: - return ( - verification.status is not SolverStatus.SUCCESS - and verification.errors == ("target_not_satisfied",) - ) - - def _next_disk_segments( - self, - current: int, - radial_geometry_error: float, - ) -> int: - """Skip disk resolutions that cannot leave a stable gap budget.""" - next_segments = current * 2 - stable_radial_budget = max( - self.config.numeric_tolerance, - ( - self.config.optimality_tolerance - - 2.0 * self.config.numeric_tolerance - ) - / 4.0, - ) - while ( - next_segments < self.config.max_disk_segments - and radial_geometry_error * (current / next_segments) ** 2 - > stable_radial_budget - ): - next_segments *= 2 - return next_segments - - def _strict_inward_candidate( - self, - scene: Scene, - spec: InterventionSpec, - boundary: Point, - origin: Point, - component: Polygon, - deadline: float | None, - ) -> tuple[_VerifiedCandidate | None, int, bool]: - representative = component.representative_point() - guard = max( - self.config.numeric_tolerance, - math.ulp(boundary.x), - math.ulp(boundary.y), - ) - span = boundary.distance(representative) - if span == 0.0: - return None, 0, False - low = min(1.0, guard / span) - high = 1.0 - evaluated = 0 - - if self._timed_out(deadline): - return None, evaluated, True - evaluated += 1 - try: - high_candidate, _ = self._evaluate_candidate( - scene, - spec, - _interpolate(boundary, representative, high), - origin, - ) - except CandidateProjectionError: - return None, evaluated, False - if self._timed_out(deadline): - return None, evaluated, True - if high_candidate is None: - return None, evaluated, False - - for _ in range(64): - middle = (low + high) / 2.0 - if self._timed_out(deadline): - return None, evaluated, True - evaluated += 1 - try: - candidate, _ = self._evaluate_candidate( - scene, - spec, - _interpolate(boundary, representative, middle), - origin, - ) - except CandidateProjectionError: - return None, evaluated, False - if self._timed_out(deadline): - return None, evaluated, True - if candidate is not None: - high = middle - else: - low = middle - - evaluated += 1 - try: - candidate, _ = self._evaluate_candidate( - scene, - spec, - _interpolate(boundary, representative, high), - origin, - ) - except CandidateProjectionError: - return None, evaluated, False - return candidate, evaluated, self._timed_out(deadline) - - def _verified_segment_boundary_candidate( - self, - scene: Scene, - spec: InterventionSpec, - boundary: Point, - interior: Point, - origin: Point, - deadline: float | None, - ) -> tuple[_VerifiedCandidate | None, int, bool]: - """Find the first verified point between an outer and inner candidate.""" - low = 0.0 - high = 1.0 - evaluated = 0 - for _ in range(64): - if self._timed_out(deadline): - return None, evaluated, True - middle = (low + high) / 2.0 - evaluated += 1 - try: - candidate, _ = self._evaluate_candidate( - scene, - spec, - _interpolate(boundary, interior, middle), - origin, - ) - except CandidateProjectionError: - return None, evaluated, False - if candidate is None: - low = middle - else: - high = middle - - if self._timed_out(deadline): - return None, evaluated, True - evaluated += 1 - try: - candidate, _ = self._evaluate_candidate( - scene, - spec, - _interpolate(boundary, interior, high), - origin, - ) - except CandidateProjectionError: - return None, evaluated, False - return candidate, evaluated, self._timed_out(deadline) - - def _guarded_target_boundary_candidate( - self, - scene: Scene, - spec: InterventionSpec, - boundary: Point, - interior: Point, - origin: Point, - deadline: float | None, - ) -> tuple[_VerifiedCandidate | None, int, bool]: - """Keep a replay guard without exceeding the optimality tolerance.""" - if self._timed_out(deadline): - return None, 0, True - span = boundary.distance(interior) - if span == 0.0: - return None, 0, False - guarded_distance = min( - span, - max( - self.config.numeric_tolerance, - self.config.optimality_tolerance - - 2.0 * self.config.numeric_tolerance, - ), - ) - point = _interpolate(boundary, interior, guarded_distance / span) - try: - candidate, _ = self._evaluate_candidate( - scene, - spec, - point, - origin, - ) - except CandidateProjectionError: - return None, 1, False - if candidate is not None and not self._candidate_is_collision_free( - scene, - spec, - point, - ): - candidate = None - return candidate, 1, self._timed_out(deadline) - - def _candidate_is_collision_free( - self, - scene: Scene, - spec: InterventionSpec, - point: Point, - ) -> bool: - after = AnalyticMotionModel().with_object_xy( - scene, - spec.subject_id, - point.x, - point.y, - ) - subject = after.object_by_id(spec.subject_id) - subject_bottom = subject.obb.center.z - subject.obb.extent.z / 2.0 - subject_top = subject.obb.center.z + subject.obb.extent.z / 2.0 - subject_footprint = obb_footprint(subject.obb) - for stationary in after.objects: - if stationary.object_id in { - spec.subject_id, - subject.support_object_id, - }: - continue - stationary_bottom = ( - stationary.obb.center.z - stationary.obb.extent.z / 2.0 - ) - stationary_top = ( - stationary.obb.center.z + stationary.obb.extent.z / 2.0 - ) - vertical_overlap = min(subject_top, stationary_top) - max( - subject_bottom, - stationary_bottom, - ) - if ( - vertical_overlap > self.config.numeric_tolerance - and subject_footprint.intersection( - obb_footprint(stationary.obb) - ).area - > self.config.numeric_tolerance - ): - return False - return True - - def _direct_far_candidate( - self, - scene: Scene, - spec: InterventionSpec, - origin: Point, - ) -> Point | None: - """Return the exact unconstrained FAR-boundary projection when defined.""" - if spec.relation_after is not Relation.FAR: - return None - subject = scene.object_by_id(spec.subject_id) - reference = scene.object_by_id(spec.reference_id) - configuration = self._target_pair_configuration(subject, reference) - distance = origin.distance(configuration) - if not math.isfinite(distance) or distance <= 0.0: - return None - contact = nearest_points(configuration, origin)[0] - radius = RelationEngine.FAR_METERS + self.config.numeric_tolerance - scale = radius / distance - point = Point( - contact.x + (origin.x - contact.x) * scale, - contact.y + (origin.y - contact.y) * scale, - ) - if not all(math.isfinite(value) for value in (point.x, point.y)): - raise ArithmeticError("non-finite direct FAR candidate") - return point - - @staticmethod - def _target_pair_configuration( - subject: SceneObject, - reference: SceneObject, - ) -> Polygon: - relative_vertices = [ - (float(x) - subject.position.x, float(y) - subject.position.y) - for x, y in tuple(obb_footprint(subject.obb).exterior.coords)[:-1] - ] - return _configuration_obstacle( - obb_footprint(reference.obb), - relative_vertices, - ) - - @staticmethod - def _target_boundary_coefficients( - scene: Scene, - spec: InterventionSpec, - ) -> tuple[float, float, float] | None: - subject = scene.object_by_id(spec.subject_id) - reference = scene.object_by_id(spec.reference_id) - calibration = AnalyticMotionModel().calibration( - scene, - subject.object_id, - spec.camera_id, - ) - reference_view = reference.views.get(spec.camera_id) - if reference_view is None: - return None - if spec.relation_after is Relation.RIGHT: - camera = calibration.camera - target = ( - reference_view.bbox.center_x - + camera.width * RelationEngine.LEFT_RIGHT_FRACTION - ) - fx, _, cx, _, _, _, _, _, _ = camera.intrinsics - x_nx, x_ny, x_constant = calibration.camera_x_coefficients - d_nx, d_ny, d_constant = calibration.depth_coefficients - scale = cx + calibration.horizontal_residual - target - return ( - fx * x_nx + scale * d_nx, - fx * x_ny + scale * d_ny, - fx * x_constant + scale * d_constant, - ) - if spec.relation_after is Relation.BEHIND: - d_nx, d_ny, d_constant = calibration.depth_coefficients - target = reference_view.camera_depth + RelationEngine.FRONT_BEHIND_METERS - return ( - d_nx, - d_ny, - d_constant + calibration.depth_residual - target, - ) - return None - - def _preserved_far_boundary_candidates( - self, - scene: Scene, - spec: InterventionSpec, - approximate: Point, - ) -> tuple[Point, ...]: - """Intersect the exact target line with a locally active FAR arc.""" - labels = RelationEngine().pair_labels( - scene, - spec.subject_id, - spec.reference_id, - spec.camera_id, - ) - coefficients = self._target_boundary_coefficients(scene, spec) - if Relation.FAR not in labels or coefficients is None: - return () - subject = scene.object_by_id(spec.subject_id) - reference = scene.object_by_id(spec.reference_id) - configuration = self._target_pair_configuration(subject, reference) - contact = nearest_points(configuration, approximate)[0] - nx, ny, constant = coefficients - norm_squared = nx * nx + ny * ny - if not math.isfinite(norm_squared) or norm_squared <= 0.0: - raise ArithmeticError("invalid target boundary normal") - signed = (nx * contact.x + ny * contact.y + constant) / norm_squared - foot_x = contact.x - nx * signed - foot_y = contact.y - ny * signed - center_distance = abs(nx * contact.x + ny * contact.y + constant) / math.sqrt( - norm_squared - ) - radius = RelationEngine.FAR_METERS + self.config.numeric_tolerance - if center_distance > radius: - return () - offset = math.sqrt(max(0.0, radius * radius - center_distance**2)) - norm = math.sqrt(norm_squared) - tangent_x = -ny / norm - tangent_y = nx / norm - points = ( - Point( - foot_x - offset * tangent_x, - foot_y - offset * tangent_y, - ), - Point( - foot_x + offset * tangent_x, - foot_y + offset * tangent_y, - ), - ) - if not all( - math.isfinite(value) for point in points for value in (point.x, point.y) - ): - raise ArithmeticError("non-finite preserved FAR candidate") - return tuple(sorted(points, key=lambda point: (point.x, point.y))) - - def solve( - self, - scene: Scene, - spec: InterventionSpec, - ) -> CertifiedSolveResult: - """Return a strict candidate only after its distance bounds close.""" - deadline = self._deadline(self.config) - if ( - spec.relation_before, - spec.relation_after, - ) not in SUPPORTED_DIRECTIONS: - return self._failure( - SolverStatus.UNSUPPORTED, - 0, - "unsupported_direction", - ) - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, 0, "timeout") - invalid = self._validate_scene(scene, spec) - if invalid is not None: - return invalid - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, 0, "timeout") - - subject = scene.object_by_id(spec.subject_id) - origin = Point(subject.position.x, subject.position.y) - best: _VerifiedCandidate | None = None - evaluated = 0 - segments = self.config.initial_disk_segments - - try: - direct_far = self._direct_far_candidate(scene, spec, origin) - if direct_far is not None: - best, _ = self._evaluate_candidate( - scene, - spec, - direct_far, - origin, - ) - evaluated += 1 - except CandidateProjectionError: - best = None - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, evaluated, "timeout") - - while segments <= self.config.max_disk_segments: - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, evaluated, "timeout") - try: - bracket = CertifiedConstraintBuilder().build( - scene, - spec, - disk_segments=segments, - numeric_tolerance=self.config.numeric_tolerance, - target_interior_margin=self.config.target_interior_margin, - ) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, evaluated, "timeout") - if bracket.outer.is_empty: - return self._failure( - SolverStatus.UNSATISFIABLE, - evaluated, - "empty_outer_region", - ) - - try: - lower = origin.distance(bracket.outer) - if not math.isfinite(lower): - raise ArithmeticError("non-finite lower distance bound") - nearest_candidates = _nearest_candidate_entries( - bracket.inner, - origin, - ) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - verified_inner: list[_VerifiedCandidate] = [] - for nearest in nearest_candidates: - point = nearest.point - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, evaluated, "timeout") - try: - if not all(math.isfinite(value) for value in (point.x, point.y)): - raise ArithmeticError("non-finite nearest candidate") - candidate, verification = self._evaluate_candidate( - scene, spec, point, origin - ) - except CandidateProjectionError: - candidate = None - verification = None - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - evaluated += 1 - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, evaluated, "timeout") - if ( - candidate is None - and verification is not None - and self._is_strict_preservation_failure(verification) - ): - component = ( - nearest.component - if isinstance(nearest.component, Polygon) - else None - ) - if component is not None: - try: - candidate, nudge_evaluated, timed_out = ( - self._strict_inward_candidate( - scene, - spec, - point, - origin, - component, - deadline, - ) - ) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - evaluated += nudge_evaluated - if timed_out: - return self._failure( - SolverStatus.TIMEOUT, - evaluated, - "timeout", - ) - if candidate is not None and (best is None or candidate.key < best.key): - best = candidate - if candidate is not None: - verified_inner.append(candidate) - - try: - outer_nearest = tuple( - candidate - for candidate in _nearest_candidate_entries( - bracket.outer, - origin, - ) - if candidate.distance <= lower + self.config.numeric_tolerance - ) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - for outer in outer_nearest: - if self._timed_out(deadline): - return self._failure( - SolverStatus.TIMEOUT, - evaluated, - "timeout", - ) - try: - outer_candidate, outer_verification = self._evaluate_candidate( - scene, - spec, - outer.point, - origin, - ) - except CandidateProjectionError: - outer_candidate = None - outer_verification = None - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - evaluated += 1 - if outer_candidate is not None: - if best is None or outer_candidate.key < best.key: - best = outer_candidate - continue - try: - exact_candidates = self._preserved_far_boundary_candidates( - scene, - spec, - outer.point, - ) - for point in exact_candidates: - exact_candidate, _ = self._evaluate_candidate( - scene, - spec, - point, - origin, - ) - evaluated += 1 - if exact_candidate is not None and ( - best is None or exact_candidate.key < best.key - ): - best = exact_candidate - except CandidateProjectionError: - pass - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - if ( - outer_verification is None - or not verified_inner - ): - continue - strict_preservation_failure = self._is_strict_preservation_failure( - outer_verification - ) - target_boundary_failure = self._is_target_boundary_failure( - outer_verification - ) - current_gap = ( - best.distance - lower + 2 * self.config.numeric_tolerance - if best is not None - else math.inf - ) - if not strict_preservation_failure and not ( - target_boundary_failure - and current_gap <= self.config.optimality_tolerance - ): - continue - interior = min( - verified_inner, - key=lambda candidate: ( - outer.point.distance(candidate.point), - candidate.key, - ), - ) - try: - refinement = ( - self._guarded_target_boundary_candidate - if target_boundary_failure - else self._verified_segment_boundary_candidate - ) - refined, refinement_evaluated, timed_out = refinement( - scene, - spec, - outer.point, - interior.point, - origin, - deadline, - ) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - evaluated += refinement_evaluated - if timed_out: - return self._failure( - SolverStatus.TIMEOUT, - evaluated, - "timeout", - ) - if refined is not None and (best is None or refined.key < best.key): - best = refined - - if self._timed_out(deadline): - return self._failure(SolverStatus.TIMEOUT, evaluated, "timeout") - - if best is not None: - gap = best.distance - lower + 2 * self.config.numeric_tolerance - if gap <= self.config.optimality_tolerance: - try: - outer_points = tuple( - candidate.point - for candidate in _nearest_candidate_entries( - bracket.outer, - origin, - ) - if candidate.distance - <= lower + self.config.numeric_tolerance - ) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - strict_failures: list[bool] = [] - for point in outer_points: - if self._timed_out(deadline): - return self._failure( - SolverStatus.TIMEOUT, - evaluated, - "timeout", - ) - try: - outer_candidate, outer_verification = ( - self._evaluate_candidate( - scene, - spec, - point, - origin, - ) - ) - except CandidateProjectionError: - strict_failures.append(False) - except (ArithmeticError, GEOSException): - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - "numeric_geometry_failure", - ) - else: - strict_failures.append( - outer_candidate is None - and self._is_strict_preservation_failure( - outer_verification - ) - ) - evaluated += 1 - if self._timed_out(deadline): - return self._failure( - SolverStatus.TIMEOUT, - evaluated, - "timeout", - ) - infimum_only = bool(strict_failures) and all(strict_failures) - certified_lower = min(lower, best.distance) - certificate = OptimalityCertificate.create( - distance_lower_bound=certified_lower, - distance_upper_bound=best.distance, - radial_geometry_error=bracket.radial_geometry_error, - numeric_error_bound=self.config.numeric_tolerance, - disk_segments=segments, - infimum_only=infimum_only, - ) - return CertifiedSolveResult.success( - subject_position=best.position, - score=best.score, - quality=best.verification.quality, - evaluated_candidates=evaluated, - certificate=certificate, - tolerance=self.config.optimality_tolerance, - leakage_count=best.verification.leakage_count, - relation_diff=best.verification.changed_relations, - spec=spec, - ) - segments = ( - self._next_disk_segments( - segments, - bracket.radial_geometry_error, - ) - if best is not None - else segments * 2 - ) - - return self._failure( - SolverStatus.UNCERTIFIED, - evaluated, - ( - "optimality_gap_not_closed" - if best is not None - else "no_verified_inner_candidate" - ), - ) diff --git a/src/spatialcf/solver/objective.py b/src/spatialcf/solver/objective.py deleted file mode 100644 index 3a2175f..0000000 --- a/src/spatialcf/solver/objective.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Normalized objective terms for verified spatial counterfactuals.""" - -import math -from dataclasses import dataclass - -from shapely.geometry import Polygon - -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import InterventionSpec, Scene -from spatialcf.geometry.obb import ( - OBB_INTERSECTION_Z_OVERLAP_TOLERANCE, - obb_footprint, - obb_z_overlap_depth, -) -from spatialcf.relations.engine import RelationEngine - - -@dataclass(frozen=True) -class ObjectiveBreakdown: - """The normalized, weighted cost of one accepted intervention.""" - - normalized_translation: float - leakage: float - visibility_change: float - inverse_safety_margin: float - total: float - - @property - def relation_damage(self) -> float: - """Normalized collateral relation cost (legacy name: leakage).""" - return self.leakage - - -@dataclass(frozen=True) -class ObjectiveWeights: - """Explicit weights for the fixed-camera minimum-total-cost objective.""" - - translation: float = 1.0 - relation_damage: float = 5.0 - visibility_change: float = 2.0 - inverse_safety_margin: float = 1.0 - - def __post_init__(self) -> None: - values = ( - self.translation, - self.relation_damage, - self.visibility_change, - self.inverse_safety_margin, - ) - if any(type(value) is not float for value in values): - raise ValueError("objective weights must be exact floats") - if any(not math.isfinite(value) or value < 0.0 for value in values): - raise ValueError("objective weights must be finite and non-negative") - if not any(value > 0.0 for value in values): - raise ValueError("at least one objective weight must be positive") - - -DEFAULT_OBJECTIVE_WEIGHTS = ObjectiveWeights() - - -def visibility_change(before: Scene, after: Scene, spec: InterventionSpec) -> float: - """Return the largest normalized rendered-view change for the query pair.""" - def relative(old: float, new: float) -> float: - return abs(old - new) / max(abs(old), 1e-6) - - deltas: list[float] = [] - for object_id in (spec.subject_id, spec.reference_id): - old = before.object_by_id(object_id).views[spec.camera_id] - new = after.object_by_id(object_id).views[spec.camera_id] - deltas.extend(( - relative(old.visible_fraction, new.visible_fraction), - relative(old.image_area_fraction, new.image_area_fraction), - relative(old.truncated_fraction, new.truncated_fraction), - )) - return min(1.0, max(deltas)) - - -def inverse_safety_margin( - scene: Scene, - spec: InterventionSpec, - engine: RelationEngine, -) -> float: - """Penalize candidates with limited geometric or target-relation slack.""" - subject = scene.object_by_id(spec.subject_id) - footprint = obb_footprint(subject.obb) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - clearances = [footprint.distance(room.boundary)] - clearances.extend( - footprint.distance(obb_footprint(obj.obb)) - for obj in scene.objects - if obj.object_id not in {spec.subject_id, subject.support_object_id} - and obb_z_overlap_depth(subject.obb, obj.obb) - > OBB_INTERSECTION_Z_OVERLAP_TOLERANCE - ) - for obstacle in scene.collision_obstacles: - conservative_obb = obstacle.conservative_obb() - if ( - obb_z_overlap_depth(subject.obb, conservative_obb) - > OBB_INTERSECTION_Z_OVERLAP_TOLERANCE - ): - clearances.append( - footprint.distance(obb_footprint(conservative_obb)) - ) - xs = [point.x for point in scene.room_polygon_xy] - ys = [point.y for point in scene.room_polygon_xy] - room_diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) - normalized_clearance = min(clearances) / max(0.02 * room_diagonal, 0.10) - - target = engine.observe( - scene, - spec.subject_id, - spec.reference_id, - spec.relation_after, - spec.camera_id, - ) - camera = scene.camera_by_id(spec.camera_id) - relation_scale = { - Relation.LEFT: camera.width * engine.LEFT_RIGHT_FRACTION, - Relation.RIGHT: camera.width * engine.LEFT_RIGHT_FRACTION, - Relation.FRONT: engine.FRONT_BEHIND_METERS, - Relation.BEHIND: engine.FRONT_BEHIND_METERS, - Relation.NEAR: engine.NEAR_METERS, - Relation.FAR: engine.FAR_METERS, - }[spec.relation_after] - normalized_relation_margin = target.margin / max(relation_scale, 1e-9) - safety = min(normalized_clearance, normalized_relation_margin) - return 1.0 / (1.0 + max(0.0, safety)) - - -def objective_score( - normalized_translation: float, - leakage: float, - visibility_change: float, - inverse_safety_margin: float, - *, - weights: ObjectiveWeights = DEFAULT_OBJECTIVE_WEIGHTS, -) -> ObjectiveBreakdown: - """Apply the approved normalized objective weights.""" - total = ( - weights.translation * normalized_translation - + weights.relation_damage * leakage - + weights.visibility_change * visibility_change - + weights.inverse_safety_margin * inverse_safety_margin - ) - return ObjectiveBreakdown( - normalized_translation, - leakage, - visibility_change, - inverse_safety_margin, - total, - ) - - -def score_candidate( - before: Scene, - after: Scene, - spec: InterventionSpec, - leakage_count: int, - engine: RelationEngine, - weights: ObjectiveWeights = DEFAULT_OBJECTIVE_WEIGHTS, -) -> ObjectiveBreakdown: - """Compute objective terms using the original scene as the normalization base.""" - old = before.object_by_id(spec.subject_id).position - new = after.object_by_id(spec.subject_id).position - xs = [point.x for point in before.room_polygon_xy] - ys = [point.y for point in before.room_polygon_xy] - room_diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) - non_target_pair_count = max( - 1, - len(before.objects) * (len(before.objects) - 1) // 2 - 1, - ) - return objective_score( - normalized_translation=math.hypot(new.x - old.x, new.y - old.y) - / max(room_diagonal, 1e-9), - leakage=min(1.0, leakage_count / non_target_pair_count), - visibility_change=visibility_change(before, after, spec), - inverse_safety_margin=inverse_safety_margin(after, spec, engine), - weights=weights, - ) - - -def score_minimum_cost_candidate( - before: Scene, - after: Scene, - spec: InterventionSpec, - relation_damage_count: int, - engine: RelationEngine, - weights: ObjectiveWeights = DEFAULT_OBJECTIVE_WEIGHTS, -) -> ObjectiveBreakdown: - """Score a hard-valid candidate using unordered pair-axis damage. - - The requested target axis is excluded from the denominator because changing - it is the intervention itself. Reciprocal directed labels are represented by - one unordered pair-axis slot and therefore cannot be double-counted. - """ - # Only the subject moves in the canonical problem, so normalizing by every - # stationary-stationary pair would make identical damage artificially cheap - # in larger scenes. There are three axes for each subject/other pair, minus - # the requested target axis whose change is mandatory rather than damage. - soft_relation_axis_count = max(1, 3 * (len(before.objects) - 1) - 1) - old = before.object_by_id(spec.subject_id).position - new = after.object_by_id(spec.subject_id).position - xs = [point.x for point in before.room_polygon_xy] - ys = [point.y for point in before.room_polygon_xy] - room_diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) - return objective_score( - normalized_translation=math.hypot(new.x - old.x, new.y - old.y) - / max(room_diagonal, 1e-9), - leakage=min(1.0, relation_damage_count / soft_relation_axis_count), - visibility_change=visibility_change(before, after, spec), - inverse_safety_margin=inverse_safety_margin(after, spec, engine), - weights=weights, - ) diff --git a/src/spatialcf/solver/search.py b/src/spatialcf/solver/search.py deleted file mode 100644 index b935219..0000000 --- a/src/spatialcf/solver/search.py +++ /dev/null @@ -1,901 +0,0 @@ -"""Deterministic coarse-to-fine search over a verified feasible region.""" - -import math -import time -from dataclasses import dataclass -from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal -from typing import Generic, TypeVar - -from shapely.geometry import Point -from shapely.geometry.base import BaseGeometry -from shapely.ops import nearest_points - -from spatialcf.adapters.base import XYSceneTransformer -from spatialcf.domain.enums import QualityTier, SolverStatus -from spatialcf.domain.models import InterventionSpec, Scene, Vec3 -from spatialcf.solver.analytic_motion import CandidateProjectionError -from spatialcf.solver.execution import ( - CandidateExecution, - CandidateExecutionStatus, - CandidateExecutor, - ExecutionResidual, -) -from spatialcf.solver.feasible import FeasibleRegionBuilder -from spatialcf.solver.objective import ( - DEFAULT_OBJECTIVE_WEIGHTS, - ObjectiveBreakdown, - ObjectiveWeights, - score_candidate, - score_minimum_cost_candidate, -) -from spatialcf.verification.verifier import VerificationResult, Verifier - -MIN_SEARCH_STEP = 1e-9 -ExecutionEvidenceT = TypeVar("ExecutionEvidenceT") - - -@dataclass(frozen=True) -class SearchConfig: - seed: int = 20260723 - grid_step: float = 0.10 - refine_steps: tuple[float, ...] = (0.05, 0.01) - max_candidates: int = 10_000 - timeout_seconds: float | None = None - - def __post_init__(self) -> None: - if type(self.seed) is not int: - raise ValueError("seed must be an exact integer") - if type(self.grid_step) is not float: - raise ValueError("grid_step must be an exact float") - if ( - type(self.refine_steps) is not tuple - or any(type(step) is not float for step in self.refine_steps) - ): - raise ValueError("refine_steps must be a tuple of exact floats") - if type(self.max_candidates) is not int: - raise ValueError("max_candidates must be an exact integer") - if not math.isfinite(self.grid_step): - raise ValueError("grid_step must be finite") - if self.grid_step <= 0.0: - raise ValueError("grid_step must be positive") - if self.grid_step < MIN_SEARCH_STEP: - raise ValueError("grid_step must be at least 1e-9 metres") - if any(not math.isfinite(step) for step in self.refine_steps): - raise ValueError("refine_steps must be finite") - if any(step <= 0.0 for step in self.refine_steps): - raise ValueError("refine_steps must be positive") - if any(step < MIN_SEARCH_STEP for step in self.refine_steps): - raise ValueError("refine_steps must be at least 1e-9 metres") - if self.max_candidates <= 0: - raise ValueError("max_candidates must be positive") - if self.timeout_seconds is not None and ( - isinstance(self.timeout_seconds, bool) - or not isinstance(self.timeout_seconds, (int, float)) - or not math.isfinite(float(self.timeout_seconds)) - or self.timeout_seconds < 0.0 - ): - raise ValueError( - "timeout_seconds must be None or a finite non-negative value" - ) - - -@dataclass(frozen=True) -class SolveResult: - status: SolverStatus - subject_position: Vec3 | None - score: ObjectiveBreakdown | None - quality: QualityTier - evaluated_candidates: int - reason: str | None - relation_damage_count: int = 0 - relation_damage_items: tuple[str, ...] = () - - -@dataclass(frozen=True) -class GroundedCandidateAttempt: - """Compact audit record for one executed command from the proposal region.""" - - commanded_position: Vec3 - analytic_verification: VerificationResult - analytic_score: ObjectiveBreakdown | None - execution_status: CandidateExecutionStatus - execution_errors: tuple[str, ...] - execution_residuals: tuple[ExecutionResidual, ...] - observed_before_position: Vec3 | None - observed_position: Vec3 | None - observed_score: ObjectiveBreakdown | None - verification: VerificationResult | None - - -@dataclass(frozen=True) -class GroundedCandidate(Generic[ExecutionEvidenceT]): - """The selected command, its actual observation, and immutable evidence.""" - - attempt_index: int - subject_id: str - commanded_scene: Scene - observed_before_scene: Scene - observed_scene: Scene - analytic_score: ObjectiveBreakdown | None - observed_score: ObjectiveBreakdown - verification: VerificationResult - execution: CandidateExecution[ExecutionEvidenceT] - - @property - def commanded_position(self) -> Vec3: - return self.commanded_scene.object_by_id(self.subject_id).position - - -@dataclass(frozen=True) -class GroundedSolveResult(Generic[ExecutionEvidenceT]): - """Minimum observed cost over the finite executed episode-command pairs.""" - - status: SolverStatus - candidate: GroundedCandidate[ExecutionEvidenceT] | None - evaluated_candidates: int - executed_candidates: int - budget_exhausted: bool - attempts: tuple[GroundedCandidateAttempt, ...] - reason: str | None - - @property - def selected_attempt_index(self) -> int | None: - if self.candidate is None: - return None - return self.candidate.attempt_index - - @property - def commanded_position(self) -> Vec3 | None: - if self.candidate is None: - return None - return self.candidate.commanded_position - - @property - def observed_position(self) -> Vec3 | None: - if self.candidate is None: - return None - return self.candidate.observed_scene.object_by_id( - self.candidate.subject_id - ).position - - @property - def observed_before_position(self) -> Vec3 | None: - if self.candidate is None: - return None - return self.candidate.observed_before_scene.object_by_id( - self.candidate.subject_id - ).position - - @property - def score(self) -> ObjectiveBreakdown | None: - return None if self.candidate is None else self.candidate.observed_score - - @property - def quality(self) -> QualityTier: - if self.candidate is None: - return QualityTier.REJECTED - return self.candidate.verification.quality - - @property - def relation_damage_count(self) -> int: - if self.candidate is None: - return 0 - return self.candidate.verification.relation_damage_count - - @property - def relation_damage_items(self) -> tuple[str, ...]: - if self.candidate is None: - return () - return self.candidate.verification.relation_damage_items - - -class SpatialCFSolver: - """Search feasible X/Y translations and retain only verifier-approved edits.""" - - _LOCAL_OFFSETS: tuple[tuple[int, int], ...] = ( - (-1, -1), (-1, 0), (-1, 1), - (0, -1), (0, 0), (0, 1), - (1, -1), (1, 0), (1, 1), - ) - - def __init__( - self, - adapter: XYSceneTransformer, - config: SearchConfig, - verifier: Verifier | None = None, - ) -> None: - self.adapter = adapter - self.config = config - self.verifier = verifier or Verifier() - self.regions = FeasibleRegionBuilder() - - def _grid(self, bounds: tuple[float, float, float, float], step: float): - minx, miny, maxx, maxy = bounds - decimal_step = Decimal(str(step)) - - def indices(minimum: float, maximum: float) -> range: - minimum_index = int(( - Decimal(str(minimum)) / decimal_step - ).to_integral_value(rounding=ROUND_CEILING)) - maximum_index = int(( - Decimal(str(maximum)) / decimal_step - ).to_integral_value(rounding=ROUND_FLOOR)) - return range(minimum_index, maximum_index + 1) - - y_indices = indices(miny, maxy) - for x_index in indices(minx, maxx): - x = float(decimal_step * x_index) - for y_index in y_indices: - y = float(decimal_step * y_index) - yield x, y - - def _timed_out(self, started: float | None) -> bool: - return ( - started is not None - and self.config.timeout_seconds is not None - and time.monotonic() - started >= self.config.timeout_seconds - ) - - def _initial_candidates( - self, - region: BaseGeometry, - scene: Scene, - spec: InterventionSpec, - ) -> tuple[tuple[float, float], ...]: - return () - - def _result( - self, - status: SolverStatus, - evaluated: int, - reason: str, - ) -> SolveResult: - return SolveResult(status, None, None, QualityTier.REJECTED, evaluated, reason) - - def _verify_candidate( - self, - scene: Scene, - candidate: Scene, - spec: InterventionSpec, - ) -> VerificationResult: - return self.verifier.verify(scene, candidate, spec) - - def _score_candidate( - self, - scene: Scene, - candidate: Scene, - spec: InterventionSpec, - verification: VerificationResult, - ) -> ObjectiveBreakdown: - return score_candidate( - scene, - candidate, - spec, - verification.leakage_count, - self.verifier.engine, - ) - - @staticmethod - def _candidate_key( - score: ObjectiveBreakdown, - x: float, - y: float, - ) -> tuple[float, float, float, float]: - return score.normalized_translation, score.total, x, y - - def solve(self, scene: Scene, spec: InterventionSpec) -> SolveResult: - if scene.children_by_support().get(spec.subject_id): - return self._result( - SolverStatus.INVALID_SCENE, - 0, - "subject_has_supported_objects", - ) - started = ( - time.monotonic() - if self.config.timeout_seconds is not None - else None - ) - region = self.regions.build(scene, spec) - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, 0, "timeout") - if region.is_empty: - return self._result(SolverStatus.UNSATISFIABLE, 0, "empty_region") - - before = scene.object_by_id(spec.subject_id) - best: tuple[ - tuple[float, float, float, float], - Vec3, - ObjectiveBreakdown, - VerificationResult, - ] | None = None - evaluated = 0 - - def evaluate(x: float, y: float) -> None: - nonlocal best, evaluated - evaluated += 1 - try: - candidate = self.adapter.with_object_xy( - scene, spec.subject_id, x, y - ) - except CandidateProjectionError: - return - verification = self._verify_candidate(scene, candidate, spec) - if verification.status is not SolverStatus.SUCCESS: - return - score = self._score_candidate(scene, candidate, spec, verification) - key = self._candidate_key(score, x, y) - position = Vec3(x=x, y=y, z=before.position.z) - if best is None or key < best[0]: - best = (key, position, score, verification) - - seeded_positions: set[tuple[float, float]] = set() - for x, y in self._initial_candidates(region, scene, spec): - if evaluated >= self.config.max_candidates: - break - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - if region.covers(Point(x, y)): - seeded_positions.add((x, y)) - evaluate(x, y) - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - - for x, y in self._grid(region.bounds, self.config.grid_step): - if evaluated >= self.config.max_candidates: - break - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - if (x, y) not in seeded_positions and region.covers(Point(x, y)): - evaluate(x, y) - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - - if best is None: - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - return self._result( - SolverStatus.UNSATISFIABLE, - evaluated, - "no_verified_candidate", - ) - - for step in self.config.refine_steps: - _, center, _, _ = best - decimal_step = Decimal(str(step)) - local_positions: set[tuple[float, float]] = set() - for dx, dy in self._LOCAL_OFFSETS: - if evaluated >= self.config.max_candidates: - break - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - x = float(Decimal(str(center.x)) + decimal_step * dx) - y = float(Decimal(str(center.y)) + decimal_step * dy) - if (x, y) in local_positions: - continue - local_positions.add((x, y)) - if ( - (x, y) not in seeded_positions - and region.covers(Point(x, y)) - ): - evaluate(x, y) - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - if evaluated >= self.config.max_candidates: - break - - if self._timed_out(started): - return self._result(SolverStatus.TIMEOUT, evaluated, "timeout") - verification = best[3] - return SolveResult( - SolverStatus.SUCCESS, - best[1], - best[2], - verification.quality, - evaluated, - None, - verification.relation_damage_count, - verification.relation_damage_items, - ) - - -class MinimumCostSpatialCFSolver(SpatialCFSolver): - """Minimize weighted cost over hard-valid, fixed-camera XY interventions. - - Unlike :class:`SpatialCFSolver`, collateral relation changes are measured and - priced rather than treated as hard infeasibility. Structural validity, - physical constraints, query visibility, and the requested relation flip stay - hard requirements. - """ - - def __init__( - self, - adapter: XYSceneTransformer, - config: SearchConfig, - verifier: Verifier | None = None, - *, - objective_weights: ObjectiveWeights = DEFAULT_OBJECTIVE_WEIGHTS, - ) -> None: - super().__init__(adapter, config, verifier) - if type(objective_weights) is not ObjectiveWeights: - raise ValueError("objective_weights must be ObjectiveWeights") - self.objective_weights = objective_weights - - def _verify_candidate( - self, - scene: Scene, - candidate: Scene, - spec: InterventionSpec, - ) -> VerificationResult: - return self.verifier.verify_minimum_cost(scene, candidate, spec) - - def _initial_candidates( - self, - region: BaseGeometry, - scene: Scene, - spec: InterventionSpec, - ) -> tuple[tuple[float, float], ...]: - subject = scene.object_by_id(spec.subject_id) - origin = Point(subject.position.x, subject.position.y) - components = tuple(getattr(region, "geoms", (region,))) - components = tuple(sorted(components, key=lambda item: item.bounds)) - points = [nearest_points(origin, region)[1]] - points.extend(component.representative_point() for component in components) - unique: list[tuple[float, float]] = [] - seen: set[tuple[float, float]] = set() - for point in points: - candidate = float(point.x), float(point.y) - if candidate not in seen: - seen.add(candidate) - unique.append(candidate) - return tuple(unique) - - def _score_candidate( - self, - scene: Scene, - candidate: Scene, - spec: InterventionSpec, - verification: VerificationResult, - ) -> ObjectiveBreakdown: - return score_minimum_cost_candidate( - scene, - candidate, - spec, - verification.relation_damage_count, - self.verifier.engine, - self.objective_weights, - ) - - @staticmethod - def _candidate_key( - score: ObjectiveBreakdown, - x: float, - y: float, - ) -> tuple[float, float, float, float]: - return score.total, score.normalized_translation, x, y - - @staticmethod - def _grounded_position_only_command( - scene: Scene, - subject_id: str, - x: float, - y: float, - ) -> Scene: - """Move only canonical XY geometry when analytic projection is unavailable.""" - subject = scene.object_by_id(subject_id) - delta_x = x - subject.position.x - delta_y = y - subject.position.y - moved = subject.model_copy( - update={ - "position": subject.position.model_copy(update={"x": x, "y": y}), - "obb": subject.obb.model_copy( - update={ - "center": subject.obb.center.model_copy( - update={ - "x": subject.obb.center.x + delta_x, - "y": subject.obb.center.y + delta_y, - } - ) - } - ), - } - ) - return scene.model_copy( - update={ - "objects": tuple( - moved if obj.object_id == subject_id else obj - for obj in scene.objects - ) - } - ) - - def _episode_baseline_errors( - self, - nominal: Scene, - observed: Scene, - spec: InterventionSpec, - ) -> tuple[str, ...]: - """Require one fresh episode to preserve nominal source semantics. - - Physics engines may settle continuous geometry and rendered view - fractions slightly differently across isolated episodes. Those values - belong to the episode and are compared only to its own after-scene. - Candidate costs remain comparable only when fixed camera, structure, - support, and the complete discrete relation graph still match the - nominal planning scene. - """ - errors: list[str] = [] - for field in ( - "scene_id", - "source", - "coordinate_system", - "generation_seed", - "pinned_object_ids", - ): - if getattr(nominal, field) != getattr(observed, field): - errors.append(f"episode_{field}_changed") - if nominal.room_polygon_xy != observed.room_polygon_xy: - errors.append("episode_room_changed") - if nominal.cameras != observed.cameras: - errors.append("episode_camera_changed") - if nominal.collision_obstacles != observed.collision_obstacles: - errors.append("episode_collision_obstacles_changed") - if nominal.subject_position_regions != observed.subject_position_regions: - errors.append("episode_subject_position_regions_changed") - - nominal_ids = {obj.object_id for obj in nominal.objects} - observed_ids = {obj.object_id for obj in observed.objects} - if nominal_ids != observed_ids: - errors.append("episode_object_set_changed") - return tuple(sorted(set(errors))) - - for expected in nominal.objects: - current = observed.object_by_id(expected.object_id) - if any( - getattr(expected, field) != getattr(current, field) - for field in ( - "name", - "category", - "movable", - "request_eligible", - "support_object_id", - ) - ): - errors.append( - f"episode_object_structure_changed:{expected.object_id}" - ) - - try: - nominal.camera_by_id(spec.camera_id) - observed.camera_by_id(spec.camera_id) - observed.object_by_id(spec.subject_id) - observed.object_by_id(spec.reference_id) - source = self.verifier.engine.observe( - observed, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - relation_graph_changed = any( - self.verifier.engine.pair_labels( - nominal, - first.object_id, - second.object_id, - spec.camera_id, - ) - != self.verifier.engine.pair_labels( - observed, - first.object_id, - second.object_id, - spec.camera_id, - ) - for first in nominal.objects - for second in nominal.objects - if first.object_id != second.object_id - ) - except (KeyError, TypeError, ValueError): - errors.append("episode_relation_graph_invalid") - else: - if relation_graph_changed: - errors.append("episode_relation_graph_changed") - if not source.satisfied: - errors.append("episode_source_relation_not_satisfied") - return tuple(sorted(set(errors))) - - def solve_grounded( - self, - scene: Scene, - spec: InterventionSpec, - executor: CandidateExecutor[ExecutionEvidenceT], - ) -> GroundedSolveResult[ExecutionEvidenceT]: - """Search with an external execution model inside candidate evaluation. - - Every unique command in the analytic proposal region is executed. An - analytic verifier failure is recorded but does not veto execution: the - returned canonical episode before/after pair is independently verified - and scored by the core. Search therefore selects the minimum *observed* - cost over the executed finite candidate set; it never stops at the first - native success and does not claim a continuous platform optimum or a - deterministic command-level optimum across stochastic episodes. - """ - - attempts: list[GroundedCandidateAttempt] = [] - evaluated = 0 - - def result( - status: SolverStatus, - reason: str | None, - candidate: GroundedCandidate[ExecutionEvidenceT] | None = None, - ) -> GroundedSolveResult[ExecutionEvidenceT]: - return GroundedSolveResult( - status=status, - candidate=candidate, - evaluated_candidates=evaluated, - executed_candidates=len(attempts), - budget_exhausted=evaluated >= self.config.max_candidates, - attempts=tuple(attempts), - reason=reason, - ) - - if scene.children_by_support().get(spec.subject_id): - return result(SolverStatus.INVALID_SCENE, "subject_has_supported_objects") - started = ( - time.monotonic() - if self.config.timeout_seconds is not None - else None - ) - region = self.regions.build(scene, spec) - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - if region.is_empty: - return result(SolverStatus.UNSATISFIABLE, "empty_region") - - best: tuple[ - tuple[float, float, float, float, float, float], - GroundedCandidate[ExecutionEvidenceT], - ] | None = None - proposal_best: tuple[ - tuple[float, float, float], - Vec3, - ] | None = None - fatal_error: str | None = None - seen_positions: set[tuple[float, float]] = set() - - def evaluate(x: float, y: float) -> None: - nonlocal best, evaluated, fatal_error, proposal_best - position_key = (x, y) - if position_key in seen_positions: - return - seen_positions.add(position_key) - evaluated += 1 - try: - commanded = self.adapter.with_object_xy( - scene, - spec.subject_id, - x, - y, - ) - except CandidateProjectionError: - commanded = self._grounded_position_only_command( - scene, - spec.subject_id, - x, - y, - ) - analytic_verification = VerificationResult( - SolverStatus.UNCERTIFIED, - QualityTier.REJECTED, - 0, - (), - ("analytic_projection_failed",), - ) - else: - analytic_verification = self._verify_candidate( - scene, - commanded, - spec, - ) - commanded_position = commanded.object_by_id(spec.subject_id).position - source_position = scene.object_by_id(spec.subject_id).position - proposal_key = ( - math.hypot(x - source_position.x, y - source_position.y), - x, - y, - ) - if proposal_best is None or proposal_key < proposal_best[0]: - proposal_best = (proposal_key, commanded_position) - analytic_score = ( - self._score_candidate( - scene, - commanded, - spec, - analytic_verification, - ) - if analytic_verification.status is SolverStatus.SUCCESS - else None - ) - - try: - execution = executor.execute_candidate(scene, commanded, spec) - if not isinstance(execution, CandidateExecution): - raise TypeError("executor did not return CandidateExecution") - if execution.commanded_scene != commanded: - execution = CandidateExecution.error( - commanded, - ("executor_commanded_scene_mismatch",), - ) - # An executor is an external-platform boundary. Any ordinary - # exception means its state/evidence is no longer trustworthy, so - # convert it to fail-stop UNCERTIFIED rather than leaking it or - # continuing with a possibly corrupted platform. BaseException - # (interrupts and process termination) is deliberately not caught. - except Exception as error: # noqa: BLE001 - execution = CandidateExecution.error( - commanded, - (f"executor_exception:{type(error).__name__}:{error}",), - ) - - episode_before = execution.observed_before_scene - if execution.status is not CandidateExecutionStatus.ERROR: - if episode_before is None: # Defensive against invalid executors. - execution = CandidateExecution.error( - commanded, - ("episode_before_scene_missing",), - evidence=execution.evidence, - ) - else: - episode_errors = self._episode_baseline_errors( - scene, - episode_before, - spec, - ) - if episode_errors: - execution = CandidateExecution.error( - commanded, - episode_errors, - evidence=execution.evidence, - ) - episode_before = execution.observed_before_scene - observed = execution.observed_scene - observed_before_position: Vec3 | None = None - observed_position: Vec3 | None = None - if episode_before is not None: - try: - observed_before_position = episode_before.object_by_id( - spec.subject_id - ).position - except KeyError: - observed_before_position = None - if observed is not None: - try: - observed_position = observed.object_by_id( - spec.subject_id - ).position - except KeyError: - observed_position = None - observed_score: ObjectiveBreakdown | None = None - observed_verification: VerificationResult | None = None - selected: GroundedCandidate[ExecutionEvidenceT] | None = None - if execution.status is CandidateExecutionStatus.ERROR: - fatal_error = "executor_error:" + ";".join(execution.errors) - elif execution.status is CandidateExecutionStatus.OBSERVED: - if episode_before is None or observed is None: - fatal_error = "executor_error:episode_observation_missing" - else: - observed_verification = self._verify_candidate( - episode_before, - observed, - spec, - ) - if observed_verification.status is SolverStatus.SUCCESS: - observed_score = self._score_candidate( - episode_before, - observed, - spec, - observed_verification, - ) - selected = GroundedCandidate( - attempt_index=len(attempts), - subject_id=spec.subject_id, - commanded_scene=commanded, - observed_before_scene=episode_before, - observed_scene=observed, - analytic_score=analytic_score, - observed_score=observed_score, - verification=observed_verification, - execution=execution, - ) - - attempts.append( - GroundedCandidateAttempt( - commanded_position=commanded_position, - analytic_verification=analytic_verification, - analytic_score=analytic_score, - execution_status=execution.status, - execution_errors=execution.errors, - execution_residuals=execution.residuals, - observed_before_position=observed_before_position, - observed_position=observed_position, - observed_score=observed_score, - verification=observed_verification, - ) - ) - if selected is not None and observed_position is not None: - grounded_key = ( - selected.observed_score.total, - selected.observed_score.normalized_translation, - x, - y, - observed_position.x, - observed_position.y, - ) - if best is None or grounded_key < best[0]: - best = (grounded_key, selected) - - def consider(x: float, y: float) -> None: - if ( - fatal_error is None - and evaluated < self.config.max_candidates - and (x, y) not in seen_positions - and region.covers(Point(x, y)) - ): - evaluate(x, y) - - for x, y in self._initial_candidates(region, scene, spec): - if fatal_error is not None or evaluated >= self.config.max_candidates: - break - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - consider(x, y) - if fatal_error is not None: - return result(SolverStatus.UNCERTIFIED, fatal_error) - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - - for x, y in self._grid(region.bounds, self.config.grid_step): - if fatal_error is not None or evaluated >= self.config.max_candidates: - break - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - consider(x, y) - if fatal_error is not None: - return result(SolverStatus.UNCERTIFIED, fatal_error) - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - - if fatal_error is not None: - return result(SolverStatus.UNCERTIFIED, fatal_error) - - for step in self.config.refine_steps: - if best is not None: - center = best[1].commanded_position - elif proposal_best is not None: - center = proposal_best[1] - else: - break - decimal_step = Decimal(str(step)) - for dx, dy in self._LOCAL_OFFSETS: - if fatal_error is not None or evaluated >= self.config.max_candidates: - break - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - x = float(Decimal(str(center.x)) + decimal_step * dx) - y = float(Decimal(str(center.y)) + decimal_step * dy) - consider(x, y) - if fatal_error is not None: - return result(SolverStatus.UNCERTIFIED, fatal_error) - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - if fatal_error is not None: - return result(SolverStatus.UNCERTIFIED, fatal_error) - if evaluated >= self.config.max_candidates: - break - - if self._timed_out(started): - return result(SolverStatus.TIMEOUT, "timeout") - if best is None: - return result( - SolverStatus.UNCERTIFIED, - "no_grounded_candidate_within_budget", - ) - return result(SolverStatus.SUCCESS, None, best[1]) diff --git a/src/spatialcf/solver/stress/__init__.py b/src/spatialcf/solver/stress/__init__.py deleted file mode 100644 index 4668e6b..0000000 --- a/src/spatialcf/solver/stress/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Deterministic, solver-independent stress-case contracts.""" - -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCase, - StressCaseDraft, - StressDirection, - StressFamily, - StressOracle, - StressOracleResult, - StressProfileName, - StressSlot, - StressTransform, - UnsatStressOracle, - expected_oracle_result, -) -from spatialcf.solver.stress.profiles import stress_slots -from spatialcf.solver.stress.sampling import HashSampler - -__all__ = ( - "HashSampler", - "SatStressOracle", - "StressCase", - "StressCaseDraft", - "StressDirection", - "StressFamily", - "StressOracle", - "StressOracleResult", - "StressProfileName", - "StressSlot", - "StressTransform", - "UnsatStressOracle", - "expected_oracle_result", - "stress_slots", -) diff --git a/src/spatialcf/solver/stress/cases.py b/src/spatialcf/solver/stress/cases.py deleted file mode 100644 index 2edcd9c..0000000 --- a/src/spatialcf/solver/stress/cases.py +++ /dev/null @@ -1,265 +0,0 @@ -"""Deterministic assembly and identity binding for solver stress cases.""" - -from __future__ import annotations - -import hashlib -import json -import re -from collections.abc import Callable -from decimal import Decimal -from functools import cache - -from spatialcf.domain.models import Vec2 -from spatialcf.solver.stress.families_depth import build_depth_case -from spatialcf.solver.stress.families_distance import build_distance_case -from spatialcf.solver.stress.families_horizontal import build_horizontal_case -from spatialcf.solver.stress.models import ( - StressCase, - StressCaseDraft, - StressDirection, - StressProfileName, - StressSlot, - StressTransform, -) -from spatialcf.solver.stress.profiles import ( - DEEP_SEEDS, - QUICK_SEEDS, - STRESS_DIRECTIONS, - stress_slots, -) -from spatialcf.solver.stress.sampling import HashSampler -from spatialcf.solver.stress.scene_factory import ( - StressCaseError, - validate_before_case, - validate_before_draft, -) -from spatialcf.solver.stress.transforms import apply_stress_transform - -_Builder = Callable[[StressSlot, int], StressCaseDraft] -_BUILDERS: dict[StressDirection, _Builder] = { - "lr": build_horizontal_case, - "fb": build_depth_case, - "nf": build_distance_case, -} - - -class StressGenerationError(ValueError): - """The frozen schedule could not be generated without weakening a gate.""" - - -def _canonical_json_bytes(value: object) -> bytes: - return ( - json.dumps( - value, - allow_nan=False, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ) - + "\n" - ).encode("utf-8") - - -def _placeholder_digest(draft: StressCaseDraft) -> str: - payload = draft.model_dump(mode="json") - scene = payload["scene"] - if not isinstance(scene, dict): - raise StressGenerationError("stress draft scene payload is not an object") - scene["scene_id"] = "__CASE_ID__" - return hashlib.sha256(_canonical_json_bytes(payload)).hexdigest() - - -def _sample_transform( - slot: StressSlot, - attempt: int, - base_case_digest: str, -) -> StressTransform: - sampler = HashSampler(f"{slot.key}/{attempt}/transform") - translation = Vec2( - x=sampler.grid( - "translation_x", - Decimal("-2.00"), - Decimal("2.00"), - Decimal("0.25"), - ), - y=sampler.grid( - "translation_y", - Decimal("-2.00"), - Decimal("2.00"), - Decimal("0.25"), - ), - ) - if slot.expected_outcome == "UNSAT" and slot.direction in {"lr", "fb"}: - translation = Vec2(x=0.0, y=0.0) - mirror = sampler.choice( - "mirror", - ("none", "camera_horizontal", "camera_depth"), - ) - rotation = sampler.choice("rotation", (0, 90, 180, 270)) - transform = StressTransform( - translation_xy=translation, - mirror=mirror, - rotation_degrees=rotation, - base_case_digest=base_case_digest, - ) - if not transform.transformed: - transform = transform.model_copy( - update={ - "mirror": ( - "camera_horizontal" - if slot.expected_outcome == "UNSAT" - and slot.direction in {"lr", "fb"} - else transform.mirror - ), - "translation_xy": ( - transform.translation_xy - if slot.expected_outcome == "UNSAT" - and slot.direction in {"lr", "fb"} - else Vec2(x=0.25, y=0.0) - ), - } - ) - return transform - - -def _require_base_matches_slot(base: StressCaseDraft, slot: StressSlot) -> None: - if ( - base.seed, - base.direction, - base.raw_slot, - base.family, - base.expected_outcome, - ) != ( - slot.seed, - slot.direction, - slot.raw_slot, - slot.family, - slot.expected_outcome, - ): - raise StressGenerationError( - f"builder result does not match requested stress slot: {slot.key}" - ) - if base.transform != StressTransform(): - raise StressGenerationError( - f"builder result must use the identity base transform: {slot.key}" - ) - - -def _first_valid_draft(slot: StressSlot) -> StressCaseDraft: - builder = _BUILDERS[slot.direction] - for attempt in range(64): - try: - base = builder(slot, attempt) - _require_base_matches_slot(base, slot) - validate_before_draft(base) - if not slot.transformed: - return base - base_digest = _placeholder_digest(base) - transformed = apply_stress_transform( - base, - _sample_transform(slot, attempt, base_digest), - ) - validate_before_draft(transformed) - except StressCaseError: - continue - return transformed - raise StressGenerationError(f"no valid stress draft after 64 attempts: {slot.key}") - - -def _bind_case_id(draft: StressCaseDraft, case_id: str) -> StressCase: - scene = draft.scene.model_copy(update={"scene_id": case_id}) - case = StressCase( - case_id=case_id, - scene=scene, - **draft.model_dump(mode="python", exclude={"scene"}), - ) - validate_before_case(case) - return case - - -def _generate_direction_group_cases( - seed: int, - direction: StressDirection, -) -> tuple[StressCase, ...]: - if isinstance(seed, bool) or seed not in DEEP_SEEDS: - raise StressGenerationError(f"unknown stress seed: {seed!r}") - if direction not in STRESS_DIRECTIONS: - raise StressGenerationError(f"unknown stress direction: {direction!r}") - slots = tuple( - slot - for slot in stress_slots("deep") - if slot.seed == seed and slot.direction == direction - ) - drafts = tuple(_first_valid_draft(slot) for slot in slots) - digests = tuple(_placeholder_digest(draft) for draft in drafts) - if len(digests) != 100 or len(set(digests)) != 100: - raise StressGenerationError("stress direction group has non-unique digests") - ordered = sorted(zip(digests, drafts, strict=True), key=lambda item: item[0]) - return tuple( - _bind_case_id( - draft, - f"stress-{seed}-{direction}-{index:03d}", - ) - for index, (_, draft) in enumerate(ordered) - ) - - -@cache -def _cached_direction_group_payloads( - seed: int, - direction: StressDirection, -) -> tuple[bytes, ...]: - return tuple( - _canonical_json_bytes(case.model_dump(mode="json")) - for case in _generate_direction_group_cases(seed, direction) - ) - - -def generate_direction_group( - seed: int, - direction: StressDirection, -) -> tuple[StressCase, ...]: - """Generate a fresh model view of one cached, immutable 100-case group.""" - if isinstance(seed, bool) or seed not in DEEP_SEEDS: - raise StressGenerationError(f"unknown stress seed: {seed!r}") - if direction not in STRESS_DIRECTIONS: - raise StressGenerationError(f"unknown stress direction: {direction!r}") - return tuple( - StressCase.model_validate_json(payload) - for payload in _cached_direction_group_payloads(seed, direction) - ) - - -def generate_stress_cases(profile: StressProfileName) -> tuple[StressCase, ...]: - """Generate one exact frozen quick or deep profile in schedule order.""" - if profile == "quick": - seeds = QUICK_SEEDS - elif profile == "deep": - seeds = DEEP_SEEDS - else: - raise StressGenerationError(f"unknown stress profile: {profile!r}") - return tuple( - case - for seed in seeds - for direction in STRESS_DIRECTIONS - for case in generate_direction_group(seed, direction) - ) - - -def replay_stress_case(case_id: str) -> StressCase: - """Regenerate and return exactly one frozen stress case by public ID.""" - if not isinstance(case_id, str): - raise StressGenerationError("stress case ID must be a string") - match = re.fullmatch(r"stress-(\d{10})-(lr|fb|nf)-(\d{3})", case_id) - if match is None: - raise StressGenerationError(f"invalid stress case ID: {case_id!r}") - seed = int(match.group(1)) - direction = match.group(2) - index = int(match.group(3)) - if seed not in DEEP_SEEDS or index >= 100: - raise StressGenerationError(f"unknown stress case ID: {case_id!r}") - payloads = _cached_direction_group_payloads(seed, direction) # type: ignore[arg-type] - case = StressCase.model_validate_json(payloads[index]) - if case.case_id != case_id: - raise StressGenerationError(f"stress replay identity mismatch: {case_id!r}") - return case diff --git a/src/spatialcf/solver/stress/families_depth.py b/src/spatialcf/solver/stress/families_depth.py deleted file mode 100644 index 4d14b74..0000000 --- a/src/spatialcf/solver/stress/families_depth.py +++ /dev/null @@ -1,524 +0,0 @@ -"""Independent analytic constructors for depth stress families.""" - -from __future__ import annotations - -import math -from decimal import Decimal - -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import Camera, InterventionSpec, SceneObject, Vec2, Vec3 -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCaseDraft, - StressSlot, - UnsatStressOracle, -) -from spatialcf.solver.stress.sampling import HashSampler -from spatialcf.solver.stress.scene_factory import ( - StressCaseError, - make_camera, - make_object, - make_scene, -) - -# Frozen from the public relation-label boundary contract; constructors derive -# their oracle geometry without importing production relation code. -_OPERATIONAL_COMPARISON_TOLERANCE = 1e-9 - - -def _target_boundary_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - sampler = HashSampler(f"{slot.key}/{attempt}") - focal_px = sampler.choice("focal_px", (40.0, 50.0, 80.0, 100.0)) - depth_offset = sampler.grid( - "depth_offset", - Decimal("2.00"), - Decimal("4.00"), - Decimal("0.25"), - ) - subject_extent = sampler.choice("subject_extent", (0.2, 0.4, 0.6)) - reference_extent = sampler.choice("reference_extent", (0.2, 0.4, 0.6)) - subject_yaw = sampler.choice("subject_yaw", (0, 15, 30, 45, 60, 75)) - reference_yaw = sampler.choice("reference_yaw", (0, 30, 45, 60, 90)) - camera = make_camera(focal_px=focal_px, depth_offset=depth_offset) - subject = make_object( - "subject", - position=Vec3(x=-4.0, y=-0.5, z=0.7), - extent=Vec3(x=subject_extent, y=subject_extent, z=1.0), - yaw_degrees=subject_yaw, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=0.0, y=0.5, z=0.7), - extent=Vec3(x=reference_extent, y=reference_extent, z=1.0), - yaw_degrees=reference_yaw, - movable=False, - camera=camera, - ) - witness_y = 0.7 - _OPERATIONAL_COMPARISON_TOLERANCE - return _draft( - slot, - attempt, - room_bounds=(-4.5, -1.0, 4.5, 2.0), - objects=(subject, reference), - camera=camera, - oracle=SatStressOracle( - proof_kind="target_boundary", - exact_infimum_m=round(1.2 - _OPERATIONAL_COMPARISON_TOLERANCE, 12), - exact_infimum_points=(Vec2(x=-4.0, y=round(witness_y, 12)),), - derivation=( - f"reference depth {depth_offset + 0.5:.2f} m plus the 0.2 m " - "BEHIND threshold gives world y = 0.7 m" - ), - ), - ) - - -def _pair( - camera: Camera, - *, - subject_x: float = -2.5, -) -> tuple[SceneObject, SceneObject]: - subject = make_object( - "subject", - position=Vec3(x=subject_x, y=-0.5, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=1.0, y=0.5, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - return subject, reference - - -def _slanted_camera() -> Camera: - camera = make_camera(focal_px=80.0, depth_offset=4.0) - matrix = list(camera.world_to_camera) - matrix[8] = 1.0 - return camera.model_copy(update={"world_to_camera": tuple(matrix)}) - - -def _sampled_room( - slot: StressSlot, - attempt: int, - bounds: tuple[float, float, float, float], -) -> tuple[float, float, float, float]: - min_x, min_y, max_x, max_y = bounds - base_depth = max_y - min_y - maximum_steps = min(8, round((8.0 - base_depth) / 0.25)) - extra_steps = HashSampler(f"{slot.key}/{attempt}").integer( - "room_depth_extra", - 0, - maximum_steps, - ) - return min_x, min_y - 0.25 * extra_steps, max_x, max_y - - -def _draft( - slot: StressSlot, - attempt: int, - *, - room_bounds: tuple[float, float, float, float], - objects: tuple[SceneObject, ...], - oracle: SatStressOracle | UnsatStressOracle, - camera: Camera | None = None, -) -> StressCaseDraft: - scene_camera = camera or make_camera(focal_px=80.0, depth_offset=3.0) - return StressCaseDraft( - seed=slot.seed, - direction=slot.direction, - raw_slot=slot.raw_slot, - family=slot.family, - scene=make_scene( - scene_id=f"draft-{slot.seed}-fb-{slot.raw_slot:03d}-a{attempt:02d}", - generation_seed=slot.seed, - room_bounds=_sampled_room(slot, attempt, room_bounds), - camera=scene_camera, - objects=objects, - ), - intervention=InterventionSpec( - subject_id="subject", - reference_id="reference", - relation_before=Relation.FRONT, - relation_after=Relation.BEHIND, - camera_id=scene_camera.camera_id, - ), - oracle=oracle, - ) - - -def _obstacle_case( - slot: StressSlot, - attempt: int, - *, - tied: bool, -) -> StressCaseDraft: - camera = make_camera(focal_px=80.0, depth_offset=3.0) - sampler = HashSampler(f"{slot.key}/{attempt}") - subject, reference = _pair(camera, subject_x=-2.0 if tied else -2.5) - blocker_extent_x = sampler.choice("blocker_extent_x", (0.8, 1.0, 1.2)) - blocker_extent_y = sampler.choice("blocker_extent_y", (0.4, 0.6, 0.8)) - blocker = make_object( - "blocker", - position=Vec3(x=-2.0, y=0.8, z=0.7), - extent=Vec3(x=blocker_extent_x, y=blocker_extent_y, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - configuration_half_width = (blocker_extent_x + 0.4) / 2.0 - x_offsets = ( - (-configuration_half_width, configuration_half_width) - if tied - else (0.5 - configuration_half_width,) - ) - witness_y = 0.7 - _OPERATIONAL_COMPARISON_TOLERANCE - witnesses = tuple( - Vec2(x=subject.position.x + offset, y=round(witness_y, 12)) - for offset in x_offsets - ) - exact = round( - math.hypot( - abs(x_offsets[0]), - 1.2 - _OPERATIONAL_COMPARISON_TOLERANCE, - ), - 12, - ) - family = "tied_optimum" if tied else "obstacle_corner" - return _draft( - slot, - attempt, - room_bounds=(-3.0, -1.0, 3.0, 2.0), - objects=(subject, reference, blocker), - oracle=SatStressOracle( - proof_kind=family, - exact_infimum_m=exact, - exact_infimum_points=witnesses, - derivation=( - "symmetric configuration-edge contacts tie" - if tied - else "the nearer configuration-edge contact is uniquely optimal" - ), - ), - ) - - -def _room_bounds_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = make_camera(focal_px=80.0, depth_offset=3.0) - subject, reference = _pair(camera) - return _draft( - slot, - attempt, - room_bounds=(-3.0, -2.2, 3.0, 0.8), - objects=(subject, reference), - oracle=UnsatStressOracle( - proof_kind="room_bounds", - maximum_possible_value_m=0.6, - required_value_m=round( - 0.7 - _OPERATIONAL_COMPARISON_TOLERANCE, - 12, - ), - expected_reason="empty_outer_region", - derivation="room top 0.8 minus subject half-depth 0.2 gives y <= 0.6", - ), - ) - - -def _support_locus_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = make_camera(focal_px=80.0, depth_offset=3.0) - subject, reference = _pair(camera) - support = make_object( - "support", - position=Vec3(x=-1.0, y=-0.2, z=0.1), - extent=Vec3(x=4.0, y=2.0, z=0.2), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - subject = subject.model_copy(update={"support_object_id": "support"}) - return _draft( - slot, - attempt, - room_bounds=(-3.0, -2.0, 3.0, 2.0), - objects=(subject, reference, support), - oracle=UnsatStressOracle( - proof_kind="support_locus", - maximum_possible_value_m=0.6, - required_value_m=round( - 0.7 - _OPERATIONAL_COMPARISON_TOLERANCE, - 12, - ), - expected_reason="empty_outer_region", - derivation="support top edge 0.8 minus subject half-depth 0.2 gives y <= 0.6", - ), - ) - - -def _obstacle_coverage_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = make_camera(focal_px=80.0, depth_offset=3.0) - subject, reference = _pair(camera) - blockers = tuple( - make_object( - f"blocker_{index}", - position=Vec3(x=x, y=1.35, z=0.7), - extent=Vec3(x=1.2, y=1.2, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - for index, x in enumerate((-2.2, -0.65, 0.9)) - ) - return _draft( - slot, - attempt, - room_bounds=(-3.0, -1.0, 1.75, 2.0), - objects=(subject, reference, *blockers), - oracle=UnsatStressOracle( - proof_kind="obstacle_coverage", - maximum_possible_value_m=0.55, - required_value_m=round( - 0.7 - _OPERATIONAL_COMPARISON_TOLERANCE, - 12, - ), - expected_reason="empty_outer_region", - derivation="three configuration strips cover every center with y >= 0.55", - ), - ) - - -def _support_boundary_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = make_camera(focal_px=80.0, depth_offset=3.0) - matrix = list(camera.world_to_camera) - matrix[8] = 0.5 - camera = camera.model_copy(update={"world_to_camera": tuple(matrix)}) - subject = make_object( - "subject", - position=Vec3(x=-2.5, y=-0.5, z=0.7), - extent=Vec3(x=0.2, y=0.2, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - support_object_id="support", - ) - reference = make_object( - "reference", - position=Vec3(x=1.0, y=0.5, z=0.7), - extent=Vec3(x=0.2, y=0.2, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - support = make_object( - "support", - position=Vec3(x=-0.1, y=0.45, z=0.1), - extent=Vec3(x=5.0, y=2.5, z=0.2), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - witness = Vec2( - x=round(-0.8 - 2.0 * _OPERATIONAL_COMPARISON_TOLERANCE, 12), - y=1.6, - ) - return StressCaseDraft( - seed=slot.seed, - direction=slot.direction, - raw_slot=slot.raw_slot, - family=slot.family, - scene=make_scene( - scene_id=f"draft-{slot.seed}-fb-{slot.raw_slot:03d}-a{attempt:02d}", - generation_seed=slot.seed, - room_bounds=_sampled_room( - slot, - attempt, - (-3.0, -1.0, 3.0, 2.0), - ), - camera=camera, - objects=(subject, reference, support), - ), - intervention=InterventionSpec( - subject_id="subject", - reference_id="reference", - relation_before=Relation.FRONT, - relation_after=Relation.BEHIND, - camera_id=camera.camera_id, - ), - oracle=SatStressOracle( - proof_kind="support_boundary", - exact_infimum_m=round( - math.hypot( - 1.7 - 2.0 * _OPERATIONAL_COMPARISON_TOLERANCE, - 2.1, - ), - 12, - ), - exact_infimum_points=(witness,), - derivation=( - "the operational depth line meets the support center edge y = 1.6" - ), - ), - ) - - -def _preservation_intersection_case( - slot: StressSlot, - attempt: int, -) -> StressCaseDraft: - camera = make_camera(focal_px=80.0, depth_offset=3.0) - subject = make_object( - "subject", - position=Vec3(x=-2.0, y=-0.5, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=-0.2, y=0.5, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - witness = Vec2( - x=-2.1, - y=round(0.7 - _OPERATIONAL_COMPARISON_TOLERANCE, 12), - ) - exact = round( - math.hypot(0.1, 1.2 - _OPERATIONAL_COMPARISON_TOLERANCE), - 12, - ) - return StressCaseDraft( - seed=slot.seed, - direction=slot.direction, - raw_slot=slot.raw_slot, - family=slot.family, - scene=make_scene( - scene_id=f"draft-{slot.seed}-fb-{slot.raw_slot:03d}-a{attempt:02d}", - generation_seed=slot.seed, - room_bounds=_sampled_room( - slot, - attempt, - (-3.0, -1.0, 2.0, 2.0), - ), - camera=camera, - objects=(subject, reference), - ), - intervention=InterventionSpec( - subject_id="subject", - reference_id="reference", - relation_before=Relation.FRONT, - relation_after=Relation.BEHIND, - camera_id=camera.camera_id, - ), - oracle=SatStressOracle( - proof_kind="preservation_intersection", - exact_infimum_m=exact, - exact_infimum_points=(witness,), - derivation=( - "the target depth line y = 0.7 meets the preserved FAR side x = -2.1" - ), - ), - ) - - -def _relation_upper_bound_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _slanted_camera() - subject = make_object( - "subject", - position=Vec3(x=-1.0, y=-0.5, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=1.0, y=0.5, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - reference_depth = 5.5 - reference_u = 320.0 + 80.0 / reference_depth - preserved_u = reference_u - (32.0 - _OPERATIONAL_COMPARISON_TOLERANCE) - ratio = (preserved_u - 320.0) / 80.0 - maximum_depth = subject.position.x / ratio - maximum_linear_value = (maximum_depth - 4.0) / math.sqrt(2.0) - required_linear_value = (1.7 - _OPERATIONAL_COMPARISON_TOLERANCE) / math.sqrt(2.0) - return StressCaseDraft( - seed=slot.seed, - direction=slot.direction, - raw_slot=slot.raw_slot, - family=slot.family, - scene=make_scene( - scene_id=f"draft-{slot.seed}-fb-{slot.raw_slot:03d}-a{attempt:02d}", - generation_seed=slot.seed, - room_bounds=_sampled_room( - slot, - attempt, - (-1.2, -1.0, 3.8, 4.0), - ), - camera=camera, - objects=(subject, reference), - ), - intervention=InterventionSpec( - subject_id="subject", - reference_id="reference", - relation_before=Relation.FRONT, - relation_after=Relation.BEHIND, - camera_id=camera.camera_id, - ), - oracle=UnsatStressOracle( - proof_kind="relation_upper_bound", - maximum_possible_value_m=round(maximum_linear_value, 12), - required_value_m=round(required_linear_value, 12), - expected_reason="empty_outer_region", - derivation="preserved LEFT and room x >= -1 strictly cap calibrated depth", - ), - ) - - -def build_depth_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - """Build one deterministic direct FRONT-to-BEHIND stress draft.""" - if slot.direction != "fb": - raise StressCaseError("depth builder requires direction 'fb'") - if ( - isinstance(attempt, bool) - or not isinstance(attempt, int) - or not 0 <= attempt < 64 - ): - raise StressCaseError("attempt must be an integer from 0 through 63") - if slot.family == "target_boundary": - return _target_boundary_case(slot, attempt) - if slot.family == "obstacle_corner": - return _obstacle_case(slot, attempt, tied=False) - if slot.family == "tied_optimum": - return _obstacle_case(slot, attempt, tied=True) - if slot.family == "room_bounds": - return _room_bounds_case(slot, attempt) - if slot.family == "support_locus": - return _support_locus_case(slot, attempt) - if slot.family == "obstacle_coverage": - return _obstacle_coverage_case(slot, attempt) - if slot.family == "support_boundary": - return _support_boundary_case(slot, attempt) - if slot.family == "preservation_intersection": - return _preservation_intersection_case(slot, attempt) - if slot.family == "relation_upper_bound": - return _relation_upper_bound_case(slot, attempt) - raise StressCaseError(f"unsupported depth stress family: {slot.family}") diff --git a/src/spatialcf/solver/stress/families_distance.py b/src/spatialcf/solver/stress/families_distance.py deleted file mode 100644 index b183f8d..0000000 --- a/src/spatialcf/solver/stress/families_distance.py +++ /dev/null @@ -1,424 +0,0 @@ -"""Independent analytic constructors for distance stress families.""" - -from __future__ import annotations - -import math - -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import InterventionSpec, SceneObject, Vec2, Vec3 -from spatialcf.geometry.obb import ground_gap -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCaseDraft, - StressSlot, - UnsatStressOracle, -) -from spatialcf.solver.stress.sampling import HashSampler -from spatialcf.solver.stress.scene_factory import ( - StressCaseError, - make_camera, - make_object, - make_scene, -) - - -def _camera(): - camera = make_camera(focal_px=40.0, depth_offset=4.0) - matrix = list(camera.world_to_camera) - matrix[9] = 0.0 - return camera.model_copy(update={"world_to_camera": tuple(matrix)}) - - -def _pair( - *, - subject_x: float = -0.7, - reference_x: float = 0.0, - subject_extent_xy: float = 0.4, - reference_extent_xy: float = 0.4, - yaw_degrees: int = 0, -) -> tuple[SceneObject, SceneObject]: - camera = _camera() - subject = make_object( - "subject", - position=Vec3(x=subject_x, y=0.0, z=0.7), - extent=Vec3(x=subject_extent_xy, y=subject_extent_xy, z=1.0), - yaw_degrees=yaw_degrees, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=reference_x, y=0.0, z=0.7), - extent=Vec3(x=reference_extent_xy, y=reference_extent_xy, z=1.0), - yaw_degrees=yaw_degrees, - movable=False, - camera=camera, - ) - return subject, reference - - -def _draft( - slot: StressSlot, - attempt: int, - *, - room_bounds: tuple[float, float, float, float], - objects: tuple[SceneObject, ...], - oracle: SatStressOracle | UnsatStressOracle, -) -> StressCaseDraft: - camera = _camera() - return StressCaseDraft( - seed=slot.seed, - direction=slot.direction, - raw_slot=slot.raw_slot, - family=slot.family, - scene=make_scene( - scene_id=f"draft-{slot.seed}-nf-{slot.raw_slot:03d}-a{attempt:02d}", - generation_seed=slot.seed, - room_bounds=room_bounds, - camera=camera, - objects=objects, - ), - intervention=InterventionSpec( - subject_id="subject", - reference_id="reference", - relation_before=Relation.NEAR, - relation_after=Relation.FAR, - camera_id=camera.camera_id, - ), - oracle=oracle, - ) - - -def _target_boundary_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - sampler = HashSampler(f"{slot.key}/{attempt}") - yaw_degrees = sampler.choice("pair_yaw_degrees", (0, 45)) - if yaw_degrees == 45: - extent_xy = 0.4 - reference_x = 0.0 - subject_x = -0.9 - witness_x = -(0.4 * math.sqrt(2.0) + 1.5) - else: - extent_xy = sampler.choice("pair_extent_xy", (0.2, 0.4, 0.6)) - reference_x = sampler.choice("reference_x", (-0.25, 0.0, 0.25)) - subject_x = reference_x - extent_xy - 0.3 - witness_x = reference_x - extent_xy - 1.5 - subject, reference = _pair( - subject_x=subject_x, - reference_x=reference_x, - subject_extent_xy=extent_xy, - reference_extent_xy=extent_xy, - yaw_degrees=yaw_degrees, - ) - for _ in range(8): - moved = subject.obb.model_copy( - update={"center": subject.obb.center.model_copy(update={"x": witness_x})} - ) - if ground_gap(moved, reference.obb) >= 1.5: - break - witness_x = math.nextafter(witness_x, -math.inf) - else: - raise StressCaseError("target_boundary_not_enclosed") - return _draft( - slot, - attempt, - room_bounds=(-3.0, -2.0, 2.0, 2.0), - objects=(subject, reference), - oracle=SatStressOracle( - proof_kind="target_boundary", - exact_infimum_m=round(abs(witness_x - subject.position.x), 12), - exact_infimum_points=(Vec2(x=witness_x, y=0.0),), - derivation="the nearest FAR point extends the initial left radial ray", - ), - ) - - -def _obstacle_case( - slot: StressSlot, - attempt: int, - *, - tied: bool, -) -> StressCaseDraft: - camera = _camera() - subject, reference = _pair() - sampler = HashSampler(f"{slot.key}/{attempt}") - blocker_y = 0.0 if tied else 0.2 - blocker_x_extent = sampler.choice("blocker_extent_x", (0.4, 0.6, 0.8)) - blocker_y_extent = 0.2 - configuration_half_height = blocker_y_extent / 2.0 + 0.4 / 2.0 - blocker = make_object( - "blocker", - position=Vec3(x=-1.9, y=blocker_y, z=0.7), - extent=Vec3(x=blocker_x_extent, y=blocker_y_extent, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - witness_ys = ( - (-configuration_half_height, configuration_half_height) if tied else (-0.1,) - ) - witnesses = tuple(Vec2(x=-1.9, y=y) for y in witness_ys) - exact = round(math.hypot(1.2, abs(witness_ys[0])), 12) - family = "tied_optimum" if tied else "obstacle_corner" - return _draft( - slot, - attempt, - room_bounds=(-3.0, -2.0, 2.0, 2.0), - objects=(subject, reference, blocker), - oracle=SatStressOracle( - proof_kind=family, - exact_infimum_m=exact, - exact_infimum_points=witnesses, - derivation=( - "symmetric target/blocker configuration contacts tie" - if tied - else "the lower target/blocker configuration contact is uniquely nearest" - ), - ), - ) - - -def _support_boundary_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera() - subject, reference = _pair() - support_extent_x = HashSampler(f"{slot.key}/{attempt}").choice( - "support_extent_x", - (2.5, 3.0, 3.5), - ) - support = make_object( - "support", - position=Vec3(x=-1.2 + support_extent_x / 2.0, y=0.0, z=0.1), - extent=Vec3(x=support_extent_x, y=4.5, z=0.2), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - subject = subject.model_copy(update={"support_object_id": support.object_id}) - witness_y = 0.4 + math.sqrt(1.5**2 - 0.6**2) - witnesses = ( - Vec2(x=-1.0, y=-witness_y), - Vec2(x=-1.0, y=witness_y), - ) - return _draft( - slot, - attempt, - room_bounds=(-3.0, -2.5, 2.5, 2.5), - objects=(subject, reference, support), - oracle=SatStressOracle( - proof_kind="support_boundary", - exact_infimum_m=round(math.hypot(0.3, witness_y), 12), - exact_infimum_points=witnesses, - derivation="the support left boundary intersects both exact FAR fillets", - ), - ) - - -def _preservation_intersection_case( - slot: StressSlot, - attempt: int, -) -> StressCaseDraft: - camera = _camera() - subject, reference = _pair() - guard_extent_y = HashSampler(f"{slot.key}/{attempt}").choice( - "guard_extent_y", - (0.2, 0.4, 0.6), - ) - guard = make_object( - "guard", - position=Vec3(x=-4.2, y=0.0, z=0.7), - extent=Vec3(x=0.4, y=guard_extent_y, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ) - boundary_x = -1.000000000099999 - witness_y = 1.774772708443109 - witnesses = ( - Vec2(x=boundary_x, y=-witness_y), - Vec2(x=boundary_x, y=witness_y), - ) - exact = round(math.hypot(boundary_x + 0.7, witness_y), 12) - return _draft( - slot, - attempt, - room_bounds=(-4.5, -2.5, 2.0, 2.5), - objects=(subject, reference, guard), - oracle=SatStressOracle( - proof_kind="preservation_intersection", - exact_infimum_m=exact, - exact_infimum_points=witnesses, - derivation="the preserved RIGHT boundary intersects both exact FAR fillets", - ), - ) - - -def _room_bounds_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - subject_extent, reference_extent = HashSampler(f"{slot.key}/{attempt}").choice( - "pair_extents", - ((0.6, 1.0), (0.8, 0.8), (0.8, 1.0)), - ) - subject, reference = _pair( - subject_x=-1.2, - subject_extent_xy=subject_extent, - reference_extent_xy=reference_extent, - ) - corner_gap = 2.0 - subject_extent - reference_extent / 2.0 - maximum = round(math.hypot(corner_gap, corner_gap), 12) - return _draft( - slot, - attempt, - room_bounds=(-2.0, -2.0, 2.0, 2.0), - objects=(subject, reference), - oracle=UnsatStressOracle( - proof_kind="room_bounds", - maximum_possible_value_m=maximum, - required_value_m=1.5, - expected_reason="empty_outer_region", - derivation="every room center-locus corner is less than FAR from reference", - ), - ) - - -def _support_locus_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera() - subject, reference = _pair() - support_extent_x, support_extent_y = HashSampler(f"{slot.key}/{attempt}").choice( - "support_extents", - ((2.0, 2.0), (2.0, 2.25), (2.25, 2.0)), - ) - support = make_object( - "support", - position=Vec3(x=0.0, y=0.0, z=0.1), - extent=Vec3(x=support_extent_x, y=support_extent_y, z=0.2), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - subject = subject.model_copy(update={"support_object_id": support.object_id}) - maximum = round( - math.hypot( - (support_extent_x - 1.2) / 2.0, - (support_extent_y - 1.2) / 2.0, - ), - 12, - ) - return _draft( - slot, - attempt, - room_bounds=(-2.0, -2.0, 2.0, 2.0), - objects=(subject, reference, support), - oracle=UnsatStressOracle( - proof_kind="support_locus", - maximum_possible_value_m=maximum, - required_value_m=1.5, - expected_reason="empty_outer_region", - derivation="the support center rectangle bounds every reference gap", - ), - ) - - -def _relation_upper_bound_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera() - guard_x = HashSampler(f"{slot.key}/{attempt}").choice( - "guard_x", - (0.2, 0.3), - ) - subject, reference = _pair(subject_x=guard_x - 0.9) - guard = make_object( - "guard", - position=Vec3(x=guard_x, y=0.0, z=1.45), - extent=Vec3(x=0.4, y=0.4, z=0.4), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ) - return _draft( - slot, - attempt, - room_bounds=(-2.0, -2.0, 2.0, 2.0), - objects=(subject, reference, guard), - oracle=UnsatStressOracle( - proof_kind="relation_upper_bound", - maximum_possible_value_m=round(guard_x + 0.500000009192, 12), - required_value_m=1.5, - expected_reason="empty_outer_region", - derivation="preserved NEAR to the elevated visible guard caps reference gap", - ), - ) - - -def _obstacle_coverage_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera() - subject, reference = _pair( - subject_x=-0.3, - reference_x=-1.4, - subject_extent_xy=0.8, - reference_extent_xy=0.8, - ) - blocker_extent_x = HashSampler(f"{slot.key}/{attempt}").choice( - "blocker_extent_x", - (0.8, 1.0, 1.2), - ) - blockers = tuple( - make_object( - f"blocker_{index}", - position=Vec3(x=1.0, y=y, z=0.7), - extent=Vec3(x=blocker_extent_x, y=0.8, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - for index, y in enumerate((-1.1, 0.0, 1.1)) - ) - return _draft( - slot, - attempt, - room_bounds=(-2.0, -2.0, 2.0, 2.0), - objects=(subject, reference, *blockers), - oracle=UnsatStressOracle( - proof_kind="obstacle_coverage", - maximum_possible_value_m=round( - math.hypot(1.2 - blocker_extent_x / 2.0, 0.8), - 12, - ), - required_value_m=1.5, - expected_reason="empty_outer_region", - derivation="three overlapping configuration obstacles cover the FAR strip", - ), - ) - - -def build_distance_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - """Build one deterministic direct NEAR-to-FAR stress draft.""" - if slot.direction != "nf": - raise StressCaseError("distance_direction_required") - if ( - isinstance(attempt, bool) - or not isinstance(attempt, int) - or not 0 <= attempt < 64 - ): - raise StressCaseError("invalid_attempt") - if slot.family == "target_boundary": - return _target_boundary_case(slot, attempt) - if slot.family == "obstacle_corner": - return _obstacle_case(slot, attempt, tied=False) - if slot.family == "support_boundary": - return _support_boundary_case(slot, attempt) - if slot.family == "preservation_intersection": - return _preservation_intersection_case(slot, attempt) - if slot.family == "tied_optimum": - return _obstacle_case(slot, attempt, tied=True) - if slot.family == "room_bounds": - return _room_bounds_case(slot, attempt) - if slot.family == "support_locus": - return _support_locus_case(slot, attempt) - if slot.family == "obstacle_coverage": - return _obstacle_coverage_case(slot, attempt) - if slot.family == "relation_upper_bound": - return _relation_upper_bound_case(slot, attempt) - raise StressCaseError(f"unsupported distance stress family: {slot.family}") diff --git a/src/spatialcf/solver/stress/families_horizontal.py b/src/spatialcf/solver/stress/families_horizontal.py deleted file mode 100644 index 4c4a6a3..0000000 --- a/src/spatialcf/solver/stress/families_horizontal.py +++ /dev/null @@ -1,520 +0,0 @@ -"""Independent analytic constructors for horizontal stress families.""" - -from __future__ import annotations - -import math -from decimal import Decimal - -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import Camera, InterventionSpec, SceneObject, Vec2, Vec3 -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCaseDraft, - StressSlot, - UnsatStressOracle, -) -from spatialcf.solver.stress.sampling import HashSampler -from spatialcf.solver.stress.scene_factory import ( - StressCaseError, - make_camera, - make_object, - make_scene, -) - -# Frozen from the public relation-label boundary contract; constructors derive -# their oracle geometry without importing production relation code. -_OPERATIONAL_COMPARISON_TOLERANCE = 1e-9 - - -def _camera( - *, - constant_depth: bool = False, - focal_px: float = 320.0, - depth_offset: float = 4.0, -) -> Camera: - camera = make_camera(focal_px=focal_px, depth_offset=depth_offset) - if constant_depth: - matrix = list(camera.world_to_camera) - matrix[9] = 0.0 - camera = camera.model_copy(update={"world_to_camera": tuple(matrix)}) - return camera - - -def _sampled_y_shift(slot: StressSlot, attempt: int) -> float: - return HashSampler(f"{slot.key}/{attempt}").grid( - "world_y_shift", - Decimal("-0.50"), - Decimal("0.50"), - Decimal("0.25"), - ) - - -def _sampled_room_top( - slot: StressSlot, - attempt: int, - *, - minimum_y: float, - minimum_depth: str, -) -> float: - sampler = HashSampler(f"{slot.key}/{attempt}") - depth = sampler.grid( - "room_depth", - Decimal(minimum_depth), - Decimal("8.00"), - Decimal("0.25"), - ) - return minimum_y + depth - - -def _draft( - slot: StressSlot, - attempt: int, - *, - room_bounds: tuple[float, float, float, float], - camera: Camera, - objects: tuple[SceneObject, ...], - oracle: SatStressOracle | UnsatStressOracle, -) -> StressCaseDraft: - return StressCaseDraft( - seed=slot.seed, - direction=slot.direction, - raw_slot=slot.raw_slot, - family=slot.family, - scene=make_scene( - scene_id=f"draft-{slot.seed}-lr-{slot.raw_slot:03d}-a{attempt:02d}", - generation_seed=slot.seed, - room_bounds=room_bounds, - camera=camera, - objects=objects, - ), - intervention=InterventionSpec( - subject_id="subject", - reference_id="reference", - relation_before=Relation.LEFT, - relation_after=Relation.RIGHT, - camera_id=camera.camera_id, - ), - oracle=oracle, - ) - - -def _target_boundary_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - sampler = HashSampler(f"{slot.key}/{attempt}") - focal_px = sampler.choice("focal_px", (320.0, 400.0, 500.0, 640.0)) - depth_offset = sampler.grid( - "subject_depth", - Decimal("2.00"), - Decimal("4.00"), - Decimal("0.25"), - ) - subject_extent = sampler.choice("subject_xy_extent", (0.2, 0.4)) - reference_extent = sampler.choice("reference_xy_extent", (0.2, 0.4)) - subject_yaw = sampler.choice("subject_yaw", (0, 15, 30, 45)) - reference_yaw = sampler.choice("reference_yaw", (0, 30, 45, 60)) - camera = _camera(focal_px=focal_px, depth_offset=depth_offset) - subject = make_object( - "subject", - position=Vec3(x=-0.5, y=0.0, z=1.3), - extent=Vec3(x=subject_extent, y=subject_extent, z=1.0), - yaw_degrees=subject_yaw, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=0.5, y=1.0, z=1.3), - extent=Vec3(x=reference_extent, y=reference_extent, z=1.0), - yaw_degrees=reference_yaw, - movable=False, - camera=camera, - ) - operational_pixels = 32.0 - _OPERATIONAL_COMPARISON_TOLERANCE - slope = 0.5 / (depth_offset + 1.0) + operational_pixels / focal_px - deficit = slope * depth_offset + 0.5 - witness = Vec2( - x=round(-0.5 + deficit / (1.0 + slope * slope), 12), - y=round(-slope * deficit / (1.0 + slope * slope), 12), - ) - exact = round(math.hypot(witness.x + 0.5, witness.y), 12) - return _draft( - slot, - attempt, - room_bounds=( - -2.5, - -1.5, - 2.5, - _sampled_room_top(slot, attempt, minimum_y=-1.5, minimum_depth="4.00"), - ), - camera=camera, - objects=(subject, reference), - oracle=SatStressOracle( - proof_kind="target_boundary", - exact_infimum_m=exact, - exact_infimum_points=(witness,), - derivation=( - f"orthogonal projection onto x - {slope:.12g} y >= " - f"{slope * depth_offset:.12g}" - ), - ), - ) - - -def _constant_pair( - camera: Camera, - *, - extent_xy: float = 0.4, - reference_y: float = 0.0, - y_shift: float = 0.0, -) -> tuple[SceneObject, SceneObject]: - subject = make_object( - "subject", - position=Vec3(x=-0.5, y=y_shift, z=0.7), - extent=Vec3(x=extent_xy, y=extent_xy, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=0.0, y=reference_y + y_shift, z=0.7), - extent=Vec3(x=extent_xy, y=extent_xy, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - return subject, reference - - -def _obstacle_corner_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera(constant_depth=True) - sampler = HashSampler(f"{slot.key}/{attempt}") - y_shift = _sampled_y_shift(slot, attempt) - obstacle_y_extent = sampler.choice("obstacle_y_extent", (0.4, 0.6, 0.8)) - reference_y = {0.4: 1.4, 0.6: 1.6, 0.8: 1.8}[obstacle_y_extent] - configuration_half_height = (obstacle_y_extent + 0.4) / 2.0 - subject, reference = _constant_pair( - camera, - reference_y=reference_y, - y_shift=y_shift, - ) - obstacle = make_object( - "obstacle", - position=Vec3(x=0.4, y=y_shift, z=0.7), - extent=Vec3(x=0.4, y=obstacle_y_extent, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - target_x = 0.4 - _OPERATIONAL_COMPARISON_TOLERANCE / 80.0 - witness_x = round(target_x, 12) - return _draft( - slot, - attempt, - room_bounds=( - -2.5, - y_shift - obstacle_y_extent / 2.0, - 2.5, - _sampled_room_top( - slot, - attempt, - minimum_y=y_shift - obstacle_y_extent / 2.0, - minimum_depth="3.00", - ), - ), - camera=camera, - objects=(subject, reference, obstacle), - oracle=SatStressOracle( - proof_kind="obstacle_corner", - exact_infimum_m=round( - math.hypot(witness_x + 0.5, configuration_half_height), - 12, - ), - exact_infimum_points=( - Vec2(x=witness_x, y=configuration_half_height + y_shift), - ), - derivation="nearest reachable RIGHT point is the upper configuration corner", - ), - ) - - -def _support_boundary_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera() - support = make_object( - "support", - position=Vec3(x=0.0, y=0.9, z=0.1), - extent=Vec3(x=4.0, y=2.0, z=0.2), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - subject = make_object( - "subject", - position=Vec3(x=-0.5, y=0.0, z=0.7), - extent=Vec3(x=0.2, y=0.2, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - support_object_id="support", - ) - reference = make_object( - "reference", - position=Vec3(x=0.5, y=1.0, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - witness_x = 0.8 - _OPERATIONAL_COMPARISON_TOLERANCE / 80.0 - witness = Vec2(x=round(witness_x, 12), y=0.0) - return _draft( - slot, - attempt, - room_bounds=( - -2.5, - -0.5, - 2.5, - _sampled_room_top( - slot, - attempt, - minimum_y=-0.5, - minimum_depth="3.00", - ), - ), - camera=camera, - objects=(subject, reference, support), - oracle=SatStressOracle( - proof_kind="support_boundary", - exact_infimum_m=round( - math.hypot(witness.x + 0.5, witness.y), - 12, - ), - exact_infimum_points=(witness,), - derivation="support y >= 0 clips the operational target boundary", - ), - ) - - -def _preservation_intersection_case( - slot: StressSlot, - attempt: int, -) -> StressCaseDraft: - camera = _camera() - subject, reference = _constant_pair( - camera, - extent_xy=0.2, - reference_y=-0.2, - ) - operational_pixels = 32.0 - _OPERATIONAL_COMPARISON_TOLERANCE - witness = Vec2( - x=round( - operational_pixels * (4.0 - _OPERATIONAL_COMPARISON_TOLERANCE) / 320.0, - 12, - ), - y=-_OPERATIONAL_COMPARISON_TOLERANCE, - ) - return _draft( - slot, - attempt, - room_bounds=( - -2.5, - -1.5, - 2.5, - _sampled_room_top( - slot, - attempt, - minimum_y=-1.5, - minimum_depth="3.00", - ), - ), - camera=camera, - objects=(subject, reference), - oracle=SatStressOracle( - proof_kind="preservation_intersection", - exact_infimum_m=round(math.hypot(witness.x + 0.5, witness.y), 12), - exact_infimum_points=(witness,), - derivation="target RIGHT and preserved target-pair BEHIND intersect", - ), - ) - - -def _tied_optimum_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera(constant_depth=True) - y_shift = _sampled_y_shift(slot, attempt) - subject, reference = _constant_pair( - camera, - reference_y=1.4, - y_shift=y_shift, - ) - obstacle = make_object( - "obstacle", - position=Vec3(x=0.4, y=y_shift, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - target_x = 0.4 - _OPERATIONAL_COMPARISON_TOLERANCE / 80.0 - witness_x = round(target_x, 12) - exact = round(math.hypot(witness_x + 0.5, 0.4), 12) - return _draft( - slot, - attempt, - room_bounds=( - -2.5, - -1.5 + y_shift, - 2.5, - _sampled_room_top( - slot, - attempt, - minimum_y=-1.5 + y_shift, - minimum_depth="3.25", - ), - ), - camera=camera, - objects=(subject, reference, obstacle), - oracle=SatStressOracle( - proof_kind="tied_optimum", - exact_infimum_m=exact, - exact_infimum_points=( - Vec2(x=witness_x, y=-0.4 + y_shift), - Vec2(x=witness_x, y=0.4 + y_shift), - ), - derivation="symmetric upper and lower configuration corners tie", - ), - ) - - -def _unsat_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - camera = _camera(constant_depth=True) - y_shift = _sampled_y_shift(slot, attempt) - if slot.family == "relation_upper_bound": - subject = make_object( - "subject", - position=Vec3(x=3.0, y=y_shift, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=True, - camera=camera, - ) - reference = make_object( - "reference", - position=Vec3(x=3.55, y=y_shift, z=0.7), - extent=Vec3(x=0.4, y=0.4, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - ) - objects = (subject, reference) - room = ( - 0.0, - -1.5 + y_shift, - 4.0, - _sampled_room_top( - slot, - attempt, - minimum_y=-1.5 + y_shift, - minimum_depth="3.00", - ), - ) - maximum = 3.8 - required = round( - (3.95 * 80.0 - _OPERATIONAL_COMPARISON_TOLERANCE) / 80.0, - 12, - ) - else: - subject, reference = _constant_pair(camera, y_shift=y_shift) - objects = (subject, reference) - room = ( - -3.5, - -1.5 + y_shift, - 0.5, - _sampled_room_top( - slot, - attempt, - minimum_y=-1.5 + y_shift, - minimum_depth="3.00", - ), - ) - maximum = 0.3 - required = round( - 0.4 - _OPERATIONAL_COMPARISON_TOLERANCE / 80.0, - 12, - ) - if slot.family == "support_locus": - room = ( - -2.5, - -1.5 + y_shift, - 2.5, - _sampled_room_top( - slot, - attempt, - minimum_y=-1.5 + y_shift, - minimum_depth="3.00", - ), - ) - support = make_object( - "support", - position=Vec3(x=-0.5, y=y_shift, z=0.1), - extent=Vec3(x=2.0, y=2.0, z=0.2), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - subject = subject.model_copy(update={"support_object_id": "support"}) - objects = (subject, reference, support) - elif slot.family == "obstacle_coverage": - room = (-2.25, -1.5 + y_shift, 1.75, 1.5 + y_shift) - walls = tuple( - make_object( - object_id, - position=Vec3(x=1.0, y=wall_y + y_shift, z=0.7), - extent=Vec3(x=1.2, y=1.2, z=1.0), - yaw_degrees=0, - movable=False, - camera=camera, - request_eligible=False, - ).model_copy(update={"views": {}}) - for object_id, wall_y in ( - ("wall_lower", -0.7), - ("wall_upper", 0.7), - ) - ) - objects = (subject, reference, *walls) - maximum = 0.2 - return _draft( - slot, - attempt, - room_bounds=room, - camera=camera, - objects=objects, - oracle=UnsatStressOracle( - proof_kind=slot.family, - maximum_possible_value_m=maximum, - required_value_m=required, - expected_reason="empty_outer_region", - derivation=f"{slot.family} keeps maximum RIGHT value below threshold", - ), - ) - - -def build_horizontal_case(slot: StressSlot, attempt: int) -> StressCaseDraft: - """Build one direct LEFT-to-RIGHT stress draft without production solver code.""" - if slot.direction != "lr": - raise StressCaseError("horizontal_direction_required") - if not 0 <= attempt < 64: - raise StressCaseError("invalid_attempt") - if slot.family == "target_boundary": - return _target_boundary_case(slot, attempt) - if slot.family == "obstacle_corner": - return _obstacle_corner_case(slot, attempt) - if slot.family == "support_boundary": - return _support_boundary_case(slot, attempt) - if slot.family == "preservation_intersection": - return _preservation_intersection_case(slot, attempt) - if slot.family == "tied_optimum": - return _tied_optimum_case(slot, attempt) - return _unsat_case(slot, attempt) diff --git a/src/spatialcf/solver/stress/models.py b/src/spatialcf/solver/stress/models.py deleted file mode 100644 index e6a81ce..0000000 --- a/src/spatialcf/solver/stress/models.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Frozen data contracts shared by solver-independent stress modules.""" - -from __future__ import annotations - -import math -from typing import Annotated, Literal, TypeAlias - -from pydantic import BaseModel, ConfigDict, Field, model_validator - -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import InterventionSpec, Scene, Vec2 - -StressProfileName: TypeAlias = Literal["quick", "deep"] -StressDirection: TypeAlias = Literal["lr", "fb", "nf"] -StressFamily: TypeAlias = Literal[ - "target_boundary", - "obstacle_corner", - "support_boundary", - "preservation_intersection", - "tied_optimum", - "room_bounds", - "support_locus", - "obstacle_coverage", - "relation_upper_bound", -] - -SAT_STRESS_FAMILIES = frozenset( - { - "target_boundary", - "obstacle_corner", - "support_boundary", - "preservation_intersection", - "tied_optimum", - } -) -UNSAT_STRESS_FAMILIES = frozenset( - { - "room_bounds", - "support_locus", - "obstacle_coverage", - "relation_upper_bound", - } -) -_DIRECTION_FLIPS: dict[StressDirection, tuple[Relation, Relation]] = { - "lr": (Relation.LEFT, Relation.RIGHT), - "fb": (Relation.FRONT, Relation.BEHIND), - "nf": (Relation.NEAR, Relation.FAR), -} - - -class _FrozenStressModel(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - -class StressTransform(_FrozenStressModel): - """An identity or oracle-preserving whole-scene XY transformation.""" - - translation_xy: Vec2 = Vec2(x=0.0, y=0.0) - mirror: Literal["none", "camera_horizontal", "camera_depth"] = "none" - rotation_degrees: Literal[0, 90, 180, 270] = 0 - base_case_digest: str | None = Field(default=None, pattern=r"^[0-9a-f]{64}$") - - @property - def transformed(self) -> bool: - return ( - self.translation_xy != Vec2(x=0.0, y=0.0) - or self.mirror != "none" - or self.rotation_degrees != 0 - ) - - -class StressSlot(_FrozenStressModel): - """One closed schedule position before case construction and ID sorting.""" - - seed: int = Field(ge=2026080200, le=2026080209, strict=True) - direction: StressDirection - raw_slot: int = Field(ge=0, le=99, strict=True) - family: StressFamily - expected_outcome: Literal["SAT", "UNSAT"] - transformed: bool - - @model_validator(mode="after") - def validate_outcome_family_and_slot(self) -> StressSlot: - if self.expected_outcome == "SAT": - if self.family not in SAT_STRESS_FAMILIES or self.raw_slot >= 60: - raise ValueError("SAT stress slot has an invalid family or raw slot") - elif self.family not in UNSAT_STRESS_FAMILIES or self.raw_slot < 60: - raise ValueError("UNSAT stress slot has an invalid family or raw slot") - return self - - @property - def key(self) -> str: - return f"stress-v1/{self.seed}/{self.direction}/{self.raw_slot:03d}" - - -class SatStressOracle(_FrozenStressModel): - expected_outcome: Literal["SAT"] = "SAT" - proof_kind: StressFamily - exact_infimum_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - exact_infimum_points: tuple[Vec2, ...] = Field(min_length=1) - derivation: str = Field(min_length=1) - - @model_validator(mode="after") - def validate_sat_oracle(self) -> SatStressOracle: - if self.proof_kind not in SAT_STRESS_FAMILIES: - raise ValueError("SAT oracle proof_kind must be a SAT stress family") - if not all( - math.isfinite(value) - for point in self.exact_infimum_points - for value in (point.x, point.y) - ): - raise ValueError("SAT oracle witness points must be finite") - return self - - -class UnsatStressOracle(_FrozenStressModel): - expected_outcome: Literal["UNSAT"] = "UNSAT" - proof_kind: StressFamily - maximum_possible_value_m: float = Field( - ge=0.0, - allow_inf_nan=False, - strict=True, - ) - required_value_m: float = Field(allow_inf_nan=False, strict=True) - expected_reason: str = Field(min_length=1) - derivation: str = Field(min_length=1) - - @model_validator(mode="after") - def validate_unsat_oracle(self) -> UnsatStressOracle: - if self.proof_kind not in UNSAT_STRESS_FAMILIES: - raise ValueError("UNSAT oracle proof_kind must be an UNSAT stress family") - if self.maximum_possible_value_m >= self.required_value_m: - raise ValueError("UNSAT oracle must have a strict reachable-value bound") - return self - - -StressOracle = Annotated[ - SatStressOracle | UnsatStressOracle, - Field(discriminator="expected_outcome"), -] - - -class StressOracleResult(_FrozenStressModel): - expected_outcome: Literal["SAT", "UNSAT"] - exact_infimum_m: float | None - exact_infimum_points: tuple[Vec2, ...] - maximum_possible_value_m: float | None - required_value_m: float | None - - -def expected_oracle_result(oracle: StressOracle) -> StressOracleResult: - """Project stored declarations to fields independently recomputed later.""" - if isinstance(oracle, SatStressOracle): - return StressOracleResult( - expected_outcome="SAT", - exact_infimum_m=oracle.exact_infimum_m, - exact_infimum_points=oracle.exact_infimum_points, - maximum_possible_value_m=None, - required_value_m=None, - ) - return StressOracleResult( - expected_outcome="UNSAT", - exact_infimum_m=None, - exact_infimum_points=(), - maximum_possible_value_m=oracle.maximum_possible_value_m, - required_value_m=oracle.required_value_m, - ) - - -class StressCaseDraft(_FrozenStressModel): - seed: int = Field(ge=2026080200, le=2026080209, strict=True) - direction: StressDirection - raw_slot: int = Field(ge=0, le=99, strict=True) - family: StressFamily - transform: StressTransform = StressTransform() - scene: Scene - intervention: InterventionSpec - oracle: StressOracle - - @model_validator(mode="after") - def validate_draft_oracle(self) -> StressCaseDraft: - is_sat = isinstance(self.oracle, SatStressOracle) - if (is_sat and self.family not in SAT_STRESS_FAMILIES) or ( - not is_sat and self.family not in UNSAT_STRESS_FAMILIES - ): - raise ValueError("stress case family must match its oracle outcome") - if (self.intervention.relation_before, self.intervention.relation_after) != ( - _DIRECTION_FLIPS[self.direction] - ): - raise ValueError("stress case direction must match its intervention flip") - return self - - @property - def expected_outcome(self) -> Literal["SAT", "UNSAT"]: - return self.oracle.expected_outcome - - -class StressCase(StressCaseDraft): - case_id: str = Field(pattern=r"^stress-\d{10}-(?:lr|fb|nf)-\d{3}$") - - @model_validator(mode="after") - def validate_scene_identity(self) -> StressCase: - if self.scene.scene_id != self.case_id: - raise ValueError("stress case scene_id must equal case_id") - _, encoded_seed, encoded_direction, _ = self.case_id.split("-") - if int(encoded_seed) != self.seed or encoded_direction != self.direction: - raise ValueError( - "stress case_id seed and direction must match the stress case" - ) - return self - - def as_draft(self) -> StressCaseDraft: - return StressCaseDraft.model_validate( - self.model_dump(mode="python", exclude={"case_id"}) - ) diff --git a/src/spatialcf/solver/stress/oracles.py b/src/spatialcf/solver/stress/oracles.py deleted file mode 100644 index 6c63043..0000000 --- a/src/spatialcf/solver/stress/oracles.py +++ /dev/null @@ -1,1796 +0,0 @@ -"""Independent analytic geometry for deterministic solver stress cases.""" - -from __future__ import annotations - -import math -from collections.abc import Callable -from dataclasses import dataclass -from itertools import pairwise - -from shapely.geometry import LineString, MultiPoint, Point, Polygon, box -from shapely.geometry.base import BaseGeometry -from shapely.ops import nearest_points, unary_union - -from spatialcf.domain.enums import Relation, RelationAxis -from spatialcf.domain.models import Camera, SceneObject, Vec2 -from spatialcf.geometry.obb import ground_gap, obb_footprint -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.stress.models import ( - StressCase, - StressCaseDraft, - StressOracleResult, -) - -_EPS = 1e-12 -_GEOMETRY_TOLERANCE = 1e-9 -# Frozen from the public relation-label boundary contract. Stress oracles keep -# this value local so their operational geometry is derived independently of -# the production relation engine implementation. -_OPERATIONAL_COMPARISON_TOLERANCE = 1e-9 -_BUFFER_QUAD_SEGS = 8192 -_AXIS_RELATIONS: dict[RelationAxis, tuple[Relation, Relation]] = { - RelationAxis.HORIZONTAL: (Relation.LEFT, Relation.RIGHT), - RelationAxis.DEPTH: (Relation.FRONT, Relation.BEHIND), - RelationAxis.DISTANCE: (Relation.NEAR, Relation.FAR), -} - - -@dataclass(frozen=True) -class PointProjection: - """The Euclidean projection of one point onto a closed primitive.""" - - point: Vec2 - distance: float - - -@dataclass(frozen=True) -class _ConvexOffsetSegment: - """One exact translated edge of a convex Euclidean offset.""" - - start: Vec2 - end: Vec2 - - -@dataclass(frozen=True) -class _ConvexOffsetArc: - """One exact CCW circular fillet of a convex Euclidean offset.""" - - center: Vec2 - radius: float - start_angle: float - end_angle: float - - -@dataclass(frozen=True) -class _ConvexOffsetPrimitives: - """Finite exact boundary primitives for a convex polygon offset.""" - - segments: tuple[_ConvexOffsetSegment, ...] - arcs: tuple[_ConvexOffsetArc, ...] - - -class UnattainedOracleInfimumError(ValueError): - """The closed geometric infimum has no legal exact stress witness.""" - - -def point_to_segment(point: Vec2, start: Vec2, end: Vec2) -> PointProjection: - """Return the exact clamped projection of ``point`` onto one segment.""" - dx = end.x - start.x - dy = end.y - start.y - squared_length = dx * dx + dy * dy - if squared_length <= _EPS: - closest = start - else: - parameter = ( - (point.x - start.x) * dx + (point.y - start.y) * dy - ) / squared_length - parameter = min(1.0, max(0.0, parameter)) - closest = Vec2( - x=start.x + parameter * dx, - y=start.y + parameter * dy, - ) - return PointProjection( - point=closest, - distance=math.hypot(point.x - closest.x, point.y - closest.y), - ) - - -def project_to_half_space( - point: Vec2, - *, - normal: Vec2, - offset: float, -) -> PointProjection: - """Project onto ``normal dot point >= offset``.""" - squared_norm = normal.x * normal.x + normal.y * normal.y - if squared_norm <= _EPS: - raise ValueError("half-space normal must be non-zero") - signed_deficit = offset - (normal.x * point.x + normal.y * point.y) - if signed_deficit <= 0.0: - return PointProjection(point=point, distance=0.0) - scale = signed_deficit / squared_norm - projected = Vec2( - x=point.x + scale * normal.x, - y=point.y + scale * normal.y, - ) - return PointProjection( - point=projected, - distance=signed_deficit / math.sqrt(squared_norm), - ) - - -def _relative_subject_vertices(subject: SceneObject) -> tuple[Vec2, ...]: - return tuple( - Vec2(x=float(x) - subject.position.x, y=float(y) - subject.position.y) - for x, y in tuple(obb_footprint(subject.obb).exterior.coords)[:-1] - ) - - -def _canonical_polygon_vertices(polygon: BaseGeometry) -> tuple[Vec2, ...]: - if polygon.is_empty: - return () - if not isinstance(polygon, Polygon): - raise TypeError("configuration-space hull must be a polygon") - coordinates = [(float(x), float(y)) for x, y in polygon.exterior.coords[:-1]] - signed_area = sum( - x1 * y2 - x2 * y1 - for (x1, y1), (x2, y2) in zip( - coordinates, - coordinates[1:] + coordinates[:1], - strict=True, - ) - ) - if signed_area < 0.0: - coordinates.reverse() - first = min( - range(len(coordinates)), - key=lambda index: (coordinates[index][1], coordinates[index][0]), - ) - ordered = coordinates[first:] + coordinates[:first] - return tuple(Vec2(x=x, y=y) for x, y in ordered) - - -def configuration_space_vertices( - subject: SceneObject, - obstacle: SceneObject, -) -> tuple[Vec2, ...]: - """Return the convex obstacle OBB vertices minus relative subject vertices.""" - obstacle_vertices = tuple(obb_footprint(obstacle.obb).exterior.coords)[:-1] - relative_vertices = _relative_subject_vertices(subject) - hull = MultiPoint( - [ - (float(ox) - subject_vertex.x, float(oy) - subject_vertex.y) - for ox, oy in obstacle_vertices - for subject_vertex in relative_vertices - ] - ).convex_hull - return _canonical_polygon_vertices(hull) - - -def _configuration_polygon(subject: SceneObject, obstacle: SceneObject) -> Polygon: - vertices = configuration_space_vertices(subject, obstacle) - if len(vertices) < 3: - return Polygon() - return Polygon([(point.x, point.y) for point in vertices]) - - -def _distance_buffer_envelopes( - configuration: BaseGeometry, - radius: float, -) -> tuple[BaseGeometry, BaseGeometry]: - """Return inscribed and circumscribed polygonal envelopes of an exact buffer.""" - inner = configuration.buffer(radius, quad_segs=_BUFFER_QUAD_SEGS) - # GEOS rounds a fillet's segment count as floor(angle / quantum + 1/2), - # where quantum = pi / (2 * quad_segs). For every emitted fillet segment, - # its angle is therefore less than 3/2 * quantum, and hence less than - # 2 * quantum. The supported OBB yaw grid has no non-zero turn below 15 - # degrees, so GEOS's near-parallel endpoint shortcut cannot apply. Thus a - # chord's half-angle is strictly below quantum, and scaling by sec(quantum) - # makes the polygonal chord cover the exact radius-r circular arc. - chord_half_angle_bound = math.pi / (2.0 * _BUFFER_QUAD_SEGS) - outer_radius = radius / math.cos(chord_half_angle_bound) - outer = configuration.buffer(outer_radius, quad_segs=_BUFFER_QUAD_SEGS) - return inner, outer - - -def _without_nearly_collinear_vertices( - coordinates: list[tuple[float, float]], -) -> list[tuple[float, float]]: - simplified = coordinates.copy() - while len(simplified) > 3: - for index, current in enumerate(simplified): - previous = simplified[index - 1] - following = simplified[(index + 1) % len(simplified)] - incoming = (current[0] - previous[0], current[1] - previous[1]) - outgoing = (following[0] - current[0], following[1] - current[1]) - cross = incoming[0] * outgoing[1] - incoming[1] * outgoing[0] - cross_scale = math.hypot(*incoming) * math.hypot(*outgoing) - cross_tolerance = 64.0 * math.ulp(cross_scale) - between = ( - incoming[0] * (current[0] - following[0]) - + incoming[1] * (current[1] - following[1]) - <= cross_tolerance - ) - if abs(cross) <= cross_tolerance and between: - del simplified[index] - break - else: - return simplified - return simplified - - -def _convex_offset_primitives( - configuration: Polygon, - radius: float, -) -> _ConvexOffsetPrimitives: - """Return exact edge offsets and convex-vertex fillets for a convex polygon.""" - if radius <= 0.0 or not math.isfinite(radius): - raise ValueError("offset radius must be finite and positive") - if ( - configuration.is_empty - or not configuration.is_valid - or not configuration.equals(configuration.convex_hull) - ): - raise ValueError("configuration must be a non-empty convex polygon") - coordinates = [ - (float(x), float(y)) for x, y in tuple(configuration.exterior.coords)[:-1] - ] - if not configuration.exterior.is_ccw: - coordinates.reverse() - coordinates = _without_nearly_collinear_vertices(coordinates) - - normals: list[tuple[float, float]] = [] - segments: list[_ConvexOffsetSegment] = [] - for start, end in zip( - coordinates, - coordinates[1:] + coordinates[:1], - strict=True, - ): - dx, dy = end[0] - start[0], end[1] - start[1] - length = math.hypot(dx, dy) - if length <= _EPS: - raise ValueError("configuration has a degenerate edge") - normal = (dy / length, -dx / length) - normals.append(normal) - segments.append( - _ConvexOffsetSegment( - start=Vec2( - x=start[0] + radius * normal[0], - y=start[1] + radius * normal[1], - ), - end=Vec2( - x=end[0] + radius * normal[0], - y=end[1] + radius * normal[1], - ), - ) - ) - - arcs: list[_ConvexOffsetArc] = [] - for index, coordinate in enumerate(coordinates): - incoming_normal = normals[index - 1] - outgoing_normal = normals[index] - start_angle = math.atan2(incoming_normal[1], incoming_normal[0]) - turn = ( - math.atan2(outgoing_normal[1], outgoing_normal[0]) - start_angle - ) % math.tau - if not 0.0 < turn < math.pi: - raise ValueError("configuration must have strictly convex turns") - arcs.append( - _ConvexOffsetArc( - center=Vec2(x=coordinate[0], y=coordinate[1]), - radius=radius, - start_angle=start_angle, - end_angle=start_angle + turn, - ) - ) - return _ConvexOffsetPrimitives(segments=tuple(segments), arcs=tuple(arcs)) - - -def _point_on_arc(arc: _ConvexOffsetArc, angle: float) -> Vec2: - return Vec2( - x=arc.center.x + arc.radius * math.cos(angle), - y=arc.center.y + arc.radius * math.sin(angle), - ) - - -def _unwrapped_arc_angle(arc: _ConvexOffsetArc, angle: float) -> float: - return arc.start_angle + (angle - arc.start_angle) % math.tau - - -def _arc_contains_angle(arc: _ConvexOffsetArc, angle: float) -> bool: - unwrapped = _unwrapped_arc_angle(arc, angle) - return unwrapped <= arc.end_angle + _GEOMETRY_TOLERANCE - - -def _bisect_to_adjacent_outside( - inside: float, - outside: float, - is_outside, -) -> float: - """Bisect an inside/outside interval to adjacent floating-point values.""" - if is_outside(inside) or not is_outside(outside): - raise ValueError("invalid inside/outside bracket") - while math.nextafter(inside, outside) != outside: - midpoint = inside + (outside - inside) / 2.0 - if midpoint == inside: - midpoint = math.nextafter(inside, outside) - elif midpoint == outside: - midpoint = math.nextafter(outside, inside) - if is_outside(midpoint): - outside = midpoint - else: - inside = midpoint - return outside - - -def _segment_circle_intersections( - start: Vec2, - end: Vec2, - *, - center: Vec2, - radius: float, -) -> tuple[tuple[float, Vec2], ...]: - reversed_input = (end.x, end.y) < (start.x, start.y) - if reversed_input: - start, end = end, start - dx, dy = end.x - start.x, end.y - start.y - fx, fy = start.x - center.x, start.y - center.y - squared_length = dx * dx + dy * dy - if squared_length <= _EPS: - return () - unclamped_closest = -(fx * dx + fy * dy) / squared_length - closest_parameter = min(1.0, max(0.0, unclamped_closest)) - - def point_at(parameter: float) -> Vec2: - return Vec2(x=start.x + parameter * dx, y=start.y + parameter * dy) - - def distance_at(parameter: float) -> float: - point = point_at(parameter) - return math.hypot(point.x - center.x, point.y - center.y) - - def outside_endpoint(inside: float, outside: float) -> float: - return _bisect_to_adjacent_outside( - inside, - outside, - lambda parameter: distance_at(parameter) >= radius, - ) - - linear = 2.0 * (fx * dx + fy * dy) - constant = fx * fx + fy * fy - radius * radius - discriminant = linear * linear - 4.0 * squared_length * constant - guard = 64.0 * math.ulp( - max(1.0, abs(linear * linear), abs(4.0 * squared_length * constant)) - ) - if discriminant < -guard: - return () - closest_distance = distance_at(closest_parameter) - if closest_distance > radius and not 0.0 <= unclamped_closest <= 1.0: - return () - if closest_distance >= radius: - parameters = (closest_parameter,) - else: - parameters = tuple( - outside_endpoint(closest_parameter, endpoint) - for endpoint in (0.0, 1.0) - if endpoint != closest_parameter and distance_at(endpoint) >= radius - ) - intersections: list[tuple[float, Vec2]] = [] - for parameter in parameters: - point = point_at(parameter) - if math.hypot(point.x - center.x, point.y - center.y) < radius: - raise ArithmeticError("circle intersection was not enclosed") - original_parameter = 1.0 - parameter if reversed_input else parameter - if not any( - math.isclose( - original_parameter, - existing[0], - rel_tol=0.0, - abs_tol=_EPS, - ) - for existing in intersections - ): - intersections.append((original_parameter, point)) - return tuple(intersections) - - -def _geometry_boundary_segments( - geometry: BaseGeometry, -) -> tuple[tuple[Vec2, Vec2], ...]: - segments: list[tuple[Vec2, Vec2]] = [] - for ring in _rings(geometry): - coordinates = tuple((float(x), float(y)) for x, y in ring.coords) - segments.extend( - (Vec2(x=start[0], y=start[1]), Vec2(x=end[0], y=end[1])) - for start, end in pairwise(coordinates) - if start != end - ) - return tuple(segments) - - -def _point_is_within_geometry_tolerance( - point: Vec2, - geometry: BaseGeometry, -) -> bool: - return geometry.buffer(_GEOMETRY_TOLERANCE).covers(Point(point.x, point.y)) - - -def _intersection_geometry_candidates( - origin: Vec2, - geometry: BaseGeometry, -) -> tuple[Vec2, ...]: - if geometry.is_empty: - return () - geometries = getattr(geometry, "geoms", (geometry,)) - candidates: list[Vec2] = [] - for item in geometries: - if isinstance(item, Point): - candidates.append(Vec2(x=float(item.x), y=float(item.y))) - elif hasattr(item, "coords"): - coordinates = tuple((float(x), float(y)) for x, y in item.coords) - candidates.extend(Vec2(x=x, y=y) for x, y in coordinates) - candidates.extend( - point_to_segment( - origin, - Vec2(x=start[0], y=start[1]), - Vec2(x=end[0], y=end[1]), - ).point - for start, end in pairwise(coordinates) - ) - else: - candidates.extend(_intersection_geometry_candidates(origin, item)) - return tuple(candidates) - - -def _deduplicate_points(points: list[Vec2]) -> tuple[Vec2, ...]: - unique: dict[tuple[float, float], Vec2] = {} - for point in points: - scale = max(1.0, abs(point.x), abs(point.y)) - zero_tolerance = 64.0 * math.ulp(scale) - canonical = Vec2( - x=0.0 if abs(point.x) <= zero_tolerance else point.x, - y=0.0 if abs(point.y) <= zero_tolerance else point.y, - ) - unique.setdefault( - (round(canonical.x, 12), round(canonical.y, 12)), - canonical, - ) - return tuple(unique[key] for key in sorted(unique)) - - -def _enclose_far_candidate( - candidate: Vec2, - configuration: Polygon, - radius: float, -) -> Vec2 | None: - candidate_point = Point(candidate.x, candidate.y) - if candidate_point.distance(configuration) >= radius: - return candidate - nearest = nearest_points(configuration, candidate_point)[0] - nearest_x, nearest_y = float(nearest.x), float(nearest.y) - dx, dy = candidate.x - nearest_x, candidate.y - nearest_y - distance = math.hypot(dx, dy) - if distance <= _EPS: - return None - unit_x, unit_y = dx / distance, dy / distance - excess = max(radius - distance, math.ulp(radius)) - while True: - outside_distance = radius + excess - if not math.isfinite(outside_distance): - return None - outside = Vec2( - x=nearest_x + outside_distance * unit_x, - y=nearest_y + outside_distance * unit_y, - ) - if Point(outside.x, outside.y).distance(configuration) >= radius: - break - next_excess = excess * 2.0 - if not math.isfinite(next_excess) or next_excess == excess: - return None - excess = next_excess - - def point_at(parameter: float) -> Vec2: - return Vec2( - x=candidate.x + parameter * (outside.x - candidate.x), - y=candidate.y + parameter * (outside.y - candidate.y), - ) - - def is_enclosed(parameter: float) -> bool: - point = point_at(parameter) - return Point(point.x, point.y).distance(configuration) >= radius - - parameter = _bisect_to_adjacent_outside( - 0.0, - 1.0, - is_enclosed, - ) - return point_at(parameter) - - -def _exact_far_candidates( - origin: Vec2, - physical: BaseGeometry, - configuration: Polygon, - radius: float, -) -> tuple[Vec2, ...]: - """Return a complete finite candidate set for an exact convex FAR optimum.""" - if physical.is_empty: - return () - primitives = _convex_offset_primitives(configuration, radius) - physical_edges = _geometry_boundary_segments(physical) - candidates: list[Vec2] = [] - - if ( - _point_is_within_geometry_tolerance(origin, physical) - and Point(origin.x, origin.y).distance(configuration) >= radius - ): - candidates.append(origin) - - for segment in primitives.segments: - intersection = LineString( - ((segment.start.x, segment.start.y), (segment.end.x, segment.end.y)) - ).intersection(physical) - candidates.extend(_intersection_geometry_candidates(origin, intersection)) - - for arc in primitives.arcs: - events: dict[float, tuple[float, Vec2]] = { - round(arc.start_angle, 15): ( - arc.start_angle, - _point_on_arc(arc, arc.start_angle), - ), - round(arc.end_angle, 15): ( - arc.end_angle, - _point_on_arc(arc, arc.end_angle), - ), - } - for start, end in physical_edges: - for _, point in _segment_circle_intersections( - start, - end, - center=arc.center, - radius=arc.radius, - ): - angle = math.atan2(point.y - arc.center.y, point.x - arc.center.x) - if _arc_contains_angle(arc, angle): - unwrapped = _unwrapped_arc_angle(arc, angle) - events.setdefault(round(unwrapped, 15), (unwrapped, point)) - ordered_events = tuple(events[key] for key in sorted(events)) - for _, point in ordered_events: - if _point_is_within_geometry_tolerance(point, physical): - candidates.append(point) - for start_event, end_event in pairwise(ordered_events): - start_angle, start_point = start_event - end_angle, end_point = end_event - if end_angle - start_angle <= _EPS: - continue - midpoint = _point_on_arc(arc, (start_angle + end_angle) / 2.0) - if not _point_is_within_geometry_tolerance(midpoint, physical): - continue - candidates.extend((start_point, end_point)) - if ( - math.hypot( - origin.x - arc.center.x, - origin.y - arc.center.y, - ) - > _EPS - ): - radial_angle = _unwrapped_arc_angle( - arc, - math.atan2( - origin.y - arc.center.y, - origin.x - arc.center.x, - ), - ) - if start_angle <= radial_angle <= end_angle: - candidates.append(_point_on_arc(arc, radial_angle)) - - for start, end in physical_edges: - parameters = [0.0, 1.0] - edge_line = LineString(((start.x, start.y), (end.x, end.y))) - for segment in primitives.segments: - intersection = edge_line.intersection( - LineString( - ( - (segment.start.x, segment.start.y), - (segment.end.x, segment.end.y), - ) - ) - ) - for point in _intersection_geometry_candidates(origin, intersection): - edge_length = math.hypot(end.x - start.x, end.y - start.y) - if edge_length > _EPS: - parameters.append( - math.hypot(point.x - start.x, point.y - start.y) / edge_length - ) - for arc in primitives.arcs: - parameters.extend( - parameter - for parameter, point in _segment_circle_intersections( - start, - end, - center=arc.center, - radius=arc.radius, - ) - if _arc_contains_angle( - arc, - math.atan2(point.y - arc.center.y, point.x - arc.center.x), - ) - ) - parameters = sorted( - {round(min(1.0, max(0.0, parameter)), 15) for parameter in parameters} - ) - for lower, upper in pairwise(parameters): - if upper - lower <= _EPS: - continue - midpoint_parameter = (lower + upper) / 2.0 - midpoint = Vec2( - x=start.x + midpoint_parameter * (end.x - start.x), - y=start.y + midpoint_parameter * (end.y - start.y), - ) - if Point(midpoint.x, midpoint.y).distance(configuration) + _EPS < radius: - continue - interval_start = Vec2( - x=start.x + lower * (end.x - start.x), - y=start.y + lower * (end.y - start.y), - ) - interval_end = Vec2( - x=start.x + upper * (end.x - start.x), - y=start.y + upper * (end.y - start.y), - ) - candidates.extend( - ( - interval_start, - interval_end, - point_to_segment(origin, interval_start, interval_end).point, - ) - ) - - enclosed = [ - enclosed_candidate - for candidate in candidates - if ( - enclosed_candidate := _enclose_far_candidate( - candidate, - configuration, - radius, - ) - ) - is not None - ] - return _deduplicate_points(enclosed) - - -def _attained_candidate_neighbor( - case: StressCaseDraft, - point: Vec2, - physical: BaseGeometry, - strict_boundaries: tuple[BaseGeometry, ...], -) -> Vec2 | None: - """Repair only finite-precision misses around a non-strict exact candidate.""" - shapely_point = Point(point.x, point.y) - - def is_on_strict_boundary(candidate: Vec2) -> bool: - candidate_point = Point(candidate.x, candidate.y) - return any( - not boundary.is_empty and boundary.distance(candidate_point) <= _EPS - for boundary in strict_boundaries - ) - - if is_on_strict_boundary(point): - return None - - def is_valid(candidate: Vec2) -> bool: - return ( - not is_on_strict_boundary(candidate) - and physical.covers(Point(candidate.x, candidate.y)) - and _candidate_projection_is_valid(case, candidate) - and _candidate_preserves_relations(case, candidate) - and _candidate_satisfies_target(case, candidate) - ) - - nearest_physical = nearest_points(physical, shapely_point)[0] - projected = Vec2(x=float(nearest_physical.x), y=float(nearest_physical.y)) - repairs: list[Vec2] = [] - if is_valid(projected): - repairs.append(projected) - - for start, end in _geometry_boundary_segments(physical): - projection = point_to_segment(point, start, end) - if projection.distance > _GEOMETRY_TOLERANCE: - continue - base = projection.point - if is_valid(base): - repairs.append(base) - continue - dx, dy = end.x - start.x, end.y - start.y - squared_length = dx * dx + dy * dy - if squared_length <= _EPS: - continue - base_parameter = ( - (base.x - start.x) * dx + (base.y - start.y) * dy - ) / squared_length - base_parameter = min(1.0, max(0.0, base_parameter)) - - def point_at( - parameter: float, - segment_start: Vec2 = start, - segment_dx: float = dx, - segment_dy: float = dy, - ) -> Vec2: - return Vec2( - x=segment_start.x + parameter * segment_dx, - y=segment_start.y + parameter * segment_dy, - ) - - rebuilt_base = point_at(base_parameter) - if is_valid(rebuilt_base): - repairs.append(rebuilt_base) - continue - if _candidate_satisfies_target(case, rebuilt_base): - continue - - for endpoint_parameter in (0.0, 1.0): - if endpoint_parameter == base_parameter: - continue - endpoint = point_at(endpoint_parameter) - if not _candidate_satisfies_target(case, endpoint): - continue - parameter = _bisect_to_adjacent_outside( - base_parameter, - endpoint_parameter, - lambda value: _candidate_satisfies_target(case, point_at(value)), - ) - repaired = point_at(parameter) - if is_valid(repaired): - repairs.append(repaired) - - if not repairs and physical.covers(shapely_point): - subject = case.scene.object_by_id(case.intervention.subject_id) - reference = case.scene.object_by_id(case.intervention.reference_id) - configuration = _configuration_polygon(subject, reference) - nearest_configuration = nearest_points(configuration, shapely_point)[0] - nearest_x = float(nearest_configuration.x) - nearest_y = float(nearest_configuration.y) - dx, dy = point.x - nearest_x, point.y - nearest_y - distance = math.hypot(dx, dy) - if distance > _EPS: - unit_x, unit_y = dx / distance, dy / distance - excess = max(RelationEngine.FAR_METERS - distance, math.ulp(distance)) - while True: - outside_distance = RelationEngine.FAR_METERS + excess - if not math.isfinite(outside_distance): - break - outside = Vec2( - x=nearest_x + outside_distance * unit_x, - y=nearest_y + outside_distance * unit_y, - ) - if _candidate_satisfies_target(case, outside): - parameter = _bisect_to_adjacent_outside( - 0.0, - 1.0, - lambda value, outside_candidate=outside: ( - _candidate_satisfies_target( - case, - Vec2( - x=point.x + value * (outside_candidate.x - point.x), - y=point.y + value * (outside_candidate.y - point.y), - ), - ) - ), - ) - repaired = Vec2( - x=point.x + parameter * (outside.x - point.x), - y=point.y + parameter * (outside.y - point.y), - ) - if is_valid(repaired): - repairs.append(repaired) - break - next_excess = excess * 2.0 - if not math.isfinite(next_excess) or next_excess == excess: - break - excess = next_excess - - if not repairs: - return None - return min( - repairs, - key=lambda candidate: ( - math.hypot(candidate.x - point.x, candidate.y - point.y), - candidate.x, - candidate.y, - ), - ) - - -def support_center_rectangle( - subject: SceneObject, - support: SceneObject, -) -> tuple[float, float, float, float]: - """Return the exact subject-position rectangle fully supported by an axis-aligned OBB.""" - footprint = obb_footprint(support.obb) - bounds = footprint.bounds - scale = max(1.0, *(abs(value) for value in bounds)) - axis_tolerance = 64.0 * math.ulp(scale) - min_x, min_y, max_x, max_y = bounds - vertices_are_axis_aligned = all( - min(abs(float(x) - min_x), abs(float(x) - max_x)) <= axis_tolerance - and min(abs(float(y) - min_y), abs(float(y) - max_y)) <= axis_tolerance - for x, y in footprint.exterior.coords[:-1] - ) - if footprint.is_empty or not vertices_are_axis_aligned: - raise ValueError("support footprint must be an axis-aligned rectangle") - relative = _relative_subject_vertices(subject) - lower_x = min_x - min(point.x for point in relative) - lower_y = min_y - min(point.y for point in relative) - upper_x = max_x - max(point.x for point in relative) - upper_y = max_y - max(point.y for point in relative) - if lower_x > upper_x or lower_y > upper_y: - raise ValueError("subject footprint cannot fit on support") - - def canonical_zero(value: float) -> float: - return 0.0 if abs(value) <= axis_tolerance else value - - return ( - canonical_zero(lower_x), - canonical_zero(lower_y), - canonical_zero(upper_x), - canonical_zero(upper_y), - ) - - -def _geometry_vertices(geometry: BaseGeometry) -> tuple[Point, ...]: - if geometry.is_empty: - return () - geometries = getattr(geometry, "geoms", (geometry,)) - points: list[Point] = [] - for item in geometries: - if isinstance(item, Polygon): - points.extend( - Point(float(x), float(y)) for x, y in item.exterior.coords[:-1] - ) - for ring in item.interiors: - points.extend(Point(float(x), float(y)) for x, y in ring.coords[:-1]) - elif hasattr(item, "coords"): - points.extend(Point(float(x), float(y)) for x, y in item.coords) - return tuple(points) - - -def maximum_ground_gap( - subject: SceneObject, - reference: SceneObject, - center_locus: BaseGeometry, -) -> float: - """Maximize exact OBB ground gap over every vertex of a polygonal center locus.""" - configuration = _configuration_polygon(subject, reference) - vertices = _geometry_vertices(center_locus) - if not vertices: - return 0.0 - return max(float(point.distance(configuration)) for point in vertices) - - -def _rectangular_center_locus( - container: BaseGeometry, - subject: SceneObject, -) -> BaseGeometry: - if container.is_empty or not container.equals(box(*container.bounds)): - raise ValueError("stress oracle container must be an axis-aligned rectangle") - min_x, min_y, max_x, max_y = container.bounds - relative = _relative_subject_vertices(subject) - lower_x = min_x - min(point.x for point in relative) - lower_y = min_y - min(point.y for point in relative) - upper_x = max_x - max(point.x for point in relative) - upper_y = max_y - max(point.y for point in relative) - if lower_x > upper_x or lower_y > upper_y: - return Polygon() - return box(lower_x, lower_y, upper_x, upper_y) - - -def _physical_center_locus(case: StressCaseDraft) -> BaseGeometry: - scene = case.scene - subject = scene.object_by_id(case.intervention.subject_id) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - locus = _rectangular_center_locus(room, subject) - support_id = subject.support_object_id - if support_id is not None: - support = scene.object_by_id(support_id) - locus = locus.intersection(box(*support_center_rectangle(subject, support))) - for obstacle in sorted(scene.objects, key=lambda item: item.object_id): - if obstacle.object_id in {subject.object_id, support_id}: - continue - subject_bottom = subject.obb.center.z - subject.obb.extent.z / 2.0 - subject_top = subject.obb.center.z + subject.obb.extent.z / 2.0 - obstacle_bottom = obstacle.obb.center.z - obstacle.obb.extent.z / 2.0 - obstacle_top = obstacle.obb.center.z + obstacle.obb.extent.z / 2.0 - z_overlap = min(subject_top, obstacle_top) - max( - subject_bottom, - obstacle_bottom, - ) - if z_overlap <= _GEOMETRY_TOLERANCE: - continue - locus = locus.difference(_configuration_polygon(subject, obstacle)) - return locus - - -def _camera_affine_coefficients( - camera: Camera, - row: int, - z: float, -) -> tuple[float, float, float]: - start = row * 4 - matrix = camera.world_to_camera - return ( - matrix[start], - matrix[start + 1], - matrix[start + 2] * z + matrix[start + 3], - ) - - -def _linear_expression_half_space( - geometry: BaseGeometry, - coefficients: tuple[float, float, float], - *, - keep_greater: bool, -) -> BaseGeometry: - x_coefficient, y_coefficient, constant = coefficients - sign = 1.0 if keep_greater else -1.0 - if math.hypot(x_coefficient, y_coefficient) <= _EPS: - return geometry if sign * constant >= 0.0 else Polygon() - normal = Vec2(x=sign * x_coefficient, y=sign * y_coefficient) - offset = -sign * constant - return _intersect_half_space(geometry, normal, offset) - - -def _subject_projection_residuals( - case: StressCaseDraft, -) -> tuple[float, float, float] | None: - """Independently recover observed-minus-analytic subject calibration.""" - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - camera = scene.camera_by_id(spec.camera_id) - subject_view = subject.views.get(spec.camera_id) - if subject_view is None: - return None - - matrix = camera.world_to_camera - position = subject.position - - def source_camera_coordinate(row: int) -> float: - start = 4 * row - return ( - matrix[start] * position.x - + matrix[start + 1] * position.y - + matrix[start + 2] * position.z - + matrix[start + 3] - ) - - source_camera_x = source_camera_coordinate(0) - source_camera_y = source_camera_coordinate(1) - source_depth = source_camera_coordinate(2) - if source_depth <= 0.0: - return None - fx, _, cx, _, fy, cy, _, _, _ = camera.intrinsics - source_center_x = fx * source_camera_x / source_depth + cx - source_center_y = cy - fy * source_camera_y / source_depth - bbox = subject_view.bbox - observed_center_y = (bbox.ymin + bbox.ymax) / 2.0 - residuals = ( - bbox.center_x - source_center_x, - observed_center_y - source_center_y, - subject_view.camera_depth - source_depth, - ) - calibration_values = ( - source_center_x, - source_center_y, - source_depth, - *residuals, - ) - if not all(math.isfinite(value) for value in calibration_values): - return None - return residuals - - -def _visibility_center_locus( - case: StressCaseDraft, - geometry: BaseGeometry, -) -> BaseGeometry: - """Return a conservative positive-depth outer for reachable subject centers. - - Product visibility permits a partially clipped bounding box, whose exact - clipped-area constraint is nonlinear when both image axes clip. Keep this - locus conservative and enforce that constraint in the final candidate gate. - """ - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - camera = scene.camera_by_id(spec.camera_id) - depth = _camera_affine_coefficients(camera, 2, subject.position.z) - return _linear_expression_half_space( - geometry, - depth, - keep_greater=True, - ) - - -def _horizontal_relation_half_space( - case: StressCaseDraft, - stationary: SceneObject, - relation: Relation, - *, - include_comparison_tolerance: bool = True, -) -> tuple[Vec2, float]: - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - camera = scene.camera_by_id(spec.camera_id) - reference_view = stationary.views[spec.camera_id] - fx, _, cx, _, _, _, _, _, _ = camera.intrinsics - direction = 1.0 if relation is Relation.RIGHT else -1.0 - comparison_tolerance = ( - _OPERATIONAL_COMPARISON_TOLERANCE if include_comparison_tolerance else 0.0 - ) - residuals = _subject_projection_residuals(case) - if residuals is None: - raise ValueError("subject projection calibration is invalid") - horizontal_residual, _, _ = residuals - threshold = camera.width * RelationEngine.LEFT_RIGHT_FRACTION - target_u = reference_view.bbox.center_x + direction * ( - threshold - comparison_tolerance - ) - pixel_offset = target_u - cx - horizontal_residual - matrix = camera.world_to_camera - z = subject.position.z - normal = Vec2( - x=direction * (fx * matrix[0] - pixel_offset * matrix[8]), - y=direction * (fx * matrix[1] - pixel_offset * matrix[9]), - ) - constant = direction * ( - fx * (matrix[2] * z + matrix[3]) - pixel_offset * (matrix[10] * z + matrix[11]) - ) - return normal, -constant - - -def _depth_relation_half_space( - case: StressCaseDraft, - stationary: SceneObject, - relation: Relation, - *, - include_comparison_tolerance: bool = True, -) -> tuple[Vec2, float]: - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - camera = scene.camera_by_id(spec.camera_id) - reference_depth = stationary.views[spec.camera_id].camera_depth - direction = 1.0 if relation is Relation.BEHIND else -1.0 - comparison_tolerance = ( - _OPERATIONAL_COMPARISON_TOLERANCE if include_comparison_tolerance else 0.0 - ) - residuals = _subject_projection_residuals(case) - if residuals is None: - raise ValueError("subject projection calibration is invalid") - _, _, depth_residual = residuals - threshold = RelationEngine.FRONT_BEHIND_METERS - comparison_tolerance - target_depth = reference_depth + direction * threshold - depth_residual - matrix = camera.world_to_camera - z = subject.position.z - normal = Vec2(x=direction * matrix[8], y=direction * matrix[9]) - constant = direction * (matrix[10] * z + matrix[11] - target_depth) - return normal, -constant - - -def _relation_locus( - case: StressCaseDraft, - geometry: BaseGeometry, - stationary: SceneObject, - relation: Relation, - *, - expand_boundary: bool = True, -) -> BaseGeometry: - if relation.axis is RelationAxis.HORIZONTAL: - normal, offset = _horizontal_relation_half_space(case, stationary, relation) - return _intersect_half_space( - geometry, - normal, - offset, - expand_boundary=expand_boundary, - ) - if relation.axis is RelationAxis.DEPTH: - normal, offset = _depth_relation_half_space(case, stationary, relation) - return _intersect_half_space( - geometry, - normal, - offset, - expand_boundary=expand_boundary, - ) - subject = case.scene.object_by_id(case.intervention.subject_id) - configuration = _configuration_polygon(subject, stationary) - radius = ( - RelationEngine.NEAR_METERS - if relation is Relation.NEAR - else RelationEngine.FAR_METERS - ) - inner, outer = _distance_buffer_envelopes(configuration, radius) - if relation is Relation.NEAR: - return geometry.intersection(outer) - return geometry.difference(inner).union(geometry.intersection(inner.boundary)) - - -def _ambiguous_distance_outer_locus( - case: StressCaseDraft, - geometry: BaseGeometry, - stationary: SceneObject, -) -> tuple[BaseGeometry, tuple[BaseGeometry, BaseGeometry]]: - subject = case.scene.object_by_id(case.intervention.subject_id) - configuration = _configuration_polygon(subject, stationary) - near_inner, _ = _distance_buffer_envelopes( - configuration, - RelationEngine.NEAR_METERS, - ) - _, far_outer = _distance_buffer_envelopes( - configuration, - RelationEngine.FAR_METERS, - ) - outside_near_inner = geometry.difference(near_inner).union( - geometry.intersection(near_inner.boundary) - ) - return outside_near_inner.intersection(far_outer), ( - near_inner.boundary, - far_outer.boundary, - ) - - -def _relation_boundary( - case: StressCaseDraft, - geometry: BaseGeometry, - stationary: SceneObject, - relation: Relation, -) -> BaseGeometry: - if relation.axis is RelationAxis.HORIZONTAL: - normal, offset = _horizontal_relation_half_space(case, stationary, relation) - return _half_space_boundary_line(geometry, normal, offset) - if relation.axis is RelationAxis.DEPTH: - normal, offset = _depth_relation_half_space(case, stationary, relation) - return _half_space_boundary_line(geometry, normal, offset) - subject = case.scene.object_by_id(case.intervention.subject_id) - radius = ( - RelationEngine.NEAR_METERS - if relation is Relation.NEAR - else RelationEngine.FAR_METERS - ) - return ( - _configuration_polygon(subject, stationary) - .buffer( - radius, - quad_segs=_BUFFER_QUAD_SEGS, - ) - .boundary - ) - - -def _preserved_relations_center_locus( - case: StressCaseDraft, - geometry: BaseGeometry, -) -> tuple[BaseGeometry, tuple[BaseGeometry, ...]]: - """Intersect the exact relation-label preservation contract used by Verifier.""" - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - engine = RelationEngine() - result = geometry - strict_boundaries: list[BaseGeometry] = [] - for stationary in sorted(scene.objects, key=lambda item: item.object_id): - if stationary.object_id == subject.object_id: - continue - stationary_view = stationary.views.get(spec.camera_id) - subject_view = subject.views.get(spec.camera_id) - permanently_invisible = ( - subject_view is None - or stationary_view is None - or subject_view.visible_fraction < engine.MIN_VISIBLE_FRACTION - or subject_view.image_area_fraction < engine.MIN_IMAGE_AREA_FRACTION - or subject_view.truncated_fraction > engine.MAX_TRUNCATED_FRACTION - or stationary_view.visible_fraction < engine.MIN_VISIBLE_FRACTION - or stationary_view.image_area_fraction < engine.MIN_IMAGE_AREA_FRACTION - or stationary_view.truncated_fraction > engine.MAX_TRUNCATED_FRACTION - ) - labels = engine.pair_labels( - scene, - subject.object_id, - stationary.object_id, - spec.camera_id, - ) - for axis in RelationAxis: - if ( - stationary.object_id == spec.reference_id - and axis is spec.relation_after.axis - ): - continue - axis_labels = tuple( - relation for relation in _AXIS_RELATIONS[axis] if relation in labels - ) - if len(axis_labels) == 1: - result = _relation_locus( - case, - result, - stationary, - axis_labels[0], - expand_boundary=False, - ) - elif len(axis_labels) > 1: - raise ValueError("before scene has contradictory relation labels") - elif not permanently_invisible: - if axis is RelationAxis.DISTANCE: - result, distance_boundaries = _ambiguous_distance_outer_locus( - case, - result, - stationary, - ) - strict_boundaries.extend(distance_boundaries) - else: - first, second = _AXIS_RELATIONS[axis] - labelled = _relation_locus(case, result, stationary, first).union( - _relation_locus(case, result, stationary, second) - ) - result = result.difference(labelled) - strict_boundaries.extend( - ( - _relation_boundary(case, geometry, stationary, first), - _relation_boundary(case, geometry, stationary, second), - ) - ) - if result.is_empty: - return result, tuple(strict_boundaries) - return result, tuple(strict_boundaries) - - -def _horizontal_half_space(case: StressCaseDraft) -> tuple[Vec2, float]: - reference = case.scene.object_by_id(case.intervention.reference_id) - return _horizontal_relation_half_space( - case, - reference, - case.intervention.relation_after, - ) - - -def _depth_half_space(case: StressCaseDraft) -> tuple[Vec2, float]: - reference = case.scene.object_by_id(case.intervention.reference_id) - return _depth_relation_half_space( - case, - reference, - case.intervention.relation_after, - ) - - -def _half_space_polygon( - geometry: BaseGeometry, - normal: Vec2, - offset: float, - *, - expand_boundary: bool = True, -) -> Polygon: - norm = math.hypot(normal.x, normal.y) - if norm <= _EPS: - raise ValueError("target relation has a zero half-space normal") - unit_x, unit_y = normal.x / norm, normal.y / norm - tangent_x, tangent_y = -unit_y, unit_x - if geometry.is_empty: - return Polygon() - min_x, min_y, max_x, max_y = geometry.bounds - magnitude = max( - 1.0, - abs(offset), - *( - abs(normal.x * x) + abs(normal.y * y) - for x in (min_x, max_x) - for y in (min_y, max_y) - ), - ) - guarded_offset = offset - 32.0 * math.ulp(magnitude) if expand_boundary else offset - boundary_x = normal.x * guarded_offset / (norm * norm) - boundary_y = normal.y * guarded_offset / (norm * norm) - scale = 8.0 * max( - 1.0, - abs(min_x), - abs(min_y), - abs(max_x), - abs(max_y), - abs(boundary_x), - abs(boundary_y), - ) - return Polygon( - [ - (boundary_x - scale * tangent_x, boundary_y - scale * tangent_y), - (boundary_x + scale * tangent_x, boundary_y + scale * tangent_y), - ( - boundary_x + scale * tangent_x + scale * unit_x, - boundary_y + scale * tangent_y + scale * unit_y, - ), - ( - boundary_x - scale * tangent_x + scale * unit_x, - boundary_y - scale * tangent_y + scale * unit_y, - ), - ] - ) - - -def _intersect_half_space( - geometry: BaseGeometry, - normal: Vec2, - offset: float, - *, - expand_boundary: bool = True, -) -> BaseGeometry: - if math.hypot(normal.x, normal.y) <= _EPS: - return geometry if offset <= 0.0 else Polygon() - return geometry.intersection( - _half_space_polygon( - geometry, - normal, - offset, - expand_boundary=expand_boundary, - ) - ) - - -def _half_space_boundary_line( - geometry: BaseGeometry, - normal: Vec2, - offset: float, -) -> LineString: - norm = math.hypot(normal.x, normal.y) - if norm <= _EPS: - return LineString() - unit_x, unit_y = normal.x / norm, normal.y / norm - tangent_x, tangent_y = -unit_y, unit_x - boundary_x = normal.x * offset / (norm * norm) - boundary_y = normal.y * offset / (norm * norm) - bounds = geometry.bounds if not geometry.is_empty else (0.0, 0.0, 0.0, 0.0) - scale = 16.0 * max( - 1.0, *(abs(value) for value in bounds), abs(boundary_x), abs(boundary_y) - ) - return LineString( - ( - (boundary_x - scale * tangent_x, boundary_y - scale * tangent_y), - (boundary_x + scale * tangent_x, boundary_y + scale * tangent_y), - ) - ) - - -def _target_locus( - case: StressCaseDraft, - physical: BaseGeometry, -) -> tuple[BaseGeometry, Vec2 | None, float]: - relation = case.intervention.relation_after - if relation in {Relation.LEFT, Relation.RIGHT}: - normal, offset = _horizontal_half_space(case) - target = _intersect_half_space(physical, normal, offset) - return target, normal, offset - if relation in {Relation.FRONT, Relation.BEHIND}: - normal, offset = _depth_half_space(case) - target = _intersect_half_space(physical, normal, offset) - return target, normal, offset - if relation is not Relation.FAR: - raise ValueError("stress oracle supports only opposite target relations") - scene = case.scene - subject = scene.object_by_id(case.intervention.subject_id) - reference = scene.object_by_id(case.intervention.reference_id) - configuration = _configuration_polygon(subject, reference) - excluded, _ = _distance_buffer_envelopes( - configuration, - RelationEngine.FAR_METERS, - ) - return physical.difference(excluded), None, RelationEngine.FAR_METERS - - -def _rings(geometry: BaseGeometry): - geometries = getattr(geometry, "geoms", (geometry,)) - for item in geometries: - if isinstance(item, Polygon): - yield item.exterior - yield from item.interiors - elif hasattr(item, "coords"): - yield item - - -def _nearest_geometry_points( - origin: Vec2, - geometry: BaseGeometry, - *, - is_valid: Callable[[Vec2], bool] | None = None, - strict_boundaries: tuple[BaseGeometry, ...] = (), -) -> tuple[Vec2, ...]: - point = Point(origin.x, origin.y) - if geometry.covers(point): - return (origin,) - candidates: list[PointProjection] = [] - for ring in _rings(geometry): - coordinates = tuple((float(x), float(y)) for x, y in ring.coords) - for start, end in pairwise(coordinates): - candidates.append( - point_to_segment( - origin, - Vec2(x=start[0], y=start[1]), - Vec2(x=end[0], y=end[1]), - ) - ) - if not candidates: - raise ValueError("target locus has no boundary") - minimum = min(candidate.distance for candidate in candidates) - unique: set[tuple[float, float]] = set() - for candidate in candidates: - if not math.isclose(candidate.distance, minimum, rel_tol=0.0, abs_tol=_EPS): - continue - candidate_geometry = Point(candidate.point.x, candidate.point.y) - if any( - not boundary.is_empty and boundary.distance(candidate_geometry) <= _EPS - for boundary in strict_boundaries - ): - continue - rounded_x = round(candidate.point.x, 12) - rounded_y = round(candidate.point.y, 12) - # Decimal canonicalization can round an exact operational boundary to - # the excluded side (for example .3999999999875 -> .399999999987). - # Select the nearest adjacent 12-place point still enclosed by the - # independently derived locus and accepted by every caller gate. - adjacent = [ - (x, y) - for x in ( - round(rounded_x - 1e-12, 12), - rounded_x, - round(rounded_x + 1e-12, 12), - ) - for y in ( - round(rounded_y - 1e-12, 12), - rounded_y, - round(rounded_y + 1e-12, 12), - ) - if geometry.covers(Point(x, y)) - and (is_valid is None or is_valid(Vec2(x=x, y=y))) - ] - if adjacent: - unique.add( - min( - adjacent, - key=lambda point: ( - math.hypot( - point[0] - candidate.point.x, - point[1] - candidate.point.y, - ), - point, - ), - ) - ) - return tuple(Vec2(x=x, y=y) for x, y in sorted(unique)) - - -def _maximum_half_space_value( - physical: BaseGeometry, - normal: Vec2, - offset: float, -) -> tuple[float, float]: - norm = math.hypot(normal.x, normal.y) - vertices = _geometry_vertices(physical) - maximum = ( - max((normal.x * point.x + normal.y * point.y) / norm for point in vertices) - if vertices - else 0.0 - ) - return maximum, offset / norm - - -def _positive_depth_boundary( - case: StressCaseDraft, - geometry: BaseGeometry, -) -> BaseGeometry: - subject = case.scene.object_by_id(case.intervention.subject_id) - camera = case.scene.camera_by_id(case.intervention.camera_id) - x_coefficient, y_coefficient, constant = _camera_affine_coefficients( - camera, - 2, - subject.position.z, - ) - if math.hypot(x_coefficient, y_coefficient) <= _EPS: - return LineString() - return _half_space_boundary_line( - geometry, - Vec2(x=x_coefficient, y=y_coefficient), - -constant, - ) - - -def _candidate_projection( - case: StressCaseDraft, - point: Vec2, -) -> tuple[float, float, float]: - subject = case.scene.object_by_id(case.intervention.subject_id) - camera = case.scene.camera_by_id(case.intervention.camera_id) - coefficients = tuple( - _camera_affine_coefficients(camera, row, subject.position.z) for row in range(3) - ) - camera_x, camera_y, depth = ( - x_coefficient * point.x + y_coefficient * point.y + constant - for x_coefficient, y_coefficient, constant in coefficients - ) - if depth <= 0.0: - return math.nan, math.nan, depth - fx, _, cx, _, fy, cy, _, _, _ = camera.intrinsics - return fx * camera_x / depth + cx, cy - fy * camera_y / depth, depth - - -def _candidate_projection_is_valid(case: StressCaseDraft, point: Vec2) -> bool: - center_x, center_y, depth = _candidate_projection(case, point) - if depth <= 0.0 or not all( - math.isfinite(value) for value in (center_x, center_y, depth) - ): - return False - scene = case.scene - spec = case.intervention - camera = scene.camera_by_id(spec.camera_id) - subject = scene.object_by_id(spec.subject_id) - subject_view = subject.views.get(spec.camera_id) - residuals = _subject_projection_residuals(case) - if subject_view is None or residuals is None: - return False - bbox = subject_view.bbox - horizontal_residual, vertical_residual, depth_residual = residuals - center_x += horizontal_residual - center_y += vertical_residual - calibrated_depth = depth + depth_residual - if not math.isfinite(calibrated_depth) or calibrated_depth <= 0.0: - return False - - half_width = (bbox.xmax - bbox.xmin) / 2.0 - half_height = (bbox.ymax - bbox.ymin) / 2.0 - xmin, xmax = center_x - half_width, center_x + half_width - ymin, ymax = center_y - half_height, center_y + half_height - full_area = (xmax - xmin) * (ymax - ymin) - if full_area <= 0.0: - return False - clipped_width = max( - 0.0, - min(xmax, camera.width) - max(xmin, 0.0), - ) - clipped_height = max( - 0.0, - min(ymax, camera.height) - max(ymin, 0.0), - ) - truncated_fraction = 1.0 - clipped_width * clipped_height / full_area - return ( - subject_view.visible_fraction >= RelationEngine.MIN_VISIBLE_FRACTION - and subject_view.image_area_fraction >= RelationEngine.MIN_IMAGE_AREA_FRACTION - and truncated_fraction <= RelationEngine.MAX_TRUNCATED_FRACTION - ) - - -def _candidate_axis_labels( - case: StressCaseDraft, - stationary: SceneObject, - axis: RelationAxis, - point: Vec2, -) -> frozenset[Relation]: - scene = case.scene - camera_id = case.intervention.camera_id - if axis is RelationAxis.DISTANCE: - subject = scene.object_by_id(case.intervention.subject_id) - delta_x = point.x - subject.position.x - delta_y = point.y - subject.position.y - moved_obb = subject.obb.model_copy( - update={ - "center": subject.obb.center.model_copy( - update={ - "x": subject.obb.center.x + delta_x, - "y": subject.obb.center.y + delta_y, - } - ) - } - ) - gap = ground_gap(moved_obb, stationary.obb) - if gap <= RelationEngine.NEAR_METERS: - return frozenset({Relation.NEAR}) - if gap >= RelationEngine.FAR_METERS: - return frozenset({Relation.FAR}) - return frozenset() - - stationary_view = stationary.views[camera_id] - center_x, _, depth = _candidate_projection(case, point) - residuals = _subject_projection_residuals(case) - if residuals is None: - return frozenset() - horizontal_residual, _, depth_residual = residuals - center_x += horizontal_residual - depth += depth_residual - if axis is RelationAxis.HORIZONTAL: - delta = stationary_view.bbox.center_x - center_x - threshold = ( - scene.camera_by_id(camera_id).width * RelationEngine.LEFT_RIGHT_FRACTION - ) - positive_relation, negative_relation = Relation.LEFT, Relation.RIGHT - else: - delta = stationary_view.camera_depth - depth - threshold = RelationEngine.FRONT_BEHIND_METERS - positive_relation, negative_relation = Relation.FRONT, Relation.BEHIND - distance = abs(delta) - at_threshold = math.isclose( - distance, - threshold, - rel_tol=0.0, - abs_tol=_OPERATIONAL_COMPARISON_TOLERANCE, - ) - if distance < threshold and not at_threshold: - return frozenset() - return frozenset({positive_relation if delta > 0.0 else negative_relation}) - - -def _candidate_preserves_relations(case: StressCaseDraft, point: Vec2) -> bool: - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - engine = RelationEngine() - for stationary in sorted(scene.objects, key=lambda item: item.object_id): - if stationary.object_id == subject.object_id: - continue - subject_view = subject.views.get(spec.camera_id) - stationary_view = stationary.views.get(spec.camera_id) - permanently_invisible = ( - subject_view is None - or stationary_view is None - or subject_view.visible_fraction < engine.MIN_VISIBLE_FRACTION - or subject_view.image_area_fraction < engine.MIN_IMAGE_AREA_FRACTION - or subject_view.truncated_fraction > engine.MAX_TRUNCATED_FRACTION - or stationary_view.visible_fraction < engine.MIN_VISIBLE_FRACTION - or stationary_view.image_area_fraction < engine.MIN_IMAGE_AREA_FRACTION - or stationary_view.truncated_fraction > engine.MAX_TRUNCATED_FRACTION - ) - if permanently_invisible: - continue - before = engine.pair_labels( - scene, - subject.object_id, - stationary.object_id, - spec.camera_id, - ) - for axis in RelationAxis: - if ( - stationary.object_id == spec.reference_id - and axis is spec.relation_after.axis - ): - continue - before_axis = frozenset( - relation for relation in before if relation.axis is axis - ) - if _candidate_axis_labels(case, stationary, axis, point) != before_axis: - return False - return True - - -def _candidate_satisfies_target(case: StressCaseDraft, point: Vec2) -> bool: - reference = case.scene.object_by_id(case.intervention.reference_id) - relation = case.intervention.relation_after - return relation in _candidate_axis_labels(case, reference, relation.axis, point) - - -def _actual_locus_is_empty( - geometry: BaseGeometry, - strict_boundaries: tuple[BaseGeometry, ...], -) -> bool: - if geometry.is_empty: - return True - boundaries = tuple( - boundary for boundary in strict_boundaries if not boundary.is_empty - ) - if not boundaries: - return False - return geometry.difference(unary_union(boundaries)).is_empty - - -def recompute_stress_oracle(case: StressCaseDraft | StressCase) -> StressOracleResult: - """Recompute outcome and optimum/bound fields from scene geometry alone.""" - draft = case.as_draft() if isinstance(case, StressCase) else case - physical = _physical_center_locus(draft) - physical = _visibility_center_locus(draft, physical) - visibility_boundary = _positive_depth_boundary(draft, physical) - physical, preservation_boundaries = _preserved_relations_center_locus( - draft, - physical, - ) - strict_boundaries = (visibility_boundary, *preservation_boundaries) - target, normal, required = _target_locus(draft, physical) - subject = draft.scene.object_by_id(draft.intervention.subject_id) - reference = draft.scene.object_by_id(draft.intervention.reference_id) - origin = Vec2(x=subject.position.x, y=subject.position.y) - if draft.intervention.relation_after is Relation.FAR and not _actual_locus_is_empty( - target, - strict_boundaries, - ): - configuration = _configuration_polygon(subject, reference) - exact_candidates = _exact_far_candidates( - origin, - physical, - configuration, - RelationEngine.FAR_METERS, - ) - valid_candidates = [ - point - for point in exact_candidates - if physical.covers(Point(point.x, point.y)) - and _candidate_projection_is_valid(draft, point) - and _candidate_preserves_relations(draft, point) - and _candidate_satisfies_target(draft, point) - ] - for point in exact_candidates: - if point in valid_candidates: - continue - repaired = _attained_candidate_neighbor( - draft, - point, - physical, - strict_boundaries, - ) - if repaired is not None: - valid_candidates.append(repaired) - if not valid_candidates: - raise UnattainedOracleInfimumError("unattained_oracle_infimum") - minimum = min( - math.hypot(point.x - origin.x, point.y - origin.y) - for point in valid_candidates - ) - witnesses = tuple( - point - for point in valid_candidates - if math.isclose( - math.hypot(point.x - origin.x, point.y - origin.y), - minimum, - rel_tol=0.0, - abs_tol=_EPS, - ) - ) - return StressOracleResult( - expected_outcome="SAT", - exact_infimum_m=round(minimum, 12), - exact_infimum_points=witnesses, - maximum_possible_value_m=None, - required_value_m=None, - ) - if not _actual_locus_is_empty(target, strict_boundaries): - witnesses = _nearest_geometry_points( - origin, - target, - is_valid=lambda point: ( - _candidate_projection_is_valid(draft, point) - and _candidate_preserves_relations(draft, point) - and _candidate_satisfies_target(draft, point) - ), - strict_boundaries=strict_boundaries, - ) - valid_witnesses = tuple( - point - for point in witnesses - if _candidate_projection_is_valid(draft, point) - and _candidate_preserves_relations(draft, point) - and _candidate_satisfies_target(draft, point) - ) - if not valid_witnesses: - raise UnattainedOracleInfimumError("unattained_oracle_infimum") - exact = min( - math.hypot(point.x - origin.x, point.y - origin.y) - for point in valid_witnesses - ) - return StressOracleResult( - expected_outcome="SAT", - exact_infimum_m=round(exact, 12), - exact_infimum_points=valid_witnesses, - maximum_possible_value_m=None, - required_value_m=None, - ) - if normal is not None: - maximum, required_value = _maximum_half_space_value(physical, normal, required) - else: - maximum = maximum_ground_gap(subject, reference, physical) - required_value = required - return StressOracleResult( - expected_outcome="UNSAT", - exact_infimum_m=None, - exact_infimum_points=(), - maximum_possible_value_m=round(maximum, 12), - required_value_m=round(required_value, 12), - ) diff --git a/src/spatialcf/solver/stress/profiles.py b/src/spatialcf/solver/stress/profiles.py deleted file mode 100644 index 2bcf34b..0000000 --- a/src/spatialcf/solver/stress/profiles.py +++ /dev/null @@ -1,109 +0,0 @@ -"""The closed deterministic quick/deep stress schedule.""" - -from __future__ import annotations - -import hashlib -from typing import Final - -from spatialcf.solver.stress.models import ( - StressDirection, - StressFamily, - StressProfileName, - StressSlot, -) - -DEEP_SEEDS: Final = tuple(range(2026080200, 2026080210)) -QUICK_SEEDS: Final = DEEP_SEEDS[:1] -STRESS_DIRECTIONS: Final[tuple[StressDirection, ...]] = ("lr", "fb", "nf") -SAT_FAMILIES: Final[tuple[StressFamily, ...]] = ( - "target_boundary", - "obstacle_corner", - "support_boundary", - "preservation_intersection", - "tied_optimum", -) -UNSAT_FAMILIES: Final[tuple[StressFamily, ...]] = ( - "room_bounds", - "support_locus", - "obstacle_coverage", - "relation_upper_bound", -) - -_SAT_SLOTS_PER_FAMILY: Final = 12 -_UNSAT_SLOTS_PER_FAMILY: Final = 10 -_SAT_TRANSFORMED_PER_GROUP: Final = 18 -_UNSAT_TRANSFORMED_PER_GROUP: Final = 12 - - -def _ranked_transformed_slots( - seed: int, - direction: StressDirection, - raw_slots: range, - count: int, -) -> frozenset[int]: - ranked = sorted( - raw_slots, - key=lambda raw_slot: hashlib.sha256( - f"stress-v1/{seed}/{direction}/{raw_slot:03d}/transform".encode("utf-8") - ).digest(), - ) - return frozenset(ranked[:count]) - - -def _direction_slots(seed: int, direction: StressDirection) -> tuple[StressSlot, ...]: - transformed_sat = _ranked_transformed_slots( - seed, - direction, - range(0, 60), - _SAT_TRANSFORMED_PER_GROUP, - ) - transformed_unsat = _ranked_transformed_slots( - seed, - direction, - range(60, 100), - _UNSAT_TRANSFORMED_PER_GROUP, - ) - slots: list[StressSlot] = [] - for family_index, family in enumerate(SAT_FAMILIES): - for offset in range(_SAT_SLOTS_PER_FAMILY): - raw_slot = family_index * _SAT_SLOTS_PER_FAMILY + offset - slots.append( - StressSlot( - seed=seed, - direction=direction, - raw_slot=raw_slot, - family=family, - expected_outcome="SAT", - transformed=raw_slot in transformed_sat, - ) - ) - for family_index, family in enumerate(UNSAT_FAMILIES): - for offset in range(_UNSAT_SLOTS_PER_FAMILY): - raw_slot = 60 + family_index * _UNSAT_SLOTS_PER_FAMILY + offset - slots.append( - StressSlot( - seed=seed, - direction=direction, - raw_slot=raw_slot, - family=family, - expected_outcome="UNSAT", - transformed=raw_slot in transformed_unsat, - ) - ) - return tuple(slots) - - -def stress_slots(profile: StressProfileName) -> tuple[StressSlot, ...]: - """Return the only supported schedule, in frozen seed/direction/raw order.""" - if profile == "quick": - seeds = QUICK_SEEDS - elif profile == "deep": - seeds = DEEP_SEEDS - else: - raise ValueError(f"unknown stress profile: {profile!r}") - return tuple( - slot - for seed in seeds - for direction in STRESS_DIRECTIONS - for slot in _direction_slots(seed, direction) - ) diff --git a/src/spatialcf/solver/stress/sampling.py b/src/spatialcf/solver/stress/sampling.py deleted file mode 100644 index 80b8811..0000000 --- a/src/spatialcf/solver/stress/sampling.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Stateless SHA-256 sampling on the frozen stress grids.""" - -from __future__ import annotations - -from decimal import Decimal -import hashlib -from typing import TypeVar - -T = TypeVar("T") - - -class HashSampler: - def __init__(self, namespace: str) -> None: - self.namespace = namespace - - def integer(self, label: str, minimum: int, maximum: int) -> int: - payload = f"{self.namespace}\0{label}".encode("utf-8") - raw = int.from_bytes(hashlib.sha256(payload).digest()[:8], "big") - return minimum + raw % (maximum - minimum + 1) - - def choice(self, label: str, values: tuple[T, ...]) -> T: - return values[self.integer(label, 0, len(values) - 1)] - - def grid( - self, - label: str, - start: Decimal, - stop: Decimal, - step: Decimal, - ) -> float: - count = int((stop - start) / step) - return float(start + step * self.integer(label, 0, count)) diff --git a/src/spatialcf/solver/stress/scene_factory.py b/src/spatialcf/solver/stress/scene_factory.py deleted file mode 100644 index ffd52d2..0000000 --- a/src/spatialcf/solver/stress/scene_factory.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Canonical scene construction and fail-closed before-scene validation.""" - -from __future__ import annotations - -import math -from collections.abc import Iterable - -from shapely.geometry import Polygon - -from spatialcf.domain.enums import SolverStatus -from spatialcf.domain.models import ( - OBB, - BBox2D, - Camera, - ObjectView, - Quaternion, - Scene, - SceneObject, - Vec2, - Vec3, -) -from spatialcf.geometry.obb import ground_gap, obb_footprint -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.stress.models import ( - StressCase, - StressCaseDraft, - StressOracleResult, - expected_oracle_result, -) -from spatialcf.solver.stress.oracles import ( - UnattainedOracleInfimumError, - recompute_stress_oracle, -) - -_SOURCE = "core-solver-stress-v1" -_CAMERA_ID = "camera" -_BBOX_HALF_SIZE = 10.0 -_GEOMETRY_TOLERANCE = 1e-9 -_PROJECTION_TOLERANCE = 1e-12 - - -class StressCaseError(ValueError): - """A generated stress case cannot support a validation claim.""" - - -def yaw_quaternion(degrees: int) -> Quaternion: - radians = math.radians(degrees) / 2.0 - return Quaternion( - x=0.0, - y=0.0, - z=math.sin(radians), - w=math.cos(radians), - ) - - -def make_camera( - *, - focal_px: float, - depth_offset: float = 0.0, - camera_id: str = _CAMERA_ID, - camera_height: float = 1.5, -) -> Camera: - """Build the frozen affine Z-up camera used by direct stress families.""" - values = (focal_px, depth_offset, camera_height) - if focal_px <= 0.0 or not all(math.isfinite(value) for value in values): - raise StressCaseError("invalid_camera") - return Camera( - camera_id=camera_id, - width=640, - height=480, - intrinsics=( - float(focal_px), - 0.0, - 320.0, - 0.0, - float(focal_px), - 240.0, - 0.0, - 0.0, - 1.0, - ), - world_to_camera=( - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - -float(camera_height), - 0.0, - 1.0, - 0.0, - float(depth_offset), - 0.0, - 0.0, - 0.0, - 1.0, - ), - ) - - -def _camera_coordinate(camera: Camera, row: int, point: Vec3) -> float: - start = row * 4 - matrix = camera.world_to_camera - return ( - matrix[start] * point.x - + matrix[start + 1] * point.y - + matrix[start + 2] * point.z - + matrix[start + 3] - ) - - -def _initial_view(camera: Camera, position: Vec3) -> ObjectView: - camera_x = _camera_coordinate(camera, 0, position) - camera_y = _camera_coordinate(camera, 1, position) - depth = _camera_coordinate(camera, 2, position) - if ( - not all(math.isfinite(value) for value in (camera_x, camera_y, depth)) - or depth <= 0.0 - ): - raise StressCaseError("invalid_initial_projection") - fx, _, cx, _, fy, cy, _, _, _ = camera.intrinsics - center_x = fx * camera_x / depth + cx - center_y = cy - fy * camera_y / depth - bbox = BBox2D( - xmin=center_x - _BBOX_HALF_SIZE, - ymin=center_y - _BBOX_HALF_SIZE, - xmax=center_x + _BBOX_HALF_SIZE, - ymax=center_y + _BBOX_HALF_SIZE, - ) - if not ( - 0.0 <= bbox.xmin <= bbox.xmax <= camera.width - and 0.0 <= bbox.ymin <= bbox.ymax <= camera.height - ): - raise StressCaseError("initial_bbox_outside_image") - return ObjectView( - camera_id=camera.camera_id, - bbox=bbox, - camera_depth=depth, - visible_fraction=0.90, - image_area_fraction=0.02, - truncated_fraction=0.0, - ) - - -def make_object( - object_id: str, - *, - position: Vec3, - extent: Vec3, - yaw_degrees: int, - movable: bool, - camera: Camera, - category: str | None = None, - support_object_id: str | None = None, - request_eligible: bool = True, -) -> SceneObject: - """Build one canonical OBB object and its direct initial projection.""" - numeric = (*position.model_dump().values(), *extent.model_dump().values()) - if ( - not object_id - or not all(math.isfinite(float(value)) for value in numeric) - or min(extent.x, extent.y, extent.z) <= 0.0 - ): - raise StressCaseError("invalid_object") - rotation = yaw_quaternion(yaw_degrees) - return SceneObject( - object_id=object_id, - name=object_id, - category=category or object_id, - movable=movable, - request_eligible=request_eligible, - position=position, - rotation=rotation, - obb=OBB(center=position, extent=extent, rotation=rotation), - support_object_id=support_object_id, - views={camera.camera_id: _initial_view(camera, position)}, - ) - - -def make_scene( - *, - scene_id: str, - generation_seed: int, - room_bounds: tuple[float, float, float, float], - camera: Camera, - objects: Iterable[SceneObject], - source: str = _SOURCE, -) -> Scene: - """Build a rectangular Canonical Scene without binding a final case ID.""" - min_x, min_y, max_x, max_y = room_bounds - if ( - not all(math.isfinite(value) for value in room_bounds) - or min_x >= max_x - or min_y >= max_y - ): - raise StressCaseError("invalid_room") - return Scene( - scene_id=scene_id, - source=source, - room_polygon_xy=( - Vec2(x=min_x, y=min_y), - Vec2(x=max_x, y=min_y), - Vec2(x=max_x, y=max_y), - Vec2(x=min_x, y=max_y), - ), - cameras=(camera,), - objects=tuple(objects), - pinned_object_ids=frozenset(), - generation_seed=generation_seed, - ) - - -def _require_finite_tree(value: object) -> None: - if isinstance(value, bool) or value is None or isinstance(value, str): - return - if isinstance(value, (int, float)): - if not math.isfinite(float(value)): - raise StressCaseError("non_finite") - return - if isinstance(value, dict): - for item in value.values(): - _require_finite_tree(item) - return - if isinstance(value, (list, tuple, set, frozenset)): - for item in value: - _require_finite_tree(item) - - -def _require_scene_references(case: StressCaseDraft) -> None: - scene = case.scene - object_ids = [obj.object_id for obj in scene.objects] - camera_ids = [camera.camera_id for camera in scene.cameras] - if len(object_ids) != len(set(object_ids)) or len(camera_ids) != len( - set(camera_ids) - ): - raise StressCaseError("scene_references") - objects = set(object_ids) - cameras = set(camera_ids) - spec = case.intervention - if ( - spec.subject_id == spec.reference_id - or spec.subject_id not in objects - or spec.reference_id not in objects - or spec.camera_id not in cameras - ): - raise StressCaseError("scene_references") - subject = scene.object_by_id(spec.subject_id) - if not subject.movable or spec.subject_id in scene.pinned_object_ids: - raise StressCaseError("scene_references") - if scene.children_by_support().get(spec.subject_id): - raise StressCaseError("scene_references") - if any(obj.movable for obj in scene.objects if obj.object_id != spec.subject_id): - raise StressCaseError("scene_references") - for obj in scene.objects: - if obj.support_object_id is not None and ( - obj.support_object_id not in objects - or obj.support_object_id == obj.object_id - ): - raise StressCaseError("scene_references") - if any( - key != view.camera_id or key not in cameras - for key, view in obj.views.items() - ): - raise StressCaseError("scene_references") - for object_id in (spec.subject_id, spec.reference_id): - if spec.camera_id not in scene.object_by_id(object_id).views: - raise StressCaseError("scene_references") - - -def _require_inside_room(scene: Scene) -> None: - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - if not room.is_valid or room.is_empty: - raise StressCaseError("outside_room") - for obj in scene.objects: - if not room.buffer(_GEOMETRY_TOLERANCE).covers(obb_footprint(obj.obb)): - raise StressCaseError("outside_room") - - -def _require_initial_boxes_inside_image(scene: Scene) -> None: - for obj in scene.objects: - for camera_id, view in obj.views.items(): - camera = scene.camera_by_id(camera_id) - bbox = view.bbox - if not ( - 0.0 <= bbox.xmin <= bbox.xmax <= camera.width - and 0.0 <= bbox.ymin <= bbox.ymax <= camera.height - ): - raise StressCaseError("initial_bbox_outside_image") - - -def _require_canonical_initial_observations(scene: Scene) -> None: - for camera in scene.cameras: - if camera.width != 640 or camera.height != 480: - raise StressCaseError("initial_observation_mismatch") - for obj in scene.objects: - for camera_id, view in obj.views.items(): - camera = scene.camera_by_id(camera_id) - camera_x = _camera_coordinate(camera, 0, obj.position) - camera_y = _camera_coordinate(camera, 1, obj.position) - depth = _camera_coordinate(camera, 2, obj.position) - if depth <= 0.0: - raise StressCaseError("initial_observation_mismatch") - fx, _, cx, _, fy, cy, _, _, _ = camera.intrinsics - projected_x = fx * camera_x / depth + cx - projected_y = cy - fy * camera_y / depth - bbox = view.bbox - observed_y = (bbox.ymin + bbox.ymax) / 2.0 - comparisons = ( - (bbox.xmax - bbox.xmin, 2.0 * _BBOX_HALF_SIZE), - (bbox.ymax - bbox.ymin, 2.0 * _BBOX_HALF_SIZE), - (bbox.center_x, projected_x), - (observed_y, projected_y), - (view.camera_depth, depth), - (view.visible_fraction, 0.90), - (view.image_area_fraction, 0.02), - (view.truncated_fraction, 0.0), - ) - if not all( - math.isclose( - observed, - expected, - rel_tol=0.0, - abs_tol=_PROJECTION_TOLERANCE, - ) - for observed, expected in comparisons - ): - raise StressCaseError("initial_observation_mismatch") - - -def _require_support_contact(scene: Scene) -> None: - for obj in scene.objects: - if obj.support_object_id is None: - continue - support = scene.object_by_id(obj.support_object_id) - if ( - not obb_footprint(support.obb) - .buffer(_GEOMETRY_TOLERANCE) - .covers(obb_footprint(obj.obb)) - ): - raise StressCaseError("support_overhang") - object_bottom = obj.obb.center.z - obj.obb.extent.z / 2.0 - support_top = support.obb.center.z + support.obb.extent.z / 2.0 - if not math.isclose( - object_bottom, - support_top, - rel_tol=0.0, - abs_tol=_GEOMETRY_TOLERANCE, - ): - raise StressCaseError("support_separation") - - -def _require_no_3d_intersection(scene: Scene, minimum_gap: float) -> None: - for index, first in enumerate(scene.objects): - for second in scene.objects[index + 1 :]: - if ( - first.support_object_id == second.object_id - or second.support_object_id == first.object_id - ): - continue - first_bottom = first.obb.center.z - first.obb.extent.z / 2.0 - first_top = first.obb.center.z + first.obb.extent.z / 2.0 - second_bottom = second.obb.center.z - second.obb.extent.z / 2.0 - second_top = second.obb.center.z + second.obb.extent.z / 2.0 - z_overlap = min(first_top, second_top) - max(first_bottom, second_bottom) - if z_overlap > _GEOMETRY_TOLERANCE and ground_gap(first.obb, second.obb) < ( - minimum_gap - _GEOMETRY_TOLERANCE - ): - raise StressCaseError("object_intersection") - - -def _require_source_relation(case: StressCaseDraft) -> None: - spec = case.intervention - observed = RelationEngine().observe( - case.scene, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - if observed.status is not SolverStatus.SUCCESS or not observed.satisfied: - raise StressCaseError("source_unsatisfied") - - -def _within_coordinate_ulps(first: Vec2, second: Vec2) -> bool: - scale = max( - 1.0, - abs(first.x), - abs(first.y), - abs(second.x), - abs(second.y), - ) - tolerance = 64.0 * math.ulp(scale) - return abs(first.x - second.x) <= tolerance and abs(first.y - second.y) <= tolerance - - -def _transformed_oracle_matches( - expected: StressOracleResult, - recomputed: StressOracleResult, -) -> bool: - if ( - expected.expected_outcome != recomputed.expected_outcome - or expected.exact_infimum_m != recomputed.exact_infimum_m - or expected.maximum_possible_value_m != recomputed.maximum_possible_value_m - or expected.required_value_m != recomputed.required_value_m - or len(expected.exact_infimum_points) != len(recomputed.exact_infimum_points) - ): - return False - candidate_indices = tuple( - tuple( - recomputed_index - for recomputed_index, recomputed_point in enumerate( - recomputed.exact_infimum_points - ) - if _within_coordinate_ulps(expected_point, recomputed_point) - ) - for expected_point in expected.exact_infimum_points - ) - matched_expected: list[int | None] = [None] * len(recomputed.exact_infimum_points) - - def augment(expected_index: int, visited: set[int]) -> bool: - for recomputed_index in candidate_indices[expected_index]: - if recomputed_index in visited: - continue - visited.add(recomputed_index) - previous = matched_expected[recomputed_index] - if previous is None or augment(previous, visited): - matched_expected[recomputed_index] = expected_index - return True - return False - - for expected_index in sorted( - range(len(candidate_indices)), - key=lambda index: (len(candidate_indices[index]), index), - ): - if not augment(expected_index, set()): - return False - return True - - -def validate_before_draft(case: StressCaseDraft) -> None: - """Validate geometry and oracle without requiring final public identity.""" - _require_finite_tree(case.model_dump(mode="json")) - _require_scene_references(case) - _require_initial_boxes_inside_image(case.scene) - _require_inside_room(case.scene) - _require_support_contact(case.scene) - _require_no_3d_intersection(case.scene, minimum_gap=0.05) - _require_canonical_initial_observations(case.scene) - _require_source_relation(case) - try: - recomputed = recompute_stress_oracle(case) - except UnattainedOracleInfimumError as error: - raise StressCaseError("unattained_oracle_infimum") from error - expected = expected_oracle_result(case.oracle) - oracle_matches = ( - _transformed_oracle_matches(expected, recomputed) - if case.transform.transformed - else recomputed == expected - ) - if not oracle_matches: - raise StressCaseError("oracle_mismatch") - - -def validate_before_case(case: StressCase) -> None: - """Add final case/scene identity binding to the draft validity contract.""" - if case.scene.scene_id != case.case_id: - raise StressCaseError("scene_id_mismatch") - validate_before_draft(case.as_draft()) diff --git a/src/spatialcf/solver/stress/transforms.py b/src/spatialcf/solver/stress/transforms.py deleted file mode 100644 index bbbd638..0000000 --- a/src/spatialcf/solver/stress/transforms.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Solver-independent rigid transformations for stress-case drafts.""" - -from __future__ import annotations - -import math -from typing import TypeAlias - -from spatialcf.domain.enums import Relation -from spatialcf.domain.models import Camera, Quaternion, Scene, SceneObject, Vec2, Vec3 -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCaseDraft, - StressTransform, - UnsatStressOracle, -) - -_XYMatrix: TypeAlias = tuple[int, int, int, int] - - -def _multiply(left: _XYMatrix, right: _XYMatrix) -> _XYMatrix: - l00, l01, l10, l11 = left - r00, r01, r10, r11 = right - return ( - l00 * r00 + l01 * r10, - l00 * r01 + l01 * r11, - l10 * r00 + l11 * r10, - l10 * r01 + l11 * r11, - ) - - -def _linear_matrix(transform: StressTransform) -> _XYMatrix: - mirror = { - "none": (1, 0, 0, 1), - "camera_horizontal": (-1, 0, 0, 1), - "camera_depth": (1, 0, 0, -1), - }[transform.mirror] - rotation = { - 0: (1, 0, 0, 1), - 90: (0, -1, 1, 0), - 180: (-1, 0, 0, -1), - 270: (0, 1, -1, 0), - }[transform.rotation_degrees] - return _multiply(rotation, mirror) - - -def _transform_xy(point: Vec2, matrix: _XYMatrix, translation: Vec2) -> Vec2: - a00, a01, a10, a11 = matrix - return Vec2( - x=a00 * point.x + a01 * point.y + translation.x, - y=a10 * point.x + a11 * point.y + translation.y, - ) - - -def _transform_position( - point: Vec3, - matrix: _XYMatrix, - translation: Vec2, -) -> Vec3: - transformed = _transform_xy(Vec2(x=point.x, y=point.y), matrix, translation) - return Vec3(x=transformed.x, y=transformed.y, z=point.z) - - -def _canonical_yaw(rotation: Quaternion, matrix: _XYMatrix) -> Quaternion: - cosine = rotation.w * rotation.w - rotation.z * rotation.z - sine = 2.0 * rotation.w * rotation.z - a00, a01, a10, a11 = matrix - axis_x = a00 * cosine + a01 * sine - axis_y = a10 * cosine + a11 * sine - half_angle = math.atan2(axis_y, axis_x) / 2.0 - z = math.sin(half_angle) - w = math.cos(half_angle) - if abs(w) < 1e-15 and z < 0.0: - z, w = -z, -w - if abs(z) < 1e-15: - z = 0.0 - if abs(w) < 1e-15: - w = 0.0 - return Quaternion(x=0.0, y=0.0, z=z, w=w) - - -def _transform_camera( - camera: Camera, - linear: _XYMatrix, - translation: Vec2, -) -> Camera: - a00, a01, a10, a11 = linear - matrix = list(camera.world_to_camera) - for row in range(4): - start = 4 * row - old_x = camera.world_to_camera[start] - old_y = camera.world_to_camera[start + 1] - new_x = old_x * a00 + old_y * a01 - new_y = old_x * a10 + old_y * a11 - matrix[start] = 0.0 if new_x == 0.0 else new_x - matrix[start + 1] = 0.0 if new_y == 0.0 else new_y - new_offset = ( - camera.world_to_camera[start + 3] - - new_x * translation.x - - new_y * translation.y - ) - matrix[start + 3] = 0.0 if new_offset == 0.0 else new_offset - return camera.model_copy(update={"world_to_camera": tuple(matrix)}) - - -def _transform_object( - obj: SceneObject, - linear: _XYMatrix, - translation: Vec2, -) -> SceneObject: - position = _transform_position(obj.position, linear, translation) - center = _transform_position(obj.obb.center, linear, translation) - rotation = _canonical_yaw(obj.rotation, linear) - obb_rotation = _canonical_yaw(obj.obb.rotation, linear) - return obj.model_copy( - update={ - "position": position, - "rotation": rotation, - "obb": obj.obb.model_copy( - update={"center": center, "rotation": obb_rotation} - ), - } - ) - - -def _signed_area(points: tuple[Vec2, ...]) -> float: - return 0.5 * sum( - first.x * second.y - second.x * first.y - for first, second in zip(points, (*points[1:], points[0]), strict=True) - ) - - -def _derivation_suffix(transform: StressTransform) -> str: - return ( - "; rigid transform " - f"translation=({transform.translation_xy.x:.12g}," - f"{transform.translation_xy.y:.12g}), " - f"mirror={transform.mirror}, rotation={transform.rotation_degrees}deg, " - f"base={transform.base_case_digest or 'none'}" - ) - - -def _target_unit_normal(draft: StressCaseDraft, scene: Scene) -> Vec2 | None: - relation = draft.intervention.relation_after - camera = scene.camera_by_id(draft.intervention.camera_id) - matrix = camera.world_to_camera - if relation in {Relation.LEFT, Relation.RIGHT}: - reference = scene.object_by_id(draft.intervention.reference_id) - reference_view = reference.views[draft.intervention.camera_id] - direction = 1.0 if relation is Relation.RIGHT else -1.0 - fx, _, cx, _, _, _, _, _, _ = camera.intrinsics - target_u = reference_view.bbox.center_x + direction * ( - camera.width * RelationEngine.LEFT_RIGHT_FRACTION - ) - pixel_offset = target_u - cx - normal_x = direction * (fx * matrix[0] - pixel_offset * matrix[8]) - normal_y = direction * (fx * matrix[1] - pixel_offset * matrix[9]) - elif relation in {Relation.FRONT, Relation.BEHIND}: - direction = 1.0 if relation is Relation.BEHIND else -1.0 - normal_x = direction * matrix[8] - normal_y = direction * matrix[9] - else: - return None - norm = math.hypot(normal_x, normal_y) - if norm <= 1e-12: - raise ValueError("stress transform target has a zero half-space normal") - return Vec2(x=normal_x / norm, y=normal_y / norm) - - -def apply_stress_transform( - draft: StressCaseDraft, - transform: StressTransform, -) -> StressCaseDraft: - """Apply one declared rigid world-XY transform to a stress draft.""" - translation = transform.translation_xy - linear = _linear_matrix(transform) - room = tuple( - _transform_xy(point, linear, translation) - for point in draft.scene.room_polygon_xy - ) - if _signed_area(room) < 0.0: - room = tuple(reversed(room)) - scene = draft.scene.model_copy( - update={ - "room_polygon_xy": room, - "cameras": tuple( - _transform_camera(camera, linear, translation) - for camera in draft.scene.cameras - ), - "objects": tuple( - _transform_object(obj, linear, translation) - for obj in draft.scene.objects - ), - } - ) - oracle = draft.oracle - if isinstance(oracle, SatStressOracle): - oracle = SatStressOracle( - proof_kind=oracle.proof_kind, - exact_infimum_m=oracle.exact_infimum_m, - exact_infimum_points=tuple( - _transform_xy(point, linear, translation) - for point in oracle.exact_infimum_points - ), - derivation=oracle.derivation + _derivation_suffix(transform), - ) - elif isinstance(oracle, UnsatStressOracle): - target_normal = _target_unit_normal(draft, scene) - scalar_shift = ( - 0.0 - if target_normal is None - else (target_normal.x * translation.x + target_normal.y * translation.y) - ) - oracle = UnsatStressOracle( - proof_kind=oracle.proof_kind, - maximum_possible_value_m=round( - oracle.maximum_possible_value_m + scalar_shift, - 12, - ), - required_value_m=round( - oracle.required_value_m + scalar_shift, - 12, - ), - expected_reason=oracle.expected_reason, - derivation=oracle.derivation + _derivation_suffix(transform), - ) - payload = draft.model_dump(mode="python") - payload.update( - { - "scene": scene, - "oracle": oracle, - "transform": transform, - } - ) - return StressCaseDraft.model_validate(payload) diff --git a/src/spatialcf/solver/validation.py b/src/spatialcf/solver/validation.py deleted file mode 100644 index 494a4e9..0000000 --- a/src/spatialcf/solver/validation.py +++ /dev/null @@ -1,546 +0,0 @@ -"""Dataset-independent acceptance contracts for the certified solver.""" - -from __future__ import annotations - -from dataclasses import dataclass -import hashlib -import json -import math -from pathlib import Path -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator -from shapely.geometry import Polygon - -from spatialcf.domain.enums import QualityTier, Relation, SolverStatus -from spatialcf.domain.models import InterventionSpec, Scene, Vec2 -from spatialcf.geometry.obb import footprints_overlap, inside_room -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver.analytic_motion import AnalyticMotionModel, CandidateProjectionError -from spatialcf.solver.certified_models import ( - CertifiedSolveResult, - CertifiedSolverConfig, - expected_target_diff, -) -from spatialcf.solver.continuous import CertifiedSpatialCFSolver -from spatialcf.verification.verifier import Verifier - - -_CASE_IDS = ("left-to-right", "front-to-behind", "near-to-far") -_DIRECTIONS = ( - (Relation.LEFT, Relation.RIGHT), - (Relation.FRONT, Relation.BEHIND), - (Relation.NEAR, Relation.FAR), -) -_SOURCE = "core-solver-validation-v1" -_CHECK_NAMES = ( - "source_relation_satisfied", - "solver_success", - "certificate_gap_closed", - "exact_infimum_bracketed", - "realized_within_tolerance", - "exact_position_within_tolerance", - "verifier_success", - "relation_diff_exact", - "only_subject_xy_changed", - "subject_z_unchanged", - "subject_rotation_unchanged", - "subject_extent_unchanged", - "subject_identity_unchanged", - "support_assignment_unchanged", - "stationary_objects_unchanged", - "camera_unchanged", - "room_unchanged", - "subject_position_matches_result", - "subject_view_matches_analytic_motion", - "inside_room", - "collision_free", - "floor_contact", -) - - -class CoreValidationError(ValueError): - """The validation input or outcome cannot support an acceptance claim.""" - - def __init__(self, message: str, *, failed_checks: tuple[str, ...] = ()) -> None: - super().__init__(message) - self.failed_checks = failed_checks - - -class ValidationCaseSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+$") - scene_file: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+\.json$") - intervention: InterventionSpec - exact_infimum_xy: Vec2 - exact_infimum_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - derivation: str = Field(min_length=1) - expected_relation_diff: tuple[str, str, str, str] - - @model_validator(mode="after") - def validate_exact_values(self) -> "ValidationCaseSpec": - if not all( - math.isfinite(value) - for value in (self.exact_infimum_xy.x, self.exact_infimum_xy.y) - ): - raise ValueError("exact_infimum_xy must be finite") - return self - - -class ValidationManifest(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: Literal[1] - cases: tuple[ValidationCaseSpec, ...] - - -@dataclass(frozen=True) -class LoadedValidationCase: - spec: ValidationCaseSpec - scene: Scene - scene_sha256: str - - -class CertificateRecord(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - distance_lower_bound: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - distance_upper_bound: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - optimality_gap: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - radial_geometry_error: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - numeric_error_bound: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - disk_segments: int = Field(ge=4, strict=True) - infimum_only: bool = Field(strict=True) - - -class CaseValidationRecord(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str - scene_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - derivation: str = Field(min_length=1) - relation_before: Relation - relation_after: Relation - before_xy: Vec2 - after_xy: Vec2 - exact_infimum_xy: Vec2 - exact_infimum_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - realized_displacement_m: float = Field( - ge=0.0, - allow_inf_nan=False, - strict=True, - ) - realized_error_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - solver_status: SolverStatus - quality: QualityTier - verifier_status: SolverStatus - leakage_count: int = Field(ge=0, strict=True) - changed_relations: tuple[str, ...] - certificate: CertificateRecord - checks: dict[str, bool] - - @model_validator(mode="after") - def validate_all_checks(self) -> "CaseValidationRecord": - if set(self.checks) != set(_CHECK_NAMES) or not all(self.checks.values()): - raise ValueError("validation record requires every canonical check") - return self - - -class ValidationReport(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: Literal[1] = 1 - status: Literal["PASS"] = "PASS" - optimality_tolerance_m: Literal[1e-6] = 1e-6 - cases: tuple[CaseValidationRecord, CaseValidationRecord, CaseValidationRecord] - - @model_validator(mode="after") - def validate_case_order(self) -> "ValidationReport": - if tuple(case.case_id for case in self.cases) != _CASE_IDS: - raise ValueError("validation report case order mismatch") - return self - - -def _read_regular_file(path: Path, label: str) -> bytes: - if path.is_symlink() or not path.is_file(): - raise CoreValidationError(f"{label} must be a regular file") - try: - return path.read_bytes() - except OSError as exc: - raise CoreValidationError(f"cannot read {label}: {exc}") from exc - - -def _parse_json(payload: bytes, label: str) -> object: - try: - return json.loads(payload.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise CoreValidationError(f"invalid UTF-8 JSON in {label}: {exc}") from exc - - -def _require_finite_scene(scene: Scene, case_id: str) -> None: - def visit(value: object) -> None: - if isinstance(value, float) and not math.isfinite(value): - raise CoreValidationError(f"{case_id}: scene geometry must be finite") - if isinstance(value, dict): - for item in value.values(): - visit(item) - elif isinstance(value, (list, tuple, set, frozenset)): - for item in value: - visit(item) - - visit(scene.model_dump(mode="python")) - - -def _floor_bottom(scene: Scene, object_id: str) -> float: - obj = scene.object_by_id(object_id) - return obj.obb.center.z - obj.obb.extent.z / 2.0 - - -def _validate_scene(case: ValidationCaseSpec, scene: Scene) -> None: - if scene.scene_id != case.case_id: - raise CoreValidationError(f"{case.case_id}: scene_id mismatch") - if scene.source != _SOURCE: - raise CoreValidationError(f"{case.case_id}: source mismatch") - object_ids = tuple(obj.object_id for obj in scene.objects) - if len(object_ids) != len(set(object_ids)): - raise CoreValidationError(f"{case.case_id}: object ids must be unique") - camera_ids = tuple(camera.camera_id for camera in scene.cameras) - if len(camera_ids) != len(set(camera_ids)): - raise CoreValidationError(f"{case.case_id}: camera ids must be unique") - _require_finite_scene(scene, case.case_id) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - if not room.is_valid or room.is_empty or room.area <= 0.0: - raise CoreValidationError(f"{case.case_id}: room polygon must be valid") - - intervention = case.intervention - try: - subject = scene.object_by_id(intervention.subject_id) - reference = scene.object_by_id(intervention.reference_id) - scene.camera_by_id(intervention.camera_id) - except KeyError as exc: - raise CoreValidationError(f"{case.case_id}: intervention endpoint missing") from exc - if not subject.movable or reference.movable: - raise CoreValidationError(f"{case.case_id}: movability contract mismatch") - if any(obj.movable for obj in scene.objects if obj.object_id != subject.object_id): - raise CoreValidationError(f"{case.case_id}: only the subject may be movable") - for endpoint in (subject.object_id, reference.object_id): - if not math.isclose(_floor_bottom(scene, endpoint), 0.0, abs_tol=1e-12): - raise CoreValidationError(f"{case.case_id}: floor contact mismatch") - - source = RelationEngine().observe( - scene, - intervention.subject_id, - intervention.reference_id, - intervention.relation_before, - intervention.camera_id, - ) - if source.status is not SolverStatus.SUCCESS or not source.satisfied: - raise CoreValidationError(f"{case.case_id}: source relation is not satisfied") - - distance = math.hypot( - case.exact_infimum_xy.x - subject.position.x, - case.exact_infimum_xy.y - subject.position.y, - ) - if not math.isclose(distance, case.exact_infimum_m, abs_tol=1e-12): - raise CoreValidationError(f"{case.case_id}: exact_infimum values disagree") - - -def load_validation_cases(root: Path) -> tuple[LoadedValidationCase, ...]: - """Load the exact closed-form validation corpus and fail closed.""" - if root.is_symlink() or not root.is_dir(): - raise CoreValidationError("validation root must be a real directory") - manifest_payload = _read_regular_file(root / "cases.json", "cases.json") - try: - manifest = ValidationManifest.model_validate( - _parse_json(manifest_payload, "cases.json") - ) - except ValidationError as exc: - raise CoreValidationError(f"invalid validation manifest: {exc}") from exc - - case_ids = tuple(case.case_id for case in manifest.cases) - if len(case_ids) != len(set(case_ids)): - raise CoreValidationError("validation case ids must be unique") - if case_ids != _CASE_IDS: - if set(case_ids) != set(_CASE_IDS): - raise CoreValidationError("manifest must contain all supported directions") - raise CoreValidationError("validation case ids must use canonical order") - directions = tuple( - (case.intervention.relation_before, case.intervention.relation_after) - for case in manifest.cases - ) - if directions != _DIRECTIONS: - raise CoreValidationError("manifest supported directions do not match") - - loaded: list[LoadedValidationCase] = [] - resolved_root = root.resolve() - for case in manifest.cases: - scene_name = Path(case.scene_file) - if ( - scene_name.name != case.scene_file - or scene_name.suffix != ".json" - or case.scene_file == "cases.json" - ): - raise CoreValidationError(f"{case.case_id}: invalid scene_file") - if case.expected_relation_diff != expected_target_diff(case.intervention): - raise CoreValidationError( - f"{case.case_id}: expected_relation_diff mismatch" - ) - scene_path = root / scene_name - if scene_path.resolve().parent != resolved_root: - raise CoreValidationError(f"{case.case_id}: scene_file escapes root") - scene_payload = _read_regular_file(scene_path, f"{case.case_id} scene_file") - try: - scene = Scene.model_validate(_parse_json(scene_payload, case.scene_file)) - except ValidationError as exc: - raise CoreValidationError(f"{case.case_id}: invalid scene: {exc}") from exc - _validate_scene(case, scene) - loaded.append( - LoadedValidationCase( - spec=case, - scene=scene, - scene_sha256=hashlib.sha256(scene_payload).hexdigest(), - ) - ) - return tuple(loaded) - - -def _failed(checks: dict[str, bool], case_id: str) -> CoreValidationError: - failed_checks = tuple(name for name in _CHECK_NAMES if not checks[name]) - return CoreValidationError( - f"{case_id}: failed checks: {', '.join(failed_checks)}", - failed_checks=failed_checks, - ) - - -def validate_solver_outcome( - case: LoadedValidationCase, - solve_result: CertifiedSolveResult, - after: Scene, -) -> CaseValidationRecord: - """Independently validate a solver result against one closed-form case.""" - before = case.scene - spec = case.spec.intervention - tolerance = CertifiedSolverConfig().optimality_tolerance - before_subject = before.object_by_id(spec.subject_id) - try: - after_subject = after.object_by_id(spec.subject_id) - except KeyError as exc: - raise CoreValidationError( - f"{case.spec.case_id}: subject missing", - failed_checks=("only_subject_xy_changed",), - ) from exc - - source = RelationEngine().observe( - before, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - certificate = solve_result.certificate - result_position = solve_result.subject_position - solver_success = ( - solve_result.status is SolverStatus.SUCCESS - and solve_result.quality is QualityTier.PURE - and solve_result.score is not None - and result_position is not None - and certificate is not None - ) - - realized_displacement = math.hypot( - after_subject.position.x - before_subject.position.x, - after_subject.position.y - before_subject.position.y, - ) - realized_error = abs(realized_displacement - case.spec.exact_infimum_m) - exact_position_error = math.hypot( - after_subject.position.x - case.spec.exact_infimum_xy.x, - after_subject.position.y - case.spec.exact_infimum_xy.y, - ) - - expected_after_subject = None - if result_position is not None: - try: - expected_after_subject = AnalyticMotionModel().with_object_xy( - before, - spec.subject_id, - result_position.x, - result_position.y, - ).object_by_id(spec.subject_id) - except CandidateProjectionError: - expected_after_subject = None - - verification = Verifier().verify(before, after, spec) - before_ids = tuple(obj.object_id for obj in before.objects) - after_ids = tuple(obj.object_id for obj in after.objects) - position_dx = after_subject.position.x - before_subject.position.x - position_dy = after_subject.position.y - before_subject.position.y - obb_dx = after_subject.obb.center.x - before_subject.obb.center.x - obb_dy = after_subject.obb.center.y - before_subject.obb.center.y - only_subject_xy = ( - before_ids == after_ids - and math.isclose(position_dx, obb_dx, abs_tol=1e-12) - and math.isclose(position_dy, obb_dy, abs_tol=1e-12) - ) - stationary_unchanged = before_ids == after_ids and all( - before.object_by_id(object_id) == after.object_by_id(object_id) - for object_id in before_ids - if object_id != spec.subject_id - ) - - room = Polygon([(point.x, point.y) for point in before.room_polygon_xy]) - support_id = before_subject.support_object_id - collision_free = all( - not footprints_overlap(after_subject.obb, obj.obb) - for obj in after.objects - if obj.object_id not in {spec.subject_id, support_id} - ) - floor_contact = math.isclose( - _floor_bottom(before, spec.subject_id), - 0.0, - abs_tol=1e-12, - ) and math.isclose( - _floor_bottom(after, spec.subject_id), - 0.0, - abs_tol=1e-12, - ) - - checks = { - "source_relation_satisfied": ( - source.status is SolverStatus.SUCCESS and source.satisfied - ), - "solver_success": solver_success, - "certificate_gap_closed": ( - certificate is not None and certificate.optimality_gap <= tolerance - ), - "exact_infimum_bracketed": ( - certificate is not None - and certificate.distance_lower_bound - certificate.numeric_error_bound - <= case.spec.exact_infimum_m - <= certificate.distance_upper_bound + certificate.numeric_error_bound - ), - "realized_within_tolerance": realized_error <= tolerance, - "exact_position_within_tolerance": exact_position_error <= tolerance, - "verifier_success": ( - verification.status is SolverStatus.SUCCESS - and verification.quality is QualityTier.PURE - and verification.leakage_count == 0 - ), - "relation_diff_exact": ( - verification.changed_relations == case.spec.expected_relation_diff - ), - "only_subject_xy_changed": only_subject_xy, - "subject_z_unchanged": ( - after_subject.position.z == before_subject.position.z - and after_subject.obb.center.z == before_subject.obb.center.z - ), - "subject_rotation_unchanged": ( - after_subject.rotation == before_subject.rotation - and after_subject.obb.rotation == before_subject.obb.rotation - ), - "subject_extent_unchanged": ( - after_subject.obb.extent == before_subject.obb.extent - ), - "subject_identity_unchanged": ( - after_subject.object_id == before_subject.object_id - and after_subject.name == before_subject.name - and after_subject.category == before_subject.category - and after_subject.movable == before_subject.movable - and after_subject.request_eligible == before_subject.request_eligible - ), - "support_assignment_unchanged": ( - after_subject.support_object_id == before_subject.support_object_id - ), - "stationary_objects_unchanged": stationary_unchanged, - "camera_unchanged": after.cameras == before.cameras, - "room_unchanged": after.room_polygon_xy == before.room_polygon_xy, - "subject_position_matches_result": ( - result_position is not None - and after_subject.position == result_position - ), - "subject_view_matches_analytic_motion": ( - expected_after_subject is not None - and after_subject == expected_after_subject - ), - "inside_room": inside_room(after_subject.obb, room), - "collision_free": collision_free, - "floor_contact": floor_contact, - } - if not all(checks.values()): - raise _failed(checks, case.spec.case_id) - - assert certificate is not None - return CaseValidationRecord( - case_id=case.spec.case_id, - scene_sha256=case.scene_sha256, - derivation=case.spec.derivation, - relation_before=spec.relation_before, - relation_after=spec.relation_after, - before_xy=Vec2(x=before_subject.position.x, y=before_subject.position.y), - after_xy=Vec2(x=after_subject.position.x, y=after_subject.position.y), - exact_infimum_xy=case.spec.exact_infimum_xy, - exact_infimum_m=case.spec.exact_infimum_m, - realized_displacement_m=realized_displacement, - realized_error_m=realized_error, - solver_status=solve_result.status, - quality=solve_result.quality, - verifier_status=verification.status, - leakage_count=verification.leakage_count, - changed_relations=verification.changed_relations, - certificate=CertificateRecord( - distance_lower_bound=certificate.distance_lower_bound, - distance_upper_bound=certificate.distance_upper_bound, - optimality_gap=certificate.optimality_gap, - radial_geometry_error=certificate.radial_geometry_error, - numeric_error_bound=certificate.numeric_error_bound, - disk_segments=certificate.disk_segments, - infimum_only=certificate.infimum_only, - ), - checks=checks, - ) - - -def run_validation_case( - case: LoadedValidationCase, -) -> tuple[CaseValidationRecord, Scene]: - """Solve and independently validate one committed acceptance case.""" - result = CertifiedSpatialCFSolver().solve(case.scene, case.spec.intervention) - if result.subject_position is None: - raise CoreValidationError( - f"{case.spec.case_id}: solver did not return a position", - failed_checks=("solver_success",), - ) - try: - after = AnalyticMotionModel().with_object_xy( - case.scene, - case.spec.intervention.subject_id, - result.subject_position.x, - result.subject_position.y, - ) - except CandidateProjectionError as exc: - raise CoreValidationError( - f"{case.spec.case_id}: analytic replay failed", - failed_checks=("subject_view_matches_analytic_motion",), - ) from exc - return validate_solver_outcome(case, result, after), after - - -def run_validation_suite( - root: Path, -) -> tuple[ValidationReport, dict[str, tuple[Scene, Scene]]]: - """Run the exact three cases twice and return only repeatable results.""" - records: list[CaseValidationRecord] = [] - scenes: dict[str, tuple[Scene, Scene]] = {} - for case in load_validation_cases(root): - first_record, first_after = run_validation_case(case) - second_record, second_after = run_validation_case(case) - if first_record != second_record or first_after != second_after: - raise CoreValidationError( - f"{case.spec.case_id}: validation is not repeatable" - ) - records.append(first_record) - scenes[case.spec.case_id] = (case.scene, first_after) - if len(records) != 3: - raise CoreValidationError("validation suite did not produce three cases") - report = ValidationReport(cases=(records[0], records[1], records[2])) - return report, scenes diff --git a/src/spatialcf/verification/__init__.py b/src/spatialcf/verification/__init__.py index 64a469e..91a2c11 100644 --- a/src/spatialcf/verification/__init__.py +++ b/src/spatialcf/verification/__init__.py @@ -1,5 +1,20 @@ -"""Independent validation of spatial counterfactuals.""" +"""Independent validation of spatial counterfactuals and published datasets.""" +from spatialcf.verification.dataset import ( + DatasetWriter, + FailureRecord, + PairRecord, + audit_dataset, + read_authenticated_dataset_identity, +) from spatialcf.verification.verifier import VerificationResult, Verifier -__all__ = ["VerificationResult", "Verifier"] +__all__ = [ + "DatasetWriter", + "FailureRecord", + "PairRecord", + "VerificationResult", + "Verifier", + "audit_dataset", + "read_authenticated_dataset_identity", +] diff --git a/src/spatialcf/verification/artifacts.py b/src/spatialcf/verification/artifacts.py new file mode 100644 index 0000000..5582812 --- /dev/null +++ b/src/spatialcf/verification/artifacts.py @@ -0,0 +1,182 @@ +"""Read-only descriptor-bound verification for retained artifact trees.""" + +from __future__ import annotations + +import ctypes +import errno +import hashlib +import os +import sys +from collections.abc import Mapping +from pathlib import Path + +from spatialcf.verification.filesystem import ( + read_regular_at, + revalidate_entries, + scan_directory, + snapshot_exact_directory, +) + + +def snapshot_exact_artifact_tree( + descriptor: int, + *, + regular_names: set[str] | frozenset[str], + directory_names: set[str] | frozenset[str] = frozenset(), +): + """Bind one exact direct-child roster before semantic verification.""" + + return snapshot_exact_directory( + descriptor, + regular_names=regular_names, + directory_names=directory_names, + ) + + +def scan_retained_directory( + descriptor: int, + *, + maximum_entries: int, +): + """Enumerate one retained directory without mutation or path recursion.""" + + return scan_directory(descriptor, maximum_entries=maximum_entries) + + +def read_retained_artifact( + directory_descriptor: int, + name: str, + maximum_bytes: int, + *, + expected_stat=None, +) -> bytes: + """Read one direct retained artifact and reject binding changes.""" + + return read_regular_at( + directory_descriptor, + name, + maximum_bytes, + expected_stat=expected_stat, + ) + + +def revalidate_retained_tree( + descriptor: int, + entries: Mapping, +) -> None: + """Require each retained direct child to preserve its bound fingerprint.""" + + revalidate_entries(descriptor, dict(entries)) + + +def retained_sha256_digests(payloads: Mapping[str, bytes]) -> dict[str, str]: + """Compute an exact direct-child checksum roster for retained bytes.""" + + digests: dict[str, str] = {} + for name in sorted(payloads): + if ( + type(name) is not str + or not name + or "/" in name + or "\\" in name + or name in {".", ".."} + ): + raise ValueError("competition native filename is invalid") + payload = payloads[name] + if type(payload) is not bytes: + raise TypeError("retained artifact payloads must be exact bytes") + digests[name] = hashlib.sha256(payload).hexdigest() + return digests + + +def canonical_checksum_ledger(digests: Mapping[str, str]) -> bytes: + """Encode one canonical direct-child SHA-256 ledger without writing it.""" + + lines: list[str] = [] + for name in sorted(digests): + if ( + type(name) is not str + or not name + or "/" in name + or "\\" in name + or name in {".", ".."} + ): + raise ValueError("competition native filename is invalid") + digest = digests[name] + if ( + type(digest) is not str + or len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + ): + raise ValueError("retained artifact digest is invalid") + lines.append(f"{digest} {name}\n") + return "".join(lines).encode("ascii") + + +def require_exact_checksum_ledger( + payload: bytes, + expected_digests: Mapping[str, str], +) -> None: + """Reject a changed, missing, extra, or non-canonical checksum roster.""" + + if type(payload) is not bytes: + raise TypeError("retained artifact checksum ledger must be exact bytes") + if payload != canonical_checksum_ledger(expected_digests): + raise ValueError("native asset checksum ledger mismatch") + + +def _rename_no_replace(source: Path, destination: Path) -> None: + """Rename without replacement, or fail closed when unavailable.""" + if os.name == "nt": + if os.path.lexists(destination): + raise FileExistsError(destination) + os.rename(source, destination) + return + if sys.platform.startswith("linux"): + libc = ctypes.CDLL(None, use_errno=True) + renameat2 = getattr(libc, "renameat2", None) + if renameat2 is None: + raise RuntimeError("renameat2(RENAME_NOREPLACE) unavailable") + renameat2.argtypes = [ + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_int, + ctypes.c_char_p, + ctypes.c_uint, + ] + renameat2.restype = ctypes.c_int + result = renameat2( + -100, + os.fsencode(source), + -100, + os.fsencode(destination), + 1, + ) + if result == 0: + return + code = ctypes.get_errno() + if code == errno.EEXIST: + raise FileExistsError(destination) + unsupported = { + errno.ENOSYS, + getattr(errno, "ENOTSUP", errno.ENOSYS), + getattr(errno, "EOPNOTSUPP", errno.ENOSYS), + } + if code in unsupported: + raise RuntimeError( + "renameat2(RENAME_NOREPLACE) unsupported by this filesystem" + ) + raise OSError(code, os.strerror(code), destination) + raise RuntimeError("no safe no-replace directory rename on this platform") + + +__all__ = ( + "_rename_no_replace", + "canonical_checksum_ledger", + "read_retained_artifact", + "require_exact_checksum_ledger", + "retained_sha256_digests", + "revalidate_retained_tree", + "scan_retained_directory", + "snapshot_exact_artifact_tree", +) diff --git a/src/spatialcf/verification/dataset.py b/src/spatialcf/verification/dataset.py new file mode 100644 index 0000000..06a17f4 --- /dev/null +++ b/src/spatialcf/verification/dataset.py @@ -0,0 +1,3650 @@ +"""Dataset records, immutable publication, and read-only verification.""" + +from __future__ import annotations + +import ctypes +import errno +import hashlib +import io +import json +import math +import os +import shutil +import stat +import sys +import uuid +import warnings +from collections import Counter +from collections.abc import Mapping +from contextlib import suppress +from dataclasses import dataclass +from enum import Enum +from pathlib import Path, PurePosixPath, PureWindowsPath +from statistics import mean +from typing import Any, BinaryIO, Literal, TypeVar + +import numpy as np +from PIL import Image, ImageDraw, UnidentifiedImageError +from pydantic import ( + BaseModel, + ConfigDict, + ValidationError, + field_serializer, + field_validator, + model_validator, +) + +from spatialcf.domain.request import ( + InterventionSpec, + QualityTier, + Relation, + SolverStatus, +) +from spatialcf.domain.scene import Scene +from spatialcf.verification.artifacts import _rename_no_replace +from spatialcf.verification.integrity import ( + calculate_candidate_objective, + calculate_weighted_objective, + canonical_json_bytes, + source_corpus_digest, + topdown_payload, + validate_generation_budget, +) +from spatialcf.verification.profile import ( + ArtifactProfile, + RunProfile, + profile_from_manifest, +) +from spatialcf.verification.provenance import ( + ATTESTED_MANIFEST_SCHEMA_VERSIONS, + DATASET_MANIFEST_SCHEMA_VERSION, + DATASET_SEED, + GENERATOR_VERSION, + LEGACY_ATTESTED_MANIFEST_SCHEMA_VERSION, + AttemptEvidence, + GenerationProvenance, +) +from spatialcf.verification.split import assign_split +from spatialcf.verification.verifier import Verifier + +_DATASET_SEED = 20260723 +_HOLDOUT_TAGS = frozenset({"unseen_scene", "unseen_category", "unseen_combination"}) + + +class _FrozenRecord(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + +def _validate_relative_path(value: str) -> str: + """Keep artifact references inside the immutable dataset directory.""" + if not value or "\\" in value: + raise ValueError("artifact path must be a non-empty relative POSIX path") + posix_path = PurePosixPath(value) + windows_path = PureWindowsPath(value) + if ( + posix_path.is_absolute() + or windows_path.is_absolute() + or windows_path.drive + or any(part in {"", ".", ".."} for part in value.split("/")) + ): + raise ValueError("artifact path must be a non-empty relative POSIX path") + if value.split("/", 1)[0] not in {"assets", "scenes", "relations", "topdown"}: + raise ValueError("artifact path must use an approved artifact prefix") + if value in {"pairs.jsonl", "failures.jsonl", "manifest.json", "checksums.sha256"}: + raise ValueError("artifact path may not alias dataset metadata") + return value + + +class PairRecord(_FrozenRecord): + """An accepted, independently verified counterfactual pair.""" + + pair_id: str + request_id: str + scene_id: str + split: Literal["train", "dev", "test"] + holdout_tags: frozenset[str] + source: str + seed: int + generator: str + subject_id: str + subject_category: str + reference_id: str + reference_category: str + camera_id: str + relation_before: Relation + relation_after: Relation + question: str + answer_before: Relation + answer_after: Relation + scene_before_path: str + scene_after_path: str + rgb_before_path: str + rgb_after_path: str + depth_before_path: str + depth_after_path: str + instance_before_path: str + instance_after_path: str + pointcloud_before_path: str + pointcloud_after_path: str + topdown_path: str + relation_graph_before_path: str + relation_graph_after_path: str + relation_diff: tuple[str, ...] + normalized_edit_distance: float + leakage_score: float + visibility_change: float + inverse_safety_margin: float + solver_status: SolverStatus + evaluated_candidates: int + quality_flags: tuple[str, ...] + quality: QualityTier + generator_version: str + + @classmethod + def artifact_path_fields(cls) -> tuple[str, ...]: + return tuple(name for name in cls.model_fields if name.endswith("_path")) + + @field_validator( + "pair_id", + "request_id", + "scene_id", + "source", + "generator", + "subject_id", + "subject_category", + "reference_id", + "reference_category", + "camera_id", + "question", + "generator_version", + ) + @classmethod + def validate_required_text(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError( + "required text fields must be non-empty and non-whitespace" + ) + return value + + @field_validator( + "scene_before_path", + "scene_after_path", + "rgb_before_path", + "rgb_after_path", + "depth_before_path", + "depth_after_path", + "instance_before_path", + "instance_after_path", + "pointcloud_before_path", + "pointcloud_after_path", + "topdown_path", + "relation_graph_before_path", + "relation_graph_after_path", + ) + @classmethod + def validate_artifact_path(cls, value: str) -> str: + if not value.strip(): + raise ValueError("artifact path must be non-empty") + return _validate_relative_path(value) + + @field_validator( + "normalized_edit_distance", + "leakage_score", + "visibility_change", + "inverse_safety_margin", + ) + @classmethod + def validate_score(cls, value: float) -> float: + if not math.isfinite(value) or value < 0: + raise ValueError("scores must be finite and non-negative") + return value + + @field_validator("normalized_edit_distance", "leakage_score") + @classmethod + def validate_normalized_score(cls, value: float) -> float: + if value > 1.0: + raise ValueError("normalized scores must not exceed one") + return value + + @field_serializer("holdout_tags", when_used="json") + def serialize_holdout_tags(self, value: frozenset[str]) -> list[str]: + return sorted(value) + + @model_validator(mode="after") + def validate_accepted_pair(self) -> PairRecord: + if any(not value for value in (self.pair_id, self.request_id, self.scene_id)): + raise ValueError("pair_id, request_id, and scene_id must be non-empty") + if self.seed != _DATASET_SEED: + raise ValueError( + f"seed must be the deterministic dataset seed {_DATASET_SEED}" + ) + if self.subject_id == self.reference_id: + raise ValueError("subject_id and reference_id must differ") + if self.relation_before.opposite is not self.relation_after: + raise ValueError("accepted pairs must use an opposite relation flip") + if ( + self.answer_before is not self.relation_before + or self.answer_after is not self.relation_after + ): + raise ValueError("answers must preserve independently verified relations") + if self.solver_status is not SolverStatus.SUCCESS: + raise ValueError( + "accepted pairs require independent verifier status SUCCESS" + ) + if self.quality is QualityTier.REJECTED: + raise ValueError("accepted pairs cannot have REJECTED quality") + if self.quality is QualityTier.PURE and ( + self.leakage_score != 0.0 or self.quality_flags != ("PURE",) + ): + raise ValueError("PURE pairs require zero leakage and a PURE quality flag") + if self.quality is QualityTier.LOW_LEAKAGE and ( + self.leakage_score <= 0.0 or self.quality_flags != ("LOW_LEAKAGE",) + ): + raise ValueError( + "LOW_LEAKAGE pairs require positive leakage and a LOW_LEAKAGE quality flag" + ) + if not self.holdout_tags.issubset(_HOLDOUT_TAGS): + raise ValueError("unknown holdout tag") + if self.split == "test": + if "unseen_scene" not in self.holdout_tags: + raise ValueError("test pairs require the unseen_scene holdout tag") + if self.quality is not QualityTier.PURE: + raise ValueError("test split must remain PURE") + elif self.holdout_tags: + raise ValueError("holdout tags are permitted only on the test split") + if self.evaluated_candidates < 0: + raise ValueError("evaluated_candidates must be non-negative") + return self + + +class FailureRecord(_FrozenRecord): + """Append-only evidence for a request that did not become accepted data.""" + + failure_id: str + request_id: str + scene_id: str + subject_id: str + reference_id: str + relation_before: Relation + relation_after: Relation + generator: str + generator_version: str + seed: int + status: SolverStatus + reason: str + evaluated_candidates: int + + @field_validator( + "failure_id", + "request_id", + "scene_id", + "subject_id", + "reference_id", + "generator", + "generator_version", + "reason", + ) + @classmethod + def validate_required_text(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError( + "required text fields must be non-empty and non-whitespace" + ) + return value + + @model_validator(mode="after") + def validate_failure(self) -> FailureRecord: + if any( + not value + for value in ( + self.failure_id, + self.request_id, + self.scene_id, + self.reason, + ) + ): + raise ValueError( + "failure_id, request_id, scene_id, and reason must be non-empty" + ) + if self.seed != _DATASET_SEED: + raise ValueError( + f"seed must be the deterministic dataset seed {_DATASET_SEED}" + ) + if self.subject_id == self.reference_id: + raise ValueError("subject_id and reference_id must differ") + if self.relation_before.opposite is not self.relation_after: + raise ValueError("failures must retain an opposite relation flip") + if self.status is SolverStatus.SUCCESS: + raise ValueError("failure status must not be SUCCESS") + if self.evaluated_candidates < 0: + raise ValueError("evaluated_candidates must be non-negative") + return self + + +_LOCK_SCHEMA_VERSION = 1 +_LOCK_OFFSET = 1 << 30 +_LOCK_KEYS = frozenset( + {"dataset_version", "pid", "schema_version", "staging_name", "token"} +) +_WINDOWS_REPARSE_POINT = 0x400 + + +class DatasetDurabilityError(OSError): + """Publication durability failed, with an explicit visibility outcome.""" + + def __init__( + self, + final_path: Path, + *, + published: bool, + detail: str, + recovery_path: Path | None = None, + recovery_required: bool = False, + ) -> None: + canonical_path_visible = _lexists(final_path) + state = ( + "published" + if published + else ( + "not published; recovery required" + if recovery_required + else "rolled back" + ) + ) + recovery = ( + f"; recovery path: {recovery_path}" if recovery_path is not None else "" + ) + super().__init__( + errno.EIO, + f"{detail}; dataset is {state}: {final_path}{recovery}", + ) + self.final_path = final_path + self.published = published + self.recovery_path = recovery_path + self.recovery_required = recovery_required + self.canonical_path_visible = canonical_path_visible + + +def _lexists(path: Path) -> bool: + return os.path.lexists(path) + + +def _unsafe(path: Path) -> bool: + if path.is_symlink(): + return True + try: + result = os.stat(path, follow_symlinks=False) + except FileNotFoundError: + return False + return bool(getattr(result, "st_file_attributes", 0) & _WINDOWS_REPARSE_POINT) + + +def _validate_dataset_version(dataset_version: str) -> str: + if ( + not isinstance(dataset_version, str) + or not dataset_version.strip() + or dataset_version in {".", ".."} + or dataset_version.startswith(".") + ): + raise ValueError("dataset_version must be a non-hidden directory name") + if ( + "/" in dataset_version + or "\\" in dataset_version + or PurePosixPath(dataset_version).is_absolute() + or PureWindowsPath(dataset_version).is_absolute() + or PureWindowsPath(dataset_version).drive + ): + raise ValueError("dataset_version must be a non-hidden directory name") + return dataset_version + + +def _canonical_value(value: Any) -> Any: + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {str(key): _canonical_value(item) for key, item in sorted(value.items())} + if isinstance(value, (set, frozenset)): + return sorted( + (_canonical_value(item) for item in value), + key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")), + ) + if isinstance(value, (list, tuple)): + return [_canonical_value(item) for item in value] + return value + + +def _json_bytes(value: Any, *, pretty: bool = False) -> bytes: + kwargs: dict[str, Any] = { + "allow_nan": False, + "ensure_ascii": False, + "sort_keys": True, + } + if pretty: + kwargs["indent"] = 2 + else: + kwargs["separators"] = (",", ":") + return (json.dumps(_canonical_value(value), **kwargs) + "\n").encode("utf-8") + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _file_identity(result: os.stat_result) -> tuple[int, int]: + return (result.st_dev, result.st_ino) + + +def _meaningful_file_identity( + result: os.stat_result, +) -> tuple[int, int] | None: + """Return a usable physical identity, tolerating unavailable zero fields.""" + device = getattr(result, "st_dev", 0) + inode = getattr(result, "st_ino", 0) + if type(device) is not int or type(inode) is not int or device == 0 or inode == 0: + return None + return (device, inode) + + +def _identity_for_path(path: Path, *, directory: bool) -> tuple[int, int]: + if not _lexists(path) or _unsafe(path): + raise RuntimeError(f"unsafe or missing owned path: {path}") + result = os.stat(path, follow_symlinks=False) + expected = stat.S_ISDIR if directory else stat.S_ISREG + if not expected(result.st_mode): + raise RuntimeError(f"owned path has the wrong type: {path}") + return _file_identity(result) + + +def _windows_open_lock(path: Path, *, create: bool, audit: bool = False) -> BinaryIO: + """Open an owner/recovery handle, or a read-only compatible audit.""" + import msvcrt + + if create and audit: + raise ValueError("an audit handle cannot create a lock") + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + create_file = kernel32.CreateFileW + create_file.argtypes = [ + ctypes.c_wchar_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_uint32, + ctypes.c_void_p, + ] + create_file.restype = ctypes.c_void_p + close_handle = kernel32.CloseHandle + close_handle.argtypes = [ctypes.c_void_p] + close_handle.restype = ctypes.c_int + if audit: + access = 0x80000000 # GENERIC_READ + # The audit grants all sharing to remain compatible with the retained + # owner's WRITE and DELETE access; its own access remains read-only. + sharing = 0x1 | 0x2 | 0x4 + else: + access = 0x80000000 | 0x40000000 | 0x00010000 + # GENERIC_READ | GENERIC_WRITE | DELETE + sharing = 0x1 # FILE_SHARE_READ + disposition = 1 if create else 3 # CREATE_NEW | OPEN_EXISTING + flags = 0x80 | (0 if create else 0x00200000) + handle = create_file( + str(path), + access, + sharing, + None, + disposition, + flags, + None, + ) + invalid_handle = ctypes.c_void_p(-1).value + if handle == invalid_handle: + code = ctypes.get_last_error() + if create and code in {80, 183}: + raise FileExistsError(path) + if not create and code in {2, 3}: + raise FileNotFoundError(path) + raise OSError(code, ctypes.FormatError(code), path) + try: + descriptor = msvcrt.open_osfhandle( + int(handle), + (os.O_RDONLY if audit else os.O_RDWR) | getattr(os, "O_BINARY", 0), + ) + except BaseException: + close_handle(ctypes.c_void_p(handle)) + raise + try: + return os.fdopen(descriptor, "rb" if audit else "r+b", buffering=0) + except BaseException: + os.close(descriptor) + raise + + +def _windows_mark_delete_on_close(stream: BinaryIO) -> None: + """Make the retained owner handle deletion-pending before it is closed.""" + import msvcrt + + class FileDispositionInfo(ctypes.Structure): + _fields_ = [("DeleteFile", ctypes.c_int)] + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + set_file_information = kernel32.SetFileInformationByHandle + set_file_information.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_uint32, + ] + set_file_information.restype = ctypes.c_int + handle = msvcrt.get_osfhandle(stream.fileno()) + disposition = FileDispositionInfo(1) + if not set_file_information( + ctypes.c_void_p(handle), + 4, # FileDispositionInfo + ctypes.byref(disposition), + ctypes.sizeof(disposition), + ): + code = ctypes.get_last_error() + raise OSError(code, ctypes.FormatError(code)) + + +def _windows_live_open_error(error: OSError) -> bool: + codes = {error.errno, getattr(error, "winerror", None)} + return os.name == "nt" and bool(codes & {5, 32}) + + +def _open_lock(path: Path, *, create: bool) -> BinaryIO: + if os.name == "nt": + return _windows_open_lock(path, create=create) + if not (sys.platform.startswith("linux") or sys.platform == "darwin"): + raise RuntimeError("safe advisory lock support is unavailable") + flags = os.O_RDWR + if create: + flags |= os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + return os.fdopen(descriptor, "r+b", buffering=0) + + +def _try_advisory_lock(stream: BinaryIO) -> bool: + if os.name == "nt": + import msvcrt + + stream.seek(_LOCK_OFFSET) + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as error: + if error.errno in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}: + return False + raise + finally: + stream.seek(0) + return True + if sys.platform.startswith("linux") or sys.platform == "darwin": + import fcntl + + try: + fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + if error.errno in {errno.EACCES, errno.EAGAIN}: + return False + raise + return True + raise RuntimeError("safe advisory lock support is unavailable") + + +def _unlock_advisory(stream: BinaryIO) -> None: + if os.name == "nt": + import msvcrt + + stream.seek(_LOCK_OFFSET) + try: + msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1) + finally: + stream.seek(0) + return + if sys.platform.startswith("linux") or sys.platform == "darwin": + import fcntl + + fcntl.flock(stream.fileno(), fcntl.LOCK_UN) + return + raise RuntimeError("safe advisory lock support is unavailable") + + +def _open_staging_directory(path: Path) -> int: + if not (sys.platform.startswith("linux") or sys.platform == "darwin"): + raise RuntimeError("safe staging-directory locking is unavailable") + if not hasattr(os, "O_DIRECTORY") or not hasattr(os, "O_NOFOLLOW"): + raise RuntimeError("safe staging-directory open flags are unavailable") + return os.open( + path, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + + +def _try_staging_lock(descriptor: int) -> bool: + import fcntl + + try: + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as error: + if error.errno in {errno.EACCES, errno.EAGAIN}: + return False + raise + return True + + +def _unlock_staging(descriptor: int) -> None: + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_UN) + + +def _pid_is_alive(pid: int) -> bool: + if pid == os.getpid(): + return True + if os.name == "nt": + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32] + open_process.restype = ctypes.c_void_p + close_handle = kernel32.CloseHandle + close_handle.argtypes = [ctypes.c_void_p] + close_handle.restype = ctypes.c_int + handle = open_process(0x1000, 0, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not handle: + code = ctypes.get_last_error() + if code == 87: # ERROR_INVALID_PARAMETER: no such process + return False + if code == 5: # access denied: fail closed as live + return True + raise OSError(code, ctypes.FormatError(code)) + try: + exit_code = ctypes.c_uint32() + get_exit = kernel32.GetExitCodeProcess + get_exit.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)] + get_exit.restype = ctypes.c_int + if not get_exit(handle, ctypes.byref(exit_code)): + code = ctypes.get_last_error() + raise OSError(code, ctypes.FormatError(code)) + return exit_code.value == 259 # STILL_ACTIVE + finally: + close_handle(handle) + if sys.platform.startswith("linux") or sys.platform == "darwin": + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + raise RuntimeError("safe PID liveness detection is unavailable") + + +def _read_lock_metadata(stream: BinaryIO) -> dict[str, Any]: + stream.seek(0) + payload = stream.read() + try: + metadata = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise RuntimeError("dataset lock metadata is invalid") from error + if not isinstance(metadata, dict) or frozenset(metadata) != _LOCK_KEYS: + raise RuntimeError("dataset lock metadata schema is invalid") + if ( + type(metadata["schema_version"]) is not int + or metadata["schema_version"] != _LOCK_SCHEMA_VERSION + or type(metadata["pid"]) is not int + or metadata["pid"] <= 0 + or not isinstance(metadata["dataset_version"], str) + or not isinstance(metadata["staging_name"], str) + or not isinstance(metadata["token"], str) + or len(metadata["token"]) != 32 + or any(character not in "0123456789abcdef" for character in metadata["token"]) + or payload != _json_bytes(metadata) + ): + raise RuntimeError("dataset lock metadata schema is invalid") + return metadata + + +class DatasetWriter: + def __init__( + self, + artifact_root: Path, + dataset_version: str, + profile: ArtifactProfile = ArtifactProfile.for_run(RunProfile.EVIDENCE), # noqa: B008 + ) -> None: + version = _validate_dataset_version(dataset_version) + self._version = version + self._profile = profile + self.dataset_root = Path(artifact_root) / "datasets" + if _lexists(self.dataset_root) and _unsafe(self.dataset_root): + raise ValueError("unsafe dataset root") + self.dataset_root.mkdir(parents=True, exist_ok=True) + self.final_root = self.dataset_root / version + self.staging_root = self.dataset_root / f".{version}.tmp" + self.lock_path = self.dataset_root / f".{version}.lock" + for path in (self.final_root, self.staging_root, self.lock_path): + if _lexists(path) and _unsafe(path): + raise ValueError("unsafe dataset version entry") + if _lexists(self.final_root) or _lexists(self.staging_root): + raise FileExistsError(version) + + self._token = uuid.uuid4().hex + self._lock_stream: BinaryIO | None = None + self._lock_identity: tuple[int, int] | None = None + self._lock_metadata: dict[str, Any] | None = None + self._lock_held = False + self._owns_lock = False + self._staging_identity: tuple[int, int] | None = None + self._staging_descriptor: int | None = None + self._staging_lock_held = False + self._finalized = False + self._pairs: list[PairRecord] = [] + self._failures: list[FailureRecord] = [] + self._generation: dict[str, Any] | None = None + self._pair_ids: set[str] = set() + self._request_ids: set[str] = set() + try: + self._create_lock() + self.staging_root.mkdir() + self._staging_identity = _identity_for_path( + self.staging_root, directory=True + ) + self._acquire_staging_lock() + (self.staging_root / "assets").mkdir() + (self.staging_root / "topdown").mkdir() + except BaseException: + try: + if self._owns_lock: + self._cleanup_owned() + else: + self._discard_initial_lock() + except BaseException: + self._owns_lock = False + try: + self._close_staging_lock() + finally: + self._close_lock_without_unlink() + raise + raise + + def __del__(self) -> None: + """Release OS handles when a caller abandons an unfinished writer. + + Deliberately keep the lock and staging paths intact: they are durable + recovery evidence and may only be removed by the authenticated abort, + finalize, or stale-recovery paths. The finalizer merely prevents the + advisory-lock streams themselves from leaking into later work. + """ + descriptor = getattr(self, "_staging_descriptor", None) + if descriptor is not None: + with suppress(OSError): + os.close(descriptor) + self._staging_descriptor = None + self._staging_lock_held = False + stream = getattr(self, "_lock_stream", None) + if stream is not None: + with suppress(OSError, ValueError): + stream.close() + self._lock_stream = None + self._lock_held = False + + def _create_lock(self) -> None: + stream = _open_lock(self.lock_path, create=True) + self._lock_stream = stream + self._lock_identity = _file_identity(os.fstat(stream.fileno())) + if not _try_advisory_lock(stream): + raise RuntimeError("newly created dataset lock is unexpectedly locked") + self._lock_held = True + metadata = { + "dataset_version": self._version, + "pid": os.getpid(), + "schema_version": _LOCK_SCHEMA_VERSION, + "staging_name": self.staging_root.name, + "token": self._token, + } + stream.seek(0) + stream.truncate() + stream.write(_json_bytes(metadata)) + stream.flush() + os.fsync(stream.fileno()) + self._lock_metadata = metadata + self._owns_lock = True + self._ensure_owner() + + def _discard_initial_lock(self) -> None: + stream = self._lock_stream + identity = self._lock_identity + if ( + stream is not None + and identity is not None + and _lexists(self.lock_path) + and not _unsafe(self.lock_path) + and _identity_for_path(self.lock_path, directory=False) == identity + ): + if os.name == "nt": + _windows_mark_delete_on_close(stream) + else: + self.lock_path.unlink() + self._close_lock_without_unlink() + + @classmethod + def recover_stale(cls, artifact_root: Path, dataset_version: str) -> None: + """Recover a crashed writer while retaining the orphan's lock.""" + writer = cls.__new__(cls) + version = _validate_dataset_version(dataset_version) + writer._version = version + writer.dataset_root = Path(artifact_root) / "datasets" + if _lexists(writer.dataset_root) and _unsafe(writer.dataset_root): + raise ValueError("unsafe dataset root") + writer.dataset_root.mkdir(parents=True, exist_ok=True) + writer.final_root = writer.dataset_root / version + writer.staging_root = writer.dataset_root / f".{version}.tmp" + writer.lock_path = writer.dataset_root / f".{version}.lock" + writer._token = "" + writer._lock_stream = None + writer._lock_identity = None + writer._lock_metadata = None + writer._lock_held = False + writer._owns_lock = False + writer._staging_identity = None + writer._staging_descriptor = None + writer._staging_lock_held = False + writer._finalized = False + if _lexists(writer.final_root): + raise FileExistsError(version) + if not _lexists(writer.lock_path): + if _lexists(writer.staging_root): + raise RuntimeError("orphan staging has no recoverable lock metadata") + return + if _unsafe(writer.lock_path): + raise ValueError("unsafe stale lock entry") + + try: + stream = _open_lock(writer.lock_path, create=False) + except OSError as error: + if _windows_live_open_error(error): + raise RuntimeError("dataset writer lock is live") from error + raise + writer._lock_stream = stream + writer._lock_identity = _file_identity(os.fstat(stream.fileno())) + try: + if not _try_advisory_lock(stream): + raise RuntimeError("dataset writer lock is live") + writer._lock_held = True + metadata = _read_lock_metadata(stream) + if ( + metadata["dataset_version"] != version + or metadata["staging_name"] != writer.staging_root.name + ): + raise RuntimeError("stale lock metadata targets another dataset") + if ( + _identity_for_path(writer.lock_path, directory=False) + != writer._lock_identity + ): + raise RuntimeError("stale lock pathname changed during recovery") + if _pid_is_alive(metadata["pid"]): + raise RuntimeError("dataset writer PID is still live") + writer._token = metadata["token"] + writer._lock_metadata = metadata + if _lexists(writer.staging_root): + if _unsafe(writer.staging_root): + raise ValueError("unsafe stale staging entry") + writer._staging_identity = _identity_for_path( + writer.staging_root, directory=True + ) + writer._acquire_staging_lock() + writer._owns_lock = True + writer._cleanup_owned() + writer._fsync_dir(writer.dataset_root) + except BaseException: + writer._owns_lock = False + try: + writer._close_staging_lock() + finally: + writer._close_lock_without_unlink() + raise + + def write_pair(self, record: PairRecord) -> None: + self._ensure_open() + validated = PairRecord.model_validate(record.model_dump(mode="python")) + if validated.pair_id in self._pair_ids: + raise ValueError(f"duplicate pair_id: {validated.pair_id}") + self._claim_request(validated.request_id) + self._pair_ids.add(validated.pair_id) + self._pairs.append(validated) + + def write_failure(self, record: FailureRecord) -> None: + self._ensure_open() + validated = FailureRecord.model_validate(record.model_dump(mode="python")) + self._claim_request(validated.request_id) + self._failures.append(validated) + + def register_generation_attestation( + self, + *, + provenance_path: str, + attempts_path: str, + source_scene_paths: tuple[str, ...], + attempted_requests: int, + requested_pairs: int | None, + attempt_limit: int | None, + ) -> None: + """Bind the non-pair files needed for deterministic official replay.""" + self._ensure_open() + if self._generation is not None: + raise ValueError("generation attestation is already registered") + if type(attempted_requests) is not int or attempted_requests < 0: + raise ValueError("attempted_requests must be a non-negative integer") + for name, value in ( + ("requested_pairs", requested_pairs), + ("attempt_limit", attempt_limit), + ): + if value is not None and (type(value) is not int or value <= 0): + raise ValueError(f"{name} must be null or a positive exact integer") + if (requested_pairs is None) == (attempt_limit is None): + raise ValueError( + "exactly one of requested_pairs and attempt_limit is required" + ) + if attempt_limit is not None and attempted_requests != attempt_limit: + raise ValueError("attempt_limit requires a complete exact attempt prefix") + paths = (provenance_path, attempts_path, *source_scene_paths) + if len(set(paths)) != len(paths): + raise ValueError("generation attestation paths must be unique") + for relative in paths: + if ( + not relative + or "\\" in relative + or PurePosixPath(relative).is_absolute() + or PureWindowsPath(relative).is_absolute() + or PureWindowsPath(relative).drive + or any(part in {"", ".", ".."} for part in relative.split("/")) + or relative.split("/", 1)[0] != "provenance" + ): + raise ValueError( + "generation attestation paths must be safe relative " + "POSIX paths under provenance/" + ) + self._generation = { + "attempt_limit": attempt_limit, + "attempted_requests": attempted_requests, + "attempts_path": attempts_path, + "provenance_path": provenance_path, + "requested_pairs": requested_pairs, + "source_scene_paths": list(source_scene_paths), + } + + def abort(self) -> None: + self._ensure_owner() + self._cleanup_owned() + + def finalize(self) -> Path: + self._ensure_open() + published_identity: tuple[int, int] | None = None + try: + self._validate_dataset() + self._validate_artifacts() + self._write_bytes( + self.staging_root / "pairs.jsonl", + b"".join( + _json_bytes(item.model_dump(mode="python")) for item in self._pairs + ), + ) + self._write_bytes( + self.staging_root / "failures.jsonl", + b"".join( + _json_bytes(item.model_dump(mode="python")) + for item in self._failures + ), + ) + manifest: dict[str, Any] = { + "accepted_pairs": len(self._pairs), + "failures": len(self._failures), + "required_artifacts": len(self._pairs) + * len(PairRecord.artifact_path_fields()), + "schema_version": ( + DATASET_MANIFEST_SCHEMA_VERSION + if self._generation is not None + else 1 + ), + "splits": { + split: sum(item.split == split for item in self._pairs) + for split in ("train", "dev", "test") + }, + } + if self._generation is not None: + manifest["generation"] = self._generation + manifest.update( + { + "run_profile": self._profile.run_profile.value, + "evidence_eligible": self._profile.evidence_eligible, + } + ) + self._write_bytes( + self.staging_root / "manifest.json", + _json_bytes(manifest, pretty=True), + ) + self._fsync_staging() + self._write_checksums() + self._validate_staged_files() + self._fsync_dir(self.staging_root) + + self._ensure_owner() + self._ensure_staging_owner() + validated_files = self._snapshot_staged_files() + if _lexists(self.final_root) or _unsafe(self.final_root): + raise FileExistsError(self.final_root.name) + _rename_no_replace(self.staging_root, self.final_root) + expected_identity = self._staging_identity + try: + observed_identity = _identity_for_path(self.final_root, directory=True) + if expected_identity is None or observed_identity != expected_identity: + raise RuntimeError("published directory identity changed") + self._validate_published_files(validated_files) + except BaseException as validation_error: # noqa: BLE001 + self._raise_unvalidated_publication(validation_error) + published_identity = expected_identity + self._ensure_staging_handle_owner() + self._ensure_owner() + self._fsync_dir(self.dataset_root) + self._finalized = True + self._release_lock() + return self.final_root + except DatasetDurabilityError: + raise + except BaseException as error: + if published_identity is not None: + try: + self._rollback_publication(published_identity) + except BaseException as rollback_error: # noqa: BLE001 + raise DatasetDurabilityError( + self.final_root, + published=_lexists(self.final_root), + detail=f"publication rollback failed: {rollback_error}", + ) from error + try: + self._cleanup_owned() + except BaseException as ownership_error: + if published_identity is not None: + raise DatasetDurabilityError( + self.final_root, + published=_lexists(self.final_root), + detail=f"owner cleanup failed: {ownership_error}", + ) from error + raise + if published_identity is not None: + try: + self._fsync_dir(self.dataset_root) + except OSError as durability_error: + raise DatasetDurabilityError( + self.final_root, + published=False, + detail=f"publication durability failed: {durability_error}", + ) from error + raise + + def _raise_unvalidated_publication( + self, + validation_error: BaseException, + ) -> None: + """Quarantine any unvalidated final entry and raise a truthful error.""" + recovery_path, quarantine_error, canonical_visible = ( + self._quarantine_unvalidated_publication() + ) + cleanup_error: BaseException | None = None + if not canonical_visible: + try: + self._cleanup_owned() + except BaseException as error: # noqa: BLE001 + cleanup_error = error + if canonical_visible: + detail = "unvalidated final entry could not be quarantined" + elif recovery_path is None and quarantine_error is None: + detail = "unvalidated final entry was already absent before quarantine" + else: + detail = "unvalidated final entry was quarantined" + details = [f"{detail}: {validation_error}"] + if quarantine_error is not None: + details.append(f"quarantine failed: {quarantine_error}") + if cleanup_error is not None: + details.append(f"owner cleanup failed: {cleanup_error}") + raise DatasetDurabilityError( + self.final_root, + published=False, + detail="; ".join(details), + recovery_path=recovery_path, + recovery_required=True, + ) from validation_error + + def _quarantine_unvalidated_publication( + self, + ) -> tuple[Path | None, BaseException | None, bool]: + """Move any final-path object to an unpredictable sibling.""" + quarantine_path = self.dataset_root / ( + f".{self._version}.quarantine-{uuid.uuid4().hex}" + ) + if not _lexists(self.final_root): + return None, None, False + try: + before = os.stat(self.final_root, follow_symlinks=False) + except BaseException as error: # noqa: BLE001 + return self.final_root, error, _lexists(self.final_root) + try: + _rename_no_replace(self.final_root, quarantine_path) + if _lexists(self.final_root): + raise RuntimeError( + "canonical final path remains visible after quarantine" + ) + if not _lexists(quarantine_path): + raise RuntimeError("quarantine path is missing after rename") + after = os.stat(quarantine_path, follow_symlinks=False) + if _file_identity(after) != _file_identity(before) or stat.S_IFMT( + after.st_mode + ) != stat.S_IFMT(before.st_mode): + raise RuntimeError("quarantined entry identity changed") + self._fsync_dir(self.dataset_root) + except BaseException as error: # noqa: BLE001 + recovery_path = ( + self.final_root + if _lexists(self.final_root) + else (quarantine_path if _lexists(quarantine_path) else None) + ) + return recovery_path, error, _lexists(self.final_root) + return quarantine_path, None, False + + def _rollback_publication(self, published_identity: tuple[int, int]) -> None: + self._ensure_owner() + self._ensure_staging_handle_owner() + if _identity_for_path(self.final_root, directory=True) != published_identity: + raise RuntimeError("published dataset ownership changed") + if _lexists(self.staging_root): + raise RuntimeError("cannot roll back over an existing staging path") + _rename_no_replace(self.final_root, self.staging_root) + if _identity_for_path(self.staging_root, directory=True) != published_identity: + try: + _rename_no_replace(self.staging_root, self.final_root) + finally: + raise RuntimeError("rolled-back dataset identity changed") + self._staging_identity = published_identity + self._ensure_staging_owner() + + def _validate_dataset(self) -> None: + held_categories: set[str] = set() + held_combinations: set[tuple[str, str, str]] = set() + scenes: dict[str, str] = {} + for item in self._pairs: + if item.split != assign_split(item.scene_id): + raise ValueError( + "caller-supplied split does not match scene assignment" + ) + if scenes.setdefault(item.scene_id, item.split) != item.split: + raise ValueError("scene appears in multiple splits") + combo = ( + item.subject_category, + item.relation_after.value, + item.reference_category, + ) + if item.split == "test" and "unseen_category" in item.holdout_tags: + held_categories.update((item.subject_category, item.reference_category)) + if item.split == "test" and "unseen_combination" in item.holdout_tags: + held_combinations.add(combo) + for item in self._pairs: + combo = ( + item.subject_category, + item.relation_after.value, + item.reference_category, + ) + category_match = ( + item.subject_category in held_categories + or item.reference_category in held_categories + ) + combo_match = combo in held_combinations + if item.split != "test" and (category_match or combo_match): + raise ValueError("held category or combination leaked outside test") + if item.split == "test" and ( + category_match != ("unseen_category" in item.holdout_tags) + or combo_match != ("unseen_combination" in item.holdout_tags) + ): + raise ValueError( + "holdout tags contradict record categories or combination" + ) + + def _validate_artifacts(self) -> None: + seen: set[str] = set() + for item in self._pairs: + for field in PairRecord.artifact_path_fields(): + relative = getattr(item, field) + target = self.staging_root.joinpath(*PurePosixPath(relative).parts) + normalized = PurePosixPath(relative).as_posix() + if normalized in seen: + raise ValueError("duplicate artifact path") + seen.add(normalized) + parent = target.parent + while parent != self.staging_root: + if _unsafe(parent): + raise ValueError( + "artifact path traverses symlink/reparse point" + ) + parent = parent.parent + if not target.is_file() or _unsafe(target): + raise ValueError("missing required artifact") + if self._generation is not None: + for relative in ( + self._generation["provenance_path"], + self._generation["attempts_path"], + *self._generation["source_scene_paths"], + ): + if relative in seen: + raise ValueError("duplicate attestation/artifact path") + seen.add(relative) + target = self.staging_root.joinpath(*PurePosixPath(relative).parts) + parent = target.parent + while parent != self.staging_root: + if _unsafe(parent): + raise ValueError( + "generation attestation traverses symlink/reparse point" + ) + parent = parent.parent + if not target.is_file() or _unsafe(target): + raise ValueError("missing registered generation attestation file") + + def _write_checksums(self) -> None: + checksum_path = self.staging_root / "checksums.sha256" + files = [ + path for path in self._validate_staged_files() if path != checksum_path + ] + self._write_bytes( + checksum_path, + "".join( + f"{_sha256(path)} {path.relative_to(self.staging_root).as_posix()}\n" + for path in files + ).encode("utf-8"), + ) + + def _validate_staged_files(self) -> list[Path]: + """Validate every staged entry without following links. + + Python exposes ``st_nlink`` on Windows/NTFS, so hardlinks are rejected + there exactly as on POSIX. Some filesystems report zero for device or + inode; those unavailable identity fields are ignored while a meaningful + link count is still required to be one. + """ + self._ensure_staging_owner() + files, _ = self._scan_file_tree(self.staging_root) + return files + + def _snapshot_staged_files( + self, + ) -> dict[str, tuple[int, int] | None]: + self._ensure_staging_owner() + _, identities = self._scan_file_tree(self.staging_root) + return identities + + def _validate_published_files( + self, + expected: dict[str, tuple[int, int] | None], + ) -> None: + _, observed = self._scan_file_tree(self.final_root) + if frozenset(observed) != frozenset(expected): + raise ValueError( + "published file set differs from the validated staging tree" + ) + changed = sorted( + relative + for relative, identity in expected.items() + if identity is not None and observed[relative] != identity + ) + if changed: + raise ValueError( + "published physical file identity differs from validated " + f"staging: {changed[:5]}" + ) + + def _scan_file_tree( + self, + root: Path, + ) -> tuple[list[Path], dict[str, tuple[int, int] | None]]: + try: + root_result = os.stat(root, follow_symlinks=False) + except OSError as error: + raise ValueError("staged/published tree root is missing") from error + if ( + not stat.S_ISDIR(root_result.st_mode) + or stat.S_ISLNK(root_result.st_mode) + or getattr(root_result, "st_file_attributes", 0) & _WINDOWS_REPARSE_POINT + ): + raise ValueError("staged/published tree root is not a safe directory") + files: list[Path] = [] + file_identities: dict[str, tuple[int, int] | None] = {} + identities: dict[tuple[int, int], str] = {} + unsafe_link: tuple[str, int] | None = None + entries = sorted( + root.rglob("*"), + key=lambda path: path.relative_to(root).as_posix(), + ) + for path in entries: + relative = path.relative_to(root).as_posix() + try: + result = os.stat(path, follow_symlinks=False) + except OSError as error: + raise ValueError( + f"staged path changed during validation: {relative}" + ) from error + if ( + stat.S_ISLNK(result.st_mode) + or getattr(result, "st_file_attributes", 0) & _WINDOWS_REPARSE_POINT + ): + raise ValueError(f"staged path is a symlink/reparse point: {relative}") + if stat.S_ISDIR(result.st_mode): + continue + if not stat.S_ISREG(result.st_mode): + raise ValueError(f"staged path is not a regular file: {relative}") + link_count = getattr(result, "st_nlink", 0) + if ( # noqa: SIM102 + type(link_count) is int and link_count > 0 and link_count != 1 + ): + if unsafe_link is None: + unsafe_link = (relative, link_count) + identity = _meaningful_file_identity(result) + if identity is not None: + previous = identities.setdefault(identity, relative) + if previous != relative: + raise ValueError( + "staged regular files have duplicate physical " + f"identity: {previous}, {relative}" + ) + file_identities[relative] = identity + files.append(path) + if unsafe_link is not None: + relative, link_count = unsafe_link + raise ValueError( + "staged regular file has an unsafe hardlink count " + f"st_nlink={link_count}: {relative}" + ) + return files, file_identities + + def _write_bytes(self, path: Path, data: bytes) -> None: + with path.open("wb") as stream: + stream.write(data) + stream.flush() + self._fsync_file(path) + + def _fsync_file(self, path: Path) -> None: + with path.open("r+b") as stream: + os.fsync(stream.fileno()) + + def _fsync_dir(self, path: Path) -> None: + try: + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + except OSError as error: + if os.name == "nt" and error.errno in { + errno.EACCES, + errno.EINVAL, + errno.ENOTSUP, + }: + return + raise + + def _fsync_staging(self) -> None: + for path in self._validate_staged_files(): + self._fsync_file(path) + directories = sorted( + (item for item in self.staging_root.rglob("*") if item.is_dir()), + key=lambda item: ( + -len(item.relative_to(self.staging_root).parts), + item.relative_to(self.staging_root).as_posix(), + ), + ) + for directory in directories: + if _unsafe(directory): + raise ValueError("staged directory is a symlink/reparse point") + self._fsync_dir(directory) + self._fsync_dir(self.staging_root) + + def _claim_request(self, request_id: str) -> None: + if request_id in self._request_ids: + raise ValueError(f"duplicate request_id: {request_id}") + self._request_ids.add(request_id) + + def _ensure_open(self) -> None: + self._ensure_owner() + self._ensure_staging_owner() + if ( + self._finalized + or self._staging_identity is None + or _identity_for_path(self.staging_root, directory=True) + != self._staging_identity + ): + raise RuntimeError("dataset writer is not open") + + def _ensure_owner(self) -> None: + stream = self._lock_stream + if ( + not self._owns_lock + or not self._lock_held + or stream is None + or stream.closed + or self._lock_identity is None + or self._lock_metadata is None + ): + raise RuntimeError("dataset writer ownership is unavailable") + try: + if _file_identity(os.fstat(stream.fileno())) != self._lock_identity: + raise RuntimeError("dataset writer lock handle changed") + if ( + _identity_for_path(self.lock_path, directory=False) + != self._lock_identity + ): + raise RuntimeError("dataset writer ownership pathname changed") + if _read_lock_metadata(stream) != self._lock_metadata: + raise RuntimeError("dataset writer ownership token changed") + except OSError as error: + raise RuntimeError("dataset writer ownership is unavailable") from error + + def _acquire_staging_lock(self) -> None: + if os.name == "nt": + return + identity = self._staging_identity + if identity is None: + raise RuntimeError("staging ownership identity is unavailable") + descriptor = _open_staging_directory(self.staging_root) + held = False + try: + if _file_identity(os.fstat(descriptor)) != identity: + raise RuntimeError("staging directory handle identity changed") + if not _try_staging_lock(descriptor): + raise RuntimeError("dataset staging directory is live and locked") + held = True + if _identity_for_path(self.staging_root, directory=True) != identity: + raise RuntimeError("staging directory pathname changed") + except BaseException: + try: + if held: + _unlock_staging(descriptor) + finally: + os.close(descriptor) + raise + self._staging_descriptor = descriptor + self._staging_lock_held = True + + def _ensure_staging_owner(self) -> None: + if ( + self._staging_identity is None + or _identity_for_path(self.staging_root, directory=True) + != self._staging_identity + ): + raise RuntimeError("staging directory pathname changed") + self._ensure_staging_handle_owner() + + def _ensure_staging_handle_owner(self) -> None: + if os.name == "nt": + return + descriptor = self._staging_descriptor + if ( + descriptor is None + or not self._staging_lock_held + or self._staging_identity is None + or _file_identity(os.fstat(descriptor)) != self._staging_identity + ): + raise RuntimeError("dataset staging ownership is unavailable") + + def _release_lock(self) -> None: + if not self._owns_lock: + return + self._ensure_owner() + if os.name == "nt": + _windows_mark_delete_on_close(self._lock_stream) + else: + release_path = self.dataset_root / ( + f".{self._version}.release-{self._token}" + ) + if _lexists(release_path): + raise RuntimeError("owned lock release path already exists") + _rename_no_replace(self.lock_path, release_path) + if ( + _identity_for_path(release_path, directory=False) != self._lock_identity + or _read_lock_metadata(self._lock_stream) != self._lock_metadata + ): + try: + _rename_no_replace(release_path, self.lock_path) + finally: + raise RuntimeError( + "dataset writer ownership changed during release" + ) + release_path.unlink() + self._owns_lock = False + try: + self._close_lock_without_unlink() + finally: + self._close_staging_lock() + + def _close_lock_without_unlink(self) -> None: + stream = self._lock_stream + if stream is None: + return + try: + if self._lock_held: + _unlock_advisory(stream) + finally: + self._lock_held = False + stream.close() + self._lock_stream = None + + def _close_staging_lock(self) -> None: + descriptor = self._staging_descriptor + if descriptor is None: + return + try: + if self._staging_lock_held: + _unlock_staging(descriptor) + finally: + self._staging_lock_held = False + os.close(descriptor) + self._staging_descriptor = None + + def _cleanup_owned(self) -> None: + if not self._owns_lock: + return + self._ensure_owner() + if _lexists(self.staging_root): + if ( + self._staging_identity is None + or _identity_for_path(self.staging_root, directory=True) + != self._staging_identity + ): + raise RuntimeError("staging ownership changed") + self._ensure_staging_owner() + cleanup_path = self.dataset_root / ( + f".{self._version}.cleanup-{self._token}" + ) + if _lexists(cleanup_path): + raise RuntimeError("owned cleanup path already exists") + _rename_no_replace(self.staging_root, cleanup_path) + if ( + _identity_for_path(cleanup_path, directory=True) + != self._staging_identity + ): + try: + _rename_no_replace(cleanup_path, self.staging_root) + finally: + raise RuntimeError("staging ownership changed during cleanup") + self._ensure_staging_handle_owner() + try: + self._ensure_owner() + self._ensure_staging_handle_owner() + except BaseException: + _rename_no_replace(cleanup_path, self.staging_root) + raise + shutil.rmtree(cleanup_path) + self._close_staging_lock() + self._staging_identity = None + self._release_lock() + + +_WINDOWS_REPARSE_POINT = 0x400 +_METADATA_FILES = frozenset( + {"checksums.sha256", "failures.jsonl", "manifest.json", "pairs.jsonl"} +) +_MANIFEST_KEYS = frozenset( + { + "accepted_pairs", + "failures", + "required_artifacts", + "schema_version", + "splits", + } +) +_ATTESTED_MANIFEST_KEYS = _MANIFEST_KEYS | {"generation"} +_GENERATION_MANIFEST_KEYS = frozenset( + { + "attempt_limit", + "attempted_requests", + "attempts_path", + "provenance_path", + "requested_pairs", + "source_scene_paths", + } +) +_RecordT = TypeVar("_RecordT", bound=BaseModel) +_DATASET_SEED = DATASET_SEED +_GENERATOR_VERSION = GENERATOR_VERSION +_GENERATOR_NAMES = frozenset({"spatialcf", "random", "target-only"}) +_REQUEST_NAMESPACE = uuid.UUID("8ab06aa5-553d-5263-a00a-602d15c53ff5") +_PAIR_NAMESPACE = uuid.UUID("6807e184-f5c3-5812-b467-8783487a9e65") +_FAILURE_NAMESPACE = uuid.UUID("44858018-d898-56fc-afca-c1637cf4e8c6") +_MAX_IMAGE_DIMENSION = 8192 +_MAX_IMAGE_PIXELS = 16_777_216 +_MAX_IMAGE_BYTES = 64 * 1024 * 1024 +_MAX_DEPTH_BYTES = 128 * 1024 * 1024 +_MAX_POINTCLOUD_BYTES = 128 * 1024 * 1024 +_MAX_DATASET_FILE_BYTES = 256 * 1024 * 1024 +_MAX_IDENTITY_PAYLOAD_BYTES = 128 * 1024 * 1024 +_MAX_IDENTITY_PAYLOAD_FILES = 1024 + + +@dataclass(frozen=True) +class _Dataset: + root: Path + pairs: tuple[PairRecord, ...] + failures: tuple[FailureRecord, ...] + manifest: dict[str, Any] + provenance: GenerationProvenance | None + attempts: tuple[AttemptEvidence, ...] + attempts_authenticated: bool + + @property + def backend(self) -> str: + return ( + self.provenance.adapter_backend + if self.provenance is not None + else "legacy-json" + ) + + +@dataclass(frozen=True) +class AuthenticatedDatasetIdentity: + """Typed identity extracted from one checksummed dataset snapshot.""" + + accepted_pairs: int + run_profile: str + evidence_eligible: bool + provenance: GenerationProvenance + attempts: tuple[AttemptEvidence, ...] + scenes: tuple[Scene, ...] + + +@dataclass(frozen=True) +class _StreamedFileDigest: + sha256: str + + +def _audit_unsafe(result: os.stat_result) -> bool: + return bool(getattr(result, "st_file_attributes", 0) & _WINDOWS_REPARSE_POINT) + + +def _regular_files( + root: Path, + *, + descriptor_capability: bool = False, +) -> dict[str, Path]: + if not descriptor_capability: + current = Path(os.path.abspath(root)) + while True: + if os.path.lexists(current): + ancestor_result = current.stat(follow_symlinks=False) + if current.is_symlink() or _audit_unsafe(ancestor_result): + raise ValueError( + "dataset root may not traverse a symlink/reparse point" + ) + if current == current.parent: + break + current = current.parent + try: + root_result = root.stat(follow_symlinks=descriptor_capability) + except FileNotFoundError as error: + raise ValueError(f"dataset root does not exist: {root}") from error + if ( + (not descriptor_capability and root.is_symlink()) + or _audit_unsafe(root_result) + or not stat.S_ISDIR(root_result.st_mode) + ): + raise ValueError("dataset root must be an ordinary directory") + + files: dict[str, Path] = {} + physical_files: dict[tuple[int, int], str] = {} + pending = [root] + while pending: + directory = pending.pop() + try: + entries = sorted(os.scandir(directory), key=lambda entry: entry.name) + except OSError as error: + raise ValueError( + f"cannot inspect dataset directory: {directory}" + ) from error + for entry in entries: + relative = Path(entry.path).relative_to(root).as_posix() + try: + result = Path(entry.path).stat(follow_symlinks=False) + except OSError as error: + raise ValueError(f"cannot inspect dataset entry: {relative}") from error + if entry.is_symlink() or _audit_unsafe(result): + raise ValueError(f"unsafe symlink/reparse dataset entry: {relative}") + if stat.S_ISDIR(result.st_mode): + pending.append(Path(entry.path)) + elif stat.S_ISREG(result.st_mode): + if result.st_nlink != 1: + raise ValueError( + "dataset regular file has unsafe hardlink count; " + f"st_nlink={result.st_nlink}: {relative}" + ) + if result.st_size > _MAX_DATASET_FILE_BYTES: + raise ValueError( + f"dataset file exceeds resource byte limit: {relative}" + ) + identity = (result.st_dev, result.st_ino) + duplicate = physical_files.get(identity) + if duplicate is not None: + raise ValueError( + "dataset files must have unique physical file identity; " + f"hardlink detected: {duplicate} and {relative}" + ) + physical_files[identity] = relative + files[relative] = Path(entry.path) + else: + raise ValueError(f"dataset entry is not a regular file: {relative}") + return files + + +def _audit_validate_relative_path(value: str) -> str: + if ( + not value + or "\\" in value + or PurePosixPath(value).is_absolute() + or PureWindowsPath(value).is_absolute() + or PureWindowsPath(value).drive + or any(part in {"", ".", ".."} for part in value.split("/")) + or PurePosixPath(value).as_posix() != value + ): + raise ValueError(f"invalid checksum path: {value!r}") + return value + + +def _audit_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _checksum_entries( + checksum_bytes: bytes, + actual_paths: set[str], +) -> tuple[dict[str, str], tuple[str, ...]]: + if b"\r" in checksum_bytes or ( + checksum_bytes and not checksum_bytes.endswith(b"\n") + ): + raise ValueError("checksums.sha256 must use canonical LF lines") + try: + lines = checksum_bytes.decode("utf-8").splitlines() + except UnicodeDecodeError as error: + raise ValueError("checksums.sha256 is not UTF-8") from error + + checksums: dict[str, str] = {} + ordered_paths: list[str] = [] + for line_number, line in enumerate(lines, start=1): + if len(line) < 67 or line[64:66] != " ": + raise ValueError(f"invalid checksum entry at line {line_number}") + digest, relative = line[:64], _audit_validate_relative_path(line[66:]) + if ( + any(character not in "0123456789abcdef" for character in digest) + or relative == "checksums.sha256" + ): + raise ValueError(f"invalid checksum entry at line {line_number}") + if relative in checksums: + raise ValueError(f"duplicate checksum path: {relative}") + checksums[relative] = digest + ordered_paths.append(relative) + if ordered_paths != sorted(ordered_paths): + raise ValueError("checksum entries must be sorted by POSIX path") + if set(checksums) != actual_paths: + missing = sorted(actual_paths - checksums.keys()) + extra = sorted(checksums.keys() - actual_paths) + raise ValueError( + f"checksum file set does not match dataset files; " + f"unlisted={missing[:5]} missing={extra[:5]}" + ) + return checksums, tuple(ordered_paths) + + +def _validate_checksums( + root: Path, + files: Mapping[str, Path | bytes | _StreamedFileDigest], +) -> None: + if not _METADATA_FILES.issubset(files): + missing = sorted(_METADATA_FILES - files.keys()) + raise ValueError(f"missing dataset metadata files: {missing}") + checksum_source = files["checksums.sha256"] + actual_paths = set(files) - {"checksums.sha256"} + if isinstance(checksum_source, bytes): + checksum_bytes = checksum_source + elif isinstance(checksum_source, Path): + checksum_bytes = checksum_source.read_bytes() + else: + raise TypeError( + "checksums.sha256 must be retained in the authenticated snapshot" + ) + checksums, ordered_paths = _checksum_entries( + checksum_bytes, + actual_paths, + ) + + def digest_for(relative: str) -> str: + source = files[relative] + if isinstance(source, bytes): + return hashlib.sha256(source).hexdigest() + if isinstance(source, _StreamedFileDigest): + return source.sha256 + return _audit_sha256(source) + + mismatches = [ + relative + for relative in ordered_paths + if digest_for(relative) != checksums[relative] + ] + if mismatches: + raise ValueError(f"checksum mismatch: {mismatches[:5]}") + + +def _capture_regular_file( + path: Path, + *, + retain_payload: bool, + retained_byte_limit: int | None = None, +) -> bytes | _StreamedFileDigest: + try: + listed = path.stat(follow_symlinks=False) + except OSError as error: + raise ValueError(f"cannot inspect dataset file: {path}") from error + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_BINARY", 0) + ) + try: + descriptor = os.open(path, flags) + except OSError as error: + raise ValueError(f"cannot open dataset file: {path}") from error + try: + opened = os.fstat(descriptor) + if ( + _audit_unsafe(opened) + or not stat.S_ISREG(opened.st_mode) + or opened.st_nlink != 1 + or opened.st_size > _MAX_DATASET_FILE_BYTES + or (listed.st_dev, listed.st_ino) != (opened.st_dev, opened.st_ino) + ): + raise ValueError(f"dataset file changed before snapshot capture: {path}") + if ( + retain_payload + and retained_byte_limit is not None + and opened.st_size > retained_byte_limit + ): + raise ValueError("identity payload byte budget exceeded") + payload = bytearray() if retain_payload else None + digest = hashlib.sha256() + byte_count = 0 + while True: + block = os.read(descriptor, 1024 * 1024) + if not block: + break + byte_count += len(block) + if ( + payload is not None + and retained_byte_limit is not None + and byte_count > retained_byte_limit + ): + raise ValueError("identity payload byte budget exceeded") + digest.update(block) + if payload is not None: + payload.extend(block) + if byte_count > _MAX_DATASET_FILE_BYTES: + raise ValueError(f"dataset file exceeds resource byte limit: {path}") + finished = os.fstat(descriptor) + stable_fields = ( + "st_dev", + "st_ino", + "st_mode", + "st_nlink", + "st_size", + "st_mtime_ns", + "st_ctime_ns", + ) + if byte_count != opened.st_size or any( + getattr(opened, field) != getattr(finished, field) + for field in stable_fields + ): + raise ValueError(f"dataset file changed during snapshot capture: {path}") + if payload is not None: + return bytes(payload) + return _StreamedFileDigest(sha256=digest.hexdigest()) + finally: + os.close(descriptor) + + +def _capture_regular_file_payloads( + root: Path, +) -> dict[str, bytes | _StreamedFileDigest]: + files = _regular_files(root) + if not _METADATA_FILES.issubset(files): + missing = sorted(_METADATA_FILES - files.keys()) + raise ValueError(f"missing dataset metadata files: {missing}") + + if _MAX_IDENTITY_PAYLOAD_FILES < 2: + raise ValueError("identity payload file count exceeds limit") + snapshot: dict[str, bytes | _StreamedFileDigest] = {} + retained_bytes = 0 + + def retain(relative: str) -> bytes: + nonlocal retained_bytes + remaining = _MAX_IDENTITY_PAYLOAD_BYTES - retained_bytes + if remaining < 0: + raise ValueError("identity payload byte budget exceeded") + captured = _capture_regular_file( + files[relative], + retain_payload=True, + retained_byte_limit=remaining, + ) + if not isinstance(captured, bytes): + raise TypeError("retained dataset payload is not bytes") + retained_bytes += len(captured) + snapshot[relative] = captured + return captured + + checksum_bytes = retain("checksums.sha256") + checksums, _ = _checksum_entries( + checksum_bytes, + set(files) - {"checksums.sha256"}, + ) + manifest_bytes = retain("manifest.json") + if hashlib.sha256(manifest_bytes).hexdigest() != checksums["manifest.json"]: + raise ValueError("checksum mismatch: ['manifest.json']") + manifest = _read_manifest(manifest_bytes) + if manifest["schema_version"] != DATASET_MANIFEST_SCHEMA_VERSION: + raise ValueError( + "authenticated dataset identity requires manifest schema version 4" + ) + generation = manifest["generation"] + if ( + generation["provenance_path"] != "provenance/generation.json" + or generation["attempts_path"] != "provenance/attempts.jsonl" + ): + raise ValueError( + "generation provenance paths are not the fixed canonical paths" + ) + pair_bytes = retain("pairs.jsonl") + if hashlib.sha256(pair_bytes).hexdigest() != checksums["pairs.jsonl"]: + raise ValueError("checksum mismatch: ['pairs.jsonl']") + pair_records = _read_records(pair_bytes, PairRecord, "pair") + pair_after_paths = {record.scene_after_path for record in pair_records} + if ( + 6 + len(generation["source_scene_paths"]) + len(pair_after_paths) + > _MAX_IDENTITY_PAYLOAD_FILES + ): + raise ValueError("identity payload file count exceeds limit") + identity_paths = { + "checksums.sha256", + "failures.jsonl", + "manifest.json", + "pairs.jsonl", + generation["provenance_path"], + generation["attempts_path"], + *generation["source_scene_paths"], + *pair_after_paths, + } + if len(identity_paths) > _MAX_IDENTITY_PAYLOAD_FILES: + raise ValueError("identity payload file count exceeds limit") + missing_identity = sorted(identity_paths - files.keys()) + if missing_identity: + raise ValueError( + f"generation attestation references missing file: {missing_identity[0]}" + ) + + for relative in sorted(set(files) - snapshot.keys()): + if relative in identity_paths: + retain(relative) + else: + snapshot[relative] = _capture_regular_file( + files[relative], + retain_payload=False, + ) + return snapshot + + +def _audit_json_bytes(value: Any, *, pretty: bool = False) -> bytes: + return canonical_json_bytes(value, pretty=pretty) + + +def _read_records( + path: Path | bytes, + model: type[_RecordT], + label: str, +) -> tuple[_RecordT, ...]: + payload = path if isinstance(path, bytes) else path.read_bytes() + filename = f"{label}.jsonl" if isinstance(path, bytes) else path.name + if b"\r" in payload or (payload and not payload.endswith(b"\n")): + raise ValueError(f"{filename} must use canonical LF JSONL") + records: list[_RecordT] = [] + for line_number, line in enumerate(payload.splitlines(), start=1): + if not line: + raise ValueError(f"blank {label} record at line {line_number}") + try: + record = model.model_validate_json(line) + except (ValidationError, ValueError) as error: + raise ValueError( + f"invalid {label} record at line {line_number}: {error}" + ) from error + canonical = _audit_json_bytes(record.model_dump(mode="python")).rstrip(b"\n") + if line != canonical: + raise ValueError( + f"invalid {label} record at line {line_number}: " + "record is not canonical JSON" + ) + records.append(record) + return tuple(records) + + +def _read_manifest(path: Path | bytes) -> dict[str, Any]: + payload = path if isinstance(path, bytes) else path.read_bytes() + if b"\r" in payload or not payload.endswith(b"\n"): + raise ValueError("manifest.json must use canonical LF JSON") + try: + manifest = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("manifest.json is invalid") from error + if not isinstance(manifest, dict) or payload != _audit_json_bytes( + manifest, + pretty=True, + ): + raise ValueError("manifest.json schema or canonical encoding is invalid") + schema_version = manifest.get("schema_version") + if schema_version == 1: + expected_keys = _MANIFEST_KEYS + elif schema_version == LEGACY_ATTESTED_MANIFEST_SCHEMA_VERSION: + expected_keys = _ATTESTED_MANIFEST_KEYS + elif schema_version == DATASET_MANIFEST_SCHEMA_VERSION: + expected_keys = _ATTESTED_MANIFEST_KEYS | { + "run_profile", + "evidence_eligible", + } + else: + raise ValueError("unsupported manifest schema_version") + if frozenset(manifest) != expected_keys: + raise ValueError("manifest.json schema or canonical encoding is invalid") + for key in ("accepted_pairs", "failures", "required_artifacts", "schema_version"): + if type(manifest[key]) is not int or manifest[key] < 0: + raise ValueError(f"manifest {key} must be a non-negative integer") + splits = manifest["splits"] + if ( + not isinstance(splits, dict) + or frozenset(splits) != {"train", "dev", "test"} + or any(type(value) is not int or value < 0 for value in splits.values()) + ): + raise ValueError("manifest splits schema is invalid") + if manifest["schema_version"] in ATTESTED_MANIFEST_SCHEMA_VERSIONS: + generation = manifest["generation"] + if ( + not isinstance(generation, dict) + or frozenset(generation) != _GENERATION_MANIFEST_KEYS + or type(generation["attempted_requests"]) is not int + or generation["attempted_requests"] < 0 + or not isinstance(generation["source_scene_paths"], list) + or any( + not isinstance(value, str) + for value in ( + generation["attempts_path"], + generation["provenance_path"], + *generation["source_scene_paths"], + ) + ) + ): + raise ValueError("manifest generation attestation schema is invalid") + validate_generation_budget( + generation["requested_pairs"], + generation["attempt_limit"], + attempted_requests=generation["attempted_requests"], + ) + paths = ( + generation["attempts_path"], + generation["provenance_path"], + *generation["source_scene_paths"], + ) + for relative in paths: + _audit_validate_relative_path(relative) + if relative.split("/", 1)[0] != "provenance": + raise ValueError( + "generation attestation paths must be under provenance/" + ) + if len(set(paths)) != len(paths): + raise ValueError("generation attestation paths must be unique") + if manifest["schema_version"] == DATASET_MANIFEST_SCHEMA_VERSION: + profile_from_manifest(manifest) + return manifest + + +def _relation_diff_errors(record: PairRecord) -> list[str]: + required = { + f"-{record.subject_id}:{record.relation_before.value}:{record.reference_id}", + f"+{record.subject_id}:{record.relation_after.value}:{record.reference_id}", + ( + f"-{record.reference_id}:{record.relation_before.converse.value}:" + f"{record.subject_id}" + ), + ( + f"+{record.reference_id}:{record.relation_after.converse.value}:" + f"{record.subject_id}" + ), + } + errors: list[str] = [] + if tuple(sorted(record.relation_diff)) != record.relation_diff: + errors.append("relation_diff must be sorted") + if len(set(record.relation_diff)) != len(record.relation_diff): + errors.append("relation_diff contains duplicates") + missing = sorted(required - set(record.relation_diff)) + if missing: + errors.append(f"relation_diff missing target changes {missing}") + for item in record.relation_diff: + if not item.startswith(("+", "-")): + errors.append(f"relation_diff has invalid operation {item!r}") + continue + parts = item[1:].split(":") + if len(parts) != 3: + errors.append(f"relation_diff has invalid entry {item!r}") + continue + try: + Relation(parts[1]) + except ValueError: + errors.append(f"relation_diff has invalid relation {item!r}") + return errors + + +def _expected_request_id(record: PairRecord) -> str: + identity = _audit_json_bytes( + { + "camera_id": record.camera_id, + "relation_after": record.relation_after, + "relation_before": record.relation_before, + "reference_id": record.reference_id, + "scene_id": record.scene_id, + "seed": _DATASET_SEED, + "subject_id": record.subject_id, + } + ) + return str( + uuid.uuid5( + _REQUEST_NAMESPACE, + identity.decode("utf-8").rstrip("\n"), + ) + ) + + +def _validate_camera_resources(scene: Scene, camera_id: str) -> Any: + camera = scene.camera_by_id(camera_id) + pixels = camera.width * camera.height + if ( + camera.width > _MAX_IMAGE_DIMENSION + or camera.height > _MAX_IMAGE_DIMENSION + or pixels > _MAX_IMAGE_PIXELS + ): + raise ValueError( + "camera dimensions exceed the bounded artifact resource contract" + ) + return camera + + +def _validate_image( + path: Path, + scene: Scene, + camera_id: str, + label: str, + backend: str, +) -> None: + if path.suffix.lower() != ".png": + raise ValueError(f"{label} image artifact must use PNG encoding") + camera = _validate_camera_resources(scene, camera_id) + if path.stat().st_size > _MAX_IMAGE_BYTES: + raise ValueError(f"{label} image artifact exceeds resource byte limit") + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(path) as image: + image.verify() + with Image.open(path) as image: + if ( + image.format != "PNG" + or image.mode != "RGB" + or image.size != (camera.width, camera.height) + ): + raise ValueError( + f"{label} image artifact schema does not match its camera" + ) + image.load() + actual = image.copy() + except ( + OSError, + SyntaxError, + UnidentifiedImageError, + Image.DecompressionBombError, + Image.DecompressionBombWarning, + ) as error: + raise ValueError(f"{label} image artifact is invalid") from error + canonical = io.BytesIO() + actual.save(canonical, format="PNG") + if canonical.getvalue() != path.read_bytes(): + raise ValueError(f"{label} image artifact is not canonical PNG") + if backend in {"json", "legacy-json"}: + instance = "instance" in label.lower() + expected = Image.new( + "RGB", + (camera.width, camera.height), + "black" if instance else "white", + ) + draw = ImageDraw.Draw(expected) + for index, obj in enumerate(scene.objects, start=1): + view = obj.views.get(camera_id) + if view is None: + continue + box = ( + view.bbox.xmin, + view.bbox.ymin, + view.bbox.xmax, + view.bbox.ymax, + ) + if instance: + draw.rectangle( + box, + fill=( + index % 255, + (index * 17) % 255, + (index * 31) % 255, + ), + ) + else: + draw.rectangle(box, outline=(40, 90, 180), width=3) + if actual.tobytes() != expected.tobytes(): + raise ValueError(f"{label} image artifact semantics do not match its scene") + + +def _validate_depth( + path: Path, + scene: Scene, + camera_id: str, + label: str, + backend: str, +) -> None: + if path.suffix.lower() != ".npy": + raise ValueError(f"{label} depth artifact must use NPY encoding") + camera = _validate_camera_resources(scene, camera_id) + if path.stat().st_size > _MAX_DEPTH_BYTES: + raise ValueError(f"{label} depth artifact exceeds resource byte limit") + try: + with path.open("rb") as stream: + version = np.lib.format.read_magic(stream) + if version != (1, 0): + raise ValueError("depth artifact must use canonical NPY v1") + shape, fortran_order, dtype = np.lib.format.read_array_header_1_0( + stream, + max_header_size=10_000, + ) + except (OSError, ValueError, EOFError) as error: + raise ValueError(f"{label} depth artifact is invalid") from error + if ( + fortran_order + or dtype != np.dtype(np.float32) + or shape != (camera.height, camera.width) + ): + raise ValueError(f"{label} depth artifact schema does not match its camera") + payload = path.read_bytes() + try: + array = np.load(io.BytesIO(payload), allow_pickle=False) + except (OSError, ValueError, EOFError) as error: + raise ValueError(f"{label} depth artifact is invalid") from error + if ( + not isinstance(array, np.ndarray) + or array.dtype != np.float32 + or array.shape != (camera.height, camera.width) + ): + raise ValueError(f"{label} depth artifact schema does not match its camera") + if backend in {"json", "legacy-json"} and not np.array_equal( + array, + np.full(array.shape, 2.0, dtype=np.float32), + ): + raise ValueError(f"{label} depth artifact semantics do not match its scene") + canonical = io.BytesIO() + np.save(canonical, array, allow_pickle=False) + if canonical.getvalue() != payload: + raise ValueError(f"{label} depth artifact is not canonical NPY") + + +def _validate_pointcloud( + path: Path, + scene: Scene, + label: str, + backend: str, +) -> None: + if path.suffix.lower() != ".ply": + raise ValueError(f"{label} pointcloud artifact must use PLY encoding") + if path.stat().st_size > _MAX_POINTCLOUD_BYTES: + raise ValueError(f"{label} pointcloud artifact exceeds resource byte limit") + payload = path.read_bytes() + if b"\r" in payload or not payload.endswith(b"\n"): + raise ValueError(f"{label} pointcloud artifact must use canonical LF") + try: + lines = payload.decode("ascii").splitlines() + except UnicodeDecodeError as error: + raise ValueError(f"{label} pointcloud artifact must be ASCII") from error + try: + end_header = lines.index("end_header") + except ValueError as error: + raise ValueError(f"{label} pointcloud artifact has no PLY header") from error + header = lines[: end_header + 1] + vertex_lines = [line for line in header if line.startswith("element vertex ")] + property_lines = [line for line in header if line.startswith("property ")] + three_properties = [ + "property float x", + "property float y", + "property float z", + ] + six_properties = [ + "property float x", + "property float y", + "property float z", + "property uchar red", + "property uchar green", + "property uchar blue", + ] + valid_properties = ( + [three_properties] if backend in {"json", "legacy-json"} else [six_properties] + ) + if ( + header[:2] != ["ply", "format ascii 1.0"] + or len(vertex_lines) != 1 + or property_lines not in valid_properties + or any( + line.startswith("element ") for line in header if line not in vertex_lines + ) + ): + raise ValueError(f"{label} pointcloud artifact has invalid PLY schema") + try: + vertex_count = int(vertex_lines[0].removeprefix("element vertex ")) + except ValueError as error: + raise ValueError( + f"{label} pointcloud artifact has invalid vertex count" + ) from error + rows = lines[end_header + 1 :] + if vertex_count < 0 or len(rows) != vertex_count: + raise ValueError(f"{label} pointcloud artifact vertex count is inconsistent") + columns = len(property_lines) + parsed_coordinates: list[tuple[float, float, float]] = [] + for row in rows: + parts = row.split(" ") + if len(parts) != columns or any(not part for part in parts): + raise ValueError(f"{label} pointcloud artifact row is not canonical") + try: + coordinates = [float(part) for part in parts[:3]] + colors = [int(part) for part in parts[3:]] + except ValueError as error: + raise ValueError(f"{label} pointcloud artifact row is invalid") from error + if not all(math.isfinite(value) for value in coordinates) or any( + value < 0 or value > 255 for value in colors + ): + raise ValueError(f"{label} pointcloud artifact row is invalid") + parsed_coordinates.append(tuple(coordinates)) + if columns == 3: + expected_coordinates = [ + (obj.obb.center.x, obj.obb.center.y, obj.obb.center.z) + for obj in scene.objects + ] + if parsed_coordinates != expected_coordinates: + raise ValueError( + f"{label} pointcloud artifact semantics do not match its scene" + ) + if backend in {"json", "legacy-json"}: + expected = ( + "ply\nformat ascii 1.0\n" + f"element vertex {len(expected_coordinates)}\n" + "property float x\nproperty float y\nproperty float z\n" + "end_header\n" + + "".join(f"{x} {y} {z}\n" for x, y, z in expected_coordinates) + ).encode("ascii") + if payload != expected: + raise ValueError( + f"{label} pointcloud artifact is not canonical JSON-renderer PLY" + ) + + +def _validate_json_artifact(path: Path, expected: Any, label: str) -> None: + expected_payload = _audit_json_bytes(expected, pretty=True) + if path.read_bytes() != expected_payload: + raise ValueError(f"{label} artifact is not canonical or semantically valid") + + +def _read_canonical_model( + path: Path | bytes, + model: type[_RecordT], + label: str, +) -> _RecordT: + payload = path if isinstance(path, bytes) else path.read_bytes() + try: + value = model.model_validate_json(payload) + except (ValidationError, ValueError) as error: + raise ValueError(f"{label} schema is invalid: {error}") from error + if payload != _audit_json_bytes(value.model_dump(mode="json"), pretty=True): + raise ValueError(f"{label} is not canonical JSON") + return value + + +def _retained_snapshot_payload( + files: dict[str, bytes | _StreamedFileDigest], + relative: str, +) -> bytes: + payload = files[relative] + if not isinstance(payload, bytes): + raise TypeError(f"authenticated identity payload was not retained: {relative}") + return payload + + +def _published_pair_after_scenes( + pairs: tuple[PairRecord, ...], + files: Mapping[str, bytes | Path | _StreamedFileDigest], +) -> dict[str, Scene]: + scenes: dict[str, Scene] = {} + for pair in pairs: + captured = files.get(pair.scene_after_path) + if captured is None: + raise ValueError( + f"attempt ledger pair references missing after scene: {pair.pair_id}" + ) + if isinstance(captured, _StreamedFileDigest): + raise TypeError( + "authenticated pair after-scene payload was not retained: " + f"{pair.scene_after_path}" + ) + after = _read_canonical_model( + captured, + Scene, + f"pair {pair.pair_id} after scene", + ) + if ( + after.scene_id != pair.scene_id + or after.source != pair.source + or after.generation_seed != pair.seed + ): + raise ValueError( + f"attempt ledger pair after scene identity is invalid: {pair.pair_id}" + ) + scenes[pair.request_id] = after + return scenes + + +def _validate_attempt_ledger( + *, + attempts: tuple[AttemptEvidence, ...], + pairs: tuple[PairRecord, ...], + failures: tuple[FailureRecord, ...], + provenance: GenerationProvenance, + source_scenes: tuple[Scene, ...], + pair_after_scenes: Mapping[str, Scene], +) -> None: + """Bind checksummed attempt evidence to its structural dataset outcomes. + + This deliberately performs no generator or simulator replay. It only + authenticates relationships already represented by immutable source, + attempt, pair, and failure records. + """ + indexes = tuple(item.attempt_index for item in attempts) + if indexes != tuple(range(1, len(attempts) + 1)): + raise ValueError("attempt ledger indexes are not a complete ordered prefix") + request_ids = tuple(item.request_id for item in attempts) + if len(set(request_ids)) != len(request_ids): + raise ValueError("attempt ledger request IDs are not unique") + if provenance.attempted_requests != len(attempts): + raise ValueError("attempt ledger count differs from generation provenance") + + sources_by_id = {scene.scene_id: scene for scene in source_scenes} + if len(sources_by_id) != len(source_scenes): + raise ValueError("attempt ledger source scenes are not unique") + pairs_by_request = {record.request_id: record for record in pairs} + failures_by_request = {record.request_id: record for record in failures} + if len(pairs_by_request) != len(pairs) or len(failures_by_request) != len(failures): + raise ValueError("attempt ledger outcome request IDs are not unique") + if set(pairs_by_request) & set(failures_by_request): + raise ValueError("attempt ledger request has both pair and failure outcomes") + if set(pairs_by_request) | set(failures_by_request) != set(request_ids): + raise ValueError("pair/failure ledgers do not exactly cover attempt ledger") + + expected_pair_order: list[str] = [] + expected_failure_order: list[str] = [] + known_holdouts = frozenset( + {"unseen_scene", "unseen_category", "unseen_combination"} + ) + for attempt in attempts: + source = sources_by_id.get(attempt.scene_id) + if source is None: + raise ValueError( + "attempt ledger scene is absent from authenticated sources" + ) + try: + subject = source.object_by_id(attempt.spec.subject_id) + reference = source.object_by_id(attempt.spec.reference_id) + source.camera_by_id(attempt.spec.camera_id) + except KeyError as error: + raise ValueError( + "attempt ledger spec is absent from its authenticated source scene" + ) from error + if attempt.spec.subject_id == attempt.spec.reference_id: + raise ValueError("attempt ledger spec aliases subject and reference") + if not attempt.holdout_tags.issubset(known_holdouts): + raise ValueError("attempt ledger contains an unknown holdout tag") + + result = attempt.generator_result + if attempt.outcome == "pair": + pair = pairs_by_request.get(attempt.request_id) + if pair is None or attempt.outcome_id != pair.pair_id: + raise ValueError("attempt ledger pair outcome binding is invalid") + if ( + pair.scene_id != attempt.scene_id + or pair.subject_id != attempt.spec.subject_id + or pair.reference_id != attempt.spec.reference_id + or pair.camera_id != attempt.spec.camera_id + or pair.relation_before is not attempt.spec.relation_before + or pair.relation_after is not attempt.spec.relation_after + or pair.holdout_tags != attempt.holdout_tags + or pair.source != source.source + or pair.subject_category != subject.category + or pair.reference_category != reference.category + or pair.generator != provenance.generator + ): + raise ValueError( + "attempt ledger pair source/request/scene/spec/holdout binding is invalid" + ) + score = result.score + after = pair_after_scenes.get(attempt.request_id) + if after is None: + raise ValueError( + "attempt ledger pair has no authenticated published after scene" + ) + try: + published_subject_position = after.object_by_id( + pair.subject_id + ).position + except KeyError as error: + raise ValueError( + "attempt ledger pair after scene has no subject" + ) from error + expected_score = calculate_weighted_objective( + pair.normalized_edit_distance, + pair.leakage_score, + pair.visibility_change, + pair.inverse_safety_margin, + translation_weight=1.0, + relation_damage_weight=5.0, + visibility_change_weight=2.0, + inverse_safety_margin_weight=1.0, + ) + if ( + result.status is not SolverStatus.SUCCESS + or result.subject_position != published_subject_position + or score is None + or result.quality is not pair.quality + or result.evaluated_candidates != pair.evaluated_candidates + or result.reason is not None + or score.normalized_translation != pair.normalized_edit_distance + or score.leakage != pair.leakage_score + or score.visibility_change != pair.visibility_change + or score.inverse_safety_margin != pair.inverse_safety_margin + or score.total != expected_score[4] + ): + raise ValueError( + "attempt ledger pair generator result binding is invalid" + ) + expected_pair_order.append(attempt.request_id) + continue + + failure = failures_by_request.get(attempt.request_id) + if failure is None or attempt.outcome_id != failure.failure_id: + raise ValueError("attempt ledger failure outcome binding is invalid") + if ( + failure.scene_id != attempt.scene_id + or failure.subject_id != attempt.spec.subject_id + or failure.reference_id != attempt.spec.reference_id + or failure.relation_before is not attempt.spec.relation_before + or failure.relation_after is not attempt.spec.relation_after + or failure.generator != provenance.generator + ): + raise ValueError( + "attempt ledger failure source/request/scene/spec binding is invalid" + ) + if ( + result.status is not failure.status + or result.subject_position is not None + or result.score is not None + or result.quality is not QualityTier.REJECTED + or result.evaluated_candidates != failure.evaluated_candidates + or result.reason != failure.reason + ): + raise ValueError( + "attempt ledger failure generator result binding is invalid" + ) + expected_failure_order.append(attempt.request_id) + + if tuple(record.request_id for record in pairs) != tuple(expected_pair_order): + raise ValueError("pair ledger is not an ordered subsequence of attempt ledger") + if tuple(record.request_id for record in failures) != tuple(expected_failure_order): + raise ValueError( + "failure ledger is not an ordered subsequence of attempt ledger" + ) + + +def read_authenticated_dataset_identity( + root: Path, +) -> AuthenticatedDatasetIdentity: + """Read checksummed identity metadata without replaying live sources.""" + dataset_root = Path(root) + files = _capture_regular_file_payloads(dataset_root) + _validate_checksums(dataset_root, files) + manifest = _read_manifest(_retained_snapshot_payload(files, "manifest.json")) + if manifest["schema_version"] != DATASET_MANIFEST_SCHEMA_VERSION: + raise ValueError( + "authenticated dataset identity requires manifest schema version 4" + ) + + generation = manifest["generation"] + if ( + generation["provenance_path"] != "provenance/generation.json" + or generation["attempts_path"] != "provenance/attempts.jsonl" + ): + raise ValueError( + "generation provenance paths are not the fixed canonical paths" + ) + referenced_paths = ( + generation["provenance_path"], + generation["attempts_path"], + *generation["source_scene_paths"], + ) + for relative in referenced_paths: + if relative not in files: + raise ValueError( + f"generation attestation references missing file: {relative}" + ) + + provenance = _read_canonical_model( + _retained_snapshot_payload( + files, + generation["provenance_path"], + ), + GenerationProvenance, + "generation provenance", + ) + attempts = _read_records( + _retained_snapshot_payload( + files, + generation["attempts_path"], + ), + AttemptEvidence, + "generation attempt", + ) + pairs = _read_records( + _retained_snapshot_payload(files, "pairs.jsonl"), + PairRecord, + "pair", + ) + failures = _read_records( + _retained_snapshot_payload(files, "failures.jsonl"), + FailureRecord, + "failure", + ) + if provenance.attempted_requests != len(attempts) or generation[ + "attempted_requests" + ] != len(attempts): + raise ValueError( + "attempted request count differs from the complete attempt ledger" + ) + if ( + generation["requested_pairs"] != provenance.requested_pairs + or generation["attempt_limit"] != provenance.attempt_limit + ): + raise ValueError( + "manifest generation budget differs from generation provenance" + ) + + source_paths = tuple(item.path for item in provenance.source_scenes) + if source_paths != tuple(generation["source_scene_paths"]): + raise ValueError( + "manifest source scene paths differ from generation provenance" + ) + source_ids = tuple(item.scene_id for item in provenance.source_scenes) + if source_ids != tuple(sorted(source_ids)): + raise ValueError( + "generation provenance sources are not in canonical source order" + ) + if len(set(source_ids)) != len(source_ids): + raise ValueError("generation provenance contains duplicate source scenes") + + scenes: list[Scene] = [] + for source in provenance.source_scenes: + expected_path = f"provenance/scenes/{source.sha256}.json" + if source.path != expected_path: + raise ValueError( + "generation provenance source path is not content-addressed" + ) + payload = _retained_snapshot_payload(files, source.path) + if hashlib.sha256(payload).hexdigest() != source.sha256: + raise ValueError("source scene attestation digest mismatch") + scene = _read_canonical_model( + payload, + Scene, + "source scene attestation", + ) + if scene.scene_id != source.scene_id: + raise ValueError("source scene attestation has the wrong identity") + scenes.append(scene) + computed_source_digest = source_corpus_digest(scenes) + if provenance.source_corpus_sha256 != computed_source_digest: + raise ValueError("source corpus digest differs from authenticated source tree") + if manifest["accepted_pairs"] != len(pairs) or manifest["failures"] != len( + failures + ): + raise ValueError("manifest outcome counts differ from attempt ledger outcomes") + _validate_attempt_ledger( + attempts=attempts, + pairs=pairs, + failures=failures, + provenance=provenance, + source_scenes=tuple(scenes), + pair_after_scenes=_published_pair_after_scenes(pairs, files), + ) + + return AuthenticatedDatasetIdentity( + accepted_pairs=manifest["accepted_pairs"], + run_profile=manifest["run_profile"], + evidence_eligible=manifest["evidence_eligible"], + provenance=provenance, + attempts=attempts, + scenes=tuple(scenes), + ) + + +def _read_dataset( + root: Path, + *, + expected_source_digest: str | None = None, + descriptor_capability: bool = False, +) -> _Dataset: + dataset_root = Path(root) + files = _regular_files( + dataset_root, + descriptor_capability=descriptor_capability, + ) + _validate_checksums(dataset_root, files) + manifest = _read_manifest(files["manifest.json"]) + pairs = _read_records(files["pairs.jsonl"], PairRecord, "pair") + failures = _read_records(files["failures.jsonl"], FailureRecord, "failure") + provenance: GenerationProvenance | None = None + attempts: tuple[AttemptEvidence, ...] = () + attempts_authenticated = False + attestation_files: set[str] = set() + if manifest["schema_version"] in ATTESTED_MANIFEST_SCHEMA_VERSIONS: + generation = manifest["generation"] + if ( + generation["provenance_path"] != "provenance/generation.json" + or generation["attempts_path"] != "provenance/attempts.jsonl" + ): + raise ValueError( + "generation provenance paths are not the fixed canonical paths" + ) + for relative in ( + generation["provenance_path"], + generation["attempts_path"], + *generation["source_scene_paths"], + ): + if relative not in files: + raise ValueError( + f"generation attestation references missing file: {relative}" + ) + attestation_files.add(relative) + provenance = _read_canonical_model( + files[generation["provenance_path"]], + GenerationProvenance, + "generation provenance", + ) + attempts = _read_records( + files[generation["attempts_path"]], + AttemptEvidence, + "generation attempt", + ) + source_paths = [item.path for item in provenance.source_scenes] + if source_paths != generation["source_scene_paths"]: + raise ValueError( + "manifest source scene paths differ from generation provenance" + ) + source_ids = [item.scene_id for item in provenance.source_scenes] + if source_ids != sorted(source_ids): + raise ValueError( + "generation provenance sources are not in canonical source order" + ) + if any( + source.path != f"provenance/scenes/{source.sha256}.json" + for source in provenance.source_scenes + ): + raise ValueError( + "generation provenance source path is not content-addressed" + ) + if provenance.attempted_requests != len(attempts) or generation[ + "attempted_requests" + ] != len(attempts): + raise ValueError( + "attempted request count differs from the complete attempt ledger" + ) + if ( + generation["requested_pairs"] != provenance.requested_pairs + or generation["attempt_limit"] != provenance.attempt_limit + ): + raise ValueError( + "manifest generation budget differs from generation provenance" + ) + if len(set(source_ids)) != len(source_ids): + raise ValueError("generation provenance contains duplicate source scenes") + source_scene_values: list[Scene] = [] + for source in provenance.source_scenes: + payload = files[source.path].read_bytes() + if hashlib.sha256(payload).hexdigest() != source.sha256: + raise ValueError("source scene attestation digest mismatch") + try: + scene = Scene.model_validate_json(payload) + except (ValidationError, ValueError) as error: + raise ValueError("source scene attestation is invalid") from error + if scene.scene_id != source.scene_id or payload != _audit_json_bytes( + scene.model_dump(mode="json"), pretty=True + ): + raise ValueError( + "source scene attestation is not canonical or has " + "the wrong identity" + ) + source_scene_values.append(scene) + computed_source_digest = source_corpus_digest(source_scene_values) + if provenance.source_corpus_sha256 != computed_source_digest: + raise ValueError( + "source corpus digest differs from authenticated source tree" + ) + if ( + expected_source_digest is not None + and expected_source_digest != computed_source_digest + ): + raise ValueError( + "dataset source corpus digest differs from caller trusted source pin" + ) + _validate_attempt_ledger( + attempts=attempts, + pairs=pairs, + failures=failures, + provenance=provenance, + source_scenes=tuple(source_scene_values), + pair_after_scenes=_published_pair_after_scenes(pairs, files), + ) + attempts_authenticated = True + + pair_ids = [record.pair_id for record in pairs] + duplicate_pairs = sorted( + value for value, count in Counter(pair_ids).items() if count > 1 + ) + if duplicate_pairs: + raise ValueError(f"duplicate pair_id: {duplicate_pairs[:5]}") + failure_ids = [record.failure_id for record in failures] + duplicate_failures = sorted( + value for value, count in Counter(failure_ids).items() if count > 1 + ) + if duplicate_failures: + raise ValueError(f"duplicate failure_id: {duplicate_failures[:5]}") + request_ids = [record.request_id for record in pairs] + [ + record.request_id for record in failures + ] + duplicate_requests = sorted( + value for value, count in Counter(request_ids).items() if count > 1 + ) + if duplicate_requests: + raise ValueError(f"duplicate request_id: {duplicate_requests[:5]}") + if expected_source_digest is not None and provenance is None: + raise ValueError( + "expected source pin requires manifest-v3 official replay " + "attestation; legacy datasets cannot claim a trusted pin" + ) + unsupported_failure_generators = sorted( + { + record.generator + for record in failures + if record.generator not in _GENERATOR_NAMES + } + ) + if unsupported_failure_generators: + raise ValueError( + "failure records contain unsupported generator provenance: " + f"{unsupported_failure_generators}" + ) + for record in failures: + expected_failure_id = str( + uuid.uuid5( + _FAILURE_NAMESPACE, + (f"{record.request_id}:{record.generator}:{_GENERATOR_VERSION}"), + ) + ) + if ( + record.failure_id != expected_failure_id + or record.generator_version != _GENERATOR_VERSION + or record.seed != _DATASET_SEED + ): + raise ValueError( + f"failure {record.failure_id} provenance identity is invalid" + ) + if failures and ( + len({record.generator for record in failures}) != 1 + or len({record.generator_version for record in failures}) != 1 + or len({record.seed for record in failures}) != 1 + ): + raise ValueError("failure ledger has heterogeneous generator provenance") + + expected_manifest = { + "accepted_pairs": len(pairs), + "failures": len(failures), + "required_artifacts": len(pairs) * len(PairRecord.artifact_path_fields()), + "schema_version": manifest["schema_version"], + "splits": { + split: sum(record.split == split for record in pairs) + for split in ("train", "dev", "test") + }, + } + for key in ("accepted_pairs", "failures", "required_artifacts", "splits"): + if manifest[key] != expected_manifest[key]: + raise ValueError( + f"manifest {key}={manifest[key]!r} does not match " + f"recomputed value={expected_manifest[key]!r}" + ) + + referenced: set[str] = set() + for record in pairs: + expected_split = assign_split(record.scene_id) + if record.split != expected_split: + raise ValueError( + f"pair {record.pair_id} split assignment={record.split!r} " + f"does not match recomputed scene split={expected_split!r}" + ) + errors = _relation_diff_errors(record) + if errors: + raise ValueError( + f"pair {record.pair_id} relation_diff is inconsistent: {errors}" + ) + for field in PairRecord.artifact_path_fields(): + relative = getattr(record, field) + if relative not in files: + raise ValueError( + f"pair {record.pair_id} references missing artifact: {relative}" + ) + if relative in referenced: + raise ValueError(f"duplicate artifact reference: {relative}") + referenced.add(relative) + published_artifacts = set(files) - _METADATA_FILES + expected_published = referenced | attestation_files + if expected_published != published_artifacts: + unreferenced = sorted(published_artifacts - expected_published) + missing = sorted(expected_published - published_artifacts) + raise ValueError( + "manifest/files do not match pair artifact references; " + f"unreferenced={unreferenced[:5]} missing={missing[:5]}" + ) + dataset = _Dataset( + dataset_root, + pairs, + failures, + manifest, + provenance, + attempts, + attempts_authenticated, + ) + _verify_scenes(dataset) + return dataset + + +def _validate_integer(name: str, value: int, *, minimum: int) -> int: + if type(value) is not int or value < minimum: + raise ValueError(f"{name} must be an integer >= {minimum}") + return value + + +def _validate_rate(name: str, value: float) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or not 0.0 <= float(value) <= 1.0 + ): + raise ValueError(f"{name} must be a finite value between 0 and 1") + return float(value) + + +def _verify_scenes(dataset: _Dataset) -> None: + verifier = Verifier() + for record in dataset.pairs: + try: + before_payload = (dataset.root / record.scene_before_path).read_bytes() + after_payload = (dataset.root / record.scene_after_path).read_bytes() + before = Scene.model_validate_json(before_payload) + after = Scene.model_validate_json(after_payload) + if before_payload != _audit_json_bytes( + before.model_dump(mode="json"), + pretty=True, + ): + raise ValueError("before scene is not canonical JSON") + if after_payload != _audit_json_bytes( + after.model_dump(mode="json"), + pretty=True, + ): + raise ValueError("after scene is not canonical JSON") + if ( + before.scene_id != record.scene_id + or after.scene_id != record.scene_id + or before.source != record.source + or after.source != record.source + or before.generation_seed != record.seed + or after.generation_seed != record.seed + ): + raise ValueError("scene identity does not match pair record") + result = verifier.verify( + before, + after, + InterventionSpec( + subject_id=record.subject_id, + reference_id=record.reference_id, + relation_before=record.relation_before, + relation_after=record.relation_after, + camera_id=record.camera_id, + ), + ) + except (OSError, ValidationError, ValueError, KeyError) as error: + raise ValueError( + f"scene re-verification could not execute for {record.pair_id}: {error}" + ) from error + if result.status is not SolverStatus.SUCCESS: + raise ValueError( + f"scene re-verification failed for {record.pair_id}: " + f"status={result.status.value} quality={result.quality.value}" + ) + if result.changed_relations != record.relation_diff: + raise ValueError( + f"pair {record.pair_id} relation_diff does not match " + "recomputed scene graph changes" + ) + subject = before.object_by_id(record.subject_id) + reference = before.object_by_id(record.reference_id) + expected_question = ( + f"What is the relation of the {subject.category} to the " + f"{reference.category}? Answer with one label." + ) + if ( + subject.category != record.subject_category + or reference.category != record.reference_category + or record.question != expected_question + ): + raise ValueError( + f"pair {record.pair_id} semantic metadata does not match its scene" + ) + if ( + record.generator not in _GENERATOR_NAMES + or record.generator_version != _GENERATOR_VERSION + or record.seed != _DATASET_SEED + ): + raise ValueError(f"pair {record.pair_id} generator provenance is invalid") + expected_request_id = _expected_request_id(record) + expected_pair_id = str( + uuid.uuid5( + _PAIR_NAMESPACE, + (f"{expected_request_id}:{record.generator}:{_GENERATOR_VERSION}"), + ) + ) + if ( + record.request_id != expected_request_id + or record.pair_id != expected_pair_id + ): + raise ValueError(f"pair {record.pair_id} provenance identity is invalid") + + score = calculate_candidate_objective( + before, + after, + InterventionSpec( + subject_id=record.subject_id, + reference_id=record.reference_id, + relation_before=record.relation_before, + relation_after=record.relation_after, + camera_id=record.camera_id, + ), + result.leakage_count, + verifier.engine, + translation_weight=1.0, + relation_damage_weight=5.0, + visibility_change_weight=2.0, + inverse_safety_margin_weight=1.0, + ) + objective_metrics = { + "normalized_edit_distance": score[0], + "leakage_score": score[1], + "visibility_change": score[2], + "inverse_safety_margin": score[3], + } + mismatched_metrics = [ + name + for name, expected in objective_metrics.items() + if not math.isclose( + getattr(record, name), + expected, + rel_tol=0.0, + abs_tol=1e-12, + ) + ] + if mismatched_metrics: + raise ValueError( + f"pair {record.pair_id} objective metric mismatch: {mismatched_metrics}" + ) + if result.quality is not record.quality or record.quality_flags != ( + result.quality.value, + ): + raise ValueError( + f"pair {record.pair_id} quality does not match recomputed evidence" + ) + + spec = InterventionSpec( + subject_id=record.subject_id, + reference_id=record.reference_id, + relation_before=record.relation_before, + relation_after=record.relation_after, + camera_id=record.camera_id, + ) + _validate_json_artifact( + dataset.root / record.relation_graph_before_path, + verifier.engine.graph(before, record.camera_id), + "before relation graph", + ) + _validate_json_artifact( + dataset.root / record.relation_graph_after_path, + verifier.engine.graph(after, record.camera_id), + "after relation graph", + ) + _validate_json_artifact( + dataset.root / record.topdown_path, + topdown_payload(before, after, spec), + "topdown", + ) + _validate_image( + dataset.root / record.rgb_before_path, + before, + record.camera_id, + "before RGB", + dataset.backend, + ) + _validate_image( + dataset.root / record.rgb_after_path, + after, + record.camera_id, + "after RGB", + dataset.backend, + ) + _validate_image( + dataset.root / record.instance_before_path, + before, + record.camera_id, + "before instance", + dataset.backend, + ) + _validate_image( + dataset.root / record.instance_after_path, + after, + record.camera_id, + "after instance", + dataset.backend, + ) + _validate_depth( + dataset.root / record.depth_before_path, + before, + record.camera_id, + "before", + dataset.backend, + ) + _validate_depth( + dataset.root / record.depth_after_path, + after, + record.camera_id, + "after", + dataset.backend, + ) + _validate_pointcloud( + dataset.root / record.pointcloud_before_path, + before, + "before", + dataset.backend, + ) + _validate_pointcloud( + dataset.root / record.pointcloud_after_path, + after, + "after", + dataset.backend, + ) + + +def audit_dataset( + root: Path, + minimum_pairs: int, + minimum_pure_test: int = 0, + verify_scenes: bool = False, + *, + maximum_mean_leakage: float = 1.0, + maximum_mean_edit_distance: float = 1.0, + minimum_pure_rate: float = 0.0, + minimum_solvable_coverage: float = 0.0, + expected_source_digest: str | None = None, + root_descriptor: int | None = None, + _descriptor_capability: bool = False, +) -> dict[str, Any]: + """Validate one immutable dataset and enforce configured quality gates.""" + if root_descriptor is not None: + if type(root_descriptor) is not int or root_descriptor < 0: + raise ValueError("root_descriptor must be an open directory fd") + try: + duplicate = os.dup(root_descriptor) + except OSError as error: + raise ValueError("cannot retain dataset root descriptor") from error + try: + descriptor_result = os.fstat(duplicate) + except OSError as error: + os.close(duplicate) + raise ValueError("cannot inspect dataset root descriptor") from error + if not stat.S_ISDIR(descriptor_result.st_mode): + os.close(duplicate) + raise ValueError("dataset root descriptor is not a directory") + descriptor_path = Path("/proc/self/fd") / str(duplicate) + if not descriptor_path.is_dir(): + os.close(duplicate) + raise ValueError("descriptor-rooted audit requires Linux procfs") + try: + return audit_dataset( + descriptor_path, + minimum_pairs, + minimum_pure_test, + verify_scenes, + maximum_mean_leakage=maximum_mean_leakage, + maximum_mean_edit_distance=maximum_mean_edit_distance, + minimum_pure_rate=minimum_pure_rate, + minimum_solvable_coverage=minimum_solvable_coverage, + expected_source_digest=expected_source_digest, + _descriptor_capability=True, + ) + finally: + os.close(duplicate) + if _descriptor_capability and not str(root).startswith("/proc/self/fd/"): + raise ValueError("invalid descriptor-rooted audit capability") + minimum_pairs = _validate_integer("minimum_pairs", minimum_pairs, minimum=0) + minimum_pure_test = _validate_integer( + "minimum_pure_test", + minimum_pure_test, + minimum=0, + ) + if type(verify_scenes) is not bool: + raise ValueError("verify_scenes must be a boolean") + maximum_mean_leakage = _validate_rate( + "maximum_mean_leakage", + maximum_mean_leakage, + ) + maximum_mean_edit_distance = _validate_rate( + "maximum_mean_edit_distance", + maximum_mean_edit_distance, + ) + minimum_pure_rate = _validate_rate("minimum_pure_rate", minimum_pure_rate) + minimum_solvable_coverage = _validate_rate( + "minimum_solvable_coverage", + minimum_solvable_coverage, + ) + + if expected_source_digest is not None and ( + type(expected_source_digest) is not str + or len(expected_source_digest) != 64 + or any( + character not in "0123456789abcdef" for character in expected_source_digest + ) + ): + raise ValueError("expected_source_digest must be lowercase SHA-256") + dataset = _read_dataset( + root, + expected_source_digest=expected_source_digest, + descriptor_capability=_descriptor_capability, + ) + records = dataset.pairs + failures = dataset.failures + empty_smoke_baseline = ( + not records + and minimum_pairs == 0 + and dataset.manifest.get("run_profile") == "smoke" + and dataset.manifest.get("evidence_eligible") is False + and dataset.provenance is not None + and dataset.provenance.generator in {"random", "target-only"} + and dataset.provenance.attempt_limit is not None + and dataset.provenance.attempt_limit > 0 + and len(dataset.attempts) == dataset.provenance.attempt_limit + ) + if not records and not empty_smoke_baseline: + raise ValueError("accepted_pairs=0; empty datasets cannot pass audit") + if len(records) < minimum_pairs: + raise ValueError(f"accepted_pairs={len(records)} below minimum={minimum_pairs}") + + pure_test = tuple( + record + for record in records + if record.split == "test" and record.quality is QualityTier.PURE + ) + if len(pure_test) < minimum_pure_test: + raise ValueError( + f"pure_test_pairs={len(pure_test)} below minimum={minimum_pure_test}" + ) + if minimum_pure_test: + for tag in ("unseen_scene", "unseen_category", "unseen_combination"): + if not any(tag in record.holdout_tags for record in pure_test): + raise ValueError(f"PURE test set has no {tag} pairs") + pure_relations = {record.relation_after for record in pure_test} + missing = sorted(relation.value for relation in set(Relation) - pure_relations) + if missing: + raise ValueError(f"PURE test set missing target relations: {missing}") + if minimum_pairs >= 100: + observed = {record.relation_after for record in records} + missing = sorted(relation.value for relation in set(Relation) - observed) + if missing: + raise ValueError(f"dataset missing target relations: {missing}") + + if dataset.provenance is None and minimum_solvable_coverage > 0.0: + raise ValueError( + "solvable coverage gate requires an authenticated attempt ledger" + ) + total_requests = ( + len(dataset.attempts) + if dataset.provenance is not None + else len(records) + len(failures) + ) + pure_rate = ( + mean(record.quality is QualityTier.PURE for record in records) + if records + else 0.0 + ) + mean_leakage = mean(record.leakage_score for record in records) if records else 0.0 + mean_edit_distance = ( + mean(record.normalized_edit_distance for record in records) if records else 0.0 + ) + mean_evaluated = ( + mean(record.evaluated_candidates for record in records) if records else 0.0 + ) + solvable_coverage = len(records) / total_requests if total_requests else 0.0 + gates = ( + ( + "mean_leakage", + mean_leakage, + maximum_mean_leakage, + "above maximum", + lambda actual, threshold: actual > threshold, + ), + ( + "mean_edit_distance", + mean_edit_distance, + maximum_mean_edit_distance, + "above maximum", + lambda actual, threshold: actual > threshold, + ), + ( + "pure_rate", + pure_rate, + minimum_pure_rate, + "below minimum", + lambda actual, threshold: actual < threshold, + ), + ( + "solvable_coverage", + solvable_coverage, + minimum_solvable_coverage, + "below minimum", + lambda actual, threshold: actual < threshold, + ), + ) + for name, actual, threshold, detail, failed in gates: + if failed(actual, threshold): + raise ValueError(f"{name}={actual:.6f} {detail}={threshold:.6f}") + + report: dict[str, Any] = { + "accepted_pairs": len(records), + "attempts_authenticated": dataset.attempts_authenticated, + "attempt_limit": ( + dataset.provenance.attempt_limit if dataset.provenance is not None else None + ), + "attempted_requests": total_requests, + "requested_pairs": ( + dataset.provenance.requested_pairs + if dataset.provenance is not None + else None + ), + "source_corpus_sha256": ( + dataset.provenance.source_corpus_sha256 + if dataset.provenance is not None + else None + ), + "failed_requests": len(failures), + "solvable_coverage": solvable_coverage, + "pure_test_pairs": len(pure_test), + "pure_rate": pure_rate, + "mean_leakage": mean_leakage, + "mean_edit_distance": mean_edit_distance, + "mean_evaluated_candidates": mean_evaluated, + "failure_statuses": { + status.value: sum(failure.status is status for failure in failures) + for status in SolverStatus + if status is not SolverStatus.SUCCESS + }, + "by_generator": {}, + } + for generator in sorted({record.generator for record in records}): + subset = tuple(record for record in records if record.generator == generator) + report["by_generator"][generator] = { + "count": len(subset), + "mean_leakage": mean(record.leakage_score for record in subset), + "pure_rate": mean(record.quality is QualityTier.PURE for record in subset), + } + return report + + +def compare_generators( + spatial_root: Path, + random_root: Path, + minimum_matched: int, + minimum_reduction: float, + *, + expected_source_digest: str, +) -> dict[str, float | int | str]: + """Enforce the matched-request Gate B leakage comparison.""" + minimum_matched = _validate_integer( + "minimum_matched", + minimum_matched, + minimum=1, + ) + minimum_reduction = _validate_rate( + "minimum_reduction", + minimum_reduction, + ) + if ( + type(expected_source_digest) is not str + or len(expected_source_digest) != 64 + or any( + character not in "0123456789abcdef" for character in expected_source_digest + ) + ): + raise ValueError("expected_source_digest must be lowercase SHA-256") + spatial_dataset = _read_dataset( + spatial_root, + expected_source_digest=expected_source_digest, + ) + random_dataset = _read_dataset( + random_root, + expected_source_digest=expected_source_digest, + ) + spatial_profile = profile_from_manifest(spatial_dataset.manifest) + random_profile = profile_from_manifest(random_dataset.manifest) + if ( + spatial_profile is None + or random_profile is None + or spatial_profile.evidence_eligible is not True + or random_profile.evidence_eligible is not True + ): + raise ValueError("Gate B inputs are not evidence eligible") + if spatial_dataset.provenance is None or random_dataset.provenance is None: + raise ValueError( + "Gate B rejects legacy datasets without official replay attestation" + ) + spatial_provenance = spatial_dataset.provenance + random_provenance = random_dataset.provenance + if ( + spatial_provenance.attempt_limit is None + or random_provenance.attempt_limit is None + ): + raise ValueError( + "Gate B requires each dataset to use a fixed authenticated attempt prefix" + ) + if spatial_provenance.attempt_limit != random_provenance.attempt_limit: + raise ValueError( + "Gate B datasets must use the same authenticated attempt_limit" + ) + attempt_limit = spatial_provenance.attempt_limit + if spatial_provenance.adapter_backend != random_provenance.adapter_backend: + raise ValueError( + "Gate B datasets must use the same authenticated adapter backend" + ) + if ( + spatial_provenance.adapter_implementation + != random_provenance.adapter_implementation + or spatial_provenance.adapter_config != random_provenance.adapter_config + ): + raise ValueError("Gate B datasets must use identical canonical adapter config") + spatial_sources = [ + (source.scene_id, source.sha256) for source in spatial_provenance.source_scenes + ] + random_sources = [ + (source.scene_id, source.sha256) for source in random_provenance.source_scenes + ] + if spatial_sources != random_sources: + raise ValueError("Gate B datasets use different authenticated inputs") + spatial_prefix = tuple(attempt.request_id for attempt in spatial_dataset.attempts) + random_prefix = tuple(attempt.request_id for attempt in random_dataset.attempts) + if spatial_prefix != random_prefix: + raise ValueError( + "Gate B datasets must use an identical authenticated request-ID prefix" + ) + for label, dataset, expected in ( + ("spatial", spatial_dataset, "spatialcf"), + ("random", random_dataset, "random"), + ): + if dataset.provenance is None or dataset.provenance.generator != expected: + actual = ( + dataset.provenance.generator if dataset.provenance is not None else None + ) + raise ValueError( + f"{label} dataset expected generator={expected!r}; " + f"found provenance={actual!r}" + ) + unexpected = sorted( + { + record.generator + for record in dataset.pairs + if record.generator != expected + } + ) + if unexpected: + raise ValueError( + f"{label} dataset expected generator={expected!r}; found={unexpected}" + ) + + spatial = {record.request_id: record for record in spatial_dataset.pairs} + random = {record.request_id: record for record in random_dataset.pairs} + request_ids = [ + request_id + for request_id in spatial_prefix + if request_id in spatial and request_id in random + ] + spatial_only = [ + request_id + for request_id in spatial_prefix + if request_id in spatial and request_id not in random + ] + random_only = [ + request_id + for request_id in spatial_prefix + if request_id in random and request_id not in spatial + ] + both_failed = [ + request_id + for request_id in spatial_prefix + if request_id not in spatial and request_id not in random + ] + if len(request_ids) < minimum_matched: + raise ValueError( + f"matched_requests={len(request_ids)} below minimum={minimum_matched}" + ) + + match_fields = ( + "request_id", + "scene_id", + "split", + "holdout_tags", + "source", + "seed", + "subject_id", + "subject_category", + "reference_id", + "reference_category", + "camera_id", + "relation_before", + "relation_after", + "question", + "answer_before", + "answer_after", + ) + for request_id in request_ids: + mismatches = [ + field + for field in match_fields + if getattr(spatial[request_id], field) != getattr(random[request_id], field) + ] + spatial_before = ( + spatial_dataset.root / spatial[request_id].scene_before_path + ).read_bytes() + random_before = ( + random_dataset.root / random[request_id].scene_before_path + ).read_bytes() + if spatial_before != random_before: + mismatches.append("scene_before_evidence") + if mismatches: + raise ValueError(f"request payload mismatch for {request_id}: {mismatches}") + + spatial_mean = mean(spatial[request_id].leakage_score for request_id in request_ids) + random_mean = mean(random[request_id].leakage_score for request_id in request_ids) + if random_mean <= 0.0: + raise ValueError( + "random matched mean leakage must be a positive reduction denominator" + ) + reduction = (random_mean - spatial_mean) / random_mean + if reduction < minimum_reduction: + raise ValueError( + f"leakage_reduction={reduction:.6f} below minimum={minimum_reduction:.6f}" + ) + return { + "attempt_limit": attempt_limit, + "both_failed_requests": len(both_failed), + "matched_coverage": len(request_ids) / attempt_limit, + "matched_requests": len(request_ids), + "random_only_requests": len(random_only), + "random_success_rate": len(random) / attempt_limit, + "spatial_only_requests": len(spatial_only), + "spatialcf_success_rate": len(spatial) / attempt_limit, + "trusted_source_digest": expected_source_digest, + "spatialcf_mean_leakage": spatial_mean, + "random_mean_leakage": random_mean, + "relative_reduction": reduction, + } diff --git a/src/spatialcf/verification/integrity.py b/src/spatialcf/verification/integrity.py new file mode 100644 index 0000000..83d1630 --- /dev/null +++ b/src/spatialcf/verification/integrity.py @@ -0,0 +1,403 @@ +"""Shared canonical serializers for published pair evidence.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Mapping, Sequence +from collections.abc import Set as AbstractSet +from enum import Enum +from typing import Any, Literal + +from pydantic import BaseModel +from shapely.geometry import Polygon, mapping + +from spatialcf.core.feasibility import FeasibleRegionBuilder +from spatialcf.domain.request import InterventionSpec, Relation +from spatialcf.domain.scene import Scene +from spatialcf.geometry.obb import ( + OBB_INTERSECTION_Z_OVERLAP_TOLERANCE, + obb_footprint, + obb_z_overlap_depth, +) +from spatialcf.relations.engine import RelationEngine + +ObjectiveValues = tuple[float, float, float, float, float] + +_COMPETITION_NATIVE_OBSERVATION_DOMAIN = ( + b"spatialcf.competition-native-observation.v2.9\0" +) + + +def competition_legacy_sha256(value: BaseModel) -> str: + """Hash one legacy model under the frozen finite-number convention.""" + + if not isinstance(value, BaseModel): + raise TypeError("competition legacy digest requires a Pydantic model") + payload = json.dumps( + _stable_competition_legacy_value(value.model_dump(mode="json")), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _stable_competition_legacy_value(value: object) -> object: + if isinstance(value, Enum): + return _stable_competition_legacy_value(value.value) + if value is None or type(value) in {str, bool, int}: + return value + if type(value) is float: + if not math.isfinite(value): + raise ValueError("competition legacy digest requires finite floats") + return 0.0 if value == 0.0 else value + if isinstance(value, Mapping): + if any(type(key) is not str for key in value): + raise TypeError("competition legacy digest requires string mapping keys") + return { + key: _stable_competition_legacy_value(item) + for key, item in sorted(value.items()) + } + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_stable_competition_legacy_value(item) for item in value] + if isinstance(value, AbstractSet): + normalized = [_stable_competition_legacy_value(item) for item in value] + return sorted( + normalized, + key=lambda item: json.dumps( + item, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ), + ) + raise TypeError( + f"unsupported competition legacy digest value {type(value).__name__!r}" + ) + + +def competition_native_observation_payload_sha256( + *, + scene: BaseModel, + rgb_png: bytes, + depth_npy: bytes, + instance_png: bytes, + pointcloud_ply: bytes, + instance_pixel_counts: Mapping[str, int], + is_scene_at_rest: bool, +) -> str: + """Hash a shared observation payload without importing an adapter type.""" + + counts = dict(instance_pixel_counts) + if any( + type(key) is not str or type(value) is not int for key, value in counts.items() + ): + raise TypeError("native observation pixel counts must be exact") + assets = { + "depth_npy_sha256": hashlib.sha256(depth_npy).hexdigest(), + "instance_png_sha256": hashlib.sha256(instance_png).hexdigest(), + "pointcloud_ply_sha256": hashlib.sha256(pointcloud_ply).hexdigest(), + "rgb_png_sha256": hashlib.sha256(rgb_png).hexdigest(), + } + payload = json.dumps( + { + "assets": assets, + "instance_pixel_counts": dict(sorted(counts.items())), + "is_scene_at_rest": is_scene_at_rest, + "scene_sha256": competition_legacy_sha256(scene), + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(_COMPETITION_NATIVE_OBSERVATION_DOMAIN + payload).hexdigest() + + +def canonical_value(value: Any) -> Any: + """Convert model values into a deterministic JSON-compatible tree.""" + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {str(key): canonical_value(item) for key, item in sorted(value.items())} + if isinstance(value, (set, frozenset)): + return sorted( + (canonical_value(item) for item in value), + key=lambda item: json.dumps( + item, + sort_keys=True, + separators=(",", ":"), + ), + ) + if isinstance(value, (list, tuple)): + return [canonical_value(item) for item in value] + return value + + +def canonical_json_bytes(value: Any, *, pretty: bool = False) -> bytes: + """Serialize JSON with the repository's immutable canonical encoding.""" + options: dict[str, Any] = { + "allow_nan": False, + "ensure_ascii": False, + "sort_keys": True, + } + if pretty: + options["indent"] = 2 + else: + options["separators"] = (",", ":") + return (json.dumps(canonical_value(value), **options) + "\n").encode("utf-8") + + +def topdown_payload( + before: Scene, + after: Scene, + spec: InterventionSpec, +) -> dict[str, Any]: + """Build the canonical, independently reproducible top-down evidence.""" + subject_before = before.object_by_id(spec.subject_id) + subject_after = after.object_by_id(spec.subject_id) + return { + "camera_id": spec.camera_id, + "feasible_region": mapping(FeasibleRegionBuilder().build(before, spec)), + "movement_path": [ + [subject_before.position.x, subject_before.position.y], + [subject_after.position.x, subject_after.position.y], + ], + "objects": [ + { + "center": [obj.position.x, obj.position.y], + "object_id": obj.object_id, + "polygon": [ + list(point) for point in obb_footprint(obj.obb).exterior.coords + ], + } + for obj in sorted( + after.objects, + key=lambda item: item.object_id, + ) + ], + "reference_id": spec.reference_id, + "relation_after": spec.relation_after, + "relation_before": spec.relation_before, + "room_polygon": [[point.x, point.y] for point in before.room_polygon_xy], + "subject_after": [ + subject_after.position.x, + subject_after.position.y, + ], + "subject_before": [ + subject_before.position.x, + subject_before.position.y, + ], + "subject_id": spec.subject_id, + } + + +def validate_generation_budget( + requested_pairs: int | None, + attempt_limit: int | None, + *, + attempted_requests: int | None = None, +) -> Literal["accepted_pairs", "attempt_prefix"]: + """Validate the mutually exclusive authenticated selection modes.""" + for name, value in ( + ("requested_pairs/limit", requested_pairs), + ("attempt_limit", attempt_limit), + ): + if value is not None and (type(value) is not int or value <= 0): + raise ValueError(f"{name} must be null or a positive exact integer") + if (requested_pairs is None) == (attempt_limit is None): + raise ValueError( + "exactly one of requested_pairs/limit and attempt_limit is required" + ) + if attempted_requests is not None and ( + type(attempted_requests) is not int or attempted_requests < 0 + ): + raise ValueError("attempted_requests must be a non-negative exact integer") + if ( + attempt_limit is not None + and attempted_requests is not None + and attempted_requests != attempt_limit + ): + raise ValueError("attempt_limit requires a complete exact attempt prefix") + return "attempt_prefix" if attempt_limit is not None else "accepted_pairs" + + +def source_corpus_digest(scenes: tuple[Scene, ...] | list[Scene]) -> str: + """Hash a sorted canonical scene corpus without invoking a generator.""" + lines = [] + for scene in sorted(scenes, key=lambda item: item.scene_id): + payload = canonical_json_bytes(scene.model_dump(mode="json"), pretty=True) + lines.append(f"{scene.scene_id} {hashlib.sha256(payload).hexdigest()}\n") + return hashlib.sha256("".join(lines).encode("utf-8")).hexdigest() + + +def calculate_visibility_change( + before: Scene, + after: Scene, + spec: InterventionSpec, +) -> float: + """Return the largest normalized rendered-view change for the query pair.""" + + def relative(old: float, new: float) -> float: + return abs(old - new) / max(abs(old), 1e-6) + + deltas: list[float] = [] + for object_id in (spec.subject_id, spec.reference_id): + old = before.object_by_id(object_id).views[spec.camera_id] + new = after.object_by_id(object_id).views[spec.camera_id] + deltas.extend( + ( + relative(old.visible_fraction, new.visible_fraction), + relative(old.image_area_fraction, new.image_area_fraction), + relative(old.truncated_fraction, new.truncated_fraction), + ) + ) + return min(1.0, max(deltas)) + + +def calculate_inverse_safety_margin( + scene: Scene, + spec: InterventionSpec, + engine: RelationEngine, +) -> float: + """Penalize candidates with limited geometric or target-relation slack.""" + subject = scene.object_by_id(spec.subject_id) + footprint = obb_footprint(subject.obb) + room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) + clearances = [footprint.distance(room.boundary)] + clearances.extend( + footprint.distance(obb_footprint(obj.obb)) + for obj in scene.objects + if obj.object_id not in {spec.subject_id, subject.support_object_id} + and obb_z_overlap_depth(subject.obb, obj.obb) + > OBB_INTERSECTION_Z_OVERLAP_TOLERANCE + ) + for obstacle in scene.collision_obstacles: + conservative_obb = obstacle.conservative_obb() + if ( + obb_z_overlap_depth(subject.obb, conservative_obb) + > OBB_INTERSECTION_Z_OVERLAP_TOLERANCE + ): + clearances.append(footprint.distance(obb_footprint(conservative_obb))) + xs = [point.x for point in scene.room_polygon_xy] + ys = [point.y for point in scene.room_polygon_xy] + room_diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) + normalized_clearance = min(clearances) / max(0.02 * room_diagonal, 0.10) + + target = engine.observe( + scene, + spec.subject_id, + spec.reference_id, + spec.relation_after, + spec.camera_id, + ) + camera = scene.camera_by_id(spec.camera_id) + relation_scale = { + Relation.LEFT: camera.width * engine.LEFT_RIGHT_FRACTION, + Relation.RIGHT: camera.width * engine.LEFT_RIGHT_FRACTION, + Relation.FRONT: engine.FRONT_BEHIND_METERS, + Relation.BEHIND: engine.FRONT_BEHIND_METERS, + Relation.NEAR: engine.NEAR_METERS, + Relation.FAR: engine.FAR_METERS, + }[spec.relation_after] + normalized_relation_margin = target.margin / max(relation_scale, 1e-9) + safety = min(normalized_clearance, normalized_relation_margin) + return 1.0 / (1.0 + max(0.0, safety)) + + +def calculate_weighted_objective( + normalized_translation: float, + leakage: float, + visibility_change: float, + inverse_safety_margin: float, + *, + translation_weight: float, + relation_damage_weight: float, + visibility_change_weight: float, + inverse_safety_margin_weight: float, +) -> ObjectiveValues: + """Apply explicit normalized objective weight scalars.""" + total = ( + translation_weight * normalized_translation + + relation_damage_weight * leakage + + visibility_change_weight * visibility_change + + inverse_safety_margin_weight * inverse_safety_margin + ) + return ( + normalized_translation, + leakage, + visibility_change, + inverse_safety_margin, + total, + ) + + +def calculate_candidate_objective( + before: Scene, + after: Scene, + spec: InterventionSpec, + leakage_count: int, + engine: RelationEngine, + *, + translation_weight: float, + relation_damage_weight: float, + visibility_change_weight: float, + inverse_safety_margin_weight: float, +) -> ObjectiveValues: + """Compute objective terms using the original scene as normalization base.""" + old = before.object_by_id(spec.subject_id).position + new = after.object_by_id(spec.subject_id).position + xs = [point.x for point in before.room_polygon_xy] + ys = [point.y for point in before.room_polygon_xy] + room_diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) + non_target_pair_count = max( + 1, + len(before.objects) * (len(before.objects) - 1) // 2 - 1, + ) + return calculate_weighted_objective( + normalized_translation=math.hypot(new.x - old.x, new.y - old.y) + / max(room_diagonal, 1e-9), + leakage=min(1.0, leakage_count / non_target_pair_count), + visibility_change=calculate_visibility_change(before, after, spec), + inverse_safety_margin=calculate_inverse_safety_margin(after, spec, engine), + translation_weight=translation_weight, + relation_damage_weight=relation_damage_weight, + visibility_change_weight=visibility_change_weight, + inverse_safety_margin_weight=inverse_safety_margin_weight, + ) + + +def calculate_minimum_cost_candidate_objective( + before: Scene, + after: Scene, + spec: InterventionSpec, + relation_damage_count: int, + engine: RelationEngine, + *, + translation_weight: float, + relation_damage_weight: float, + visibility_change_weight: float, + inverse_safety_margin_weight: float, +) -> ObjectiveValues: + """Score a hard-valid candidate using unordered pair-axis damage.""" + soft_relation_axis_count = max(1, 3 * (len(before.objects) - 1) - 1) + old = before.object_by_id(spec.subject_id).position + new = after.object_by_id(spec.subject_id).position + xs = [point.x for point in before.room_polygon_xy] + ys = [point.y for point in before.room_polygon_xy] + room_diagonal = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) + return calculate_weighted_objective( + normalized_translation=math.hypot(new.x - old.x, new.y - old.y) + / max(room_diagonal, 1e-9), + leakage=min(1.0, relation_damage_count / soft_relation_axis_count), + visibility_change=calculate_visibility_change(before, after, spec), + inverse_safety_margin=calculate_inverse_safety_margin(after, spec, engine), + translation_weight=translation_weight, + relation_damage_weight=relation_damage_weight, + visibility_change_weight=visibility_change_weight, + inverse_safety_margin_weight=inverse_safety_margin_weight, + ) diff --git a/src/spatialcf/verification/manual_review.py b/src/spatialcf/verification/manual_review.py new file mode 100644 index 0000000..40110a0 --- /dev/null +++ b/src/spatialcf/verification/manual_review.py @@ -0,0 +1,515 @@ +"""Deterministic manual-review queues and strict annotation gates.""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + +from pydantic import ( + BaseModel, + ConfigDict, + ValidationError, + field_validator, + model_validator, +) + +from spatialcf.domain.request import QualityTier, Relation +from spatialcf.verification.dataset import _read_dataset + +_QUEUE_SCHEMA_VERSION = 3 +_IMMUTABLE_FIELDS = ( + "pair_id", + "request_id", + "relation_after", + "quality", + "generator", + "rgb_before_path", + "rgb_after_path", + "topdown_path", + "expected_before", + "expected_after", +) + + +class ManualReviewRow(BaseModel): + """One sampled pair plus one unambiguous reviewer's annotations.""" + + model_config = ConfigDict(extra="forbid", strict=True) + + pair_id: str + request_id: str + relation_after: Relation + quality: QualityTier + generator: str + rgb_before_path: str + rgb_after_path: str + topdown_path: str + expected_before: Relation + expected_after: Relation + observed_before: Relation | None = None + observed_after: Relation | None = None + collision_free: bool | None = None + single_object_edit: bool | None = None + approved: bool | None = None + reviewer_id: str | None = None + reviewer_notes: str = "" + + @field_validator( + "pair_id", + "request_id", + "generator", + "rgb_before_path", + "rgb_after_path", + "topdown_path", + ) + @classmethod + def required_text(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("required review text must be non-empty") + return value + + @field_validator("reviewer_id") + @classmethod + def reviewer_identity(cls, value: str | None) -> str | None: + if value is not None and (not value or not value.strip()): + raise ValueError("reviewer_id must be non-empty when provided") + return value + + @field_validator("reviewer_notes") + @classmethod + def notes_are_text(cls, value: str) -> str: + return value + + @field_validator("rgb_before_path", "rgb_after_path", "topdown_path") + @classmethod + def relative_posix_path(cls, value: str) -> str: + if ( + "\\" in value + or PurePosixPath(value).is_absolute() + or PureWindowsPath(value).is_absolute() + or PureWindowsPath(value).drive + or any(part in {"", ".", ".."} for part in value.split("/")) + ): + raise ValueError("review artifact paths must be relative POSIX paths") + return value + + +class _ReviewIdentity(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + pair_id: str + request_id: str + relation_after: Relation + quality: QualityTier + generator: str + rgb_before_path: str + rgb_after_path: str + topdown_path: str + expected_before: Relation + expected_after: Relation + + +class _ReviewQueueManifest(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + count: int + dataset_checksums_sha256: str + rows: tuple[_ReviewIdentity, ...] + schema_version: int + + @field_validator("count") + @classmethod + def positive_count(cls, value: int) -> int: + if value <= 0: + raise ValueError("count must be positive") + return value + + @field_validator("dataset_checksums_sha256") + @classmethod + def checksum_digest(cls, value: str) -> str: + if len(value) != 64 or any( + character not in "0123456789abcdef" for character in value + ): + raise ValueError("dataset checksum digest must be lowercase SHA-256") + return value + + @field_validator("schema_version") + @classmethod + def supported_schema(cls, value: int) -> int: + if value != _QUEUE_SCHEMA_VERSION: + raise ValueError("unsupported review queue schema") + return value + + @model_validator(mode="after") + def count_matches_rows(self) -> _ReviewQueueManifest: + if len(self.rows) != self.count: + raise ValueError("count does not match review identity rows") + pair_ids = [row.pair_id for row in self.rows] + request_ids = [row.request_id for row in self.rows] + if len(set(pair_ids)) != len(pair_ids) or len(set(request_ids)) != len( + request_ids + ): + raise ValueError("review identity rows must be unique") + return self + + +def _manifest_path(output: Path) -> Path: + return output.with_name(f"{output.name}.queue.json") + + +def _canonical_json(value: Any, *, pretty: bool = False) -> bytes: + options: dict[str, Any] = { + "allow_nan": False, + "ensure_ascii": False, + "sort_keys": True, + } + if pretty: + options["indent"] = 2 + else: + options["separators"] = (",", ":") + return (json.dumps(value, **options) + "\n").encode("utf-8") + + +def _row_json(row: ManualReviewRow) -> bytes: + return _canonical_json(row.model_dump(mode="json")) + + +def _identity(row: ManualReviewRow) -> dict[str, Any]: + payload = row.model_dump(mode="json") + return {field: payload[field] for field in _IMMUTABLE_FIELDS} + + +def _write_exclusive(path: Path, payload: bytes) -> None: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "wb") as stream: + descriptor = -1 + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + finally: + if descriptor >= 0: + os.close(descriptor) + + +def _validate_positive_integer(name: str, value: int) -> int: + if type(value) is not int or value <= 0: + raise ValueError(f"{name} must be a positive integer") + return value + + +def _validate_rate(name: str, value: float) -> float: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or not 0.0 <= float(value) <= 1.0 + ): + raise ValueError(f"{name} must be a finite value between 0 and 1") + return float(value) + + +def sample_review( + root: Path, + output: Path, + count: int = 50, + *, + seed: int = 20260723, +) -> None: + """Write a seed-stable, relation/generator/quality-stratified PURE test queue.""" + count = _validate_positive_integer("count", count) + if type(seed) is not int or seed < 0: + raise ValueError("seed must be a non-negative integer") + output = Path(output) + manifest_path = _manifest_path(output) + if output.exists() or output.is_symlink(): + raise FileExistsError(output) + if manifest_path.exists() or manifest_path.is_symlink(): + raise FileExistsError(manifest_path) + + dataset = _read_dataset(root) + eligible = [ + record + for record in dataset.pairs + if record.split == "test" and record.quality is QualityTier.PURE + ] + if len(eligible) < count: + raise ValueError( + f"eligible PURE test pairs={len(eligible)} below count={count}" + ) + + buckets: dict[tuple[str, str, str], list[Any]] = {} + for record in eligible: + key = ( + record.relation_after.value, + record.quality.value, + record.generator, + ) + buckets.setdefault(key, []).append(record) + for key, records in buckets.items(): + records.sort( + key=lambda record: ( + hashlib.sha256( + f"{seed}:{record.request_id}:{record.pair_id}".encode() + ).hexdigest(), + record.pair_id, + ) + ) + + selected = [] + keys = sorted(buckets) + while len(selected) < count: + progressed = False + for key in keys: + if buckets[key] and len(selected) < count: + selected.append(buckets[key].pop(0)) + progressed = True + if not progressed: + raise RuntimeError("review sampler exhausted eligible rows unexpectedly") + + rows = [ + ManualReviewRow( + pair_id=record.pair_id, + request_id=record.request_id, + relation_after=record.relation_after, + quality=record.quality, + generator=record.generator, + rgb_before_path=record.rgb_before_path, + rgb_after_path=record.rgb_after_path, + topdown_path=record.topdown_path, + expected_before=record.answer_before, + expected_after=record.answer_after, + ) + for record in selected + ] + queue_payload = b"".join(_row_json(row) for row in rows) + manifest = { + "count": len(rows), + "dataset_checksums_sha256": hashlib.sha256( + (dataset.root / "checksums.sha256").read_bytes() + ).hexdigest(), + "rows": [_identity(row) for row in rows], + "schema_version": _QUEUE_SCHEMA_VERSION, + } + + output.parent.mkdir(parents=True, exist_ok=True) + _write_exclusive(output, queue_payload) + try: + _write_exclusive( + manifest_path, + _canonical_json(manifest, pretty=True), + ) + except BaseException: + output.unlink() + raise + + +def _read_manifest(path: Path) -> _ReviewQueueManifest: + if path.is_symlink() or not path.is_file(): + raise ValueError(f"review queue manifest is missing or unsafe: {path}") + payload = path.read_bytes() + if b"\r" in payload or not payload.endswith(b"\n"): + raise ValueError("review queue manifest must use canonical LF JSON") + try: + manifest = _ReviewQueueManifest.model_validate_json(payload) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + ValidationError, + ValueError, + ) as error: + raise ValueError("review queue manifest schema is invalid") from error + if payload != _canonical_json(manifest.model_dump(mode="json"), pretty=True): + raise ValueError("review queue manifest schema is invalid") + return manifest + + +def _read_rows(path: Path) -> list[ManualReviewRow]: + if path.is_symlink() or not path.is_file(): + raise ValueError("review queue must be an ordinary file") + payload = path.read_bytes() + if b"\r" in payload or (payload and not payload.endswith(b"\n")): + raise ValueError("review queue must use canonical LF JSONL") + rows: list[ManualReviewRow] = [] + for line_number, line in enumerate(payload.splitlines(), start=1): + if not line: + raise ValueError(f"invalid review row at line {line_number}: blank row") + try: + row = ManualReviewRow.model_validate_json(line) + except (ValidationError, ValueError) as error: + raise ValueError( + f"invalid review row at line {line_number}: {error}" + ) from error + if line != _row_json(row).rstrip(b"\n"): + raise ValueError( + f"invalid review row at line {line_number}: row is not canonical JSON" + ) + rows.append(row) + return rows + + +def validate_review( + path: Path, + minimum_approved: int = 50, + *, + dataset_root: Path | None = None, + minimum_relation_agreement: float = 1.0, + minimum_collision_free_rate: float = 1.0, + minimum_single_object_edit_rate: float = 1.0, + minimum_approval_rate: float = 0.0, +) -> dict[str, Any]: + """Validate exact queue membership, annotations, agreement, and gates.""" + minimum_approved = _validate_positive_integer( + "minimum_approved", + minimum_approved, + ) + minimum_relation_agreement = _validate_rate( + "minimum_relation_agreement", + minimum_relation_agreement, + ) + minimum_collision_free_rate = _validate_rate( + "minimum_collision_free_rate", + minimum_collision_free_rate, + ) + minimum_single_object_edit_rate = _validate_rate( + "minimum_single_object_edit_rate", + minimum_single_object_edit_rate, + ) + minimum_approval_rate = _validate_rate( + "minimum_approval_rate", + minimum_approval_rate, + ) + path = Path(path) + if dataset_root is None: + raise ValueError( + "dataset_root is required explicitly for portable review validation" + ) + manifest = _read_manifest(_manifest_path(path)) + rows = _read_rows(path) + + try: + source_dataset = _read_dataset(Path(dataset_root)) + except (OSError, ValueError) as error: + raise ValueError(f"source dataset validation failed: {error}") from error + source_checksum = hashlib.sha256( + (source_dataset.root / "checksums.sha256").read_bytes() + ).hexdigest() + if source_checksum != manifest.dataset_checksums_sha256: + raise ValueError( + "source dataset checksum differs from the sampled review queue" + ) + + expected_rows = manifest.rows + expected_ids = [row.pair_id for row in expected_rows] + actual_ids = [row.pair_id for row in rows] + if ( + len(rows) != manifest.count + or len(set(actual_ids)) != len(actual_ids) + or set(actual_ids) != set(expected_ids) + ): + raise ValueError( + "review queue membership differs from sampled queue; " + f"expected={len(expected_ids)} actual={len(actual_ids)}" + ) + expected_by_id = {row.pair_id: row.model_dump(mode="json") for row in expected_rows} + source_by_id = {record.pair_id: record for record in source_dataset.pairs} + for identity in expected_rows: + source = source_by_id.get(identity.pair_id) + if source is None or source.request_id != identity.request_id: + raise ValueError( + "review queue membership is absent from the source dataset: " + f"{identity.pair_id}" + ) + source_identity = { + "pair_id": source.pair_id, + "request_id": source.request_id, + "relation_after": source.relation_after.value, + "quality": source.quality.value, + "generator": source.generator, + "rgb_before_path": source.rgb_before_path, + "rgb_after_path": source.rgb_after_path, + "topdown_path": source.topdown_path, + "expected_before": source.answer_before.value, + "expected_after": source.answer_after.value, + } + if source_identity != identity.model_dump(mode="json"): + raise ValueError( + f"review queue identity differs from source dataset for " + f"{identity.pair_id}" + ) + for row in rows: + if _identity(row) != expected_by_id[row.pair_id]: + raise ValueError(f"review queue row metadata changed for {row.pair_id}") + + incomplete = [ + row.pair_id + for row in rows + if ( + row.observed_before is None + or row.observed_after is None + or row.collision_free is None + or row.single_object_edit is None + or row.approved is None + or row.reviewer_id is None + ) + ] + if incomplete: + raise ValueError(f"incomplete review rows: {incomplete[:5]}") + + reviewed = len(rows) + approved = sum( + row.approved is True + and row.observed_before is row.expected_before + and row.observed_after is row.expected_after + and row.collision_free is True + and row.single_object_edit is True + for row in rows + ) + relation_agreement = ( + sum( + row.observed_before is row.expected_before + and row.observed_after is row.expected_after + for row in rows + ) + / reviewed + ) + collision_free_rate = sum(row.collision_free is True for row in rows) / reviewed + single_object_edit_rate = ( + sum(row.single_object_edit is True for row in rows) / reviewed + ) + approval_rate = approved / reviewed + if approved < minimum_approved: + raise ValueError(f"approved={approved} below minimum={minimum_approved}") + for name, actual, threshold in ( + ( + "relation_agreement", + relation_agreement, + minimum_relation_agreement, + ), + ( + "collision_free_rate", + collision_free_rate, + minimum_collision_free_rate, + ), + ( + "single_object_edit_rate", + single_object_edit_rate, + minimum_single_object_edit_rate, + ), + ("approval_rate", approval_rate, minimum_approval_rate), + ): + if actual < threshold: + raise ValueError(f"{name}={actual:.6f} below minimum={threshold:.6f}") + return { + "approved": approved, + "approval_rate": approval_rate, + "collision_free_rate": collision_free_rate, + "relation_agreement": relation_agreement, + "reviewed": reviewed, + "reviewers": sorted({row.reviewer_id for row in rows}), + "single_object_edit_rate": single_object_edit_rate, + } diff --git a/src/spatialcf/verification/profile.py b/src/spatialcf/verification/profile.py new file mode 100644 index 0000000..b90ed15 --- /dev/null +++ b/src/spatialcf/verification/profile.py @@ -0,0 +1,45 @@ +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, model_validator + + +class RunProfile(StrEnum): + SMOKE = "smoke" + EVIDENCE = "evidence" + + +class ArtifactProfile(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + run_profile: RunProfile + evidence_eligible: bool + + @model_validator(mode="after") + def validate_eligibility(self) -> "ArtifactProfile": + expected = self.run_profile is RunProfile.EVIDENCE + if self.evidence_eligible is not expected: + if self.run_profile is RunProfile.SMOKE: + raise ValueError("smoke artifacts must remain ineligible") + raise ValueError("evidence artifacts must be eligible") + return self + + @classmethod + def for_run(cls, profile: RunProfile) -> "ArtifactProfile": + return cls( + run_profile=profile, + evidence_eligible=profile is RunProfile.EVIDENCE, + ) + + +def profile_from_manifest(manifest: dict[str, object]) -> ArtifactProfile | None: + if manifest.get("schema_version") != 4: + return None + run_profile = manifest.get("run_profile") + if isinstance(run_profile, str): + run_profile = RunProfile(run_profile) + return ArtifactProfile.model_validate( + { + "run_profile": run_profile, + "evidence_eligible": manifest.get("evidence_eligible"), + } + ) diff --git a/src/spatialcf/verification/provenance.py b/src/spatialcf/verification/provenance.py new file mode 100644 index 0000000..90c86b3 --- /dev/null +++ b/src/spatialcf/verification/provenance.py @@ -0,0 +1,320 @@ +"""Canonical evidence needed to replay official dataset generation.""" + +from __future__ import annotations + +from pathlib import PurePosixPath, PureWindowsPath +from typing import Any, Literal, Protocol + +from pydantic import ( + BaseModel, + ConfigDict, + field_serializer, + field_validator, + model_validator, +) + +from spatialcf.domain.request import InterventionSpec, QualityTier, SolverStatus +from spatialcf.domain.scene import Vec3 + +ATTESTATION_SCHEMA_VERSION = 3 +DATASET_MANIFEST_SCHEMA_VERSION = 4 +LEGACY_ATTESTED_MANIFEST_SCHEMA_VERSION = 3 +ATTESTED_MANIFEST_SCHEMA_VERSIONS = frozenset( + { + LEGACY_ATTESTED_MANIFEST_SCHEMA_VERSION, + DATASET_MANIFEST_SCHEMA_VERSION, + } +) +GENERATOR_VERSION = "0.1.0" +DATASET_SEED = 20260723 + + +class _FrozenEvidence(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + +class _ObjectiveBreakdownLike(Protocol): + normalized_translation: float + leakage: float + visibility_change: float + inverse_safety_margin: float + total: float + + +class _SolveResultLike(Protocol): + status: SolverStatus + subject_position: Vec3 | None + score: _ObjectiveBreakdownLike | None + quality: QualityTier + evaluated_candidates: int + reason: str | None + + +def _provenance_path(value: str) -> str: + if ( + not value + or "\\" in value + or PurePosixPath(value).is_absolute() + or PureWindowsPath(value).is_absolute() + or PureWindowsPath(value).drive + or any(part in {"", ".", ".."} for part in value.split("/")) + or value.split("/", 1)[0] != "provenance" + ): + raise ValueError("provenance path must be a safe relative POSIX path") + return value + + +class SourceSceneEvidence(_FrozenEvidence): + scene_id: str + path: str + sha256: str + + @field_validator("scene_id") + @classmethod + def required_scene_id(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("source scene_id must be non-empty") + return value + + @field_validator("path") + @classmethod + def safe_path(cls, value: str) -> str: + return _provenance_path(value) + + @field_validator("sha256") + @classmethod + def checksum(cls, value: str) -> str: + if len(value) != 64 or any( + character not in "0123456789abcdef" for character in value + ): + raise ValueError("source scene checksum must be lowercase SHA-256") + return value + + +class GenerationProvenance(_FrozenEvidence): + schema_version: int + adapter_backend: Literal["json", "ai2thor"] + adapter_implementation: str + adapter_config: dict[str, Any] + generator: Literal["spatialcf", "random", "target-only"] + generator_implementation: str + generator_version: str + generator_config: dict[str, Any] + dataset_seed: int + requested_pairs: int | None + attempt_limit: int | None + attempted_requests: int + source_corpus_sha256: str + source_scenes: tuple[SourceSceneEvidence, ...] + + @field_validator( + "adapter_implementation", + "generator_implementation", + "generator_version", + ) + @classmethod + def required_text(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("provenance implementation fields must be non-empty") + return value + + @field_validator("schema_version") + @classmethod + def supported_schema(cls, value: int) -> int: + if value != ATTESTATION_SCHEMA_VERSION: + raise ValueError("unsupported generation attestation schema") + return value + + @field_validator("dataset_seed") + @classmethod + def deterministic_seed(cls, value: int) -> int: + if value != DATASET_SEED: + raise ValueError("generation attestation has the wrong dataset seed") + return value + + @field_validator("generator_version") + @classmethod + def supported_generator_version(cls, value: str) -> str: + if value != GENERATOR_VERSION: + raise ValueError("unsupported official generator version") + return value + + @field_validator("attempted_requests") + @classmethod + def nonnegative_attempts(cls, value: int) -> int: + if value < 0: + raise ValueError("attempted_requests must be non-negative") + return value + + @model_validator(mode="after") + def exact_generation_budget(self) -> GenerationProvenance: + for name, value in ( + ("requested_pairs", self.requested_pairs), + ("attempt_limit", self.attempt_limit), + ): + if value is not None and (type(value) is not int or value <= 0): + raise ValueError(f"{name} must be null or a positive exact integer") + if (self.requested_pairs is None) == (self.attempt_limit is None): + raise ValueError( + "exactly one of requested_pairs and attempt_limit is required" + ) + if ( + self.attempt_limit is not None + and self.attempted_requests != self.attempt_limit + ): + raise ValueError("attempt_limit requires a complete exact attempt prefix") + return self + + @field_validator("source_corpus_sha256") + @classmethod + def corpus_checksum(cls, value: str) -> str: + if len(value) != 64 or any( + character not in "0123456789abcdef" for character in value + ): + raise ValueError("source corpus digest must be lowercase SHA-256") + return value + + @field_validator("adapter_config") + @classmethod + def exact_adapter_config( + cls, + value: dict[str, Any], + info: Any, + ) -> dict[str, Any]: + backend = info.data.get("adapter_backend") + if backend == "json": + if value != {"mode": "embedded-canonical-scenes"}: + raise ValueError("JSON adapter config is not the official config") + elif backend == "ai2thor": + if set(value) != {"scene_names", "width", "height", "seed"}: + raise ValueError("AI2-THOR adapter config has unexpected fields") + names = value["scene_names"] + if ( + type(names) is not list + or not names + or any(type(name) is not str or not name for name in names) + or len(names) != len(set(names)) + ): + raise ValueError("AI2-THOR scene_names must be a unique list") + for field in ("width", "height", "seed"): + if type(value[field]) is not int: + raise ValueError( + f"AI2-THOR adapter {field} must be an exact integer" + ) + if value["width"] <= 0 or value["height"] <= 0: + raise ValueError("AI2-THOR dimensions must be positive") + if value["seed"] != DATASET_SEED: + raise ValueError("AI2-THOR adapter seed is not official") + return value + + @field_validator("generator_config") + @classmethod + def exact_generator_config( + cls, + value: dict[str, Any], + info: Any, + ) -> dict[str, Any]: + generator = info.data.get("generator") + if generator == "spatialcf": + expected_keys = { + "seed", + "grid_step", + "refine_steps", + "max_candidates", + "timeout_seconds", + } + if set(value) != expected_keys: + raise ValueError("spatial generator config has unexpected fields") + if ( + type(value["seed"]) is not int + or type(value["max_candidates"]) is not int + ): + raise ValueError("spatial integer config fields must be exact integers") + if type(value["grid_step"]) is not float: + raise ValueError("spatial grid_step must be an exact float") + if type(value["refine_steps"]) is not list or any( + type(item) is not float for item in value["refine_steps"] + ): + raise ValueError("spatial refine_steps must be a JSON float list") + if value["timeout_seconds"] is not None: + raise ValueError("spatial timeout_seconds must be null") + elif generator in {"random", "target-only"}: + if set(value) != {"max_candidates", "seed"}: + raise ValueError("baseline generator config has unexpected fields") + if ( + type(value["max_candidates"]) is not int + or type(value["seed"]) is not int + ): + raise ValueError("baseline config values must be exact integers") + return value + + +class ObjectiveEvidence(_FrozenEvidence): + normalized_translation: float + leakage: float + visibility_change: float + inverse_safety_margin: float + total: float + + @classmethod + def from_score(cls, score: _ObjectiveBreakdownLike) -> ObjectiveEvidence: + return cls( + normalized_translation=score.normalized_translation, + leakage=score.leakage, + visibility_change=score.visibility_change, + inverse_safety_margin=score.inverse_safety_margin, + total=score.total, + ) + + +class GeneratorResultEvidence(_FrozenEvidence): + status: SolverStatus + subject_position: Vec3 | None + score: ObjectiveEvidence | None + quality: QualityTier + evaluated_candidates: int + reason: str | None + + @classmethod + def from_result(cls, result: _SolveResultLike) -> GeneratorResultEvidence: + return cls( + status=result.status, + subject_position=result.subject_position, + score=( + ObjectiveEvidence.from_score(result.score) + if result.score is not None + else None + ), + quality=result.quality, + evaluated_candidates=result.evaluated_candidates, + reason=result.reason, + ) + + +class AttemptEvidence(_FrozenEvidence): + attempt_index: int + request_id: str + scene_id: str + spec: InterventionSpec + holdout_tags: frozenset[str] + generator_result: GeneratorResultEvidence + outcome: Literal["pair", "failure"] + outcome_id: str + + @field_validator("attempt_index") + @classmethod + def positive_index(cls, value: int) -> int: + if value <= 0: + raise ValueError("attempt_index must be positive") + return value + + @field_validator("request_id", "scene_id", "outcome_id") + @classmethod + def required_text(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("attempt identity fields must be non-empty") + return value + + @field_serializer("holdout_tags", when_used="json") + def serialize_holdout_tags(self, value: frozenset[str]) -> list[str]: + return sorted(value) diff --git a/src/spatialcf/verification/split.py b/src/spatialcf/verification/split.py new file mode 100644 index 0000000..0f97954 --- /dev/null +++ b/src/spatialcf/verification/split.py @@ -0,0 +1,65 @@ +"""Deterministic, scene-isolated split and holdout selection.""" + +import hashlib +import random +from typing import Literal + +from pydantic import BaseModel, ConfigDict, model_validator + +_SEED = 20260723 +_HOLDOUT_TAGS = frozenset({"unseen_scene", "unseen_category", "unseen_combination"}) + + +class SplitAssignment(BaseModel): + """A leak-safe split decision attached to a published pair.""" + + model_config = ConfigDict(frozen=True, extra="forbid", strict=True) + + split: Literal["train", "dev", "test"] + holdout_tags: frozenset[str] = frozenset() + + @model_validator(mode="after") + def validate_isolation(self) -> "SplitAssignment": + if not self.holdout_tags.issubset(_HOLDOUT_TAGS): + raise ValueError("unknown holdout tag") + if self.split != "test" and self.holdout_tags: + raise ValueError("holdout-tagged examples are restricted to the test split") + if self.split == "test" and "unseen_scene" not in self.holdout_tags: + raise ValueError("test assignments require unseen_scene isolation") + return self + + +def _split_for_bucket(bucket: int) -> Literal["train", "dev", "test"]: + if not 0 <= bucket < 100: + raise ValueError("bucket must be in [0, 100)") + return "train" if bucket < 60 else "dev" if bucket < 80 else "test" + + +def assign_split( + scene_id: str, variant_id: str | None = None +) -> Literal["train", "dev", "test"]: + """Assign scenes, never pair variants, by a stable SHA-256 bucket.""" + del variant_id + if not scene_id or not scene_id.strip(): + raise ValueError("scene_id must be non-empty") + bucket = ( + int(hashlib.sha256(f"{_SEED}:{scene_id}".encode()).hexdigest()[:8], 16) % 100 + ) + return _split_for_bucket(bucket) + + +def select_holdouts( + eligible_categories: list[str], combinations: list[str] +) -> tuple[frozenset[str], frozenset[str]]: + """Choose fixed fractions from canonicalized eligible values.""" + rng = random.Random(_SEED) + if any(not item or not item.strip() for item in eligible_categories + combinations): + raise ValueError("holdout IDs must be non-empty") + categories = sorted(set(eligible_categories)) + combos = sorted(set(combinations)) + category_count = max(1, round(len(categories) * 0.10)) if categories else 0 + combo_count = max(1, round(len(combos) * 0.20)) if combos else 0 + return ( + frozenset(rng.sample(categories, category_count)), + frozenset(rng.sample(combos, combo_count)), + ) diff --git a/src/spatialcf/verification/verifier.py b/src/spatialcf/verification/verifier.py index a4adaa5..bfa2d93 100644 --- a/src/spatialcf/verification/verifier.py +++ b/src/spatialcf/verification/verifier.py @@ -5,8 +5,14 @@ from shapely.geometry import Point, Polygon -from spatialcf.domain.enums import QualityTier, Relation, RelationAxis, SolverStatus -from spatialcf.domain.models import InterventionSpec, Scene, SceneObject +from spatialcf.domain.request import ( + InterventionSpec, + QualityTier, + Relation, + RelationAxis, + SolverStatus, +) +from spatialcf.domain.scene import Scene, SceneObject from spatialcf.geometry.obb import inside_room, obb_footprint, obbs_intersect_3d from spatialcf.geometry.regions import subject_position_region_geometry from spatialcf.relations.engine import RelationEngine @@ -118,7 +124,9 @@ def verify_minimum_cost_with_runtime_pose_authority( or not runtime_pose_subject_object_id or runtime_pose_subject_object_id != spec.subject_id ): - raise ValueError("runtime pose authority must name the intervention subject") + raise ValueError( + "runtime pose authority must name the intervention subject" + ) before_ids = {item.object_id for item in before.objects} after_ids = {item.object_id for item in after.objects} if not set(delegated) <= before_ids & after_ids: @@ -176,20 +184,11 @@ def _verify( except KeyError: return self._invalid("unknown_spec_target") - if ( - not subject_before.request_eligible - or not reference_before.request_eligible - ): + if not subject_before.request_eligible or not reference_before.request_eligible: errors.append("request_endpoint_ineligible") - if ( - subject_after.request_eligible - != subject_before.request_eligible - ): + if subject_after.request_eligible != subject_before.request_eligible: errors.append("subject_request_eligibility_changed") - if ( - reference_after.request_eligible - != reference_before.request_eligible - ): + if reference_after.request_eligible != reference_before.request_eligible: errors.append("reference_request_eligibility_changed") if not subject_before.movable or spec.subject_id in before.pinned_object_ids: errors.append("subject_not_movable") @@ -268,8 +267,10 @@ def _verify( except KeyError: errors.append("support_missing") else: - if not obb_footprint(support.obb).buffer(1e-6).covers( - obb_footprint(subject_after.obb) + if ( + not obb_footprint(support.obb) + .buffer(1e-6) + .covers(obb_footprint(subject_after.obb)) ): errors.append("support_invalid") @@ -314,7 +315,10 @@ def _verify( spec.relation_before, spec.camera_id, ) - if source.status is SolverStatus.NOT_VISIBLE or target.status is SolverStatus.NOT_VISIBLE: + if ( + source.status is SolverStatus.NOT_VISIBLE + or target.status is SolverStatus.NOT_VISIBLE + ): return VerificationResult( SolverStatus.NOT_VISIBLE, QualityTier.REJECTED, @@ -364,10 +368,9 @@ def _verify( spec.camera_id, ) - if ( - reverse_before != self._converses(forward_before) - or reverse_after != self._converses(forward_after) - ): + if reverse_before != self._converses( + forward_before + ) or reverse_after != self._converses(forward_after): return self._rejected("reverse_relation_inconsistent") target_collateral_axes: set[RelationAxis] = set() @@ -409,9 +412,13 @@ def _verify( after, first.object_id, second.object_id, spec.camera_id ) for relation in sorted(old - new, key=lambda item: item.value): - changed.append(f"-{first.object_id}:{relation.value}:{second.object_id}") + changed.append( + f"-{first.object_id}:{relation.value}:{second.object_id}" + ) for relation in sorted(new - old, key=lambda item: item.value): - changed.append(f"+{first.object_id}:{relation.value}:{second.object_id}") + changed.append( + f"+{first.object_id}:{relation.value}:{second.object_id}" + ) if ( old != new and frozenset({first.object_id, second.object_id}) != target_pair @@ -437,14 +444,14 @@ def _verify( ( (first, second) for index, first in enumerate(objects) - for second in objects[index + 1:] + for second in objects[index + 1 :] if spec.subject_id in {first.object_id, second.object_id} ) if stationary_views_unchanged else ( (first, second) for index, first in enumerate(objects) - for second in objects[index + 1:] + for second in objects[index + 1 :] ) ) for first, second in unordered_pairs: diff --git a/tests/public_smoke/_fake_runtime.py b/tests/public_smoke/_fake_runtime.py index efc85cd..06c33fc 100644 --- a/tests/public_smoke/_fake_runtime.py +++ b/tests/public_smoke/_fake_runtime.py @@ -12,7 +12,6 @@ import numpy as np from spatialcf.adapters.ai2thor import AI2ThorAdapter, AI2ThorRuntimeIdentity -from spatialcf.solver.analytic_motion import AnalyticMotionModel def _box_corners( @@ -290,15 +289,12 @@ def projecting_step(**action: Any) -> FakeEvent: subject = next( item for item in event.metadata["objects"] if item["name"] == "Chair|0" ) - view = AnalyticMotionModel().projected_view( - source, - "chair-id", - "main", + camera = source.camera_by_id("main") + event.instance_detections2D[subject["objectId"]] = _projected_detection( float(subject["position"]["x"]), float(subject["position"]["z"]), - ) - event.instance_detections2D[subject["objectId"]] = np.asarray( - [view.bbox.xmin, view.bbox.ymin, view.bbox.xmax, view.bbox.ymax] + camera.width, + camera.height, ) return event diff --git a/tests/public_smoke/test_readme.py b/tests/public_smoke/test_readme.py index 898a83b..4c7663f 100644 --- a/tests/public_smoke/test_readme.py +++ b/tests/public_smoke/test_readme.py @@ -13,8 +13,8 @@ import pytest from typer.testing import CliRunner -import spatialcf.adapters.ai2thor as ai2thor_module import spatialcf.cli as public_cli +from spatialcf.adapters.ai2thor import capture as capture_module from spatialcf.generation import generate_dataset, read_dataset_records from tests.public_smoke._fake_runtime import READMEAdapterFactory @@ -254,7 +254,7 @@ def test_readme_fake_adapter_uses_synthetic_runtime_identity_without_metadata( def reject_distribution_metadata(_name: str) -> str: raise AssertionError("fake runtime consulted installed package metadata") - monkeypatch.setattr(ai2thor_module, "package_version", reject_distribution_metadata) + monkeypatch.setattr(capture_module, "package_version", reject_distribution_metadata) factory = READMEAdapterFactory() with factory(["FloorPlan2"], width=80, height=60, seed=20260723) as adapter: