mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 07:11:13 +00:00
[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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user