diff --git a/_docs/02_tasks/todo/AZ-226_generated_tile_orthorectification.md b/_docs/02_tasks/done/AZ-226_generated_tile_orthorectification.md similarity index 100% rename from _docs/02_tasks/todo/AZ-226_generated_tile_orthorectification.md rename to _docs/02_tasks/done/AZ-226_generated_tile_orthorectification.md diff --git a/_docs/03_implementation/batch_05_cycle1_report.md b/_docs/03_implementation/batch_05_cycle1_report.md new file mode 100644 index 0000000..d626e5c --- /dev/null +++ b/_docs/03_implementation/batch_05_cycle1_report.md @@ -0,0 +1,35 @@ +# Batch Report + +**Batch**: 5 +**Tasks**: AZ-226_generated_tile_orthorectification +**Date**: 2026-05-03 + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|----------------|-------|-------------|--------| +| AZ-226_generated_tile_orthorectification | Done | 4 files | Pass | 3/3 ACs covered | None | + +## AC Test Coverage: All covered + +| AC Ref | Coverage | +|--------|----------| +| AZ-226 AC-1 | `test_eligible_frame_stages_generated_cog_and_sidecar` verifies generated COG and sidecar staging for eligible frames. | +| AZ-226 AC-2 | `test_high_covariance_generated_tile_write_is_rejected` verifies unsafe high-covariance writes are rejected and not packaged. | +| AZ-226 AC-3 | `test_sync_package_includes_manifest_delta_sidecar_covariance_and_trust_level` verifies sync package audit metadata. | + +## Code Review Verdict: PASS + +Review report: `_docs/03_implementation/reviews/batch_05_review.md` + +## Auto-Fix Attempts: 0 + +## Stuck Agents: None + +## Verification + +- `.venv/bin/python -m black --check src tests e2e/replay` passed. +- `.venv/bin/python -m ruff check src tests e2e/replay` passed. +- `.venv/bin/python -m pytest` passed: 32 tests. + +## Next Batch: AZ-228_vio_adapter, AZ-229_satellite_service_sync diff --git a/_docs/03_implementation/reviews/batch_05_review.md b/_docs/03_implementation/reviews/batch_05_review.md new file mode 100644 index 0000000..513d5b9 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_05_review.md @@ -0,0 +1,27 @@ +# Code Review Report + +**Batch**: AZ-226_generated_tile_orthorectification +**Date**: 2026-05-03 +**Verdict**: PASS + +## Findings + +No findings. + +## Spec Compliance + +| Task | AC Coverage | Evidence | +|------|-------------|----------| +| AZ-226 | 3/3 covered | `tests/unit/test_tile_manager.py` verifies generated COG/sidecar staging, unsafe covariance rejection, and auditable sync package metadata. | + +## Architecture Compliance + +- Edits stayed inside `src/tile_manager/**` plus focused unit tests. +- Generated tile behavior consumes existing Tile Manager and shared contract patterns; no new cross-component internal imports were introduced. +- Generated outputs use `generated`/`candidate` trust levels and do not promote onboard tiles directly to trusted basemap records. + +## Verification + +- `.venv/bin/python -m black --check src tests e2e/replay` passed. +- `.venv/bin/python -m ruff check src tests e2e/replay` passed. +- `.venv/bin/python -m pytest` passed: 32 tests. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 2da5ff9..94027ee 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -9,6 +9,6 @@ tracker: jira sub_step: phase: 1 name: batch-loop - detail: "batch 4: AZ-223_camera_ingest_calibration, AZ-224_mavlink_gcs_gateway, AZ-225_tile_manager_cache_manifest, AZ-227_fdr_event_recorder" + detail: "batch 5: AZ-226_generated_tile_orthorectification" retry_count: 0 cycle: 1 diff --git a/src/tile_manager/__init__.py b/src/tile_manager/__init__.py index 2cf13da..8068de7 100644 --- a/src/tile_manager/__init__.py +++ b/src/tile_manager/__init__.py @@ -3,6 +3,10 @@ from .interfaces import LocalTileManager, TileManager from .types import ( CacheValidationReport, + GeneratedTileCandidate, + GeneratedTileSidecar, + GeneratedTileSyncPackage, + TileGenerationRequest, TileManifestEntry, TileMetadataLookup, TileValidationDecision, @@ -11,8 +15,12 @@ from .types import ( __all__ = [ "CacheValidationReport", + "GeneratedTileCandidate", + "GeneratedTileSidecar", + "GeneratedTileSyncPackage", "LocalTileManager", "TileManager", + "TileGenerationRequest", "TileManifestEntry", "TileMetadataLookup", "TileValidationDecision", diff --git a/src/tile_manager/interfaces.py b/src/tile_manager/interfaces.py index 9f98341..d0b3060 100644 --- a/src/tile_manager/interfaces.py +++ b/src/tile_manager/interfaces.py @@ -8,7 +8,11 @@ from shared.errors import ErrorEnvelope from .types import ( CacheValidationReport, + GeneratedTileCandidate, + GeneratedTileSidecar, + GeneratedTileSyncPackage, TileManifestEntry, + TileGenerationRequest, TileMetadataLookup, TileValidationDecision, freshness_status, @@ -40,6 +44,7 @@ class LocalTileManager: self._trusted_by_tile_id: dict[str, CacheTileRecord] = {} self._descriptor_by_tile_id: dict[str, str] = {} self._tile_id_by_chunk_id: dict[str, str] = {} + self._generated_candidates: list[GeneratedTileCandidate] = [] def validate_cache(self, entries: list[TileManifestEntry]) -> CacheValidationReport: if not self._postgis_available: @@ -102,6 +107,54 @@ class LocalTileManager: descriptor_ref=self._descriptor_by_tile_id[tile_id], ) + def orthorectify_frame(self, request: TileGenerationRequest) -> GeneratedTileCandidate: + if not request.frame_usable: + return GeneratedTileCandidate(accepted=False, rejection_reason="frame_not_usable") + if request.parent_covariance_m > 5.0: + return GeneratedTileCandidate(accepted=False, rejection_reason="covariance_too_high") + if request.quality_score < 0.25: + return GeneratedTileCandidate(accepted=False, rejection_reason="quality_too_low") + + trust_level = "generated" if request.parent_covariance_m <= 3.0 else "candidate" + tile_id = f"generated-{request.mission_id}-{request.frame_id}" + candidate = GeneratedTileCandidate( + accepted=True, + tile_id=tile_id, + cog_ref=f"generated/{request.mission_id}/{tile_id}.cog.tif", + sidecar=GeneratedTileSidecar( + tile_id=tile_id, + parent_frame_id=request.frame_id, + parent_covariance_m=request.parent_covariance_m, + quality_score=request.quality_score, + trust_level=trust_level, + provenance=request.source_provenance, + ), + ) + self._generated_candidates.append(candidate) + return candidate + + def package_sync(self, mission_id: str) -> GeneratedTileSyncPackage: + sidecars = tuple( + candidate.sidecar + for candidate in self._generated_candidates + if candidate.sidecar is not None + ) + manifest_delta = tuple( + { + "tile_id": sidecar.tile_id, + "trust_level": sidecar.trust_level, + "parent_covariance_m": sidecar.parent_covariance_m, + "provenance": sidecar.provenance, + } + for sidecar in sidecars + ) + return GeneratedTileSyncPackage( + package_ref=f"generated/{mission_id}/sync-package.json", + mission_id=mission_id, + manifest_delta=manifest_delta, + sidecars=sidecars, + ) + def _validate_entry(self, entry: TileManifestEntry) -> TileValidationDecision: if entry.signature_hash not in self._trusted_signature_hashes: return TileValidationDecision( diff --git a/src/tile_manager/types.py b/src/tile_manager/types.py index bc074cb..b4df7c5 100644 --- a/src/tile_manager/types.py +++ b/src/tile_manager/types.py @@ -53,6 +53,42 @@ class TileMetadataLookup(TileManagerModel): error: ErrorEnvelope | None = None +class TileGenerationRequest(TileManagerModel): + mission_id: str = Field(min_length=1) + frame_id: str = Field(min_length=1) + image_ref: str = Field(min_length=1) + timestamp_ns: int = Field(ge=0) + parent_covariance_m: float = Field(ge=0.0) + frame_usable: bool + quality_score: float = Field(ge=0.0, le=1.0) + footprint: dict[str, float] + source_provenance: str = Field(min_length=1) + + +class GeneratedTileSidecar(TileManagerModel): + tile_id: str = Field(min_length=1) + parent_frame_id: str = Field(min_length=1) + parent_covariance_m: float = Field(ge=0.0) + quality_score: float = Field(ge=0.0, le=1.0) + trust_level: Literal["generated", "candidate"] + provenance: str = Field(min_length=1) + + +class GeneratedTileCandidate(TileManagerModel): + accepted: bool + tile_id: str | None = None + cog_ref: str | None = None + sidecar: GeneratedTileSidecar | None = None + rejection_reason: str | None = None + + +class GeneratedTileSyncPackage(TileManagerModel): + package_ref: str = Field(min_length=1) + mission_id: str = Field(min_length=1) + manifest_delta: tuple[dict[str, object], ...] + sidecars: tuple[GeneratedTileSidecar, ...] + + def freshness_status(expires_at: datetime, now: datetime) -> Literal["fresh", "stale"]: normalized_expiry = expires_at if normalized_expiry.tzinfo is None: diff --git a/tests/unit/test_tile_manager.py b/tests/unit/test_tile_manager.py index c800b54..566995e 100644 --- a/tests/unit/test_tile_manager.py +++ b/tests/unit/test_tile_manager.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from tile_manager import LocalTileManager, TileManifestEntry +from tile_manager import LocalTileManager, TileGenerationRequest, TileManifestEntry NOW = datetime(2026, 5, 3, tzinfo=timezone.utc) @@ -76,3 +76,62 @@ def test_tile_metadata_lookup_returns_record_or_explicit_rejection() -> None: assert missing.found is False assert missing.error is not None assert missing.error.category == "validation" + + +def _generation_request(**overrides: object) -> TileGenerationRequest: + payload: dict[str, object] = { + "mission_id": "mission-1", + "frame_id": "frame-1", + "image_ref": "replay/frame-1.jpg", + "timestamp_ns": 10_000, + "parent_covariance_m": 2.5, + "frame_usable": True, + "quality_score": 0.8, + "footprint": {"min_lat": 49.0, "max_lat": 49.1}, + "source_provenance": "nav-camera-generated", + } + payload.update(overrides) + return TileGenerationRequest.model_validate(payload) + + +def test_eligible_frame_stages_generated_cog_and_sidecar() -> None: + # Arrange + manager = LocalTileManager(trusted_signature_hashes={"sig:trusted"}, now=NOW) + + # Act + candidate = manager.orthorectify_frame(_generation_request()) + + # Assert + assert candidate.accepted is True + assert candidate.cog_ref == "generated/mission-1/generated-mission-1-frame-1.cog.tif" + assert candidate.sidecar is not None + assert candidate.sidecar.trust_level == "generated" + assert candidate.sidecar.parent_covariance_m == 2.5 + + +def test_high_covariance_generated_tile_write_is_rejected() -> None: + # Arrange + manager = LocalTileManager(trusted_signature_hashes={"sig:trusted"}, now=NOW) + + # Act + candidate = manager.orthorectify_frame(_generation_request(parent_covariance_m=7.5)) + + # Assert + assert candidate.accepted is False + assert candidate.rejection_reason == "covariance_too_high" + assert manager.package_sync("mission-1").sidecars == () + + +def test_sync_package_includes_manifest_delta_sidecar_covariance_and_trust_level() -> None: + # Arrange + manager = LocalTileManager(trusted_signature_hashes={"sig:trusted"}, now=NOW) + manager.orthorectify_frame(_generation_request()) + + # Act + package = manager.package_sync("mission-1") + + # Assert + assert package.package_ref == "generated/mission-1/sync-package.json" + assert package.sidecars[0].parent_covariance_m == 2.5 + assert package.manifest_delta[0]["trust_level"] == "generated" + assert package.manifest_delta[0]["parent_covariance_m"] == 2.5