ADR 0002: hexagonal/ports-and-adapters architecture — components/ layout, protocol.py per component, composition root, core/ for concentrated math. ADR 0003: @dataclass(slots=True, frozen=True) on hot path; Pydantic retained only at REST/config/DB boundaries. Pose/GPSPoint migration deferred to Phase 2. ADR 0004: Stage 2 as independent iteration — own phases 1-6, own requirements, stage1 code treated as MVP starting capital. PROJECT.md: Stage 2 Key Decisions updated from Pending → Accepted with Phase 1 implementation notes, deferred work list, and final architecture summary. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3.5 KiB
ADR 0003 — @dataclass(slots=True, frozen=True) on Hot Path; Pydantic at Boundaries Only
Date: 2026-05-11 Status: Accepted (partially implemented — Phase 1 scaffolded; full migration Phase 2) Supersedes: — Implemented in: Phase 1 scaffold; Phase 2 full migration
Context
Stage 1 and the parallel try02 branch both used Pydantic models (BaseModel) for per-frame data types: FrameState, IMUSample, PositionEstimate, VOEstimate, SatelliteAnchor. Pydantic v2 is fast, but on the per-frame path at 0.7fps with Jetson's shared 8GB CPU/GPU pool, every model_validate() or __init__ triggers field validation, type coercion, and __dict__ allocation — none of which we need for internal pipeline types whose values come from trusted numpy operations.
try02's design doc noted this overhead but kept Pydantic for "consistency." We rejected this trade-off.
Pydantic remains genuinely valuable at system boundaries: REST API request/response parsing (FastAPI), config loading (pydantic-settings), and DB schema validation (SQLAlchemy models). At those boundaries, external input is untrusted and validation catches bugs early. On the per-frame path, input comes from our own numpy operations — validation is redundant overhead.
Decision
Hot-path data types use @dataclass(slots=True, frozen=True) from Python 3.10+:
FrameState— per-frame snapshot passed through the pipelineIMUSample— raw IMU measurement from MAVLinkPositionEstimate— output of ESKF, input to GPS_INPUT encodingVOEstimate— output of visual odometry backendSatelliteAnchor— accepted satellite match result
These live in src/gps_denied/hot_types/. Old schema paths (gps_denied.schemas.eskf, gps_denied.schemas.vo, etc.) are shimmed to re-export from hot_types for test compatibility.
Boundary types keep Pydantic:
- FastAPI request/response schemas (
src/gps_denied/schemas/) AppSettings/RuntimeConfig(pydantic-settings)AsyncSQLAlchemymodelsPose— special case (see below)
Consequences
Positive:
slots=Trueeliminates__dict__per instance — reduces per-frame allocations on a memory-constrained target.frozen=Trueprevents accidental mutation deep in the pipeline — catches bugs at assignment time rather than as silent state corruption.dataclasses.replace()for modified copies is explicit and cheap.- No validation overhead on trusted internal data.
Negative / Exceptions:
Posestays Pydantic in Phase 1.core/factor_graph.pymutatespose.positionat lines 182, 207, 230, 297 usingpose.position[0] = xstyle assignment. ConvertingPoseto a frozen dataclass requires rewriting 4 mutation sites to usedataclasses.replace(). Deferred to Phase 2 to avoid breaking the regression floor during the Phase 1 rename wave.GPSPointstays Pydantic — it appears in REST responses and is already at a boundary. No change needed.dataclasses.replace()is more verbose than Pydantic's.model_copy(update={...}). Acceptable trade-off.
Implementation Notes
src/gps_denied/hot_types/scaffolded in Plan 01-01 with 5 types +__init__.py.- Old schema files (
schemas/eskf.py,schemas/vo.py,schemas/satellite.py,schemas/metric.py,schemas/rotation.py) converted to re-export shims pointing tohot_types. - Phase 2 work: migrate all
Posemutation sites todataclasses.replace(); remove schema shims; update tests to import fromhot_typesdirectly.