From 5156453224f5be6e0ce2e3202e50cbbcd014e5e1 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Sun, 3 May 2026 13:22:50 +0300 Subject: [PATCH] [AZ-220] Add shared runtime contract models Implement the shared DTO contract surface with validation so runtime components consume one public model set instead of duplicating shapes. Co-authored-by: Cursor --- .../AZ-220_shared_runtime_contracts.md | 0 .../batch_02_cycle1_report.md | 34 ++++ .../reviews/batch_02_review.md | 24 +++ _docs/_autodev_state.md | 2 +- pyproject.toml | 4 +- src/shared/contracts/__init__.py | 27 ++- src/shared/contracts/models.py | 116 +++++++++++++ tests/unit/shared/test_runtime_contracts.py | 157 ++++++++++++++++++ 8 files changed, 361 insertions(+), 3 deletions(-) rename _docs/02_tasks/{todo => done}/AZ-220_shared_runtime_contracts.md (100%) create mode 100644 _docs/03_implementation/batch_02_cycle1_report.md create mode 100644 _docs/03_implementation/reviews/batch_02_review.md create mode 100644 src/shared/contracts/models.py create mode 100644 tests/unit/shared/test_runtime_contracts.py diff --git a/_docs/02_tasks/todo/AZ-220_shared_runtime_contracts.md b/_docs/02_tasks/done/AZ-220_shared_runtime_contracts.md similarity index 100% rename from _docs/02_tasks/todo/AZ-220_shared_runtime_contracts.md rename to _docs/02_tasks/done/AZ-220_shared_runtime_contracts.md diff --git a/_docs/03_implementation/batch_02_cycle1_report.md b/_docs/03_implementation/batch_02_cycle1_report.md new file mode 100644 index 0000000..245ff25 --- /dev/null +++ b/_docs/03_implementation/batch_02_cycle1_report.md @@ -0,0 +1,34 @@ +# Batch Report + +**Batch**: 2 +**Tasks**: AZ-220_shared_runtime_contracts +**Date**: 2026-05-03 + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|----------------|-------|-------------|--------| +| AZ-220_shared_runtime_contracts | Done | 8 files | Pass | 2/2 ACs covered | None | + +## AC Test Coverage: All covered + +| AC Ref | Coverage | +|--------|----------| +| AC-1 | `test_runtime_dtos_accept_valid_minimal_values` verifies the shared DTO contract surface can be imported and constructed. | +| AC-2 | `test_missing_required_timestamp_is_rejected_with_structured_error`, `test_raw_frame_retention_is_rejected`, `test_position_accuracy_cannot_under_report_covariance`, and `test_accepted_anchor_requires_estimated_pose` verify malformed DTOs are rejected with structured Pydantic validation errors. | + +## Code Review Verdict: PASS + +Review report: `_docs/03_implementation/reviews/batch_02_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: 11 tests. + +## Next Batch: AZ-221_shared_geometry_time_sync, AZ-222_runtime_config_errors_telemetry diff --git a/_docs/03_implementation/reviews/batch_02_review.md b/_docs/03_implementation/reviews/batch_02_review.md new file mode 100644 index 0000000..52eca57 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_02_review.md @@ -0,0 +1,24 @@ +# Code Review Report + +**Batch**: AZ-220_shared_runtime_contracts +**Date**: 2026-05-03 +**Verdict**: PASS + +## Findings + +| # | Severity | Category | File:Line | Title | +|---|----------|----------|-----------|-------| +| - | - | - | - | No findings | + +## Review Notes + +- AC-1 is satisfied by the public exports in `src/shared/contracts/__init__.py` and the DTO models in `src/shared/contracts/models.py`. +- AC-2 is satisfied by Pydantic validation for missing required fields, raw frame retention, optimistic covariance reporting, and inconsistent anchor decisions. +- The implementation stays inside `shared/contracts` ownership and does not introduce component-specific algorithms. +- Raw frame payloads remain references only; the model rejects retained raw-frame payload flags. + +## 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: 11 tests. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 0cf09c9..cf4f7d0 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 1: AZ-219_initial_structure" + detail: "batch 2: AZ-220_shared_runtime_contracts" retry_count: 0 cycle: 1 diff --git a/pyproject.toml b/pyproject.toml index eb87e0d..ab5b2c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,9 @@ name = "gps-denied-onboard" version = "0.1.0" description = "Jetson-hosted GPS-denied localization runtime scaffold." requires-python = ">=3.10" -dependencies = [] +dependencies = [ + "pydantic==2.13.3", +] [project.optional-dependencies] dev = [ diff --git a/src/shared/contracts/__init__.py b/src/shared/contracts/__init__.py index f1739ef..de7924c 100644 --- a/src/shared/contracts/__init__.py +++ b/src/shared/contracts/__init__.py @@ -1,3 +1,28 @@ """Shared DTO and interface contract namespace.""" -CONTRACT_VERSION = "0.1.0" +from shared.contracts.models import ( + AnchorDecision, + CacheTileRecord, + FdrEvent, + FramePacket, + PositionEstimate, + RuntimeContractModel, + TelemetrySample, + VioStatePacket, + VprCandidate, +) + +CONTRACT_VERSION = "1.0.0" + +__all__ = [ + "AnchorDecision", + "CONTRACT_VERSION", + "CacheTileRecord", + "FdrEvent", + "FramePacket", + "PositionEstimate", + "RuntimeContractModel", + "TelemetrySample", + "VioStatePacket", + "VprCandidate", +] diff --git a/src/shared/contracts/models.py b/src/shared/contracts/models.py new file mode 100644 index 0000000..5d0d7fe --- /dev/null +++ b/src/shared/contracts/models.py @@ -0,0 +1,116 @@ +"""Shared runtime DTO contracts. + +These models intentionally carry only cross-component shape and validation rules. +Component algorithms and storage choices stay in their owning packages. +""" + +from datetime import date +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, NonNegativeFloat, NonNegativeInt, PositiveFloat +from pydantic import model_validator + + +class RuntimeContractModel(BaseModel): + """Base settings for public runtime contracts.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + +class FramePacket(RuntimeContractModel): + frame_id: str = Field(min_length=1) + timestamp_ns: NonNegativeInt + image_ref: str = Field(min_length=1) + calibration_id: str = Field(min_length=1) + occlusion: Literal["clear", "partial", "total", "unreadable"] + quality: float = Field(ge=0.0, le=1.0) + normalization_hint: str | None = None + raw_frame_retained: bool = False + + @model_validator(mode="after") + def raw_frames_must_not_be_retained(self) -> "FramePacket": + if self.raw_frame_retained: + raise ValueError("raw frame payloads must be referenced, not retained") + return self + + +class TelemetrySample(RuntimeContractModel): + timestamp_ns: NonNegativeInt + imu: dict[str, float] + attitude: dict[str, float] + altitude_m: float + airspeed_mps: NonNegativeFloat + gps_health: Literal["healthy", "degraded", "lost", "spoofed"] + + +class VioStatePacket(RuntimeContractModel): + timestamp_ns: NonNegativeInt + relative_pose: dict[str, float] + velocity_mps: tuple[float, float, float] + bias_estimate: dict[str, float] | None = None + tracking_quality: float = Field(ge=0.0, le=1.0) + covariance_hint: list[list[float]] | None = None + + +class PositionEstimate(RuntimeContractModel): + timestamp_ns: NonNegativeInt + latitude_deg: float = Field(ge=-90.0, le=90.0) + longitude_deg: float = Field(ge=-180.0, le=180.0) + altitude_m: float + covariance_semimajor_m: NonNegativeFloat + source_label: Literal["satellite_anchored", "vo_extrapolated", "dead_reckoned", "no_fix"] + fix_type: int = Field(ge=0, le=3) + horizontal_accuracy_m: NonNegativeFloat + anchor_age_ms: NonNegativeInt + + @model_validator(mode="after") + def accuracy_must_not_under_report_covariance(self) -> "PositionEstimate": + if self.horizontal_accuracy_m < self.covariance_semimajor_m: + raise ValueError("horizontal_accuracy_m must not under-report covariance_semimajor_m") + return self + + +class VprCandidate(RuntimeContractModel): + chunk_id: str = Field(min_length=1) + tile_id: str = Field(min_length=1) + score: float = Field(ge=0.0, le=1.0) + footprint: dict[str, float] + freshness_status: Literal["fresh", "stale", "rejected"] + + +class AnchorDecision(RuntimeContractModel): + candidate_id: str = Field(min_length=1) + accepted: bool + estimated_pose: dict[str, float] | None = None + inliers: NonNegativeInt + mean_reprojection_error_px: NonNegativeFloat + rejection_reason: str | None = None + + @model_validator(mode="after") + def accepted_anchors_require_pose(self) -> "AnchorDecision": + if self.accepted and self.estimated_pose is None: + raise ValueError("accepted anchor decisions require estimated_pose") + if self.accepted and self.rejection_reason is not None: + raise ValueError("accepted anchor decisions must not include rejection_reason") + return self + + +class CacheTileRecord(RuntimeContractModel): + tile_id: str = Field(min_length=1) + crs: str = Field(min_length=1) + meters_per_pixel: PositiveFloat + capture_date: date + signature_hash: str = Field(min_length=1) + trust_level: Literal["trusted", "generated", "quarantined", "rejected"] + freshness_status: Literal["fresh", "stale", "rejected"] + provenance: str = Field(min_length=1) + + +class FdrEvent(RuntimeContractModel): + event_type: str = Field(min_length=1) + timestamp_ns: NonNegativeInt + component: str = Field(min_length=1) + severity: Literal["debug", "info", "warning", "error", "critical"] + payload_ref: str = Field(min_length=1) + mission_id: str = Field(min_length=1) + run_id: str = Field(min_length=1) diff --git a/tests/unit/shared/test_runtime_contracts.py b/tests/unit/shared/test_runtime_contracts.py new file mode 100644 index 0000000..38b3725 --- /dev/null +++ b/tests/unit/shared/test_runtime_contracts.py @@ -0,0 +1,157 @@ +import pytest +from pydantic import ValidationError + +from shared.contracts import ( + AnchorDecision, + CacheTileRecord, + FdrEvent, + FramePacket, + PositionEstimate, + TelemetrySample, + VioStatePacket, + VprCandidate, +) + + +def test_runtime_dtos_accept_valid_minimal_values() -> None: + # Arrange + timestamp_ns = 1_000_000 + + # Act + contracts = [ + FramePacket( + frame_id="frame-1", + timestamp_ns=timestamp_ns, + image_ref="frames/frame-1", + calibration_id="calib-1", + occlusion="clear", + quality=0.95, + ), + TelemetrySample( + timestamp_ns=timestamp_ns, + imu={"accel_x": 0.1}, + attitude={"roll": 0.0}, + altitude_m=950.0, + airspeed_mps=16.0, + gps_health="lost", + ), + VioStatePacket( + timestamp_ns=timestamp_ns, + relative_pose={"x": 1.0}, + velocity_mps=(1.0, 0.0, 0.0), + tracking_quality=0.8, + ), + PositionEstimate( + timestamp_ns=timestamp_ns, + latitude_deg=49.9, + longitude_deg=36.2, + altitude_m=950.0, + covariance_semimajor_m=12.0, + source_label="satellite_anchored", + fix_type=3, + horizontal_accuracy_m=12.0, + anchor_age_ms=200, + ), + VprCandidate( + chunk_id="chunk-1", + tile_id="tile-1", + score=0.87, + footprint={"min_lat": 49.0}, + freshness_status="fresh", + ), + AnchorDecision( + candidate_id="candidate-1", + accepted=True, + estimated_pose={"x": 1.0}, + inliers=42, + mean_reprojection_error_px=0.8, + ), + CacheTileRecord( + tile_id="tile-1", + crs="EPSG:3857", + meters_per_pixel=0.3, + capture_date="2026-05-03", + signature_hash="sha256:abc", + trust_level="trusted", + freshness_status="fresh", + provenance="suite-satellite-service", + ), + FdrEvent( + event_type="health", + timestamp_ns=timestamp_ns, + component="shared.contracts", + severity="info", + payload_ref="fdr://segment/1", + mission_id="mission-1", + run_id="run-1", + ), + ] + + # Assert + assert len(contracts) == 8 + + +def test_missing_required_timestamp_is_rejected_with_structured_error() -> None: + # Act + with pytest.raises(ValidationError) as error: + FramePacket( + frame_id="frame-1", + image_ref="frames/frame-1", + calibration_id="calib-1", + occlusion="clear", + quality=0.95, + ) + + # Assert + assert error.value.errors()[0]["loc"] == ("timestamp_ns",) + assert error.value.errors()[0]["type"] == "missing" + + +def test_raw_frame_retention_is_rejected() -> None: + # Act + with pytest.raises(ValidationError) as error: + FramePacket( + frame_id="frame-1", + timestamp_ns=1, + image_ref="frames/frame-1", + calibration_id="calib-1", + occlusion="clear", + quality=0.95, + raw_frame_retained=True, + ) + + # Assert + assert "raw frame payloads must be referenced" in str(error.value) + + +def test_position_accuracy_cannot_under_report_covariance() -> None: + # Act + with pytest.raises(ValidationError) as error: + PositionEstimate( + timestamp_ns=1, + latitude_deg=49.9, + longitude_deg=36.2, + altitude_m=950.0, + covariance_semimajor_m=50.0, + source_label="satellite_anchored", + fix_type=3, + horizontal_accuracy_m=10.0, + anchor_age_ms=200, + ) + + # Assert + assert "must not under-report" in str(error.value) + + +def test_accepted_anchor_requires_estimated_pose() -> None: + # Act + with pytest.raises(ValidationError) as error: + AnchorDecision( + candidate_id="candidate-1", + accepted=True, + inliers=42, + mean_reprojection_error_px=0.8, + ) + + # Assert + assert "accepted anchor decisions require estimated_pose" in str(error.value)