diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 074aa6f..c732bf9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,8 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" - - run: python -m pip install --upgrade pip build + - run: python -m pip install --upgrade pip + - run: python -m pip install ".[ai2thor]" - run: python -m pip install ".[test]" - run: python -m pytest -q tests/public_smoke - run: python -m build --wheel --sdist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b8b1cc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +.worktrees/ + +# Python bytecode and caches +__pycache__/ +*.py[cod] +.pytest_cache/ +.hypothesis/ +.mypy_cache/ +.ruff_cache/ +build/ +dist/ + +# Local Python environments and secrets +.venv/ +.venv-core/ +.venv-gpu/ +venv/ +.env +.env.* +!.env.example + +# Editors +.idea/ +.vscode/ + +# Generated datasets and experiment outputs +artifacts/ +outputs/ +runs/ +logs/ +checkpoints/ +models/ + +# Model weights and exported graphs +*.safetensors +*.ckpt +*.pt +*.pth +*.onnx diff --git a/README.md b/README.md index 0fbea15..bfa6256 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,26 @@ fresh verification。 Schema、求解器和验证逻辑均为平台无关设计。Unity/AI2-THOR 是首个 Adapter,只负责把 平台事实和原生操作连接到这条公共生成链。 +## 仓库与发布 + +面向用户的权威仓库是 +[`Legender134/spatialcf`](https://github.com/Legender134/spatialcf)。`v0.1.1` 是 GitHub +release,不是 PyPI 发布;请从该 release tag 克隆并在本地 checkout 中安装。 + +公共发布内容来自经过校验的确定性快照。完整开发历史、私有发布清单和恢复证据不会进入 +用户仓库;它们由维护者在独立的私有开发与归档边界中保管。 + ## 快速开始 需要 Python 3.11。以下命令创建环境、安装 AI2-THOR Adapter、生成数据,并重新打开数据集 执行验证和检查: ```bash +git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf python -m venv .venv . .venv/bin/activate -python -m pip install "spatialcf[ai2thor]" +python -m pip install ".[ai2thor]" spatialcf generate --config configs/ai2thor-example.toml --output ./dataset spatialcf verify ./dataset spatialcf inspect ./dataset diff --git a/README_EN.md b/README_EN.md index 6ac9330..7c8ce6c 100644 --- a/README_EN.md +++ b/README_EN.md @@ -11,6 +11,18 @@ 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 public generation chain. +## Repository and release + +The authoritative user-facing repository is +[`Legender134/spatialcf`](https://github.com/Legender134/spatialcf). `v0.1.1` +is a GitHub release, not a PyPI publication. Clone that release tag and install +from the local checkout. + +Public releases come from a verified deterministic snapshot. Complete +development history, private release manifests, and recovery evidence stay in +separate private development and archive boundaries and are never copied into +the user repository. + ## Quick start Python 3.11 is required. These commands create an environment, install the @@ -18,9 +30,11 @@ AI2-THOR Adapter, generate a dataset, and reopen it for verification and inspection: ```bash +git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf python -m venv .venv . .venv/bin/activate -python -m pip install "spatialcf[ai2thor]" +python -m pip install ".[ai2thor]" spatialcf generate --config configs/ai2thor-example.toml --output ./dataset spatialcf verify ./dataset spatialcf inspect ./dataset diff --git a/docs/adapters.md b/docs/adapters.md index 76e9628..2c829b5 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -11,10 +11,16 @@ repeated native execution. ## Unity/AI2-THOR -Unity/AI2-THOR is the first supported Adapter. Install it with: +Unity/AI2-THOR is the first supported Adapter. `v0.1.1` is a GitHub release, +not a PyPI publication, so install the Adapter from a local checkout of that +release: ```bash -python -m pip install "spatialcf[ai2thor]" +git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf +python -m venv .venv +. .venv/bin/activate +python -m pip install ".[ai2thor]" ``` Select it in TOML with `adapter = "ai2thor"` and provide one or more exact scene diff --git a/docs/installation.md b/docs/installation.md index b7d59f2..c5109f4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,12 +1,15 @@ # Installation -SpatialCF supports Python 3.11. Create an isolated environment and install the -package with the AI2-THOR Adapter: +SpatialCF supports Python 3.11. `v0.1.1` is a GitHub release, not a PyPI +publication. Clone that release tag, then create an isolated environment and +install the local package with the AI2-THOR Adapter: ```bash +git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf python -m venv .venv . .venv/bin/activate -python -m pip install "spatialcf[ai2thor]" +python -m pip install ".[ai2thor]" ``` The base package contains the platform-neutral Schema, solver, generation diff --git a/docs/quickstart.md b/docs/quickstart.md index dc1cb4d..1089e44 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,11 +1,14 @@ # Quick start -From the repository root, run the complete workflow: +`v0.1.1` is a GitHub release, not a PyPI publication. Clone it and run the +complete workflow from the local checkout: ```bash +git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf python -m venv .venv . .venv/bin/activate -python -m pip install "spatialcf[ai2thor]" +python -m pip install ".[ai2thor]" spatialcf generate --config configs/ai2thor-example.toml --output ./dataset spatialcf verify ./dataset spatialcf inspect ./dataset diff --git a/pyproject.toml b/pyproject.toml index 866c23b..46f0674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,11 @@ build-backend = "hatchling.build" [project] name = "spatialcf" -version = "0.1.0" +version = "0.1.1" description = "Minimal verified spatial counterfactual compiler" readme = "README_EN.md" requires-python = ">=3.11,<3.12" +license = "Apache-2.0" dependencies = [ "numpy>=2.0,<3", "pydantic>=2.8,<3", @@ -19,7 +20,7 @@ dependencies = [ [project.optional-dependencies] ai2thor = ["ai2thor>=5,<6"] -test = ["pytest>=8,<10", "pytest-xdist>=3.6,<4", "hypothesis>=6,<7"] +test = ["build>=1,<2", "pytest>=8,<10", "pytest-xdist>=3.6,<4", "hypothesis>=6,<7"] [project.scripts] spatialcf = "spatialcf.cli:app" @@ -29,6 +30,7 @@ packages = ["src/spatialcf"] [tool.hatch.build.targets.sdist] include = [ + "/.gitignore", "/.github/workflows/ci.yml", "/LICENSE", "/NOTICE", diff --git a/src/spatialcf/data/__init__.py b/src/spatialcf/data/__init__.py deleted file mode 100644 index 0294d14..0000000 --- a/src/spatialcf/data/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Frozen counterfactual dataset records and publication helpers.""" - -from spatialcf.data.models import FailureRecord, PairRecord -from spatialcf.data.profile import ArtifactProfile, RunProfile -from spatialcf.data.split import SplitAssignment, assign_split, select_holdouts -from spatialcf.data.writer import DatasetWriter - -__all__ = [ - "DatasetWriter", - "ArtifactProfile", - "FailureRecord", - "PairRecord", - "RunProfile", - "SplitAssignment", - "assign_split", - "select_holdouts", -] diff --git a/src/spatialcf/data/artifacts.py b/src/spatialcf/data/artifacts.py deleted file mode 100644 index eae4d72..0000000 --- a/src/spatialcf/data/artifacts.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Shared canonical serializers for published pair evidence.""" - -from __future__ import annotations - -from enum import Enum -import json -from typing import Any - -from shapely.geometry import mapping - -from spatialcf.domain.models import InterventionSpec, Scene -from spatialcf.geometry.obb import obb_footprint -from spatialcf.solver.feasible import FeasibleRegionBuilder - - -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, - } diff --git a/src/spatialcf/data/models.py b/src/spatialcf/data/models.py deleted file mode 100644 index 42b2109..0000000 --- a/src/spatialcf/data/models.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Strict, immutable records for published counterfactual datasets.""" - -import math -from pathlib import PurePosixPath, PureWindowsPath -from typing import Literal - -from pydantic import BaseModel, ConfigDict, field_serializer, field_validator, model_validator - -from spatialcf.domain.enums import QualityTier, Relation, SolverStatus - -_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: - if 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: - if 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 diff --git a/src/spatialcf/data/profile.py b/src/spatialcf/data/profile.py deleted file mode 100644 index b90ed15..0000000 --- a/src/spatialcf/data/profile.py +++ /dev/null @@ -1,45 +0,0 @@ -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/data/provenance.py b/src/spatialcf/data/provenance.py deleted file mode 100644 index 299cc01..0000000 --- a/src/spatialcf/data/provenance.py +++ /dev/null @@ -1,308 +0,0 @@ -"""Canonical evidence needed to replay official dataset generation.""" - -from __future__ import annotations - -from pathlib import PurePosixPath, PureWindowsPath -from typing import Any, Literal - -from pydantic import ( - BaseModel, - ConfigDict, - field_serializer, - field_validator, - model_validator, -) - -from spatialcf.domain.enums import QualityTier, SolverStatus -from spatialcf.domain.models import InterventionSpec, Vec3 -from spatialcf.solver.objective import ObjectiveBreakdown -from spatialcf.solver.search import SolveResult - - -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) - - -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: ObjectiveBreakdown) -> "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: SolveResult) -> "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/data/split.py b/src/spatialcf/data/split.py deleted file mode 100644 index 555f7e2..0000000 --- a/src/spatialcf/data/split.py +++ /dev/null @@ -1,61 +0,0 @@ -"""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("utf-8")).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/data/writer.py b/src/spatialcf/data/writer.py deleted file mode 100644 index 8f7b876..0000000 --- a/src/spatialcf/data/writer.py +++ /dev/null @@ -1,1430 +0,0 @@ -"""Durable, lock-owned publication of immutable dataset versions. - -Writers retain advisory ownership locks for their full lifetime. Publication -uses an atomic no-replace rename, and a failed post-rename parent fsync is -rolled back before an error is reported. -""" - -from __future__ import annotations - -import ctypes -import errno -import hashlib -import json -import os -import shutil -import stat -import sys -import uuid -from contextlib import suppress -from enum import Enum -from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Any, BinaryIO - -from spatialcf.data.models import FailureRecord, PairRecord -from spatialcf.data.profile import ArtifactProfile, RunProfile -from spatialcf.data.provenance import DATASET_MANIFEST_SCHEMA_VERSION -from spatialcf.data.split import assign_split - -_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 _rename_no_replace(source: Path, destination: Path) -> None: - """Rename without replacement, or fail closed when unavailable.""" - if os.name == "nt": - # Windows MoveFile semantics used by os.rename fail when the - # destination exists. The precheck only improves the exception path. - if _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") - - -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), - ) -> 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: - 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: - 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: - 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: - 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: - 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)} " - f"{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 ( - 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() diff --git a/src/spatialcf/generation/_internal/canonical_json.py b/src/spatialcf/generation/_internal/canonical_json.py new file mode 100644 index 0000000..c790823 --- /dev/null +++ b/src/spatialcf/generation/_internal/canonical_json.py @@ -0,0 +1,41 @@ +"""Canonical JSON helpers for current generation artifacts.""" + +from __future__ import annotations + +import json +from enum import Enum +from typing import Any + + +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") diff --git a/src/spatialcf/generation/_internal/source_manifest.py b/src/spatialcf/generation/_internal/source_manifest.py index 06bd5d3..8146715 100644 --- a/src/spatialcf/generation/_internal/source_manifest.py +++ b/src/spatialcf/generation/_internal/source_manifest.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from spatialcf.data.artifacts import canonical_json_bytes +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}$") diff --git a/src/spatialcf/solver/challenge_report.py b/src/spatialcf/solver/challenge_report.py deleted file mode 100644 index 0d89be7..0000000 --- a/src/spatialcf/solver/challenge_report.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Human-auditable publication for core solver challenge validation.""" - -from __future__ import annotations - -import math -from pathlib import Path -from typing import Mapping - -from PIL import Image, ImageDraw - -from spatialcf.data.artifacts import canonical_json_bytes -from spatialcf.domain.models import Scene -from spatialcf.geometry.obb import obb_footprint -from spatialcf.solver.challenge_validation import ( - ChallengeScenes, - ChallengeValidationError, - ChallengeValidationReport, - SatChallengeRecord, - UnsatChallengeRecord, -) - - -_WIDTH = 1000 -_HEIGHT = 800 -_MARGIN = 60 -_TOP_MARGIN = 145 -_PATH_DISCLAIMER = "displacement only; not a motion path" -_CLAIM_BOUNDARY = ( - "Passing this package supports only the claim that the certified solver " - "respects one explicit obstacle, preserves one finite support contact, " - "and proves one bounded FAR target infeasible on transparent Canonical " - "Scene inputs. It does not prove arbitrary-scene robustness, dataset " - "adapter correctness, physical simulation, or collision-free motion " - "planning. Every arrow is displacement only; not a motion path." -) - - -def _number(value: float) -> str: - return f"{value:.12g}" - - -def challenge_markdown(report: ChallengeValidationReport) -> str: - """Return a deterministic readable projection of a passing report.""" - lines = [ - "# Core Solver Challenge Validation: PASS", - "", - ( - "Two constrained final states and one certified infeasible target " - "passed independent replay." - ), - "", - f"Movement diagrams are {_PATH_DISCLAIMER}.", - "", - "## Satisfiable cases", - "", - "| case | relation | exact infimum (m) | realized (m) | error (m) | certificate [lower, upper] | gap (m) | verifier | result |", - "|---|---|---:|---:|---:|---|---:|---|---|", - ] - for record in report.cases: - if not isinstance(record, SatChallengeRecord): - continue - certificate = record.certificate - lines.append( - "| " - f"{record.case_id} | " - f"{record.relation_before.value} -> {record.relation_after.value} | " - f"{_number(record.exact_infimum_m)} | " - f"{_number(record.realized_displacement_m)} | " - f"{_number(record.realized_error_m)} | " - f"[{_number(certificate.distance_lower_bound)}, " - f"{_number(certificate.distance_upper_bound)}] | " - f"{_number(certificate.optimality_gap)} | " - f"{record.verifier_status.value} / leakage={record.leakage_count} | " - "PASS |" - ) - lines.extend( - ( - "", - "## Unsatisfiable case", - "", - "| case | relation | maximum achievable gap (m) | required gap (m) | solver reason | result |", - "|---|---|---:|---:|---|---|", - ) - ) - for record in report.cases: - if not isinstance(record, UnsatChallengeRecord): - continue - lines.append( - "| " - f"{record.case_id} | " - f"{record.relation_before.value} -> {record.relation_after.value} | " - f"{_number(record.maximum_possible_gap_m)} | " - f"{_number(record.required_gap_m)} | " - f"{record.reason} | PASS |" - ) - lines.extend(("", "## Closed-form derivations", "")) - for record in report.cases: - lines.extend((f"### {record.case_id}", "", record.derivation, "")) - lines.extend(("## Claim boundary", "", _CLAIM_BOUNDARY, "")) - return "\n".join(lines) - - -def _pixel_transform(scene: Scene): - xs = [point.x for point in scene.room_polygon_xy] - ys = [point.y for point in scene.room_polygon_xy] - span_x = max(xs) - min(xs) - span_y = max(ys) - min(ys) - scale = min( - (_WIDTH - 2 * _MARGIN) / span_x, - (_HEIGHT - _TOP_MARGIN - _MARGIN) / span_y, - ) - - def transform(point: tuple[float, float]) -> tuple[int, int]: - x, y = point - return ( - round(_MARGIN + (x - min(xs)) * scale), - round(_HEIGHT - _MARGIN - (y - min(ys)) * scale), - ) - - return transform - - -def _polygon_pixels( - scene: Scene, - object_id: str, - transform, -) -> list[tuple[int, int]]: - footprint = obb_footprint(scene.object_by_id(object_id).obb) - return [transform((float(x), float(y))) for x, y in footprint.exterior.coords] - - -def _arrow( - draw: ImageDraw.ImageDraw, - start: tuple[int, int], - end: tuple[int, int], -) -> None: - draw.line((start, end), fill="#6A4C93", width=5) - angle = math.atan2(end[1] - start[1], end[0] - start[0]) - length = 15.0 - spread = math.pi / 7.0 - first = ( - round(end[0] - length * math.cos(angle - spread)), - round(end[1] - length * math.sin(angle - spread)), - ) - second = ( - round(end[0] - length * math.cos(angle + spread)), - round(end[1] - length * math.sin(angle + spread)), - ) - draw.polygon((end, first, second), fill="#6A4C93") - - -def render_challenge_topdown( - before: Scene, - after: Scene | None, - record: SatChallengeRecord | UnsatChallengeRecord, -) -> Image.Image: - """Render explanatory endpoint geometry without implying a motion path.""" - transform = _pixel_transform(before) - image = Image.new("RGB", (_WIDTH, _HEIGHT), "white") - draw = ImageDraw.Draw(image) - room = [transform((point.x, point.y)) for point in before.room_polygon_xy] - draw.polygon(room, fill="#F5F8FA", outline="#1D3557", width=4) - - display_scene = after if after is not None else before - subject_id = "subject" - for obj in sorted(display_scene.objects, key=lambda item: item.object_id): - if obj.object_id == subject_id: - continue - polygon = _polygon_pixels(display_scene, obj.object_id, transform) - fill = "#C5E1A5" if obj.object_id == "support" else "#B0BEC5" - draw.polygon(polygon, fill=fill, outline="#455A64", width=3) - center = transform((obj.position.x, obj.position.y)) - draw.text((center[0] + 7, center[1] - 14), obj.object_id, fill="#263238") - - before_polygon = _polygon_pixels(before, subject_id, transform) - draw.line(before_polygon, fill="#277DA1", width=5, joint="curve") - before_subject = before.object_by_id(subject_id) - if isinstance(record, SatChallengeRecord): - if after is None: - raise ChallengeValidationError( - f"{record.case_id}: SAT render scene membership mismatch" - ) - after_polygon = _polygon_pixels(after, subject_id, transform) - draw.line(after_polygon, fill="#F94144", width=5, joint="curve") - after_subject = after.object_by_id(subject_id) - before_center = transform( - (before_subject.position.x, before_subject.position.y) - ) - after_center = transform((after_subject.position.x, after_subject.position.y)) - _arrow(draw, before_center, after_center) - draw.text((30, 95), _PATH_DISCLAIMER, fill="#6A4C93") - measurement = ( - f"exact={_number(record.exact_infimum_m)} m " - f"realized={_number(record.realized_displacement_m)} m" - ) - else: - if after is not None: - raise ChallengeValidationError( - f"{record.case_id}: UNSAT render scene membership mismatch" - ) - draw.text((30, 95), "NO LEGAL FINAL STATE", fill="#C62828") - measurement = ( - f"max gap={_number(record.maximum_possible_gap_m)} m " - f"required={_number(record.required_gap_m)} m" - ) - - draw.text((30, 20), record.case_id, fill="#111827") - draw.text( - (30, 45), - f"{record.relation_before.value} -> {record.relation_after.value}", - fill="#111827", - ) - draw.text((30, 70), measurement, fill="#111827") - axis_origin = (_MARGIN, _HEIGHT - 25) - draw.line( - (axis_origin, (axis_origin[0] + 45, axis_origin[1])), - fill="#111827", - width=2, - ) - draw.line( - (axis_origin, (axis_origin[0], axis_origin[1] - 45)), - fill="#111827", - width=2, - ) - draw.text((axis_origin[0] + 49, axis_origin[1] - 8), "+X", fill="#111827") - draw.text((axis_origin[0] - 9, axis_origin[1] - 60), "+Y", fill="#111827") - return image - - -def _validate_scene_membership( - report: ChallengeValidationReport, - scenes: Mapping[str, tuple[Scene, Scene | None]], -) -> None: - expected = tuple(record.case_id for record in report.cases) - if set(scenes) != set(expected) or len(scenes) != len(expected): - raise ChallengeValidationError("challenge report scene membership mismatch") - for record in report.cases: - pair = scenes[record.case_id] - if type(pair) is not tuple or len(pair) != 2: - raise ChallengeValidationError( - f"{record.case_id}: challenge report scene membership malformed" - ) - before, after = pair - if not isinstance(before, Scene) or before.scene_id != record.case_id: - raise ChallengeValidationError( - f"{record.case_id}: challenge report scene membership mismatch" - ) - if isinstance(record, SatChallengeRecord): - if not isinstance(after, Scene) or after.scene_id != record.case_id: - raise ChallengeValidationError( - f"{record.case_id}: challenge report scene membership mismatch" - ) - elif after is not None: - raise ChallengeValidationError( - f"{record.case_id}: challenge report scene membership mismatch" - ) - - -def _prepare_output(output_root: Path) -> None: - if output_root.is_symlink(): - raise FileExistsError(f"output path is a symlink: {output_root}") - if output_root.exists(): - if not output_root.is_dir() or any(output_root.iterdir()): - raise FileExistsError(f"output directory is non-empty: {output_root}") - return - output_root.mkdir(parents=True) - - -def _write_scene(path: Path, scene: Scene) -> None: - path.write_bytes(canonical_json_bytes(scene.model_dump(mode="json"), pretty=True)) - - -def _verify_output(root: Path, report: ChallengeValidationReport) -> None: - ChallengeValidationReport.model_validate_json((root / "report.json").read_bytes()) - (root / "report.md").read_text(encoding="utf-8") - expected = {"report.json", "report.md"} - for record in report.cases: - directory = root / record.case_id - Scene.model_validate_json((directory / "before.json").read_bytes()) - expected.add(f"{record.case_id}/before.json") - if isinstance(record, SatChallengeRecord): - Scene.model_validate_json((directory / "after.json").read_bytes()) - expected.add(f"{record.case_id}/after.json") - with Image.open(directory / "topdown.png") as image: - image.verify() - expected.add(f"{record.case_id}/topdown.png") - actual = { - path.relative_to(root).as_posix() - for path in root.rglob("*") - if path.is_file() - } - if actual != expected: - raise ChallengeValidationError("challenge report file set is not closed") - - -def publish_challenge_report( - report: ChallengeValidationReport, - scenes: ChallengeScenes, - output_root: Path, -) -> Path: - """Write the exact challenge report tree without replacing prior output.""" - _validate_scene_membership(report, scenes) - _prepare_output(output_root) - (output_root / "report.json").write_bytes( - canonical_json_bytes(report.model_dump(mode="json"), pretty=True) - ) - (output_root / "report.md").write_text( - challenge_markdown(report), - encoding="utf-8", - newline="\n", - ) - records = {record.case_id: record for record in report.cases} - for case_id, record in records.items(): - before, after = scenes[case_id] - case_root = output_root / case_id - case_root.mkdir() - _write_scene(case_root / "before.json", before) - if isinstance(record, SatChallengeRecord): - assert after is not None - _write_scene(case_root / "after.json", after) - render_challenge_topdown(before, after, record).save( - case_root / "topdown.png", - format="PNG", - optimize=False, - compress_level=9, - ) - _verify_output(output_root, report) - return output_root diff --git a/src/spatialcf/solver/generalization_cases.py b/src/spatialcf/solver/generalization_cases.py deleted file mode 100644 index 5abbbbc..0000000 --- a/src/spatialcf/solver/generalization_cases.py +++ /dev/null @@ -1,585 +0,0 @@ -"""Closed synthetic corpus and independent oracle for solver generalization.""" - -from __future__ import annotations - -import hashlib -import json -import math -from dataclasses import dataclass -from pathlib import Path -from typing import Annotated, Literal - -from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator -from shapely.geometry import MultiPoint, Point, Polygon, box -from shapely.geometry.base import BaseGeometry - -from spatialcf.data.artifacts import canonical_json_bytes -from spatialcf.domain.enums import Relation, SolverStatus -from spatialcf.domain.models import InterventionSpec, Scene, SceneObject, Vec2 -from spatialcf.geometry.obb import obb_footprint -from spatialcf.relations.engine import RelationEngine - -GENERALIZATION_CASE_IDS = ( - "lr-open-boundary-sat", - "lr-small-subject-sat", - "lr-wide-subject-sat", - "lr-rotated-subject-sat", - "lr-upper-obstacle-sat", - "lr-lower-obstacle-sat", - "lr-two-corridors-tie-sat", - "lr-room-edge-sat", - "lr-obstacle-wall-unsat", - "lr-narrow-room-unsat", - "fb-open-depth-sat", - "fb-offset-camera-sat", - "fb-rotated-subject-sat", - "fb-wide-support-sat", - "fb-tight-support-edge-sat", - "fb-obstacle-side-step-sat", - "fb-two-obstacle-tie-sat", - "fb-room-edge-sat", - "fb-short-support-unsat", - "fb-shallow-room-unsat", - "nf-open-room-x-sat", - "nf-open-room-diagonal-sat", - "nf-rotated-reference-sat", - "nf-support-corner-sat", - "nf-obstacle-detour-sat", - "nf-small-room-unsat", - "nf-short-support-unsat", - "nf-covered-far-zone-unsat", - "nf-boundary-unsat", - "nf-large-reference-unsat", -) - -_SOURCE = "core-solver-generalization-v1" -_SEED = 20260802 -_EXPECTED_DIRECTIONS = ( - *((Relation.LEFT, Relation.RIGHT),) * 10, - *((Relation.FRONT, Relation.BEHIND),) * 10, - *((Relation.NEAR, Relation.FAR),) * 10, -) -_EPS = 1e-12 -_GEOMETRY_EPS = 1e-8 - - -class GeneralizationCaseError(ValueError): - """The committed corpus cannot support an independent validation claim.""" - - -class SatOracleSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - expected_outcome: Literal["SAT"] - proof_kind: Literal[ - "target_boundary", - "obstacle_corner", - "support_boundary", - "far_boundary", - "target_preservation_intersection", - ] - 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 finite_points(self) -> SatOracleSpec: - if not all( - math.isfinite(value) - for point in self.exact_infimum_points - for value in (point.x, point.y) - ): - raise ValueError("oracle witness points must be finite") - return self - - -class UnsatOracleSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - expected_outcome: Literal["UNSAT"] - proof_kind: Literal[ - "right_boundary_exceeds_locus", - "behind_boundary_exceeds_locus", - "maximum_ground_gap", - "target_locus_covered", - ] - 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: Literal["empty_outer_region"] - derivation: str = Field(min_length=1) - - -OracleSpec = Annotated[ - SatOracleSpec | UnsatOracleSpec, - Field(discriminator="expected_outcome"), -] - - -class GeneralizationCaseSpec(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str = Field(pattern=r"^[a-z]+(?:-[a-z]+)+$") - scene: Scene - intervention: InterventionSpec - oracle: OracleSpec - - -class GeneralizationManifest(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: Literal[1] - cases: tuple[GeneralizationCaseSpec, ...] - - -@dataclass(frozen=True) -class OracleResult: - 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 - - -@dataclass(frozen=True) -class LoadedGeneralizationCase: - spec: GeneralizationCaseSpec - scene_sha256: str - oracle: OracleResult - - -def _read_regular_file(path: Path, label: str) -> bytes: - if path.is_symlink() or not path.is_file(): - raise GeneralizationCaseError(f"{label} must be a regular file") - try: - return path.read_bytes() - except OSError as exc: - raise GeneralizationCaseError(f"cannot read {label}: {exc}") from exc - - -def _parse_json(payload: bytes) -> object: - try: - return json.loads(payload.decode("utf-8")) - except (UnicodeDecodeError, json.JSONDecodeError) as exc: - raise GeneralizationCaseError( - f"invalid UTF-8 JSON in cases.json: {exc}" - ) from exc - - -def _numeric_values(value: object): - if isinstance(value, bool): - return - if isinstance(value, (int, float)): - yield float(value) - elif isinstance(value, dict): - for item in value.values(): - yield from _numeric_values(item) - elif isinstance(value, (list, tuple, set, frozenset)): - for item in value: - yield from _numeric_values(item) - - -def _relative_subject_vertices(subject: SceneObject) -> tuple[tuple[float, float], ...]: - footprint = obb_footprint(subject.obb) - return tuple( - (float(x) - subject.position.x, float(y) - subject.position.y) - for x, y in tuple(footprint.exterior.coords)[:-1] - ) - - -def _rectangular_locus( - container: BaseGeometry, - relative_vertices: tuple[tuple[float, float], ...], - label: str, -) -> BaseGeometry: - if container.is_empty or not container.equals(box(*container.bounds)): - raise GeneralizationCaseError( - f"oracle {label} must be an axis-aligned rectangle" - ) - min_x, min_y, max_x, max_y = container.bounds - rel_x = tuple(value[0] for value in relative_vertices) - rel_y = tuple(value[1] for value in relative_vertices) - lower_x = min_x - min(rel_x) - lower_y = min_y - min(rel_y) - upper_x = max_x - max(rel_x) - upper_y = max_y - max(rel_y) - if lower_x > upper_x or lower_y > upper_y: - return Polygon() - return box(lower_x, lower_y, upper_x, upper_y) - - -def _configuration_obstacle( - obstacle: BaseGeometry, - relative_vertices: tuple[tuple[float, float], ...], -) -> BaseGeometry: - obstacle_vertices = tuple(obstacle.exterior.coords)[:-1] - return MultiPoint( - [ - (float(ox) - sx, float(oy) - sy) - for ox, oy in obstacle_vertices - for sx, sy in relative_vertices - ] - ).convex_hull - - -def _physical_locus(case: GeneralizationCaseSpec) -> BaseGeometry: - scene = case.scene - subject = scene.object_by_id(case.intervention.subject_id) - relative_vertices = _relative_subject_vertices(subject) - room = Polygon([(point.x, point.y) for point in scene.room_polygon_xy]) - locus = _rectangular_locus(room, relative_vertices, "room") - support_id = subject.support_object_id - if support_id is not None: - support = scene.object_by_id(support_id) - support_locus = _rectangular_locus( - obb_footprint(support.obb), - relative_vertices, - "support", - ) - locus = locus.intersection(support_locus) - for obstacle in sorted(scene.objects, key=lambda item: item.object_id): - if obstacle.object_id in {subject.object_id, support_id}: - continue - configuration = _configuration_obstacle( - obb_footprint(obstacle.obb), - relative_vertices, - ) - locus = locus.difference(configuration).union( - locus.intersection(configuration.boundary) - ) - return locus - - -def _preserved_distance_locus( - case: GeneralizationCaseSpec, - locus: BaseGeometry, -) -> BaseGeometry: - """Preserve the target pair's original non-target distance-axis label.""" - if case.intervention.relation_after in {Relation.NEAR, Relation.FAR}: - return locus - scene = case.scene - subject = scene.object_by_id(case.intervention.subject_id) - reference = scene.object_by_id(case.intervention.reference_id) - labels = RelationEngine().pair_labels( - scene, - subject.object_id, - reference.object_id, - case.intervention.camera_id, - ) - configuration = _configuration_obstacle( - obb_footprint(reference.obb), - _relative_subject_vertices(subject), - ) - if Relation.FAR in labels: - excluded = configuration.buffer( - RelationEngine.FAR_METERS, - quad_segs=16384, - ) - return locus.difference(excluded).union(locus.intersection(excluded.boundary)) - if Relation.NEAR in labels: - return locus.intersection( - configuration.buffer(RelationEngine.NEAR_METERS, quad_segs=16384) - ) - near_zone = configuration.buffer( - RelationEngine.NEAR_METERS, - quad_segs=16384, - ) - far_zone = configuration.buffer( - RelationEngine.FAR_METERS, - quad_segs=16384, - ) - return locus.intersection(far_zone).difference(near_zone) - - -def _right_boundary(case: GeneralizationCaseSpec) -> float: - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - reference = scene.object_by_id(spec.reference_id) - camera = scene.camera_by_id(spec.camera_id) - subject_view = subject.views[spec.camera_id] - reference_view = reference.views[spec.camera_id] - fx = camera.intrinsics[0] - if fx <= 0.0 or subject_view.camera_depth <= 0.0: - raise GeneralizationCaseError( - f"{case.case_id}: oracle camera calibration invalid" - ) - target_u = ( - reference_view.bbox.center_x + camera.width * RelationEngine.LEFT_RIGHT_FRACTION - ) - return subject.position.x + ( - (target_u - subject_view.bbox.center_x) * subject_view.camera_depth / fx - ) - - -def _behind_boundary(case: GeneralizationCaseSpec) -> float: - scene = case.scene - spec = case.intervention - subject = scene.object_by_id(spec.subject_id) - reference = scene.object_by_id(spec.reference_id) - subject_view = subject.views[spec.camera_id] - reference_view = reference.views[spec.camera_id] - world_delta = reference.position.y - subject.position.y - depth_delta = reference_view.camera_depth - subject_view.camera_depth - if abs(world_delta) <= _EPS or abs(depth_delta) <= _EPS: - raise GeneralizationCaseError( - f"{case.case_id}: oracle depth calibration invalid" - ) - metres_per_depth = world_delta / depth_delta - target_depth = reference_view.camera_depth + RelationEngine.FRONT_BEHIND_METERS - return ( - subject.position.y - + (target_depth - subject_view.camera_depth) * metres_per_depth - ) - - -def _target_locus( - case: GeneralizationCaseSpec, - physical_locus: BaseGeometry, -) -> tuple[BaseGeometry, float]: - relation = case.intervention.relation_after - if physical_locus.is_empty: - return physical_locus, 0.0 - min_x, min_y, max_x, max_y = physical_locus.bounds - span = max(max_x - min_x, max_y - min_y, 1.0) - if relation is Relation.RIGHT: - boundary = _right_boundary(case) - if boundary > max_x: - return Polygon(), boundary - target = box(boundary, min_y - span, max_x + span, max_y + span) - return _preserved_distance_locus( - case, - physical_locus.intersection(target), - ), boundary - if relation is Relation.BEHIND: - boundary = _behind_boundary(case) - if boundary > max_y: - return Polygon(), boundary - target = box(min_x - span, boundary, max_x + span, max_y + span) - return _preserved_distance_locus( - case, - physical_locus.intersection(target), - ), boundary - if relation is not Relation.FAR: - raise GeneralizationCaseError(f"{case.case_id}: unsupported oracle relation") - scene = case.scene - subject = scene.object_by_id(case.intervention.subject_id) - reference = scene.object_by_id(case.intervention.reference_id) - configuration = _configuration_obstacle( - obb_footprint(reference.obb), - _relative_subject_vertices(subject), - ) - excluded = configuration.buffer( - RelationEngine.FAR_METERS, - quad_segs=16384, - ) - target = physical_locus.difference(excluded).union( - physical_locus.intersection(excluded.boundary) - ) - return target, RelationEngine.FAR_METERS - - -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 hasattr(item, "exterior"): - points.extend(Point(float(x), float(y)) for x, y in item.exterior.coords) - elif hasattr(item, "coords"): - points.extend(Point(float(x), float(y)) for x, y in item.coords) - return tuple(points) - - -def _maximum_ground_gap( - case: GeneralizationCaseSpec, - physical_locus: BaseGeometry, -) -> float: - scene = case.scene - subject = scene.object_by_id(case.intervention.subject_id) - reference = scene.object_by_id(case.intervention.reference_id) - configuration = _configuration_obstacle( - obb_footprint(reference.obb), - _relative_subject_vertices(subject), - ) - vertices = _geometry_vertices(physical_locus) - if not vertices: - return 0.0 - return max(point.distance(configuration) for point in vertices) - - -def recompute_oracle(case: GeneralizationCaseSpec) -> OracleResult: - """Recompute the fixture oracle without production solver construction.""" - physical = _physical_locus(case) - target, required = _target_locus(case, physical) - oracle = case.oracle - origin = Point( - case.scene.object_by_id(case.intervention.subject_id).position.x, - case.scene.object_by_id(case.intervention.subject_id).position.y, - ) - if isinstance(oracle, SatOracleSpec): - if target.is_empty: - raise GeneralizationCaseError(f"{case.case_id}: SAT oracle target is empty") - exact = float(origin.distance(target)) - if not math.isclose( - oracle.exact_infimum_m, - exact, - rel_tol=0.0, - abs_tol=_EPS, - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle infimum mismatch") - for witness in oracle.exact_infimum_points: - point = Point(witness.x, witness.y) - if not target.buffer(_GEOMETRY_EPS).covers(point) or not math.isclose( - origin.distance(point), - exact, - rel_tol=0.0, - abs_tol=_GEOMETRY_EPS, - ): - raise GeneralizationCaseError( - f"{case.case_id}: oracle witness mismatch" - ) - if oracle.proof_kind == "target_boundary" and any( - obj.object_id not in {"subject", "reference"} for obj in case.scene.objects - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle proof kind mismatch") - if oracle.proof_kind == "obstacle_corner" and len(case.scene.objects) <= 2: - raise GeneralizationCaseError(f"{case.case_id}: oracle proof kind mismatch") - if oracle.proof_kind == "support_boundary" and ( - case.scene.object_by_id(case.intervention.subject_id).support_object_id - is None - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle proof kind mismatch") - if oracle.proof_kind == "far_boundary" and ( - case.intervention.relation_after is not Relation.FAR - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle proof kind mismatch") - if oracle.proof_kind == "target_preservation_intersection" and ( - case.intervention.relation_after in {Relation.NEAR, Relation.FAR} - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle proof kind mismatch") - return OracleResult("SAT", exact, oracle.exact_infimum_points, None, None) - - if not target.is_empty: - raise GeneralizationCaseError( - f"{case.case_id}: UNSAT oracle target is not empty" - ) - if oracle.proof_kind == "right_boundary_exceeds_locus": - maximum = float(physical.bounds[2]) if not physical.is_empty else 0.0 - elif oracle.proof_kind == "behind_boundary_exceeds_locus": - maximum = float(physical.bounds[3]) if not physical.is_empty else 0.0 - else: - maximum = _maximum_ground_gap(case, physical) - if not math.isclose( - oracle.maximum_possible_value_m, - maximum, - rel_tol=0.0, - abs_tol=_EPS, - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle maximum mismatch") - if not math.isclose( - oracle.required_value_m, - required, - rel_tol=0.0, - abs_tol=_EPS, - ): - raise GeneralizationCaseError(f"{case.case_id}: oracle requirement mismatch") - if not maximum < required: - raise GeneralizationCaseError(f"{case.case_id}: oracle bound is not strict") - return OracleResult("UNSAT", None, (), maximum, required) - - -def _validate_common(case: GeneralizationCaseSpec) -> None: - scene = case.scene - if scene.scene_id != case.case_id: - raise GeneralizationCaseError(f"{case.case_id}: scene_id mismatch") - if scene.source != _SOURCE: - raise GeneralizationCaseError(f"{case.case_id}: source mismatch") - if scene.generation_seed != _SEED: - raise GeneralizationCaseError(f"{case.case_id}: generation seed mismatch") - if scene.coordinate_system != "RH_METERS_Z_UP": - raise GeneralizationCaseError(f"{case.case_id}: coordinate system mismatch") - if scene.pinned_object_ids: - raise GeneralizationCaseError(f"{case.case_id}: pinned objects mismatch") - if tuple(camera.camera_id for camera in scene.cameras) != ("camera",): - raise GeneralizationCaseError(f"{case.case_id}: camera set mismatch") - if not all(math.isfinite(value) for value in _numeric_values(scene.model_dump())): - raise GeneralizationCaseError(f"{case.case_id}: scene geometry must be finite") - 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 GeneralizationCaseError( - f"{case.case_id}: intervention endpoint missing" - ) from exc - if subject.object_id != "subject" or reference.object_id != "reference": - raise GeneralizationCaseError(f"{case.case_id}: canonical endpoints mismatch") - if not subject.movable or any( - obj.movable for obj in scene.objects if obj.object_id != subject.object_id - ): - raise GeneralizationCaseError( - 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 GeneralizationCaseError(f"{case.case_id}: source relation mismatch") - - -def load_generalization_cases(root: Path) -> tuple[LoadedGeneralizationCase, ...]: - """Load and independently validate the exact committed 30-case corpus.""" - if root.is_symlink() or not root.is_dir(): - raise GeneralizationCaseError("generalization root must be a real directory") - actual_files = {entry.name for entry in root.iterdir()} - if actual_files != {"cases.json"}: - raise GeneralizationCaseError("generalization fixture file set mismatch") - payload = _read_regular_file(root / "cases.json", "cases.json") - try: - manifest = GeneralizationManifest.model_validate(_parse_json(payload)) - except ValidationError as exc: - raise GeneralizationCaseError( - f"invalid generalization manifest: {exc}" - ) from exc - case_ids = tuple(case.case_id for case in manifest.cases) - if len(case_ids) != len(set(case_ids)): - raise GeneralizationCaseError("generalization case ids must be unique") - if set(case_ids) != set(GENERALIZATION_CASE_IDS): - raise GeneralizationCaseError("manifest must contain canonical case ids") - if case_ids != GENERALIZATION_CASE_IDS: - raise GeneralizationCaseError("generalization cases must use canonical order") - directions = tuple( - (case.intervention.relation_before, case.intervention.relation_after) - for case in manifest.cases - ) - if directions != _EXPECTED_DIRECTIONS: - raise GeneralizationCaseError("generalization directions mismatch") - outcomes = tuple(case.oracle.expected_outcome for case in manifest.cases) - if outcomes.count("SAT") != 21 or outcomes.count("UNSAT") != 9: - raise GeneralizationCaseError("generalization outcome counts mismatch") - - loaded: list[LoadedGeneralizationCase] = [] - for case in manifest.cases: - _validate_common(case) - oracle = recompute_oracle(case) - scene_payload = canonical_json_bytes( - case.scene.model_dump(mode="json"), - pretty=True, - ) - loaded.append( - LoadedGeneralizationCase( - spec=case, - scene_sha256=hashlib.sha256(scene_payload).hexdigest(), - oracle=oracle, - ) - ) - return tuple(loaded) diff --git a/src/spatialcf/solver/generalization_report.py b/src/spatialcf/solver/generalization_report.py deleted file mode 100644 index 4e17c4a..0000000 --- a/src/spatialcf/solver/generalization_report.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Deterministic publication for the core solver generalization gate.""" - -from __future__ import annotations - -import math -from collections.abc import Mapping -from pathlib import Path - -from PIL import Image, ImageDraw - -from spatialcf.data.artifacts import canonical_json_bytes -from spatialcf.domain.models import Scene -from spatialcf.geometry.obb import obb_footprint -from spatialcf.solver.generalization_validation import ( - FailedGeneralizationRecord, - GeneralizationScenes, - GeneralizationValidationError, - GeneralizationValidationReport, - SatGeneralizationRecord, - UnsatGeneralizationRecord, -) - -_WIDTH = 1000 -_HEIGHT = 800 -_MARGIN = 60 -_TOP_MARGIN = 165 -_PATH_DISCLAIMER = "displacement only; not a motion path" -_CLAIM_BOUNDARY = ( - "Passing this package supports only the claim that the certified solver " - "is useful on the fixed controlled Canonical Scene matrix for LEFT to " - "RIGHT, FRONT to BEHIND, and NEAR to FAR final-state interventions. It " - "does not prove arbitrary-scene robustness, dataset-adapter or perception " - "correctness, physical simulation, or collision-free motion planning. " - "Every arrow is displacement only; not a motion path." -) - - -def _number(value: float) -> str: - return f"{value:.12g}" - - -def generalization_markdown(report: GeneralizationValidationReport) -> str: - """Return a deterministic human-readable projection of all 30 records.""" - passed = sum(record.result == "PASS" for record in report.cases) - sat = [record for record in report.cases if record.expected_outcome == "SAT"] - unsat = [record for record in report.cases if record.expected_outcome == "UNSAT"] - sat_passed = sum(record.result == "PASS" for record in sat) - unsat_passed = sum(record.result == "PASS" for record in unsat) - lines = [ - f"# Core Solver Generalization Validation: {report.status}", - "", - (f"{passed}/30 cases passed; {sat_passed}/21 SAT; {unsat_passed}/9 UNSAT."), - "", - "The certified optimality and independent realized-error budget is 1e-6 m.", - "", - f"Movement diagrams are {_PATH_DISCLAIMER}.", - "", - "## Direction summary", - "", - "| direction | passed | expected SAT | expected UNSAT |", - "|---|---:|---:|---:|", - ] - for before, after in ( - ("left", "right"), - ("front", "behind"), - ("near", "far"), - ): - records = [ - record - for record in report.cases - if record.case_id.startswith( - {"left": "lr-", "front": "fb-", "near": "nf-"}[before] - ) - ] - lines.append( - f"| {before} -> {after} | " - f"{sum(record.result == 'PASS' for record in records)}/10 | " - f"{sum(record.expected_outcome == 'SAT' for record in records)} | " - f"{sum(record.expected_outcome == 'UNSAT' for record in records)} |" - ) - - lines.extend( - ( - "", - "## Satisfiable cases", - "", - "| case | relation | exact (m) | realized (m) | error (m) | certificate gap (m) | verifier | result |", - "|---|---|---:|---:|---:|---:|---|---|", - ) - ) - for record in report.cases: - if isinstance(record, SatGeneralizationRecord): - lines.append( - "| " - f"{record.case_id} | " - f"{record.relation_before.value} -> {record.relation_after.value} | " - f"{_number(record.exact_infimum_m)} | " - f"{_number(record.realized_displacement_m)} | " - f"{_number(record.realized_error_m)} | " - f"{_number(record.certificate.optimality_gap)} | " - f"{record.verifier_status.value} / leakage={record.leakage_count} | " - "PASS |" - ) - - lines.extend( - ( - "", - "## Unsatisfiable cases", - "", - "| case | relation | maximum (m) | required (m) | solver reason | result |", - "|---|---|---:|---:|---|---|", - ) - ) - for record in report.cases: - if isinstance(record, UnsatGeneralizationRecord): - lines.append( - "| " - f"{record.case_id} | " - f"{record.relation_before.value} -> {record.relation_after.value} | " - f"{_number(record.maximum_possible_value_m)} | " - f"{_number(record.required_value_m)} | " - f"{record.reason} | PASS |" - ) - - failures = [ - record - for record in report.cases - if isinstance(record, FailedGeneralizationRecord) - ] - lines.extend(("", "## Failed checks", "")) - if failures: - lines.extend( - ( - "| case | expected | solver status | reason | failed checks |", - "|---|---|---|---|---|", - ) - ) - for record in failures: - lines.append( - "| " - f"{record.case_id} | {record.expected_outcome} | " - f"{record.solver_status.value} | {record.reason or '-'} | " - f"{', '.join(record.failed_checks)} |" - ) - else: - lines.append("None.") - - lines.extend(("", "## Independent derivations", "")) - for record in report.cases: - lines.extend((f"### {record.case_id}", "", record.derivation, "")) - lines.extend(("## Claim boundary", "", _CLAIM_BOUNDARY, "")) - return "\n".join(lines) - - -def _pixel_transform(scene: Scene): - xs = [point.x for point in scene.room_polygon_xy] - ys = [point.y for point in scene.room_polygon_xy] - span_x = max(xs) - min(xs) - span_y = max(ys) - min(ys) - scale = min( - (_WIDTH - 2 * _MARGIN) / span_x, - (_HEIGHT - _TOP_MARGIN - _MARGIN) / span_y, - ) - - def transform(point: tuple[float, float]) -> tuple[int, int]: - x, y = point - return ( - round(_MARGIN + (x - min(xs)) * scale), - round(_HEIGHT - _MARGIN - (y - min(ys)) * scale), - ) - - return transform - - -def _polygon_pixels(scene: Scene, object_id: str, transform) -> list[tuple[int, int]]: - footprint = obb_footprint(scene.object_by_id(object_id).obb) - return [transform((float(x), float(y))) for x, y in footprint.exterior.coords] - - -def _arrow( - draw: ImageDraw.ImageDraw, - start: tuple[int, int], - end: tuple[int, int], -) -> None: - draw.line((start, end), fill="#6A4C93", width=5) - angle = math.atan2(end[1] - start[1], end[0] - start[0]) - length = 15.0 - spread = math.pi / 7.0 - first = ( - round(end[0] - length * math.cos(angle - spread)), - round(end[1] - length * math.sin(angle - spread)), - ) - second = ( - round(end[0] - length * math.cos(angle + spread)), - round(end[1] - length * math.sin(angle + spread)), - ) - draw.polygon((end, first, second), fill="#6A4C93") - - -def render_generalization_topdown( - before: Scene, - after: Scene | None, - record: ( - SatGeneralizationRecord | UnsatGeneralizationRecord | FailedGeneralizationRecord - ), -) -> Image.Image: - """Render endpoint geometry while explicitly disclaiming path evidence.""" - transform = _pixel_transform(before) - image = Image.new("RGB", (_WIDTH, _HEIGHT), "white") - draw = ImageDraw.Draw(image) - room = [transform((point.x, point.y)) for point in before.room_polygon_xy] - draw.polygon(room, fill="#F5F8FA", outline="#1D3557", width=4) - - display_scene = after if after is not None else before - subject_id = "subject" - support_id = before.object_by_id(subject_id).support_object_id - for obj in sorted(display_scene.objects, key=lambda item: item.object_id): - if obj.object_id == subject_id: - continue - polygon = _polygon_pixels(display_scene, obj.object_id, transform) - fill = "#C5E1A5" if obj.object_id == support_id else "#B0BEC5" - draw.polygon(polygon, fill=fill, outline="#455A64", width=3) - center = transform((obj.position.x, obj.position.y)) - draw.text((center[0] + 7, center[1] - 14), obj.object_id, fill="#263238") - - before_polygon = _polygon_pixels(before, subject_id, transform) - draw.line(before_polygon, fill="#277DA1", width=5, joint="curve") - before_subject = before.object_by_id(subject_id) - if after is not None: - after_polygon = _polygon_pixels(after, subject_id, transform) - draw.line(after_polygon, fill="#F94144", width=5, joint="curve") - after_subject = after.object_by_id(subject_id) - _arrow( - draw, - transform((before_subject.position.x, before_subject.position.y)), - transform((after_subject.position.x, after_subject.position.y)), - ) - - if isinstance(record, SatGeneralizationRecord): - if after is None: - raise GeneralizationValidationError( - f"{record.case_id}: SAT render scene membership mismatch" - ) - relation = f"{record.relation_before.value} -> {record.relation_after.value}" - measurement = ( - f"exact={_number(record.exact_infimum_m)} m " - f"realized={_number(record.realized_displacement_m)} m" - ) - elif isinstance(record, UnsatGeneralizationRecord): - if after is not None: - raise GeneralizationValidationError( - f"{record.case_id}: UNSAT render scene membership mismatch" - ) - relation = f"{record.relation_before.value} -> {record.relation_after.value}" - measurement = ( - f"NO LEGAL FINAL STATE max={_number(record.maximum_possible_value_m)} m " - f"required={_number(record.required_value_m)} m" - ) - else: - relation = f"expected {record.expected_outcome}" - measurement = f"VALIDATION FAILED: {', '.join(record.failed_checks)}" - - draw.text((30, 20), record.case_id, fill="#111827") - draw.text((30, 45), relation, fill="#111827") - draw.text((30, 70), measurement, fill="#111827") - draw.text((30, 95), _PATH_DISCLAIMER, fill="#6A4C93") - axis_origin = (_MARGIN, _HEIGHT - 25) - draw.line( - (axis_origin, (axis_origin[0] + 45, axis_origin[1])), - fill="#111827", - width=2, - ) - draw.line( - (axis_origin, (axis_origin[0], axis_origin[1] - 45)), - fill="#111827", - width=2, - ) - draw.text((axis_origin[0] + 49, axis_origin[1] - 8), "+X", fill="#111827") - draw.text((axis_origin[0] - 9, axis_origin[1] - 60), "+Y", fill="#111827") - return image - - -def _validate_scene_membership( - report: GeneralizationValidationReport, - scenes: Mapping[str, tuple[Scene, Scene | None]], -) -> None: - expected = tuple(record.case_id for record in report.cases) - if set(scenes) != set(expected) or len(scenes) != len(expected): - raise GeneralizationValidationError( - "generalization report scene membership mismatch" - ) - for record in report.cases: - pair = scenes[record.case_id] - if type(pair) is not tuple or len(pair) != 2: - raise GeneralizationValidationError( - f"{record.case_id}: generalization report scene membership malformed" - ) - before, after = pair - if not isinstance(before, Scene) or before.scene_id != record.case_id: - raise GeneralizationValidationError( - f"{record.case_id}: generalization report scene membership mismatch" - ) - if isinstance(record, SatGeneralizationRecord): - if not isinstance(after, Scene) or after.scene_id != record.case_id: - raise GeneralizationValidationError( - f"{record.case_id}: generalization report scene membership mismatch" - ) - elif isinstance(record, UnsatGeneralizationRecord): - if after is not None: - raise GeneralizationValidationError( - f"{record.case_id}: generalization report scene membership mismatch" - ) - elif after is not None and ( - record.expected_outcome == "UNSAT" - or after.scene_id != record.case_id - ): - raise GeneralizationValidationError( - f"{record.case_id}: generalization report scene membership mismatch" - ) - - -def _prepare_output(output_root: Path) -> None: - if output_root.is_symlink(): - raise FileExistsError(f"output path is a symlink: {output_root}") - if output_root.exists(): - if not output_root.is_dir() or any(output_root.iterdir()): - raise FileExistsError(f"output directory is non-empty: {output_root}") - return - output_root.mkdir(parents=True) - - -def _write_scene(path: Path, scene: Scene) -> None: - path.write_bytes(canonical_json_bytes(scene.model_dump(mode="json"), pretty=True)) - - -def _verify_output( - root: Path, - report: GeneralizationValidationReport, - scenes: Mapping[str, tuple[Scene, Scene | None]], -) -> None: - parsed = GeneralizationValidationReport.model_validate_json( - (root / "report.json").read_bytes() - ) - if parsed != report: - raise GeneralizationValidationError("generalization report JSON mismatch") - if (root / "report.md").read_text(encoding="utf-8") != generalization_markdown( - report - ): - raise GeneralizationValidationError("generalization report Markdown mismatch") - expected = {"report.json", "report.md"} - for record in report.cases: - directory = root / record.case_id - Scene.model_validate_json((directory / "before.json").read_bytes()) - expected.add(f"{record.case_id}/before.json") - _, after = scenes[record.case_id] - if after is not None: - Scene.model_validate_json((directory / "after.json").read_bytes()) - expected.add(f"{record.case_id}/after.json") - with Image.open(directory / "topdown.png") as image: - image.verify() - with Image.open(directory / "topdown.png") as image: - if image.mode != "RGB" or image.size != (_WIDTH, _HEIGHT): - raise GeneralizationValidationError( - f"{record.case_id}: generalization image contract mismatch" - ) - expected.add(f"{record.case_id}/topdown.png") - actual = { - path.relative_to(root).as_posix() for path in root.rglob("*") if path.is_file() - } - if actual != expected: - raise GeneralizationValidationError( - "generalization report file set is not closed" - ) - - -def publish_generalization_report( - report: GeneralizationValidationReport, - scenes: GeneralizationScenes, - output_root: Path, -) -> Path: - """Write the exact report tree without replacing any prior result.""" - _validate_scene_membership(report, scenes) - _prepare_output(output_root) - (output_root / "report.json").write_bytes( - canonical_json_bytes(report.model_dump(mode="json"), pretty=True) - ) - (output_root / "report.md").write_text( - generalization_markdown(report), - encoding="utf-8", - newline="\n", - ) - records = {record.case_id: record for record in report.cases} - for case_id, record in records.items(): - before, after = scenes[case_id] - case_root = output_root / case_id - case_root.mkdir() - _write_scene(case_root / "before.json", before) - if after is not None: - _write_scene(case_root / "after.json", after) - render_generalization_topdown(before, after, record).save( - case_root / "topdown.png", - format="PNG", - optimize=False, - compress_level=9, - ) - _verify_output(output_root, report, scenes) - return output_root diff --git a/src/spatialcf/solver/generalization_validation.py b/src/spatialcf/solver/generalization_validation.py deleted file mode 100644 index 389bbea..0000000 --- a/src/spatialcf/solver/generalization_validation.py +++ /dev/null @@ -1,581 +0,0 @@ -"""Execute and independently validate the fixed solver generalization corpus.""" - -from __future__ import annotations - -import math -from pathlib import Path -from typing import Literal, TypeAlias - -from pydantic import BaseModel, ConfigDict, Field, model_validator -from shapely.geometry import Polygon - -from spatialcf.domain.enums import QualityTier, Relation, SolverStatus -from spatialcf.domain.models import Scene, 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 ( - CertifiedSolverConfig, - CertifiedSolveResult, - expected_target_diff, -) -from spatialcf.solver.continuous import CertifiedSpatialCFSolver -from spatialcf.solver.generalization_cases import ( - GENERALIZATION_CASE_IDS, - LoadedGeneralizationCase, - SatOracleSpec, - UnsatOracleSpec, - load_generalization_cases, -) -from spatialcf.solver.validation import CertificateRecord -from spatialcf.verification.verifier import Verifier - -SAT_CHECKS = ( - "source_relation_satisfied", - "solver_success", - "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", - "inside_room", - "collision_free", - "support_footprint_valid", - "support_vertical_contact", - "oracle_witness_reached", - "realized_within_tolerance", - "certificate_gap_closed", - "oracle_infimum_bracketed", -) - -UNSAT_CHECKS = ( - "source_relation_satisfied", - "independent_bound_strict", - "solver_proved_unsat", - "quality_rejected", - "reason_exact", - "position_absent", - "score_absent", - "certificate_absent", -) - -_POSITION_RESULT_CHECK = "result_position_matches_after" -_STRUCTURE_TOLERANCE = 1e-12 - - -class GeneralizationValidationError(ValueError): - """One controlled case failed an independent acceptance check.""" - - def __init__( - self, - message: str, - *, - failed_checks: tuple[str, ...] = (), - checks: dict[str, bool] | None = None, - ) -> None: - super().__init__(message) - self.failed_checks = failed_checks - self.checks = dict(checks or {}) - - -class SatGeneralizationRecord(BaseModel): - """Passing evidence for one expected-SAT case.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str - expected_outcome: Literal["SAT"] = "SAT" - result: Literal["PASS"] = "PASS" - 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, ...] = Field(min_length=1) - 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_pass_contract(self) -> SatGeneralizationRecord: - if set(self.checks) != set(SAT_CHECKS) or not all(self.checks.values()): - raise ValueError("SAT record requires every generalization check") - return self - - -class UnsatGeneralizationRecord(BaseModel): - """Passing evidence for one expected-UNSAT case.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str - expected_outcome: Literal["UNSAT"] = "UNSAT" - result: Literal["PASS"] = "PASS" - scene_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - derivation: str = Field(min_length=1) - relation_before: Relation - relation_after: Relation - 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) - solver_status: SolverStatus - quality: QualityTier - reason: str - checks: dict[str, bool] - - @model_validator(mode="after") - def validate_pass_contract(self) -> UnsatGeneralizationRecord: - if set(self.checks) != set(UNSAT_CHECKS) or not all(self.checks.values()): - raise ValueError("UNSAT record requires every generalization check") - return self - - -class FailedGeneralizationRecord(BaseModel): - """Retained diagnostics for one controlled case that did not validate.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - case_id: str - expected_outcome: Literal["SAT", "UNSAT"] - result: Literal["FAIL"] = "FAIL" - scene_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - derivation: str = Field(min_length=1) - solver_status: SolverStatus - reason: str | None - failed_checks: tuple[str, ...] = Field(min_length=1) - checks: dict[str, bool] - - -GeneralizationRecord: TypeAlias = ( - SatGeneralizationRecord | UnsatGeneralizationRecord | FailedGeneralizationRecord -) -GeneralizationScenes: TypeAlias = dict[str, tuple[Scene, Scene | None]] - - -class GeneralizationValidationReport(BaseModel): - """Closed 30-case report whose status is derived from its records.""" - - model_config = ConfigDict(frozen=True, extra="forbid") - - schema_version: Literal[1] = 1 - status: Literal["PASS", "FAIL"] - optimality_tolerance_m: Literal[1e-6] = 1e-6 - cases: tuple[GeneralizationRecord, ...] = Field(min_length=30, max_length=30) - - @model_validator(mode="after") - def validate_closed_report(self) -> GeneralizationValidationReport: - if tuple(record.case_id for record in self.cases) != GENERALIZATION_CASE_IDS: - raise ValueError("generalization report case order mismatch") - expected_status = ( - "PASS" if all(record.result == "PASS" for record in self.cases) else "FAIL" - ) - if self.status != expected_status: - raise ValueError("generalization report status mismatch") - return self - - -def _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 _top(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 _source_satisfied(case: LoadedGeneralizationCase) -> bool: - spec = case.spec.intervention - source = RelationEngine().observe( - case.spec.scene, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - return source.status is SolverStatus.SUCCESS and source.satisfied - - -def _validation_failure( - case_id: str, - ordered_checks: tuple[str, ...], - checks: dict[str, bool], - *, - extra_failed: tuple[str, ...] = (), -) -> GeneralizationValidationError: - failed = tuple(name for name in ordered_checks if not checks[name]) + extra_failed - return GeneralizationValidationError( - f"{case_id}: failed checks: {', '.join(failed)}", - failed_checks=failed, - checks=checks, - ) - - -def _certificate_record(solve_result: CertifiedSolveResult) -> CertificateRecord: - certificate = solve_result.certificate - assert certificate is not None - return 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, - ) - - -def validate_sat_generalization( - case: LoadedGeneralizationCase, - solve_result: CertifiedSolveResult, - after: Scene, -) -> SatGeneralizationRecord: - """Validate an expected-SAT result against geometry and the frozen oracle.""" - oracle_spec = case.spec.oracle - if not isinstance(oracle_spec, SatOracleSpec): - raise GeneralizationValidationError( - f"{case.spec.case_id}: expected a SAT oracle", - failed_checks=("solver_success",), - ) - - before = case.spec.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 GeneralizationValidationError( - f"{case.spec.case_id}: subject missing from after scene", - failed_checks=("only_subject_xy_changed",), - ) from exc - - certificate = solve_result.certificate - result_position = solve_result.subject_position - result_position_matches_after = ( - result_position is not None and result_position == after_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 - and all( - math.isfinite(value) - for value in ( - result_position.x if result_position is not None else math.inf, - result_position.y if result_position is not None else math.inf, - result_position.z if result_position is not None else math.inf, - ) - ) - ) - - 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 - oracle_spec.exact_infimum_m) - witness_error = min( - math.hypot( - after_subject.position.x - point.x, - after_subject.position.y - point.y, - ) - for point in oracle_spec.exact_infimum_points - ) - - 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=_STRUCTURE_TOLERANCE, - ) - and math.isclose( - position_dy, - obb_dy, - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) - ) - 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 - ) - - support_id = before_subject.support_object_id - if support_id is None: - support_footprint_valid = True - support_vertical_contact = math.isclose( - _bottom(before, spec.subject_id), - 0.0, - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) and math.isclose( - _bottom(after, spec.subject_id), - 0.0, - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) - else: - try: - support_before = before.object_by_id(support_id) - support_after = after.object_by_id(support_id) - except KeyError: - support_footprint_valid = False - support_vertical_contact = False - else: - support_footprint_valid = obb_footprint(support_before.obb).buffer( - 1e-6 - ).covers(obb_footprint(before_subject.obb)) and obb_footprint( - support_after.obb - ).buffer(1e-6).covers(obb_footprint(after_subject.obb)) - support_vertical_contact = math.isclose( - _bottom(before, spec.subject_id), - _top(before, support_id), - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) and math.isclose( - _bottom(after, spec.subject_id), - _top(after, support_id), - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) - - room = Polygon([(point.x, point.y) for point in before.room_polygon_xy]) - 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_satisfied(case), - "solver_success": solver_success, - "verifier_success": ( - verification.status is SolverStatus.SUCCESS - and verification.quality is QualityTier.PURE - and verification.leakage_count == 0 - ), - "relation_diff_exact": ( - verification.changed_relations == expected_target_diff(spec) - ), - "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, - "inside_room": inside_room(after_subject.obb, room), - "collision_free": collision_free, - "support_footprint_valid": support_footprint_valid, - "support_vertical_contact": support_vertical_contact, - "oracle_witness_reached": witness_error <= tolerance, - "realized_within_tolerance": realized_error <= tolerance, - "certificate_gap_closed": ( - certificate is not None and certificate.optimality_gap <= tolerance - ), - "oracle_infimum_bracketed": ( - certificate is not None - and certificate.distance_lower_bound - certificate.numeric_error_bound - <= oracle_spec.exact_infimum_m - <= certificate.distance_upper_bound + certificate.numeric_error_bound - ), - } - extra_failed = () if result_position_matches_after else (_POSITION_RESULT_CHECK,) - if not all(checks.values()) or extra_failed: - raise _validation_failure( - case.spec.case_id, - SAT_CHECKS, - checks, - extra_failed=extra_failed, - ) - - assert certificate is not None - return SatGeneralizationRecord( - case_id=case.spec.case_id, - scene_sha256=case.scene_sha256, - derivation=oracle_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=oracle_spec.exact_infimum_points, - exact_infimum_m=oracle_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=_certificate_record(solve_result), - checks=checks, - ) - - -def validate_unsat_generalization( - case: LoadedGeneralizationCase, - solve_result: CertifiedSolveResult, -) -> UnsatGeneralizationRecord: - """Validate a real UNSAT proof; operational failures are never accepted.""" - oracle_spec = case.spec.oracle - if not isinstance(oracle_spec, UnsatOracleSpec): - raise GeneralizationValidationError( - f"{case.spec.case_id}: expected an UNSAT oracle", - failed_checks=("solver_proved_unsat",), - ) - maximum = case.oracle.maximum_possible_value_m - required = case.oracle.required_value_m - assert maximum is not None and required is not None - checks = { - "source_relation_satisfied": _source_satisfied(case), - "independent_bound_strict": maximum < required, - "solver_proved_unsat": (solve_result.status is SolverStatus.UNSATISFIABLE), - "quality_rejected": solve_result.quality is QualityTier.REJECTED, - "reason_exact": solve_result.reason == oracle_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()): - raise _validation_failure(case.spec.case_id, UNSAT_CHECKS, checks) - assert solve_result.reason is not None - return UnsatGeneralizationRecord( - case_id=case.spec.case_id, - scene_sha256=case.scene_sha256, - derivation=oracle_spec.derivation, - relation_before=case.spec.intervention.relation_before, - relation_after=case.spec.intervention.relation_after, - maximum_possible_value_m=maximum, - required_value_m=required, - solver_status=solve_result.status, - quality=solve_result.quality, - reason=solve_result.reason, - checks=checks, - ) - - -def _failed_record( - case: LoadedGeneralizationCase, - solve_result: CertifiedSolveResult, - failure: GeneralizationValidationError, -) -> FailedGeneralizationRecord: - oracle_spec = case.spec.oracle - return FailedGeneralizationRecord( - case_id=case.spec.case_id, - expected_outcome=oracle_spec.expected_outcome, - scene_sha256=case.scene_sha256, - derivation=oracle_spec.derivation, - solver_status=solve_result.status, - reason=solve_result.reason, - failed_checks=failure.failed_checks or ("validation_failed",), - checks=failure.checks, - ) - - -def run_generalization_suite( - root: Path, -) -> tuple[GeneralizationValidationReport, GeneralizationScenes]: - """Solve all 30 canonical cases and aggregate controlled failures.""" - records: list[GeneralizationRecord] = [] - scenes: GeneralizationScenes = {} - solver = CertifiedSpatialCFSolver() - motion = AnalyticMotionModel() - - for case in load_generalization_cases(root): - solve_result = solver.solve(case.spec.scene, case.spec.intervention) - after: Scene | None = None - try: - if isinstance(case.spec.oracle, SatOracleSpec): - position = solve_result.subject_position - if position is None or not all( - math.isfinite(value) - for value in (position.x, position.y, position.z) - ): - raise GeneralizationValidationError( - f"{case.spec.case_id}: solver did not return a finite position", - failed_checks=("solver_success",), - checks={"solver_success": False}, - ) - try: - after = motion.with_object_xy( - case.spec.scene, - case.spec.intervention.subject_id, - position.x, - position.y, - ) - except CandidateProjectionError as exc: - raise GeneralizationValidationError( - f"{case.spec.case_id}: analytic replay failed", - failed_checks=("solver_success",), - checks={"solver_success": False}, - ) from exc - record: GeneralizationRecord = validate_sat_generalization( - case, - solve_result, - after, - ) - else: - record = validate_unsat_generalization(case, solve_result) - except GeneralizationValidationError as exc: - record = _failed_record(case, solve_result, exc) - records.append(record) - scenes[case.spec.case_id] = (case.spec.scene, after) - - status: Literal["PASS", "FAIL"] = ( - "PASS" if all(record.result == "PASS" for record in records) else "FAIL" - ) - report = GeneralizationValidationReport(status=status, cases=tuple(records)) - return report, scenes diff --git a/src/spatialcf/solver/stress/report.py b/src/spatialcf/solver/stress/report.py deleted file mode 100644 index 574a3f6..0000000 --- a/src/spatialcf/solver/stress/report.py +++ /dev/null @@ -1,1031 +0,0 @@ -"""Deterministic publication of solver stress reports and failure evidence.""" - -from __future__ import annotations - -import hashlib -import json -import os -import stat -import tempfile -from collections.abc import Sequence -from io import BytesIO -from pathlib import Path, PurePosixPath - -from PIL import Image, ImageDraw - -from spatialcf.data.artifacts import canonical_json_bytes -from spatialcf.data.writer import _rename_no_replace -from spatialcf.domain.models import InterventionSpec, Scene -from spatialcf.geometry.obb import obb_footprint -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCase, - UnsatStressOracle, -) -from spatialcf.solver.stress.validation import ( - FailedStressValidationRecord, - SatStressValidationRecord, - StressValidationEvidence, - StressValidationReport, - UnsatStressValidationRecord, - rebuild_stress_validation_record, - stress_after_scene_digest, - stress_solver_result_digest, - stress_solver_result_payload, -) - -_GENERATOR_VERSION = "stress-v1" -_ORACLE_VERSION = "stress-v1" -_SOLVER_VERSION = "certified-continuous-v1" -_WIDTH = 1000 -_HEIGHT = 800 -_MARGIN = 60 -_TOP_MARGIN = 130 -_PATH_DISCLAIMER = "displacement only; not a motion path" - - -class StressReportPublicationError(RuntimeError): - """Publication failed with an explicit visibility and recovery location.""" - - def __init__( - self, - output: Path, - *, - published: bool, - staging_path: Path | None, - detail: str, - ) -> None: - state = "published" if published else "not published" - recovery = ( - f"; preserved staging: {staging_path}" - if staging_path is not None - else "" - ) - super().__init__(f"{detail}; report is {state}: {output}{recovery}") - self.output = output - self.published = published - self.staging_path = staging_path - - -class StressReportDurabilityError(StressReportPublicationError): - """A verified report is visible but its parent fsync failed.""" - - def __init__(self, output: Path) -> None: - super().__init__( - output, - published=True, - staging_path=None, - detail="published but parent directory fsync failed", - ) - - -def _counts( - report: StressValidationReport, - attribute: str, -) -> dict[str, dict[str, int]]: - values: dict[str, dict[str, int]] = {} - for record in report.cases: - key = str(getattr(record, attribute)) - counts = values.setdefault(key, {"passed": 0, "total": 0}) - counts["total"] += 1 - counts["passed"] += record.result == "PASS" - return values - - -def _maximum( - values: Sequence[tuple[str, float]], -) -> dict[str, str | float] | None: - if not values: - return None - case_id, value = max(values, key=lambda item: (item[1], item[0])) - return {"case_id": case_id, "value_m": value} - - -def _report_payload(report: StressValidationReport) -> dict[str, object]: - cases = [ - record.model_dump(mode="json") - | { - "failure_path": ( - f"failures/{record.case_id}" - if isinstance(record, FailedStressValidationRecord) - else None - ) - } - for record in report.cases - ] - certificates = [ - (record.case_id, record.certificate.optimality_gap) - for record in report.cases - if record.certificate is not None - ] - realized_errors = [ - (record.case_id, record.realized_error_m) - for record in report.cases - if isinstance(record, SatStressValidationRecord) - ] - return { - **report.model_dump(mode="json", exclude={"cases"}), - "generator_version": _GENERATOR_VERSION, - "oracle_version": _ORACLE_VERSION, - "solver_version": _SOLVER_VERSION, - "cases": cases, - "statistics": { - "by_direction": _counts(report, "direction"), - "by_expected_outcome": _counts(report, "expected_outcome"), - "by_family": _counts(report, "family"), - "by_seed": _counts(report, "seed"), - }, - "maximum_realized_error": _maximum(realized_errors), - "maximum_certificate_gap": _maximum(certificates), - } - - -def stress_markdown(report: StressValidationReport) -> str: - """Return the stable human-readable projection of a stress report.""" - lines = [ - f"# Core Solver Stress Validation: {report.status}", - "", - f"Profile: `{report.profile}`", - f"Cases: {report.case_count}", - f"Failures: {report.failure_count}", - "", - "| case | direction | family | expected | actual | result |", - "|---|---|---|---|---|---|", - ] - lines.extend( - "| " - f"{record.case_id} | {record.direction} | {record.family} | " - f"{record.expected_outcome} | {record.actual_outcome.value} | " - f"{record.result} |" - for record in report.cases - ) - return "\n".join((*lines, "")) - - -def _pixel_transform(scene: Scene): - xs = [point.x for point in scene.room_polygon_xy] - ys = [point.y for point in scene.room_polygon_xy] - scale = min( - (_WIDTH - 2 * _MARGIN) / (max(xs) - min(xs)), - (_HEIGHT - _TOP_MARGIN - _MARGIN) / (max(ys) - min(ys)), - ) - - def transform(point: tuple[float, float]) -> tuple[int, int]: - return ( - round(_MARGIN + (point[0] - min(xs)) * scale), - round(_HEIGHT - _MARGIN - (point[1] - min(ys)) * scale), - ) - - return transform - - -def _polygon_pixels(scene: Scene, object_id: str, transform) -> list[tuple[int, int]]: - footprint = obb_footprint(scene.object_by_id(object_id).obb) - return [transform((float(x), float(y))) for x, y in footprint.exterior.coords] - - -def render_stress_failure( - case: StressCase, - after: Scene | None, - record: FailedStressValidationRecord, -) -> Image.Image: - """Render deterministic endpoint geometry for one failed stress case.""" - before = case.scene - transform = _pixel_transform(before) - image = Image.new("RGB", (_WIDTH, _HEIGHT), "white") - draw = ImageDraw.Draw(image) - room = [transform((point.x, point.y)) for point in before.room_polygon_xy] - draw.polygon(room, fill="#F5F8FA", outline="#1D3557", width=4) - display = after if after is not None else before - subject_id = case.intervention.subject_id - for obj in sorted(display.objects, key=lambda item: item.object_id): - if obj.object_id == subject_id: - continue - draw.polygon( - _polygon_pixels(display, obj.object_id, transform), - fill="#B0BEC5", - outline="#455A64", - width=3, - ) - draw.line( - _polygon_pixels(before, subject_id, transform), - fill="#277DA1", - width=5, - joint="curve", - ) - if after is not None: - draw.line( - _polygon_pixels(after, subject_id, transform), - fill="#F94144", - width=5, - joint="curve", - ) - draw.text((30, 20), record.case_id, fill="#111827") - draw.text((30, 45), f"FAILED: {', '.join(record.errors)}", fill="#C1121F") - draw.text((30, 70), _PATH_DISCLAIMER, fill="#6A4C93") - return image - - -def _render_png_bytes( - case: StressCase, - after: Scene, - record: FailedStressValidationRecord, -) -> bytes: - stream = BytesIO() - render_stress_failure(case, after, record).save( - stream, - format="PNG", - optimize=False, - compress_level=9, - ) - return stream.getvalue() - - -def _solver_result_payload(item: StressValidationEvidence) -> dict[str, object]: - return stress_solver_result_payload(item.solve_result) - - -def _case_sha256(case: StressCase) -> str: - return hashlib.sha256( - canonical_json_bytes(case.model_dump(mode="json")) - ).hexdigest() - - -def _validate_evidence_binding( - report: StressValidationReport, - evidence: Sequence[StressValidationEvidence], -) -> None: - if len(report.cases) != len(evidence): - raise ValueError("stress report evidence membership mismatch") - for record, item in zip(report.cases, evidence, strict=True): - case = item.case - result = item.solve_result - expected_metadata = ( - case.case_id, - case.seed, - case.direction, - case.raw_slot, - case.family, - case.transform, - _case_sha256(case), - case.expected_outcome, - ) - observed_metadata = ( - record.case_id, - record.seed, - record.direction, - record.raw_slot, - record.family, - record.transform, - record.case_sha256, - record.expected_outcome, - ) - if ( - observed_metadata != expected_metadata - or case.scene.scene_id != case.case_id - ): - raise ValueError("stress report evidence case identity mismatch") - if ( - record.solver_result_digest != stress_solver_result_digest(result) - or record.after_scene_digest != stress_after_scene_digest(item.after_scene) - ): - raise ValueError("stress report evidence digest mismatch") - - rebuilt = rebuild_stress_validation_record( - case, - result, - item.after_scene, - ) - if rebuilt != record: - raise ValueError("stress report evidence validation record mismatch") - - after = item.after_scene - if isinstance(record, SatStressValidationRecord): - if after is None or result.subject_position is None: - raise ValueError("stress report evidence SAT replay mismatch") - before_subject = case.scene.object_by_id(case.intervention.subject_id) - after_subject = after.object_by_id(case.intervention.subject_id) - if ( - record.before_xy.x != before_subject.position.x - or record.before_xy.y != before_subject.position.y - or record.after_xy.x != after_subject.position.x - or record.after_xy.y != after_subject.position.y - ): - raise ValueError("stress report evidence SAT record mismatch") - elif isinstance(record, UnsatStressValidationRecord) and after is not None: - raise ValueError("stress report evidence UNSAT replay mismatch") - - if after is not None: - position = result.subject_position - if after.scene_id != case.scene.scene_id or position is None: - raise ValueError("stress report evidence after-scene identity mismatch") - replayed = after.object_by_id(case.intervention.subject_id).position - if replayed.x != position.x or replayed.y != position.y: - raise ValueError("stress report evidence after-scene identity mismatch") - - -def _relative_parts(relative: str) -> tuple[str, ...]: - value = PurePosixPath(relative) - if value.is_absolute() or ".." in value.parts: - raise RuntimeError("stress report entry path must stay relative") - return tuple(part for part in value.parts if part != ".") - - -def _open_directory_at(root_descriptor: int, relative: str = ".") -> int: - if not hasattr(os, "O_NOFOLLOW"): - raise RuntimeError("O_NOFOLLOW is required for stress report publication") - descriptor = os.dup(root_descriptor) - try: - for part in _relative_parts(relative): - child = os.open( - part, - os.O_RDONLY - | os.O_NOFOLLOW - | getattr(os, "O_DIRECTORY", 0) - | getattr(os, "O_CLOEXEC", 0), - dir_fd=descriptor, - ) - os.close(descriptor) - descriptor = child - result = os.fstat(descriptor) - if not stat.S_ISDIR(result.st_mode): - raise RuntimeError("stress report entry is not a directory") - return descriptor - except BaseException: - os.close(descriptor) - raise - - -def _open_parent_directory_at( - root_descriptor: int, - relative: str, -) -> tuple[int, str]: - parts = _relative_parts(relative) - if not parts: - raise RuntimeError("stress report file path is empty") - parent = "/".join(parts[:-1]) or "." - return _open_directory_at(root_descriptor, parent), parts[-1] - - -def _write_bytes(root_descriptor: int, relative: str, payload: bytes) -> None: - parent_descriptor, name = _open_parent_directory_at(root_descriptor, relative) - try: - descriptor = os.open( - name, - os.O_WRONLY - | os.O_CREAT - | os.O_EXCL - | os.O_NOFOLLOW - | getattr(os, "O_CLOEXEC", 0), - 0o600, - dir_fd=parent_descriptor, - ) - finally: - os.close(parent_descriptor) - try: - label = Path(relative) - _require_regular_single_link(os.fstat(descriptor), label) - view = memoryview(payload) - offset = 0 - while offset < len(view): - written = os.write(descriptor, view[offset:]) - if written <= 0: - raise OSError("stress report write made no progress") - offset += written - _fsync_file(descriptor) - _require_regular_single_link(os.fstat(descriptor), label) - finally: - os.close(descriptor) - - -def _read_bytes( - root_descriptor: int, - relative: str, - expected_identity: tuple[int, int, int], -) -> bytes: - parent_descriptor, name = _open_parent_directory_at(root_descriptor, relative) - try: - descriptor = os.open( - name, - os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0), - dir_fd=parent_descriptor, - ) - except BaseException: - os.close(parent_descriptor) - raise - try: - before = os.fstat(descriptor) - label = Path(relative) - _require_regular_single_link(before, label) - if ( - before.st_dev, - before.st_ino, - stat.S_IFMT(before.st_mode), - ) != expected_identity: - raise RuntimeError("stress report file identity changed before read") - chunks: list[bytes] = [] - while chunk := os.read(descriptor, 1024 * 1024): - chunks.append(chunk) - after = os.fstat(descriptor) - _require_regular_single_link(after, label) - if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): - raise RuntimeError("stress report file identity changed during read") - bound = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False) - if (bound.st_dev, bound.st_ino) != (after.st_dev, after.st_ino): - raise RuntimeError("stress report file pathname changed during read") - return b"".join(chunks) - finally: - os.close(descriptor) - os.close(parent_descriptor) - - -def _decode_json(payload: bytes) -> object: - return json.loads(payload.decode("utf-8")) - - -def _require_regular_single_link(result: os.stat_result, path: Path) -> None: - if not stat.S_ISREG(result.st_mode): - raise RuntimeError(f"stress report entry is not a regular file: {path.name}") - if result.st_nlink != 1: - raise RuntimeError(f"stress report file link count must be one: {path.name}") - - -def _fsync_file(descriptor: int) -> None: - os.fsync(descriptor) - - -def _fsync_tree_directory(root_descriptor: int, relative: str) -> None: - descriptor = _open_directory_at(root_descriptor, relative) - try: - before = os.fstat(descriptor) - os.fsync(descriptor) - after = os.fstat(descriptor) - if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): - raise RuntimeError("stress report directory identity changed during fsync") - finally: - os.close(descriptor) - - -def _fsync_directory(path: Path) -> None: - if not hasattr(os, "O_NOFOLLOW"): - raise RuntimeError("O_NOFOLLOW is required for stress report publication") - descriptor = os.open( - path, - os.O_RDONLY - | os.O_NOFOLLOW - | getattr(os, "O_DIRECTORY", 0) - | getattr(os, "O_CLOEXEC", 0), - ) - try: - before = os.fstat(descriptor) - if not stat.S_ISDIR(before.st_mode): - raise RuntimeError("stress report fsync target is not a directory") - os.fsync(descriptor) - after = os.fstat(descriptor) - if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): - raise RuntimeError("stress report directory identity changed during fsync") - finally: - os.close(descriptor) - - -def _write_json(root_descriptor: int, relative: str, value: object) -> None: - _write_bytes( - root_descriptor, - relative, - canonical_json_bytes(value, pretty=True), - ) - - -def _write_text(root_descriptor: int, relative: str, value: str) -> None: - _write_bytes(root_descriptor, relative, value.encode("utf-8")) - - -def _failure_entries( - report: StressValidationReport, - evidence: Sequence[StressValidationEvidence], -) -> tuple[set[str], set[str]]: - files = {"report.json", "report.md"} - directories: set[str] = set() - for record, item in zip(report.cases, evidence, strict=True): - if not isinstance(record, FailedStressValidationRecord): - continue - directories.update({"failures", f"failures/{record.case_id}"}) - prefix = f"failures/{record.case_id}" - files.update( - { - f"{prefix}/before.json", - f"{prefix}/checks.json", - f"{prefix}/intervention.json", - f"{prefix}/oracle.json", - f"{prefix}/replay.txt", - f"{prefix}/solver-result.json", - } - ) - if item.after_scene is not None: - files.update({f"{prefix}/after.json", f"{prefix}/topdown.png"}) - return files, directories - - -def _mkdir_directory_at(root_descriptor: int, relative: str) -> None: - parent_descriptor, name = _open_parent_directory_at(root_descriptor, relative) - try: - os.mkdir(name, 0o700, dir_fd=parent_descriptor) - created = os.stat(name, dir_fd=parent_descriptor, follow_symlinks=False) - if not stat.S_ISDIR(created.st_mode): - raise RuntimeError("stress report created entry is not a directory") - descriptor = os.open( - name, - os.O_RDONLY - | os.O_NOFOLLOW - | getattr(os, "O_DIRECTORY", 0) - | getattr(os, "O_CLOEXEC", 0), - dir_fd=parent_descriptor, - ) - try: - opened = os.fstat(descriptor) - if (created.st_dev, created.st_ino) != (opened.st_dev, opened.st_ino): - raise RuntimeError("stress report directory changed before open") - os.fchmod(descriptor, 0o700) - final = os.fstat(descriptor) - if ( - (final.st_dev, final.st_ino) != (opened.st_dev, opened.st_ino) - or stat.S_IMODE(final.st_mode) != 0o700 - ): - raise RuntimeError("stress report directory changed during fchmod") - finally: - os.close(descriptor) - finally: - os.close(parent_descriptor) - - -def _collect_tree_identity_manifest( - root_descriptor: int, -) -> tuple[dict[str, tuple[int, int, int]], set[str], set[str]]: - root_result = os.fstat(root_descriptor) - if not stat.S_ISDIR(root_result.st_mode): - raise RuntimeError("stress report root is not a directory") - identity_manifest = { - ".": ( - root_result.st_dev, - root_result.st_ino, - stat.S_IFMT(root_result.st_mode), - ) - } - observed_files: set[str] = set() - observed_directories: set[str] = set() - observed_inodes = {(root_result.st_dev, root_result.st_ino): "."} - - def visit(directory_descriptor: int, prefix: str) -> None: - for name in sorted(os.listdir(directory_descriptor)): - result = os.stat( - name, - dir_fd=directory_descriptor, - follow_symlinks=False, - ) - relative = f"{prefix}/{name}" if prefix else name - if stat.S_ISREG(result.st_mode): - _require_regular_single_link(result, Path(relative)) - observed_files.add(relative) - elif stat.S_ISDIR(result.st_mode): - observed_directories.add(relative) - else: - raise RuntimeError("stress report staging contains an unsafe entry") - inode = (result.st_dev, result.st_ino) - if inode in observed_inodes: - raise RuntimeError( - "stress report staging contains a duplicate inode: " - f"{observed_inodes[inode]} and {relative}" - ) - observed_inodes[inode] = relative - identity_manifest[relative] = ( - result.st_dev, - result.st_ino, - stat.S_IFMT(result.st_mode), - ) - if stat.S_ISDIR(result.st_mode): - child = os.open( - name, - os.O_RDONLY - | os.O_NOFOLLOW - | getattr(os, "O_DIRECTORY", 0) - | getattr(os, "O_CLOEXEC", 0), - dir_fd=directory_descriptor, - ) - try: - opened = os.fstat(child) - if (result.st_dev, result.st_ino) != ( - opened.st_dev, - opened.st_ino, - ): - raise RuntimeError( - "stress report directory identity changed during traversal" - ) - visit(child, relative) - finally: - os.close(child) - - visit(root_descriptor, "") - return identity_manifest, observed_files, observed_directories - - -def _verify_json( - root_descriptor: int, - relative: str, - expected: object, - identity_manifest: dict[str, tuple[int, int, int]], -) -> object: - expected_bytes = canonical_json_bytes(expected, pretty=True) - observed = _read_bytes( - root_descriptor, - relative, - identity_manifest[relative], - ) - if observed != expected_bytes: - raise RuntimeError( - f"stress report JSON payload mismatch: {PurePosixPath(relative).name}" - ) - return _decode_json(observed) - - -def _verify_tree( - root: Path, - report: StressValidationReport, - evidence: Sequence[StressValidationEvidence], - expected_identity_manifest: dict[str, tuple[int, int, int]] | None = None, - *, - root_descriptor: int, -) -> dict[str, tuple[int, int, int]]: - expected_files, expected_directories = _failure_entries(report, evidence) - identity_manifest, observed_files, observed_directories = ( - _collect_tree_identity_manifest(root_descriptor) - ) - if ( - observed_files != expected_files - or observed_directories != expected_directories - ): - raise RuntimeError("stress report staging file set mismatch") - - _verify_json( - root_descriptor, - "report.json", - _report_payload(report), - identity_manifest, - ) - markdown = _read_bytes( - root_descriptor, - "report.md", - identity_manifest["report.md"], - ) - if markdown != stress_markdown(report).encode("utf-8"): - raise RuntimeError("stress report Markdown payload mismatch") - markdown.decode("utf-8") - - for record, item in zip(report.cases, evidence, strict=True): - if not isinstance(record, FailedStressValidationRecord): - continue - case_root = f"failures/{record.case_id}" - before = _verify_json( - root_descriptor, - f"{case_root}/before.json", - item.case.scene.model_dump(mode="json"), - identity_manifest, - ) - Scene.model_validate(before) - intervention = _verify_json( - root_descriptor, - f"{case_root}/intervention.json", - item.case.intervention.model_dump(mode="json"), - identity_manifest, - ) - InterventionSpec.model_validate(intervention) - oracle = _verify_json( - root_descriptor, - f"{case_root}/oracle.json", - item.case.oracle.model_dump(mode="json"), - identity_manifest, - ) - oracle_type = ( - SatStressOracle - if isinstance(item.case.oracle, SatStressOracle) - else UnsatStressOracle - ) - oracle_type.model_validate(oracle) - _verify_json( - root_descriptor, - f"{case_root}/solver-result.json", - _solver_result_payload(item), - identity_manifest, - ) - _verify_json( - root_descriptor, - f"{case_root}/checks.json", - { - "case_id": record.case_id, - "checks": dict(record.checks), - "errors": record.errors, - }, - identity_manifest, - ) - replay_relative = f"{case_root}/replay.txt" - replay = _read_bytes( - root_descriptor, - replay_relative, - identity_manifest[replay_relative], - ) - expected_replay = ( - "PYTHONPATH=src .venv-core/bin/python scripts/stress_core_solver.py " - f"--case-id {record.case_id} " - f"--output artifacts/core-solver-stress-{record.case_id}\n" - ).encode() - if replay != expected_replay: - raise RuntimeError("stress report replay payload mismatch") - replay.decode("utf-8") - if item.after_scene is not None: - after = _verify_json( - root_descriptor, - f"{case_root}/after.json", - item.after_scene.model_dump(mode="json"), - identity_manifest, - ) - Scene.model_validate(after) - image_relative = f"{case_root}/topdown.png" - image_payload = _read_bytes( - root_descriptor, - image_relative, - identity_manifest[image_relative], - ) - expected_image = _render_png_bytes( - item.case, - item.after_scene, - record, - ) - if image_payload != expected_image: - raise RuntimeError("stress report PNG payload mismatch") - with Image.open(BytesIO(image_payload)) as image: - image.verify() - with Image.open(BytesIO(image_payload)) as image: - if image.mode != "RGB" or image.size != (_WIDTH, _HEIGHT): - raise RuntimeError("stress report image contract mismatch") - if ( - expected_identity_manifest is not None - and identity_manifest != expected_identity_manifest - ): - raise RuntimeError("stress report identity manifest mismatch") - return identity_manifest - - -def _created_staging_identity(path: Path) -> tuple[int, int]: - result = path.stat(follow_symlinks=False) - if not stat.S_ISDIR(result.st_mode): - raise RuntimeError("stress report staging is not a directory") - if stat.S_IMODE(result.st_mode) != 0o700: - raise RuntimeError("stress report staging is not private") - return (result.st_dev, result.st_ino) - - -def _open_staging_directory( - path: Path, - *, - expected_identity: tuple[int, int] | None = None, - make_private: bool = False, -) -> int: - if not hasattr(os, "O_NOFOLLOW"): - raise RuntimeError("O_NOFOLLOW is required for stress report publication") - descriptor = os.open( - path, - os.O_RDONLY - | os.O_NOFOLLOW - | getattr(os, "O_DIRECTORY", 0) - | getattr(os, "O_CLOEXEC", 0), - ) - try: - result = os.fstat(descriptor) - if not stat.S_ISDIR(result.st_mode): - raise RuntimeError("stress report staging is not a directory") - identity = (result.st_dev, result.st_ino) - if expected_identity is not None and identity != expected_identity: - raise RuntimeError("stress report staging identity changed before open") - if make_private: - os.fchmod(descriptor, 0o700) - result = os.fstat(descriptor) - if ( - (result.st_dev, result.st_ino) != identity - or stat.S_IMODE(result.st_mode) != 0o700 - ): - raise RuntimeError("stress report staging changed during fchmod") - return descriptor - except BaseException: - os.close(descriptor) - raise - - -def _staging_identity(path: Path) -> tuple[int, int]: - descriptor = _open_staging_directory(path) - try: - result = os.fstat(descriptor) - return (result.st_dev, result.st_ino) - finally: - os.close(descriptor) - - -def _has_directory_identity(path: Path, identity: tuple[int, int]) -> bool: - try: - return _staging_identity(path) == identity - except (OSError, RuntimeError): - return False - - -def _write_tree( - root: Path, - report: StressValidationReport, - evidence: Sequence[StressValidationEvidence], - *, - root_descriptor: int, -) -> None: - _write_json(root_descriptor, "report.json", _report_payload(report)) - _write_text(root_descriptor, "report.md", stress_markdown(report)) - failed = { - record.case_id: (record, item) - for record, item in zip(report.cases, evidence, strict=True) - if isinstance(record, FailedStressValidationRecord) - } - if failed: - _mkdir_directory_at(root_descriptor, "failures") - for case_id, (record, item) in failed.items(): - case_root = f"failures/{case_id}" - _mkdir_directory_at(root_descriptor, case_root) - _write_json( - root_descriptor, - f"{case_root}/before.json", - item.case.scene.model_dump(mode="json"), - ) - _write_json( - root_descriptor, - f"{case_root}/intervention.json", - item.case.intervention.model_dump(mode="json"), - ) - _write_json( - root_descriptor, - f"{case_root}/oracle.json", - item.case.oracle.model_dump(mode="json"), - ) - _write_json( - root_descriptor, - f"{case_root}/solver-result.json", - _solver_result_payload(item), - ) - _write_json( - root_descriptor, - f"{case_root}/checks.json", - { - "case_id": case_id, - "checks": dict(record.checks), - "errors": record.errors, - }, - ) - _write_text( - root_descriptor, - f"{case_root}/replay.txt", - "PYTHONPATH=src .venv-core/bin/python " - "scripts/stress_core_solver.py " - f"--case-id {case_id} " - f"--output artifacts/core-solver-stress-{case_id}\n", - ) - if item.after_scene is not None: - _write_json( - root_descriptor, - f"{case_root}/after.json", - item.after_scene.model_dump(mode="json"), - ) - _write_bytes( - root_descriptor, - f"{case_root}/topdown.png", - _render_png_bytes(item.case, item.after_scene, record), - ) - - -def publish_stress_report( - report: StressValidationReport, - evidence: Sequence[StressValidationEvidence], - output: Path, -) -> Path: - """Atomically publish one closed deterministic stress report tree.""" - _validate_evidence_binding(report, evidence) - if os.path.lexists(output): - raise FileExistsError(output) - output.parent.mkdir(parents=True, exist_ok=True) - staging = Path( - tempfile.mkdtemp( - prefix=f".{output.name}.tmp-", - dir=output.parent, - ) - ) - published = False - identity: tuple[int, int] | None = None - identity_manifest: dict[str, tuple[int, int, int]] | None = None - staging_descriptor: int | None = None - try: - identity = _created_staging_identity(staging) - staging_descriptor = _open_staging_directory( - staging, - expected_identity=identity, - make_private=True, - ) - _write_tree( - staging, - report, - evidence, - root_descriptor=staging_descriptor, - ) - identity_manifest = _verify_tree( - staging, - report, - evidence, - root_descriptor=staging_descriptor, - ) - failure_directories = sorted( - ( - relative - for relative, (_, _, entry_type) in identity_manifest.items() - if relative != "." and stat.S_ISDIR(entry_type) - ), - key=lambda relative: ( - -len(PurePosixPath(relative).parts), - relative, - ), - ) - for relative in failure_directories: - _fsync_tree_directory(staging_descriptor, relative) - _fsync_tree_directory(staging_descriptor, ".") - _fsync_directory(output.parent) - _verify_tree( - staging, - report, - evidence, - identity_manifest, - root_descriptor=staging_descriptor, - ) - if not _has_directory_identity(staging, identity): - raise RuntimeError("stress report staging ownership changed") - _rename_no_replace(staging, output) - if not _has_directory_identity(output, identity): - raise RuntimeError("stress report output identity mismatch after rename") - published = True - _verify_tree( - output, - report, - evidence, - identity_manifest, - root_descriptor=staging_descriptor, - ) - if not _has_directory_identity(output, identity): - raise RuntimeError("stress report output identity changed after verification") - try: - _fsync_directory(output.parent) - except OSError as error: - raise StressReportDurabilityError(output) from error - except StressReportPublicationError: - raise - except BaseException as error: - detail = str(error) or type(error).__name__ - recovery_staging = None - if not published and identity is not None: - if _has_directory_identity(output, identity): - published = True - recovery_staging = None - try: - if identity_manifest is None or staging_descriptor is None: - raise RuntimeError( - "stress report verified tree state is unavailable" - ) - _verify_tree( - output, - report, - evidence, - identity_manifest, - root_descriptor=staging_descriptor, - ) - if not _has_directory_identity(output, identity): - raise RuntimeError( - "stress report output identity changed after verification" - ) - except (OSError, RuntimeError, ValueError) as verification_error: - verification_detail = ( - str(verification_error) or type(verification_error).__name__ - ) - detail = ( - f"{detail}; post-rename verification failed: " - f"{verification_detail}" - ) - elif _has_directory_identity(staging, identity): - recovery_staging = staging - raise StressReportPublicationError( - output, - published=published, - staging_path=recovery_staging, - detail=detail, - ) from error - finally: - if staging_descriptor is not None: - os.close(staging_descriptor) - return output diff --git a/src/spatialcf/solver/stress/validation.py b/src/spatialcf/solver/stress/validation.py deleted file mode 100644 index c9993ab..0000000 --- a/src/spatialcf/solver/stress/validation.py +++ /dev/null @@ -1,1012 +0,0 @@ -"""Run production stress solves through independent acceptance checks.""" - -from __future__ import annotations - -import hashlib -import math -from collections import Counter -from collections.abc import Iterator, Mapping -from dataclasses import asdict -from typing import Any, Literal - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - field_serializer, - field_validator, - model_validator, -) -from shapely.geometry import Polygon - -from spatialcf.data.artifacts import canonical_json_bytes -from spatialcf.domain.enums import QualityTier, SolverStatus -from spatialcf.domain.models import Scene, Vec2 -from spatialcf.geometry.obb import inside_room, obb_footprint -from spatialcf.relations.engine import RelationEngine -from spatialcf.solver import CertifiedSolverConfig, CertifiedSpatialCFSolver -from spatialcf.solver.analytic_motion import ( - AnalyticMotionModel, - CandidateProjectionError, -) -from spatialcf.solver.certified_models import CertifiedSolveResult, expected_target_diff -from spatialcf.solver.stress.cases import ( - _first_valid_draft, - _placeholder_digest, - generate_stress_cases, - replay_stress_case, -) -from spatialcf.solver.stress.models import ( - SatStressOracle, - StressCase, - StressDirection, - StressFamily, - StressProfileName, - StressTransform, - UnsatStressOracle, -) -from spatialcf.solver.stress.profiles import ( - DEEP_SEEDS, - QUICK_SEEDS, - SAT_FAMILIES, - STRESS_DIRECTIONS, - UNSAT_FAMILIES, - stress_slots, -) -from spatialcf.solver.validation import CertificateRecord -from spatialcf.verification.verifier import Verifier - -SAT_CHECKS = ( - "source_relation_satisfied", - "solver_success", - "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", - "inside_room", - "collision_free", - "support_footprint_valid", - "support_vertical_contact", - "result_position_matches_after", - "oracle_witness_reached", - "realized_within_tolerance", - "certificate_consistent", - "certificate_gap_closed", - "oracle_infimum_bracketed", - "transform_invariants", -) - -UNSAT_CHECKS = ( - "source_relation_satisfied", - "independent_bound_strict", - "solver_proved_unsat", - "quality_rejected", - "reason_exact", - "position_absent", - "score_absent", - "certificate_absent", - "transform_invariants", -) - -_STRUCTURE_TOLERANCE = 1e-9 -_ORACLE_ROUNDING_ERROR_M = 0.5e-12 -_NUMERIC_ERROR_BOUND_M = 1e-9 - - -class _FrozenValidationModel(BaseModel): - model_config = ConfigDict(frozen=True, extra="forbid") - - -class FrozenCheckMap(Mapping[str, bool]): - """Insertion-ordered immutable mapping with ordinary JSON serialization.""" - - __slots__ = ("_items",) - - def __init__(self, values: Mapping[str, bool]) -> None: - object.__setattr__(self, "_items", tuple(values.items())) - - def __setattr__(self, name: str, value: object) -> None: - if hasattr(self, name): - raise AttributeError("FrozenCheckMap is immutable") - object.__setattr__(self, name, value) - - def __delattr__(self, name: str) -> None: - raise AttributeError("FrozenCheckMap is immutable") - - def __getitem__(self, key: str) -> bool: - for candidate, value in self._items: - if candidate == key: - return value - raise KeyError(key) - - def __iter__(self) -> Iterator[str]: - return (key for key, _ in self._items) - - def __len__(self) -> int: - return len(self._items) - - def __repr__(self) -> str: - return repr(dict(self._items)) - - -class _FrozenChecksModel(_FrozenValidationModel): - solver_result_digest: str = Field(pattern=r"^[0-9a-f]{64}$") - after_scene_digest: str | None = Field( - default=None, - pattern=r"^[0-9a-f]{64}$", - ) - checks: Mapping[str, bool] - - @field_validator("checks", mode="after") - @classmethod - def freeze_checks(cls, value: Mapping[str, bool]) -> FrozenCheckMap: - return FrozenCheckMap(value) - - @field_serializer("checks") - def serialize_checks(self, value: Mapping[str, bool]) -> dict[str, bool]: - return dict(value.items()) - - -class StressValidationError(ValueError): - """One stress result failed its independent acceptance contract.""" - - def __init__( - self, - message: str, - *, - failed_checks: tuple[str, ...] = (), - checks: dict[str, bool] | None = None, - ) -> None: - super().__init__(message) - self.failed_checks = failed_checks - self.checks = dict(checks or {}) - - -class FailedStressValidationRecord(_FrozenChecksModel): - """Deterministic diagnostics for one controlled validation failure.""" - - case_id: str = Field(pattern=r"^stress-\d{10}-(?:lr|fb|nf)-\d{3}$") - 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 - case_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - expected_outcome: Literal["SAT", "UNSAT"] - actual_outcome: SolverStatus - result: Literal["FAIL"] = "FAIL" - quality: QualityTier - reason: str | None - certificate: CertificateRecord | None - errors: tuple[str, ...] = Field(min_length=1) - - @model_validator(mode="after") - def validate_failed_checks(self) -> FailedStressValidationRecord: - failed = tuple(name for name, passed in self.checks.items() if not passed) - if failed != self.errors: - raise ValueError("failure errors must equal failed checks in order") - return self - - -class SatStressValidationRecord(_FrozenChecksModel): - """Passing independent evidence for one expected-SAT stress case.""" - - case_id: str = Field(pattern=r"^stress-\d{10}-(?:lr|fb|nf)-\d{3}$") - 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 - case_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - expected_outcome: Literal["SAT"] = "SAT" - actual_outcome: SolverStatus - result: Literal["PASS"] = "PASS" - quality: QualityTier - reason: None = None - certificate: CertificateRecord - errors: tuple[str, ...] = () - before_xy: Vec2 - after_xy: Vec2 - exact_infimum_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - exact_infimum_points: tuple[Vec2, ...] = Field(min_length=1) - 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) - witness_error_m: float = Field(ge=0.0, allow_inf_nan=False, strict=True) - verifier_status: SolverStatus - leakage_count: int = Field(ge=0, strict=True) - changed_relations: tuple[str, ...] - - @model_validator(mode="after") - def validate_pass_contract(self) -> SatStressValidationRecord: - if ( - tuple(self.checks) != SAT_CHECKS - or not all(self.checks.values()) - or self.errors - ): - raise ValueError("SAT record requires every stress check") - return self - - -class UnsatStressValidationRecord(_FrozenChecksModel): - """Passing independent evidence for one expected-UNSAT stress case.""" - - case_id: str = Field(pattern=r"^stress-\d{10}-(?:lr|fb|nf)-\d{3}$") - 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 - case_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") - expected_outcome: Literal["UNSAT"] = "UNSAT" - actual_outcome: SolverStatus - result: Literal["PASS"] = "PASS" - quality: QualityTier - reason: str - certificate: None = None - errors: tuple[str, ...] = () - 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) - - @model_validator(mode="after") - def validate_pass_contract(self) -> UnsatStressValidationRecord: - if ( - tuple(self.checks) != UNSAT_CHECKS - or not all(self.checks.values()) - or self.errors - ): - raise ValueError("UNSAT record requires every stress check") - return self - - -StressValidationRecord = ( - SatStressValidationRecord - | UnsatStressValidationRecord - | FailedStressValidationRecord -) - - -class StressValidationReport(_FrozenValidationModel): - """Deterministic aggregate result for one requested stress run.""" - - schema_version: Literal[1] = 1 - profile: StressProfileName - requested_case_id: str | None = Field( - default=None, - pattern=r"^stress-\d{10}-(?:lr|fb|nf)-\d{3}$", - ) - status: Literal["PASS", "FAIL"] - optimality_tolerance_m: Literal[1e-6] = 1e-6 - seeds: tuple[int, ...] = Field(min_length=1) - case_count: int = Field(ge=1, strict=True) - failure_count: int = Field(ge=0, strict=True) - cases: tuple[StressValidationRecord, ...] = Field(min_length=1) - - def failure_summary(self) -> str: - """Return deterministic concise diagnostics for every failed case.""" - return "; ".join( - f"{record.case_id}: {record.reason or ','.join(record.errors)}" - for record in self.cases - if isinstance(record, FailedStressValidationRecord) - ) - - @model_validator(mode="before") - @classmethod - def derive_counts(cls, value: Any) -> Any: - if not isinstance(value, dict): - return value - cases = value.get("cases") - if not isinstance(cases, (list, tuple)): - return value - expected_cases = len(cases) - expected_failures = sum( - (item.get("result") if isinstance(item, dict) else item.result) == "FAIL" - for item in cases - ) - for name, expected in ( - ("case_count", expected_cases), - ("failure_count", expected_failures), - ): - if name in value and value[name] != expected: - raise ValueError(f"stress report {name} mismatch") - return value | { - "case_count": expected_cases, - "failure_count": expected_failures, - } - - @model_validator(mode="after") - def validate_closed_report(self) -> StressValidationReport: - expected_status = "PASS" if self.failure_count == 0 else "FAIL" - if self.status != expected_status: - raise ValueError("stress report status mismatch") - case_ids = tuple(record.case_id for record in self.cases) - if len(case_ids) != len(set(case_ids)): - raise ValueError("stress report case IDs must be unique") - if self.requested_case_id is not None and case_ids != (self.requested_case_id,): - raise ValueError("single-case stress report identity mismatch") - observed_seeds = tuple(dict.fromkeys(record.seed for record in self.cases)) - if self.seeds != observed_seeds: - raise ValueError("stress report seed order mismatch") - if self.requested_case_id is None: - expected_seeds = QUICK_SEEDS if self.profile == "quick" else DEEP_SEEDS - if self.seeds != expected_seeds: - raise ValueError("stress profile seeds mismatch") - expected_identity = tuple( - (seed, direction, f"stress-{seed}-{direction}-{index:03d}") - for seed in expected_seeds - for direction in STRESS_DIRECTIONS - for index in range(100) - ) - observed_identity = tuple( - (record.seed, record.direction, record.case_id) for record in self.cases - ) - if len(self.cases) != len(expected_identity): - raise ValueError("stress profile case count mismatch") - if observed_identity != expected_identity: - raise ValueError("stress profile case order mismatch") - expected_coverage = Counter( - { - **{(family, "SAT"): 12 for family in SAT_FAMILIES}, - **{(family, "UNSAT"): 10 for family in UNSAT_FAMILIES}, - } - ) - for offset in range(0, len(self.cases), 100): - seed, direction, _ = expected_identity[offset] - coverage = Counter( - (record.family, record.expected_outcome) - for record in self.cases[offset : offset + 100] - ) - if coverage != expected_coverage: - raise ValueError("stress profile family/outcome coverage mismatch") - expected_slots = Counter( - (slot.raw_slot, slot.family, slot.expected_outcome) - for slot in stress_slots(self.profile) - if slot.seed == seed and slot.direction == direction - ) - observed_slots = Counter( - (record.raw_slot, record.family, record.expected_outcome) - for record in self.cases[offset : offset + 100] - ) - if observed_slots != expected_slots: - raise ValueError("stress profile raw-slot schedule mismatch") - return self - - -class StressValidationEvidence(_FrozenValidationModel): - """In-memory inputs and outputs retained for later publication.""" - - case: StressCase - solve_result: CertifiedSolveResult - after_scene: Scene | None - - -def _case_sha256(case: StressCase) -> str: - return hashlib.sha256( - canonical_json_bytes(case.model_dump(mode="json")) - ).hexdigest() - - -def _canonical_digest_value(value: object) -> object: - if isinstance(value, float) and not math.isfinite(value): - if math.isnan(value): - label = "nan" - elif value > 0.0: - label = "+infinity" - else: - label = "-infinity" - return {"non_finite_float": label} - if isinstance(value, dict): - return { - str(key): _canonical_digest_value(item) - for key, item in value.items() - } - if isinstance(value, (list, tuple)): - return [_canonical_digest_value(item) for item in value] - return value - - -def stress_solver_result_payload( - solve_result: CertifiedSolveResult, -) -> dict[str, object]: - """Return a total canonical JSON payload, including malformed numerics.""" - payload = { - "status": solve_result.status.value, - "subject_position": ( - solve_result.subject_position.model_dump(mode="json") - if solve_result.subject_position is not None - else None - ), - "score": asdict(solve_result.score) if solve_result.score is not None else None, - "quality": solve_result.quality.value, - "evaluated_candidates": solve_result.evaluated_candidates, - "reason": solve_result.reason, - "certificate": ( - asdict(solve_result.certificate) - if solve_result.certificate is not None - else None - ), - } - normalized = _canonical_digest_value(payload) - assert isinstance(normalized, dict) - return normalized - - -def stress_solver_result_digest(solve_result: CertifiedSolveResult) -> str: - """Bind every solver-result field to one deterministic SHA-256 digest.""" - return hashlib.sha256( - canonical_json_bytes(stress_solver_result_payload(solve_result)) - ).hexdigest() - - -def stress_after_scene_digest(after_scene: Scene | None) -> str | None: - """Bind a retained replay scene, while preserving absence as ``None``.""" - if after_scene is None: - return None - payload = _canonical_digest_value(after_scene.model_dump(mode="json")) - return hashlib.sha256(canonical_json_bytes(payload)).hexdigest() - - -def _record_evidence_digests( - solve_result: CertifiedSolveResult, - after_scene: Scene | None, -) -> dict[str, object]: - return { - "solver_result_digest": stress_solver_result_digest(solve_result), - "after_scene_digest": stress_after_scene_digest(after_scene), - } - - -def _record_metadata(case: StressCase) -> dict[str, object]: - return { - "case_id": case.case_id, - "seed": case.seed, - "direction": case.direction, - "raw_slot": case.raw_slot, - "family": case.family, - "transform": case.transform, - "case_sha256": _case_sha256(case), - } - - -def _source_relation_satisfied(case: StressCase) -> bool: - spec = case.intervention - observed = RelationEngine().observe( - case.scene, - spec.subject_id, - spec.reference_id, - spec.relation_before, - spec.camera_id, - ) - return observed.status is SolverStatus.SUCCESS and observed.satisfied - - -def _transform_invariants(case: StressCase) -> bool: - slot = next( - ( - slot - for slot in stress_slots("deep") - if slot.seed == case.seed - and slot.direction == case.direction - and slot.raw_slot == case.raw_slot - and slot.family == case.family - and slot.expected_outcome == case.expected_outcome - ), - None, - ) - if slot is None: - return False - expected = _first_valid_draft(slot) - return _placeholder_digest(expected) == _placeholder_digest(case.as_draft()) - - -def _certificate_consistency( - solve_result: CertifiedSolveResult, -) -> tuple[bool, float]: - certificate = solve_result.certificate - if certificate is None: - return False, math.inf - values = ( - certificate.distance_lower_bound, - certificate.distance_upper_bound, - certificate.optimality_gap, - certificate.radial_geometry_error, - certificate.numeric_error_bound, - ) - if not all( - type(value) in {int, float} and math.isfinite(value) - for value in values - ): - return False, math.inf - recomputed_gap = ( - certificate.distance_upper_bound - - certificate.distance_lower_bound - + 2.0 * _NUMERIC_ERROR_BOUND_M - ) - consistent = ( - certificate.distance_lower_bound >= 0.0 - and certificate.distance_upper_bound >= certificate.distance_lower_bound - and certificate.radial_geometry_error >= 0.0 - and certificate.numeric_error_bound == _NUMERIC_ERROR_BOUND_M - and certificate.optimality_gap == recomputed_gap - and type(certificate.disk_segments) is int - and 128 <= certificate.disk_segments <= 8192 - and certificate.disk_segments & (certificate.disk_segments - 1) == 0 - and type(certificate.infimum_only) is bool - ) - return consistent, recomputed_gap - - -def _certificate_record(solve_result: CertifiedSolveResult) -> CertificateRecord: - certificate = solve_result.certificate - assert certificate is not None - return 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, - ) - - -def _safe_certificate_record( - solve_result: CertifiedSolveResult, -) -> CertificateRecord | None: - certificate = solve_result.certificate - if certificate is None: - return None - scalar_values = ( - certificate.distance_lower_bound, - certificate.distance_upper_bound, - certificate.optimality_gap, - certificate.radial_geometry_error, - certificate.numeric_error_bound, - ) - if ( - not all( - type(value) in {int, float} and math.isfinite(value) and value >= 0.0 - for value in scalar_values - ) - or type(certificate.disk_segments) is not int - or certificate.disk_segments < 4 - or type(certificate.infimum_only) is not bool - ): - return None - return _certificate_record(solve_result) - - -def _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 _top(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 _collision_free(case: StressCase, after: Scene) -> bool: - spec = case.intervention - subject = after.object_by_id(spec.subject_id) - support_id = subject.support_object_id - subject_bottom = _bottom(after, spec.subject_id) - subject_top = _top(after, spec.subject_id) - footprint = obb_footprint(subject.obb) - for stationary in after.objects: - if stationary.object_id in {spec.subject_id, support_id}: - continue - vertical_overlap = min(subject_top, _top(after, stationary.object_id)) - max( - subject_bottom, - _bottom(after, stationary.object_id), - ) - if ( - vertical_overlap > _STRUCTURE_TOLERANCE - and footprint.intersection(obb_footprint(stationary.obb)).area - > _STRUCTURE_TOLERANCE - ): - return False - return True - - -def _support_checks(case: StressCase, after: Scene) -> tuple[bool, bool]: - spec = case.intervention - before_subject = case.scene.object_by_id(spec.subject_id) - after_subject = after.object_by_id(spec.subject_id) - support_id = before_subject.support_object_id - if support_id is None: - return True, True - try: - support_before = case.scene.object_by_id(support_id) - support_after = after.object_by_id(support_id) - except KeyError: - return False, False - footprint_valid = obb_footprint(support_before.obb).buffer( - _STRUCTURE_TOLERANCE - ).covers(obb_footprint(before_subject.obb)) and obb_footprint( - support_after.obb - ).buffer(_STRUCTURE_TOLERANCE).covers(obb_footprint(after_subject.obb)) - vertical_contact = math.isclose( - _bottom(case.scene, spec.subject_id), - _top(case.scene, support_id), - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) and math.isclose( - _bottom(after, spec.subject_id), - _top(after, support_id), - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) - return footprint_valid, vertical_contact - - -def _raise_validation_failure( - case: StressCase, - ordered_checks: tuple[str, ...], - checks: dict[str, bool], -) -> None: - failed = tuple(name for name in ordered_checks if not checks[name]) - if failed: - raise StressValidationError( - f"{case.case_id}: failed checks: {', '.join(failed)}", - failed_checks=failed, - checks=checks, - ) - - -def validate_stress_sat( - case: StressCase, - solve_result: CertifiedSolveResult, - after: Scene, -) -> SatStressValidationRecord: - """Validate an expected-SAT result independently of solver geometry.""" - oracle = case.oracle - if not isinstance(oracle, SatStressOracle): - raise StressValidationError( - f"{case.case_id}: expected a SAT oracle", - failed_checks=("solver_success",), - ) - spec = case.intervention - before = case.scene - before_subject = before.object_by_id(spec.subject_id) - try: - after_subject = after.object_by_id(spec.subject_id) - except KeyError as exc: - raise StressValidationError( - f"{case.case_id}: subject missing from after scene", - failed_checks=("only_subject_xy_changed",), - ) from exc - certificate = solve_result.certificate - 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 position is not None - and certificate is not None - and all( - math.isfinite(value) - for value in ( - position.x if position is not None else math.inf, - position.y if position is not None else math.inf, - position.z if position is not None else math.inf, - ) - ) - ) - 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 - center_dx = after_subject.obb.center.x - before_subject.obb.center.x - center_dy = after_subject.obb.center.y - before_subject.obb.center.y - only_subject_xy = ( - before_ids == after_ids - and math.isclose( - position_dx, - center_dx, - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) - and math.isclose( - position_dy, - center_dy, - rel_tol=0.0, - abs_tol=_STRUCTURE_TOLERANCE, - ) - ) - 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 - ) - support_footprint_valid, support_vertical_contact = _support_checks(case, after) - room = Polygon([(point.x, point.y) for point in before.room_polygon_xy]) - realized_displacement = math.hypot(position_dx, position_dy) - realized_error = abs(realized_displacement - oracle.exact_infimum_m) - witness_error = min( - math.hypot( - after_subject.position.x - witness.x, - after_subject.position.y - witness.y, - ) - for witness in oracle.exact_infimum_points - ) - certificate_consistent, recomputed_gap = _certificate_consistency(solve_result) - checks = { - "source_relation_satisfied": _source_relation_satisfied(case), - "solver_success": solver_success, - "verifier_success": ( - verification.status is SolverStatus.SUCCESS - and verification.quality is QualityTier.PURE - and verification.leakage_count == 0 - ), - "relation_diff_exact": ( - verification.changed_relations == expected_target_diff(spec) - ), - "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, - "inside_room": inside_room(after_subject.obb, room), - "collision_free": _collision_free(case, after), - "support_footprint_valid": support_footprint_valid, - "support_vertical_contact": support_vertical_contact, - "result_position_matches_after": ( - position is not None and position == after_subject.position - ), - "oracle_witness_reached": witness_error <= 1e-6, - "realized_within_tolerance": realized_error <= 1e-6, - "certificate_consistent": certificate_consistent, - "certificate_gap_closed": certificate_consistent and recomputed_gap <= 1e-6, - "oracle_infimum_bracketed": ( - certificate_consistent - and certificate is not None - and certificate.distance_lower_bound - - certificate.numeric_error_bound - - _ORACLE_ROUNDING_ERROR_M - <= oracle.exact_infimum_m - <= certificate.distance_upper_bound - + certificate.numeric_error_bound - + _ORACLE_ROUNDING_ERROR_M - ), - "transform_invariants": _transform_invariants(case), - } - _raise_validation_failure(case, SAT_CHECKS, checks) - assert certificate is not None - return SatStressValidationRecord( - **_record_metadata(case), - **_record_evidence_digests(solve_result, after), - actual_outcome=solve_result.status, - quality=solve_result.quality, - certificate=_certificate_record(solve_result), - checks=checks, - 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_m=oracle.exact_infimum_m, - exact_infimum_points=oracle.exact_infimum_points, - realized_displacement_m=realized_displacement, - realized_error_m=realized_error, - witness_error_m=witness_error, - verifier_status=verification.status, - leakage_count=verification.leakage_count, - changed_relations=verification.changed_relations, - ) - - -def validate_stress_unsat( - case: StressCase, - solve_result: CertifiedSolveResult, -) -> UnsatStressValidationRecord: - """Accept only a real UNSAT proof with the frozen independent bound.""" - oracle = case.oracle - if not isinstance(oracle, UnsatStressOracle): - raise StressValidationError( - f"{case.case_id}: expected an UNSAT oracle", - failed_checks=("solver_proved_unsat",), - ) - checks = { - "source_relation_satisfied": _source_relation_satisfied(case), - "independent_bound_strict": ( - oracle.maximum_possible_value_m < oracle.required_value_m - ), - "solver_proved_unsat": (solve_result.status is SolverStatus.UNSATISFIABLE), - "quality_rejected": solve_result.quality is QualityTier.REJECTED, - "reason_exact": solve_result.reason == oracle.expected_reason, - "position_absent": solve_result.subject_position is None, - "score_absent": solve_result.score is None, - "certificate_absent": solve_result.certificate is None, - "transform_invariants": _transform_invariants(case), - } - _raise_validation_failure(case, UNSAT_CHECKS, checks) - assert solve_result.reason is not None - return UnsatStressValidationRecord( - **_record_metadata(case), - **_record_evidence_digests(solve_result, None), - actual_outcome=solve_result.status, - quality=solve_result.quality, - reason=solve_result.reason, - checks=checks, - maximum_possible_value_m=oracle.maximum_possible_value_m, - required_value_m=oracle.required_value_m, - ) - - -def _failed_record( - case: StressCase, - solve_result: CertifiedSolveResult, - failure: StressValidationError, - after_scene: Scene | None, -) -> FailedStressValidationRecord: - failed_checks = failure.failed_checks or ("validation_failed",) - checks = dict(failure.checks) - for name in failed_checks: - checks[name] = False - errors = tuple(name for name, passed in checks.items() if not passed) - return FailedStressValidationRecord( - **_record_metadata(case), - **_record_evidence_digests(solve_result, after_scene), - expected_outcome=case.expected_outcome, - actual_outcome=solve_result.status, - quality=solve_result.quality, - reason=solve_result.reason, - certificate=_safe_certificate_record(solve_result), - checks=checks, - errors=errors, - ) - - -def rebuild_stress_validation_record( - case: StressCase, - solve_result: CertifiedSolveResult, - after_scene: Scene | None, -) -> StressValidationRecord: - """Rebuild one Task 7 record deterministically without invoking a solver.""" - try: - if isinstance(case.oracle, SatStressOracle): - position = solve_result.subject_position - if ( - position is None - or after_scene is None - or not all( - math.isfinite(value) - for value in (position.x, position.y, position.z) - ) - ): - raise StressValidationError( - f"{case.case_id}: solver did not retain a finite replay", - failed_checks=("solver_success",), - checks={"solver_success": False}, - ) - return validate_stress_sat(case, solve_result, after_scene) - return validate_stress_unsat(case, solve_result) - except StressValidationError as failure: - return _failed_record(case, solve_result, failure, after_scene) - - -def _stress_solver_config() -> CertifiedSolverConfig: - return CertifiedSolverConfig( - optimality_tolerance=1e-6, - numeric_tolerance=_NUMERIC_ERROR_BOUND_M, - target_interior_margin=5e-7, - initial_disk_segments=128, - max_disk_segments=8192, - timeout_seconds=5.0, - ) - - -def _evaluate_stress_cases( - cases: tuple[StressCase, ...], - solver: CertifiedSpatialCFSolver, -) -> tuple[tuple[StressValidationRecord, ...], tuple[StressValidationEvidence, ...]]: - """Evaluate an already-closed case sequence through an injected solver.""" - motion = AnalyticMotionModel() - records: list[StressValidationRecord] = [] - evidence: list[StressValidationEvidence] = [] - - for case in cases: - solve_result = solver.solve(case.scene, case.intervention) - after: Scene | None = None - try: - if isinstance(case.oracle, SatStressOracle): - position = solve_result.subject_position - if position is None or not all( - math.isfinite(value) - for value in (position.x, position.y, position.z) - ): - raise StressValidationError( - f"{case.case_id}: solver did not return a finite position", - failed_checks=("solver_success",), - checks={"solver_success": False}, - ) - after = motion.with_object_xy( - case.scene, - case.intervention.subject_id, - position.x, - position.y, - ) - record: StressValidationRecord = validate_stress_sat( - case, - solve_result, - after, - ) - else: - record = validate_stress_unsat(case, solve_result) - except CandidateProjectionError: - failure = StressValidationError( - f"{case.case_id}: analytic replay failed", - failed_checks=("solver_success",), - checks={"solver_success": False}, - ) - record = _failed_record(case, solve_result, failure, after) - except StressValidationError as failure: - record = _failed_record(case, solve_result, failure, after) - records.append(record) - evidence.append( - StressValidationEvidence( - case=case, - solve_result=solve_result, - after_scene=after, - ) - ) - return tuple(records), tuple(evidence) - - -def run_stress_suite( - profile: StressProfileName, - case_id: str | None = None, -) -> tuple[StressValidationReport, tuple[StressValidationEvidence, ...]]: - """Run the requested stress cases and retain deterministic evidence.""" - cases = ( - (replay_stress_case(case_id),) - if case_id is not None - else generate_stress_cases(profile) - ) - solver = CertifiedSpatialCFSolver(_stress_solver_config()) - records, evidence = _evaluate_stress_cases(cases, solver) - - status: Literal["PASS", "FAIL"] = ( - "PASS" if all(record.result == "PASS" for record in records) else "FAIL" - ) - report = StressValidationReport( - profile=profile, - requested_case_id=case_id, - status=status, - seeds=tuple(dict.fromkeys(case.seed for case in cases)), - cases=records, - ) - return report, evidence diff --git a/src/spatialcf/solver/validation_report.py b/src/spatialcf/solver/validation_report.py deleted file mode 100644 index ed61f8e..0000000 --- a/src/spatialcf/solver/validation_report.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Human-auditable publication for independent core solver validation.""" - -from __future__ import annotations - -import json -import math -from pathlib import Path -from typing import Mapping - -from PIL import Image, ImageDraw - -from spatialcf.data.artifacts import canonical_json_bytes -from spatialcf.domain.models import Scene -from spatialcf.geometry.obb import obb_footprint -from spatialcf.solver.validation import ( - CaseValidationRecord, - CoreValidationError, - ValidationReport, -) - - -_WIDTH = 1000 -_HEIGHT = 800 -_MARGIN = 60 -_TOP_MARGIN = 120 -_CLAIM_BOUNDARY = ( - "Passing this package supports only the claim that the certified solver " - "works on three transparent Canonical Scene problems and returns " - "independently verified motions within its declared continuous optimality " - "tolerance. It does not prove robustness to arbitrary scenes, correctness " - "of a dataset adapter, perceptual accuracy, photorealism, or physical " - "simulation fidelity." -) - - -def _number(value: float) -> str: - return f"{value:.12g}" - - -def validation_markdown(report: ValidationReport) -> str: - """Return a deterministic human-readable projection of a passing report.""" - lines = [ - "# Core Solver Independent Validation: PASS", - "", - ( - "All three canonical directions passed independent replay. The " - "declared continuous optimality tolerance is 1e-6 m." - ), - "", - "| case | relation | exact infimum (m) | realized (m) | error (m) | certificate [lower, upper] | gap (m) | verifier | result |", - "|---|---|---:|---:|---:|---|---:|---|---|", - ] - for case in report.cases: - certificate = case.certificate - lines.append( - "| " - f"{case.case_id} | " - f"{case.relation_before.value} → {case.relation_after.value} | " - f"{_number(case.exact_infimum_m)} | " - f"{_number(case.realized_displacement_m)} | " - f"{_number(case.realized_error_m)} | " - f"[{_number(certificate.distance_lower_bound)}, " - f"{_number(certificate.distance_upper_bound)}] | " - f"{_number(certificate.optimality_gap)} | " - f"{case.verifier_status.value} / leakage={case.leakage_count} | PASS |" - ) - lines.extend(("", "## Closed-form derivations", "")) - for case in report.cases: - lines.extend((f"### {case.case_id}", "", case.derivation, "")) - lines.extend(("## Claim boundary", "", _CLAIM_BOUNDARY, "")) - return "\n".join(lines) - - -def _pixel_transform(scene: Scene): - xs = [point.x for point in scene.room_polygon_xy] - ys = [point.y for point in scene.room_polygon_xy] - span_x = max(xs) - min(xs) - span_y = max(ys) - min(ys) - scale = min( - (_WIDTH - 2 * _MARGIN) / span_x, - (_HEIGHT - _TOP_MARGIN - _MARGIN) / span_y, - ) - - def transform(point: tuple[float, float]) -> tuple[int, int]: - x, y = point - return ( - round(_MARGIN + (x - min(xs)) * scale), - round(_HEIGHT - _MARGIN - (y - min(ys)) * scale), - ) - - return transform - - -def _polygon_pixels(scene: Scene, object_id: str, transform) -> list[tuple[int, int]]: - footprint = obb_footprint(scene.object_by_id(object_id).obb) - return [transform((float(x), float(y))) for x, y in footprint.exterior.coords] - - -def _arrow( - draw: ImageDraw.ImageDraw, - start: tuple[int, int], - end: tuple[int, int], -) -> None: - draw.line((start, end), fill="#6A4C93", width=5) - angle = math.atan2(end[1] - start[1], end[0] - start[0]) - length = 15.0 - spread = math.pi / 7.0 - first = ( - round(end[0] - length * math.cos(angle - spread)), - round(end[1] - length * math.sin(angle - spread)), - ) - second = ( - round(end[0] - length * math.cos(angle + spread)), - round(end[1] - length * math.sin(angle + spread)), - ) - draw.polygon((end, first, second), fill="#6A4C93") - - -def render_validation_topdown( - before: Scene, - after: Scene, - record: CaseValidationRecord, -) -> Image.Image: - """Render explanatory room, OBB, and movement geometry for one case.""" - transform = _pixel_transform(before) - image = Image.new("RGB", (_WIDTH, _HEIGHT), "white") - draw = ImageDraw.Draw(image) - room = [transform((point.x, point.y)) for point in before.room_polygon_xy] - draw.polygon(room, fill="#F5F8FA", outline="#1D3557", width=4) - - subject_id = "subject" - for obj in sorted(after.objects, key=lambda item: item.object_id): - if obj.object_id == subject_id: - continue - polygon = _polygon_pixels(after, obj.object_id, transform) - draw.polygon(polygon, fill="#B0BEC5", outline="#455A64", width=3) - center = transform((obj.position.x, obj.position.y)) - draw.text((center[0] + 7, center[1] - 14), obj.object_id, fill="#263238") - - before_polygon = _polygon_pixels(before, subject_id, transform) - after_polygon = _polygon_pixels(after, subject_id, transform) - draw.line(before_polygon, fill="#277DA1", width=5, joint="curve") - draw.line(after_polygon, fill="#F94144", width=5, joint="curve") - before_subject = before.object_by_id(subject_id) - after_subject = after.object_by_id(subject_id) - before_center = transform((before_subject.position.x, before_subject.position.y)) - after_center = transform((after_subject.position.x, after_subject.position.y)) - _arrow(draw, before_center, after_center) - - draw.text((30, 20), record.case_id, fill="#111827") - draw.text( - (30, 45), - f"{record.relation_before.value} -> {record.relation_after.value}", - fill="#111827", - ) - draw.text( - (30, 70), - ( - f"exact={_number(record.exact_infimum_m)} m " - f"realized={_number(record.realized_displacement_m)} m" - ), - fill="#111827", - ) - axis_origin = (_MARGIN, _HEIGHT - 25) - draw.line((axis_origin, (axis_origin[0] + 45, axis_origin[1])), fill="#111827", width=2) - draw.line((axis_origin, (axis_origin[0], axis_origin[1] - 45)), fill="#111827", width=2) - draw.text((axis_origin[0] + 49, axis_origin[1] - 8), "+X", fill="#111827") - draw.text((axis_origin[0] - 9, axis_origin[1] - 60), "+Y", fill="#111827") - return image - - -def _validate_scene_membership( - report: ValidationReport, - scenes: Mapping[str, tuple[Scene, Scene]], -) -> None: - expected = tuple(case.case_id for case in report.cases) - if set(scenes) != set(expected) or len(scenes) != len(expected): - raise CoreValidationError("report scene membership mismatch") - for case_id in expected: - pair = scenes[case_id] - if type(pair) is not tuple or len(pair) != 2: - raise CoreValidationError(f"{case_id}: report scene membership malformed") - before, after = pair - if not isinstance(before, Scene) or not isinstance(after, Scene): - raise CoreValidationError(f"{case_id}: report scenes must be Scene values") - if before.scene_id != case_id or after.scene_id != case_id: - raise CoreValidationError(f"{case_id}: report scene membership mismatch") - - -def _prepare_output(output_root: Path) -> None: - if output_root.is_symlink(): - raise FileExistsError(f"output path is a symlink: {output_root}") - if output_root.exists(): - if not output_root.is_dir() or any(output_root.iterdir()): - raise FileExistsError(f"output directory is non-empty: {output_root}") - return - output_root.mkdir(parents=True) - - -def _verify_output(root: Path, report: ValidationReport) -> None: - ValidationReport.model_validate_json((root / "report.json").read_bytes()) - (root / "report.md").read_text(encoding="utf-8") - expected = {"report.json", "report.md"} - for case in report.cases: - directory = root / case.case_id - Scene.model_validate_json((directory / "before.json").read_bytes()) - Scene.model_validate_json((directory / "after.json").read_bytes()) - with Image.open(directory / "topdown.png") as image: - image.verify() - expected.update( - { - f"{case.case_id}/before.json", - f"{case.case_id}/after.json", - f"{case.case_id}/topdown.png", - } - ) - actual = { - path.relative_to(root).as_posix() - for path in root.rglob("*") - if path.is_file() - } - if actual != expected: - raise CoreValidationError("published report file set is not closed") - - -def publish_validation_report( - report: ValidationReport, - scenes: Mapping[str, tuple[Scene, Scene]], - output_root: Path, -) -> Path: - """Write the exact report tree without replacing prior output.""" - _validate_scene_membership(report, scenes) - _prepare_output(output_root) - (output_root / "report.json").write_bytes( - canonical_json_bytes(report.model_dump(mode="json"), pretty=True) - ) - (output_root / "report.md").write_text( - validation_markdown(report), - encoding="utf-8", - newline="\n", - ) - records = {case.case_id: case for case in report.cases} - for case_id in records: - before, after = scenes[case_id] - directory = output_root / case_id - directory.mkdir() - (directory / "before.json").write_bytes( - canonical_json_bytes(before.model_dump(mode="json"), pretty=True) - ) - (directory / "after.json").write_bytes( - canonical_json_bytes(after.model_dump(mode="json"), pretty=True) - ) - render_validation_topdown(before, after, records[case_id]).save( - directory / "topdown.png", - format="PNG", - optimize=False, - compress_level=9, - ) - _verify_output(output_root, report) - return output_root diff --git a/tests/public_smoke/test_packaging.py b/tests/public_smoke/test_packaging.py index ace830e..cda090b 100644 --- a/tests/public_smoke/test_packaging.py +++ b/tests/public_smoke/test_packaging.py @@ -8,10 +8,14 @@ from email.parser import BytesParser from pathlib import Path +import pytest + ROOT = Path(__file__).parents[2] -def test_built_wheel_has_exact_ai2thor_extra_metadata(tmp_path: Path) -> None: +def test_built_wheel_metadata_has_v011_license_and_self_contained_test_extra( + tmp_path: Path, +) -> None: environment = os.environ.copy() environment.pop("PYTHONPATH", None) environment["PYTHONDONTWRITEBYTECODE"] = "1" @@ -45,7 +49,49 @@ def test_built_wheel_has_exact_ai2thor_extra_metadata(tmp_path: Path) -> None: archive.read(metadata_names[0]) ) - provided_extras = metadata.get_all("Provides-Extra", []) - assert provided_extras.count("ai2thor") == 1 + assert metadata["Version"] == "0.1.1" + assert metadata["License-Expression"] == "Apache-2.0" + assert set(metadata.get_all("Provides-Extra", [])) == {"ai2thor", "test"} requirements = metadata.get_all("Requires-Dist", []) assert requirements.count("ai2thor<6,>=5; extra == 'ai2thor'") == 1 + assert { + "build<2,>=1; extra == 'test'", + "hypothesis<7,>=6; extra == 'test'", + "pytest-xdist<4,>=3.6; extra == 'test'", + "pytest<10,>=8; extra == 'test'", + } <= set(requirements) + + +def test_public_runtime_closure_wheel_has_no_legacy_data_members( + tmp_path: Path, +) -> None: + if (ROOT / "release").is_dir(): + pytest.skip("private development checkout retains historical data sources") + + environment = os.environ.copy() + environment.pop("PYTHONPATH", None) + environment["PYTHONDONTWRITEBYTECODE"] = "1" + result = subprocess.run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--outdir", + str(tmp_path), + str(ROOT), + ], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + wheel = next(tmp_path.glob("spatialcf-*.whl")) + with zipfile.ZipFile(wheel) as archive: + assert not any( + name.startswith("spatialcf/data/") for name in archive.namelist() + ) diff --git a/tests/public_smoke/test_readme.py b/tests/public_smoke/test_readme.py index 5d9efab..898a83b 100644 --- a/tests/public_smoke/test_readme.py +++ b/tests/public_smoke/test_readme.py @@ -24,12 +24,19 @@ if (ROOT / "release" / "public-files.txt").is_file() else ROOT / "docs" ) -QUICKSTART = """python -m venv .venv +QUICKSTART = """git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf +python -m venv .venv . .venv/bin/activate -python -m pip install "spatialcf[ai2thor]" +python -m pip install ".[ai2thor]" spatialcf generate --config configs/ai2thor-example.toml --output ./dataset spatialcf verify ./dataset spatialcf inspect ./dataset""" +LOCAL_SETUP = '''git clone --branch v0.1.1 --depth 1 https://github.com/Legender134/spatialcf.git +cd spatialcf +python -m venv .venv +. .venv/bin/activate +python -m pip install ".[ai2thor]"''' PUBLIC_DOCS = ( ROOT / "README.md", ROOT / "README_EN.md", @@ -39,6 +46,17 @@ PUBLIC_DOC_ROOT / "adapters.md", PUBLIC_DOC_ROOT / "api.md", ) +PUBLIC_INSTALLATION_SURFACES = ( + ROOT / "README.md", + ROOT / "README_EN.md", + PUBLIC_DOC_ROOT / "installation.md", + PUBLIC_DOC_ROOT / "quickstart.md", + PUBLIC_DOC_ROOT / "adapters.md", +) +SHELL_FENCE = re.compile(r"```(?:bash|sh|shell)\n(.*?)```", re.DOTALL) +SHELL_ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=.*") +PIP_EXECUTABLE = re.compile(r"pip(?:\d+(?:\.\d+)?)?$") +PYTHON_EXECUTABLE = re.compile(r"python(?:\d+(?:\.\d+)?)?$") PUBLIC_TEXT_FILES = ( *PUBLIC_DOCS, ROOT / "configs/ai2thor-example.toml", @@ -62,13 +80,157 @@ ) -def test_readmes_and_quickstart_use_the_exact_public_commands() -> None: +def _fenced_shell_blocks(path: Path) -> tuple[str, ...]: + return tuple( + block.strip() for block in SHELL_FENCE.findall(path.read_text(encoding="utf-8")) + ) + + +def _fenced_shell_commands(path: Path) -> tuple[tuple[str, ...], ...]: + return tuple( + tuple(shlex.split(line)) + for block in _fenced_shell_blocks(path) + for line in block.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + + +def _strip_environment_prefix(command: tuple[str, ...]) -> tuple[str, ...] | None: + index = 0 + while index < len(command) and SHELL_ASSIGNMENT.fullmatch(command[index]): + index += 1 + if index == len(command) or command[index] != "env": + return command[index:] + + index += 1 + while index < len(command): + token = command[index] + if token == "--": + index += 1 + while index < len(command) and SHELL_ASSIGNMENT.fullmatch(command[index]): + index += 1 + return command[index:] + if SHELL_ASSIGNMENT.fullmatch(token) or token in {"-i", "--ignore-environment"}: + index += 1 + continue + if token in {"-u", "--unset"}: + if index + 1 == len(command): + return None + index += 2 + continue + if token.startswith("-"): + return None + return command[index:] + return () + + +def _pip_install_operands(command: tuple[str, ...]) -> tuple[str, ...] | None: + invocation = _strip_environment_prefix(command) + if invocation is None: + return None + if not invocation: + return () + + executable = Path(invocation[0]).name + if PIP_EXECUTABLE.fullmatch(executable): + pip_arguments = invocation[1:] + elif PYTHON_EXECUTABLE.fullmatch(executable): + module_index = next( + ( + index + for index in range(1, len(invocation) - 1) + if invocation[index : index + 2] == ("-m", "pip") + ), + None, + ) + if module_index is None: + return () + pip_arguments = invocation[module_index + 2 :] + else: + return () + + try: + install_index = pip_arguments.index("install") + except ValueError: + return () + return tuple( + argument + for argument in pip_arguments[install_index + 1 :] + if not argument.startswith("-") + ) + + +def _is_index_only_ai2thor_install(command: tuple[str, ...]) -> bool: + operands = _pip_install_operands(command) + if operands is None: + return True + return any(operand.lower() == "spatialcf[ai2thor]" for operand in operands) + + +def _assert_no_index_only_ai2thor_install(paths: tuple[Path, ...]) -> None: + for path in paths: + for command in _fenced_shell_commands(path): + assert not _is_index_only_ai2thor_install(command), (path, command) + + +def test_public_installation_surfaces_begin_with_the_release_local_setup() -> None: + for path in PUBLIC_INSTALLATION_SURFACES: + assert any( + block.startswith(LOCAL_SETUP) for block in _fenced_shell_blocks(path) + ), path + + +def test_quickstart_fences_continue_from_the_release_local_setup() -> None: for path in ( ROOT / "README.md", ROOT / "README_EN.md", PUBLIC_DOC_ROOT / "quickstart.md", ): - assert QUICKSTART in path.read_text(encoding="utf-8"), path + assert any( + block.startswith(QUICKSTART) for block in _fenced_shell_blocks(path) + ), path + + +def test_public_documentation_shell_fences_reject_index_only_ai2thor_installs() -> None: + _assert_no_index_only_ai2thor_install(PUBLIC_DOCS) + + +@pytest.mark.parametrize( + "legacy_install", + ( + 'pip install "spatialcf[ai2thor]"', + 'pip --disable-pip-version-check install -q "spatialcf[ai2thor]"', + 'python -m pip install --no-cache-dir "spatialcf[ai2thor]"', + 'pip3 install "spatialcf[ai2thor]"', + 'python -I -m pip install "spatialcf[ai2thor]"', + 'PIP_DISABLE_PIP_VERSION_CHECK=1 pip install "spatialcf[ai2thor]"', + 'env PIP_DISABLE_PIP_VERSION_CHECK=1 pip install "spatialcf[ai2thor]"', + 'env --unknown-option pip install "spatialcf[ai2thor]"', + 'env -- PIP_DISABLE_PIP_VERSION_CHECK=1 pip install "spatialcf[ai2thor]"', + 'env -u HOME -- PIP_DISABLE_PIP_VERSION_CHECK=1 pip install "spatialcf[ai2thor]"', + ), +) +def test_shell_install_validation_rejects_mutated_index_only_commands( + tmp_path: Path, + legacy_install: str, +) -> None: + mutated_doc = tmp_path / "installation.md" + mutated_doc.write_text( + f"```bash\n{LOCAL_SETUP}\n{legacy_install}\n```\n", + encoding="utf-8", + ) + + with pytest.raises(AssertionError): + _assert_no_index_only_ai2thor_install((mutated_doc,)) + + +def test_shell_install_validation_accepts_the_required_local_setup( + tmp_path: Path, +) -> None: + local_setup_doc = tmp_path / "installation.md" + local_setup_doc.write_text(f"```bash\n{LOCAL_SETUP}\n```\n", encoding="utf-8") + + _assert_no_index_only_ai2thor_install((local_setup_doc,)) def test_example_configuration_is_exact() -> None: