Files
gps-denied-onboard/_docs/01_solution/decisions/0003-hot-path-dataclasses-vs-pydantic.md
T
Yuzviak a11ed15187 docs: add Phase 1 ADRs and update PROJECT.md with completed decisions
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>
2026-05-11 09:23:09 +03:00

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 pipeline
  • IMUSample — raw IMU measurement from MAVLink
  • PositionEstimate — output of ESKF, input to GPS_INPUT encoding
  • VOEstimate — output of visual odometry backend
  • SatelliteAnchor — 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)
  • AsyncSQLAlchemy models
  • Pose — special case (see below)

Consequences

Positive:

  • slots=True eliminates __dict__ per instance — reduces per-frame allocations on a memory-constrained target.
  • frozen=True prevents 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:

  • Pose stays Pydantic in Phase 1. core/factor_graph.py mutates pose.position at lines 182, 207, 230, 297 using pose.position[0] = x style assignment. Converting Pose to a frozen dataclass requires rewriting 4 mutation sites to use dataclasses.replace(). Deferred to Phase 2 to avoid breaking the regression floor during the Phase 1 rename wave.
  • GPSPoint stays 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 to hot_types.
  • Phase 2 work: migrate all Pose mutation sites to dataclasses.replace(); remove schema shims; update tests to import from hot_types directly.