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

53 lines
3.5 KiB
Markdown

# 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.