[AZ-265] Replay as configuration of airborne binary (ADR-011)

Re-design replay mode per user direction: replay is no longer a fourth
Docker image with a reduced component set, but a `config.mode = "replay"`
branch of the single airborne binary. The pre-flight workflow (route in
suite UI -> C12 tile download via real satellite-provider -> C10
manifest+engines build) is identical between live and replay; only three
strategies swap at compose time:

  FrameSource:      Live <-> Video
  FcAdapter:        Pymavlink/MSP2 <-> TlogReplay
  MavlinkTransport: Serial <-> Noop

The C8 outbound MAVLink encoders run unchanged in both modes; their
bytes hit `NoopMavlinkTransport` in replay and disappear. A new
`JsonlReplaySink` taps C5's `EstimatorOutput` stream so the parent-suite
UI sees per-tick coordinates by tailing `results.jsonl`. MAVLink 2.0
signing key remains mandatory (operator supplies a dummy file).

A new `replay_input/` Layer-4 cross-cutting coordinator owns
`(video, tlog) -> (FrameSource, FcAdapter, Clock)` convergence; the
composition root sees only standard interfaces past `.open()`.

Docs:
- architecture.md: new ADR-011 with full rationale; ADR-002 binary
  narrative updated.
- contracts/replay/replay_protocol.md: bumped to v2.0.0; 12 invariants
  (notably mode-agnosticism + encoder byte-equality + signing key
  mandatory + real C6 cache in replay).
- module-layout.md: Build-Time Exclusion Map dropped from 4 to 3 binary
  columns; replay-mode `BUILD_*` flags default ON in airborne;
  `shared/replay_input` cross-cutting entry added.
- epics.md: E-DEMO-REPLAY scope reframed; story points 27-32 -> 19-24.

Task respecs:
- AZ-401: shrunk 3 -> 2 pts; `compose_root` mode branch + JSONL sink +
  NoopMavlinkTransport wiring; legacy `compose_replay` export deleted.
- AZ-402: console-script wrapper that mutates `config.mode = "replay"`
  and dispatches into the shared airborne main; `--mavlink-signing-key`
  mandatory.
- AZ-403: CANCELLED. Moved to done/ with banner; Jira transition deferred
  via `_docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md`.
- AZ-404: AC-4 reworded as mode-agnosticism AST scan + encoder
  byte-equality test; new AC-8 operator-workflow rehearsal.
- AZ-405: also owns the `replay_input/` module + `ReplayInputAdapter`.

_dependencies_table.md updated: AZ-401 gains AZ-405 dep; AZ-404 drops
AZ-403 dep; AZ-403 row marked CANCELLED.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:01:04 +03:00
parent fa3742d582
commit 5adf3dd04f
13 changed files with 765 additions and 418 deletions
+46 -4
View File
@@ -140,7 +140,7 @@ The system is a **Jetson Orin Nano Super-hosted onboard companion** that deliver
**Infrastructure**:
- **No cloud orchestration**. The companion is an embedded edge device; the operator's workstation is a single host that runs the operator tooling (C11 Tile Manager + C12 Operator Pre-flight Orchestrator) and a local `satellite-provider` mirror or VPN-reaches the lab `satellite-provider`.
- **Two binaries shipped on every PR** (ADR-002): `deployment-binary` (links the production-default strategy on each component + the mandatory simple-baseline; CMake `BUILD_VINS_MONO=OFF`, `BUILD_SALAD=OFF`, …) and `research-binary` (links every available strategy on every component; all `BUILD_*` flags `ON`, used for the IT-12 comparative study). The deployment binary is what installs onto an operational Jetson; the research binary runs on dev/lab Jetson hardware for the comparative-study report. The same code base produces both — ADR-002 mechanism scales to additional binary variants later if packaging strategy requires it.
- **Two airborne binaries shipped on every PR** (ADR-002): `deployment-binary` (links the production-default strategy on each component + the mandatory simple-baseline; CMake `BUILD_VINS_MONO=OFF`, `BUILD_SALAD=OFF`, …) and `research-binary` (links every available strategy on every component; all `BUILD_*` flags `ON`, used for the IT-12 comparative study). The deployment binary is what installs onto an operational Jetson; the research binary runs on dev/lab Jetson hardware for the comparative-study report. The same code base produces both — ADR-002 mechanism scales to additional binary variants later if packaging strategy requires it. **Replay is not a separate binary** (ADR-011): the deployment-binary runs both live and replay modes from the same image, swapping `FrameSource` / `FcAdapter` / `MavlinkTransport` strategies at startup based on `config.mode`. A third binary — `operator-orchestrator` (C10 + C11 + C12) — ships from the same source tree for the operator workstation; the airborne deployment-binary does NOT contain the operator-orchestrator components (ADR-004 process isolation).
- **Container scope**: Tier-1 uses Docker (`docker compose` for the developer setup including a `mock-suite-sat-service` container, the operator-orchestrator container, and a Postgres for C6). **Tier-2 (Jetson) does NOT use Docker** — TensorRT INT8 calibration caches and `jetson-stats` thermal telemetry are most reliable without a container layer, per D-C7-9 + D-C10-6. The deployed image on the Jetson is a JetPack-based system image with the deployment binary preinstalled.
- **Scaling**: not applicable (per-UAV, single companion). Failover is per-airframe (the FC's IMU-only fallback at AC-5.2 is the system's "scale-out").
@@ -167,8 +167,8 @@ source repo
│ └─ tier2 (self-hosted Jetson) AC-bound suite (NFT-PERF-*, NFT-LIM-*, IT-12)
├─→ release artifacts:
│ ├─ deployment-binary tarball (production-default strategies + mandatory baselines, ADR-002)
│ ├─ research-binary tarball (all strategies linked; for IT-12 comparative study)
│ ├─ deployment-binary tarball (production-default strategies + mandatory baselines + replay strategies, ADR-002 + ADR-011; runs both live and replay modes from a single image)
│ ├─ research-binary tarball (all strategies linked; for IT-12 comparative study; also includes replay strategies)
│ ├─ JetPack image (deployment-binary preinstalled)
│ └─ operator-orchestrator tarball (C11 + C12 + e2e-test mock-suite-sat-service compose for offline integration testing)
@@ -647,4 +647,46 @@ The ADR-009 "interface, not concrete" rule has an architectural sibling: cross-c
- C5 gains a `set_takeoff_origin(origin, sigma_horiz_m, sigma_vert_m)` method on the `StateEstimator` protocol (AZ-490). Protocol contract version bumps to v1.1.0.
- C12 gains the `FlightsApiClient` boundary + offline `--flight-file` path (AZ-489).
- Principle #11 (the spoofed-GPS gate) is extended with the bounded-delta clause; the gate now serves both takeoff and mid-flight.
- The companion binary's network surface is unchanged — only C12 (operator-side, separate binary) talks to the flights service.
- The companion binary's network surface is unchanged — only C12 (operator-side, separate binary) talks to the flights service.
### ADR-011 — Replay is a configuration of the airborne binary, not a separate image (REVERSES the v1.0.0 four-binary design)
**Context**: The original Decompose Step 2 design for epic AZ-265 (E-DEMO-REPLAY) treated replay as a **fourth Docker image** (`gps-denied-replay-cli`) built from the same source tree with a different `BUILD_*` flag combination — specifically `BUILD_C6=OFF`, `BUILD_C10=OFF`, `BUILD_C11=OFF`, `BUILD_C12=OFF`, plus the new replay-only build flags ON. The justification was the same as ADR-002 for the live/research/operator split: minimize binary size, attack surface, and accidental-selection risk. An SBOM-diff CI step was specified (AZ-403) to enforce the exclusion of the four "off" components from the replay binary.
Two facts surfaced during the Step 7 (Implement) batch loop that contradicted this design:
1. **The C2 (VPR) → C6 dependency cannot be honestly removed.** C2 retrieves candidate tiles by querying the C6 `DescriptorIndex` (FAISS HNSW over pre-built per-tile descriptors). With C6 absent the index has no host, and C2's `VprStrategy.lookup(c1)` either returns empty (replay produces no positioning fixes, defeating epic AC-3 of ≤ 100 m for ≥ 80 % of ticks) or has to be backed by a parallel "lite" index variant (which is not the production code path and therefore destroys the epic's premise that demo confidence equals field-test confidence on the same footage). Either way the v1.0.0 design's `BUILD_C6=OFF` flag for replay conflicts with the v1.0.0 epic AC-3.
2. **The user requirement is the opposite of binary isolation.** Replay's purpose is "demo confidence equals field-test confidence on the same footage" — i.e., the demo and the real flight should run **exactly** the same code path. Reducing the binary's component set (even one with a sound technical justification like ADR-002) actively works against that purpose: any divergence between the replay image and the airborne image becomes a potential source of demo↔field drift that no SBOM diff can detect once the two binaries' source trees evolve independently.
**Decision**:
1. **Replay is a configuration of the airborne binary.** The airborne Docker image is the replay image. No fourth Docker image, no SBOM-diff CI step, no `BUILD_C6=OFF` for replay. The operator runs the same image with the same `gps-denied-onboard` entry point (or its sibling `gps-denied-replay` console-script wrapper) — only the config differs.
2. **The mode-aware decision is `config.mode = "live" | "replay"` resolved once at startup in `compose_root`.** The composition root branch (the single point of mode awareness in the codebase) swaps three strategies and adds one observer:
- `FrameSource`: `LiveCameraFrameSource` ↔ `VideoFileFrameSource`.
- `FcAdapter`: `PymavlinkArdupilotAdapter` / `Msp2InavAdapter` ↔ `TlogReplayFcAdapter`.
- `MavlinkTransport`: `SerialMavlinkTransport` ↔ `NoopMavlinkTransport` (the outbound bytes go nowhere in replay; the C8 encoder code path is unchanged — see Invariant 5 of the replay protocol).
- **Adds** `JsonlReplaySink` as an additional listener on C5's `EstimatorOutput` stream (replay-only; the UI consumes the JSONL file). The live binary's downstream sinks (C8 outbound to FC, QGC telemetry adapter, C13 FDR) are unchanged.
3. **A new `replay_input/` Layer-4 cross-cutting module owns `(video, tlog)` → `(FrameSource, FcAdapter, Clock)` convergence.** It instantiates the replay strategies, applies the time-offset (manual or auto via AZ-405), and hands the composition root a `ReplayInputBundle`. The composition root sees no `if mode == "replay"` plumbing — it sees standard `FrameSource` + `FcAdapter` + `Clock` instances. This is the architectural mechanism that delivers Principle #13's interface-first promise for the replay-vs-live boundary.
4. **Operator pre-flight workflow is identical between replay and live.** The operator plans a route in the parent-suite Mission Planner UI (`suite/ui`); the route persists in the `flights` REST service; C12 reads the `Flight`, derives the bbox + takeoff origin, calls C11 `TileDownloader` against `satellite-provider`, builds the C10 cache (descriptor index + engines + manifest). The only step that differs is "go fly" → "run `gps-denied-replay` against video + tlog". The companion image consumes the cache identically in both modes (Invariant 12 of the replay protocol).
5. **MAVLink emit destinations in replay are no-op sinks for non-UI consumers.** The C8 outbound encoders (`GPS_INPUT`, GCS `STATUSTEXT`, `NAMED_VALUE_FLOAT`, `MAV_CMD_SET_EKF_SOURCE_SET`) run unchanged; their byte streams hit `NoopMavlinkTransport` and disappear. The user-confirmed design intent: the **only** position output the UI cares about in replay is the per-tick C5 `EstimatorOutput`, which is captured by `JsonlReplaySink` and tailed by the parent-suite UI. MAVLink signing key is mandatory in both modes (Invariant 11 of the replay protocol — the operator supplies a dummy key file for replay; the signing handshake runs and its bytes are dropped by the noop transport).
6. **Three binaries, not four.** The active build matrix returns to the ADR-002 cadence: **airborne** (Tier-1 + Tier-2 production; live + replay both run from this image), **research** (IT-12 comparative-study, mirrors airborne plus the additional VioStrategy / VprStrategy variants), **operator-orchestrator** (pre-flight workflows on operator workstation). The replay-cli column is removed from `module-layout.md`'s Build-Time Exclusion Map; the replay-only `BUILD_*` flags (`BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL`) are ON in airborne and research, OFF in operator-orchestrator.
**Alternatives considered**:
1. **Keep the fourth `gps-denied-replay-cli` binary with `BUILD_C6=OFF`** (status quo of v1.0.0) — rejected for the two reasons in the Context section: the C2→C6 dependency makes `BUILD_C6=OFF` incompatible with epic AC-3, and the very purpose of replay (demo↔field fidelity) is undermined by any source-tree divergence the SBOM-diff step cannot detect.
2. **Keep the fourth binary but with `BUILD_C6=ON`** — rejected: same code as airborne minus C10/C11/C12, which is exactly what airborne already is (the airborne binary already excludes C10/C11/C12 per ADR-002 / ADR-004). The fourth binary would be byte-identical to the airborne image; maintaining it as a separate CI artifact adds work for zero gain.
3. **Make replay an HTTP service rather than a CLI** — rejected as out-of-scope for this ADR (the parent-suite UI subprocess + JSONL tail design predates this decision and is not in scope here). The replay CLI / live entry-point split is a CLI shape concern, not an architectural concern; the airborne binary remains a long-lived process with no HTTP listener.
4. **Move the JSONL sink to a different output (e.g., piped into stdout, or a unix socket)** — deferred. The current `results.jsonl` file output is the simplest UI-tailable contract and matches the parent-suite UI's subprocess assumption. If the UI later needs streaming-without-disk, the sink Protocol allows a `StdoutReplaySink` or `UnixSocketReplaySink` strategy without any change to the composition root.
**Consequences**:
- `_docs/02_document/contracts/replay/replay_protocol.md` is at **v2.0.0** (replaces v1.0.0). New invariants 5, 11, 12 codify the encoder-mode-agnosticism, the signing-key mandate, and the real-C6-cache-in-replay properties.
- `module-layout.md` Build-Time Exclusion Map drops the `Replay-cli` column; airborne column gains `BUILD_VIDEO_FILE_FRAME_SOURCE=ON`, `BUILD_TLOG_REPLAY_ADAPTER=ON`, `BUILD_REPLAY_SINK_JSONL=ON`. The narrative reduces "Four binaries…" to "Three binaries…".
- `module-layout.md` Cross-Cutting section gains a `replay_input/` entry (Layer-4 coordinator, owned by AZ-405).
- AZ-403 (replay-cli Dockerfile + SBOM diff CI step) is **cancelled**; its task file moves to `done/` with a cancellation banner pointing at this ADR. Its dependency edges (incoming from AZ-404, outgoing to nothing) are removed from `_docs/02_tasks/_dependencies_table.md`. The Jira ticket transition to "Cancelled" is recorded in `_docs/_process_leftovers/` if the tracker MCP is unavailable at execution time.
- AZ-401 shrinks: it no longer authors a separate `compose_replay` function; it extends `compose_root` with the `config.mode == "replay"` branch and wires `JsonlReplaySink` + `NoopMavlinkTransport`. Complexity drops from 3 → 2 points.
- AZ-402 shrinks: it is a thin mode-config wrapper that dispatches into the live entry point, not a standalone CLI.
- AZ-405 grows slightly: it now also owns the `replay_input/` coordinator (the natural home for the auto-sync logic + the time-offset application).
- AZ-404 (E2E replay test) is unchanged in scope but reworded: it asserts mode-agnosticism (Invariant 1) and runs against the unified airborne image — no fourth-image entrypoint to verify.
- C8 gains a thin `MavlinkTransport` Protocol seam introduced by AZ-400: `SerialMavlinkTransport` (live) and `NoopMavlinkTransport` (replay) implement it. This is a no-op restructure of the existing C8 transport code; the encoders are unchanged. The Protocol seam is the architectural mechanism for Invariant 5 (encoders are byte-identical).
- Demo↔field fidelity is now structurally guaranteed: the same binary runs in both contexts; any drift between them is a behavioural-test failure, not an SBOM-diff failure.
@@ -1,33 +1,52 @@
# Contract: Replay Mode (`FrameSource` + `ReplaySink` + `Clock` + replay composition)
# Contract: Replay Mode (`replay_input` module + `FrameSource` + `Clock` + `ReplaySink` + `NoopMavlinkTransport`)
**Owner**: replay (epic AZ-265 / E-DEMO-REPLAY) — strategies live inside existing components (`frame_source/`, `c8_fc_adapter/`); only the composition root and CLI are net-new top-level files.
**Owner**: replay (epic AZ-265 / E-DEMO-REPLAY) — strategies live inside existing components (`frame_source/`, `clock/`, `c8_fc_adapter/`); a small new `replay_input/` cross-cutting module converges `(video, tlog)` inputs into the standard `FrameSource` + `FcAdapter` boundaries the rest of the system already consumes.
**Producer task**: AZ-398 (`FrameSource` Protocol + `VideoFileFrameSource` + `LiveCameraFrameSource` retrofit + `Clock` Protocol)
**Consumer tasks**: AZ-399 (TlogReplayFcAdapter), AZ-400 (ReplaySink + JsonlReplaySink), AZ-401 (compose_replay + Clock injection), AZ-402 (gps-denied-replay CLI), AZ-403 (Dockerfile + CI matrix + SBOM diff), AZ-404 (E2E replay fixture test), AZ-405 (Auto-sync IMU take-off detection).
**Version**: 1.0.0
**Consumer tasks**: AZ-399 (TlogReplayFcAdapter), AZ-400 (ReplaySink + JsonlReplaySink + NoopMavlinkTransport), AZ-401 (replay-mode branch in `compose_root`), AZ-402 (gps-denied-replay CLI wrapper), AZ-404 (E2E replay fixture test), AZ-405 (Auto-sync IMU take-off detection inside `replay_input/`).
**Version**: 2.0.0 (replaces v1.0.0 — "replay is a fourth Docker image" design replaced by "replay is a configuration of the airborne binary"; see ADR-011)
**Status**: draft
**Last Updated**: 2026-05-10
**Last Updated**: 2026-05-14
**Module-layout home**:
- `src/gps_denied_onboard/frame_source/interface.py`, `__init__.py``FrameSource` Protocol (Layer 1 cross-cutting per `module-layout.md`).
- `src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py``TlogReplayFcAdapter` (gated `BUILD_TLOG_REPLAY_ADAPTER`).
- `src/gps_denied_onboard/components/c8_fc_adapter/replay_sink.py``ReplaySink` interface + `JsonlReplaySink` (gated `BUILD_REPLAY_SINK_JSONL`).
- `src/gps_denied_onboard/clock/interface.py`, `__init__.py``Clock` Protocol.
- `src/gps_denied_onboard/runtime_root/replay.py``compose_replay(config) -> ReplayRoot`.
- `src/gps_denied_onboard/clock/interface.py`, `__init__.py``Clock` Protocol (Layer 1 cross-cutting).
- `src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py``TlogReplayFcAdapter` strategy (gated `BUILD_TLOG_REPLAY_ADAPTER`; ON in the airborne binary).
- `src/gps_denied_onboard/components/c8_fc_adapter/replay_sink.py``ReplaySink` Protocol + `JsonlReplaySink` strategy (gated `BUILD_REPLAY_SINK_JSONL`; ON in the airborne binary).
- `src/gps_denied_onboard/components/c8_fc_adapter/noop_mavlink_transport.py``NoopMavlinkTransport` strategy (gated `BUILD_REPLAY_SINK_JSONL`; ON in the airborne binary; wraps the live MAVLink transport layer so C8 encoders are unchanged).
- `src/gps_denied_onboard/replay_input/` — new Layer-4 cross-cutting coordinator that owns `(video, tlog)``(FrameSource, FcAdapter, Clock)` convergence + auto-sync + time-offset application.
- `src/gps_denied_onboard/runtime_root/__init__.py``compose_root(config)` extended with a `config.mode = "live" | "replay"` branch (no separate `compose_replay` composition root; replay is a configuration of the single airborne composition root).
- `src/gps_denied_onboard/cli/replay.py``gps-denied-replay` console-script: builds a replay-mode `Config` and dispatches into the same companion entry point as live.
## Purpose
Defines the public interfaces enabling **offline replay mode** per epic AZ-265: run the production C1C5 pipeline against historical inputs (12 min Derkachi-style clip + matching pymavlink `.tlog`) so the parent-suite UI demo has end-to-end fidelity equal to a live flight. Production C1C5 components MUST remain mode-agnostic — replay-aware logic lives ONLY in the composition root, the new strategies, and the CLI. The replay binary is a fourth Docker image (`gps-denied-replay-cli`) containing C1C5 + replay strategies but NOT C6/C10/C11/C12 (no operator-side workflows; tile cache is read pre-built).
Defines the public interfaces enabling **offline replay mode** per epic AZ-265: run the production C1C5 pipeline (with the full C6 tile cache + the same C7 inference runtime + the same C13 FDR) against historical inputs (12 min Derkachi-style clip + matching pymavlink `.tlog`) so the parent-suite UI demo has end-to-end fidelity equal to a live flight.
This contract defines four Protocols and the replay composition surface:
- **`FrameSource`** — the formalised cross-cutting interface for camera-frame ingestion (previously implicit). Two strategies: `LiveCameraFrameSource` (retrofit; existing camera plumbing renamed and put behind the Protocol) and `VideoFileFrameSource` (replay-only, gated `BUILD_VIDEO_FILE_FRAME_SOURCE`).
- **`Clock`** — the wall-clock vs. tlog-derived time abstraction (R-DEMO-4 mitigation). Two strategies: `WallClock` (live/research/operator) and `TlogDerivedClock` (replay only).
- **`ReplaySink`** — the offline `EstimatorOutput` consumer interface. One strategy: `JsonlReplaySink` (one `EstimatorOutput` per JSONL line; gated `BUILD_REPLAY_SINK_JSONL`).
**Design (v2.0.0 — replaces v1.0.0)**: replay is a **configuration of the airborne binary**, not a separate Docker image. See ADR-011 for the full rationale. The same image, same components, same composition root, same pre-flight workflow as a live flight; only three strategies differ at runtime:
| Concern | Live strategy | Replay strategy |
|---|---|---|
| `FrameSource` | `LiveCameraFrameSource` | `VideoFileFrameSource` |
| `FcAdapter` (inbound IMU/attitude/GPS/flight-state) | `PymavlinkArdupilotAdapter` / `Msp2InavAdapter` | `TlogReplayFcAdapter` |
| `FcAdapter` outbound transport (the bytes that go onto the wire) | Real serial/UART link to ArduPilot Plane / iNav | `NoopMavlinkTransport` (sink; C8 encoders unchanged) |
| `Clock` | `WallClock` | `TlogDerivedClock` (pace=ASAP) or `WallClock` (pace=REALTIME) |
| Per-tick position observable to the UI | C8 outbound + GCS telemetry summary | Additional `JsonlReplaySink` tap on C5's `EstimatorOutput` stream |
Everything else is identical: C6 reads the same pre-built tile cache the operator built via the normal C10/C11/C12 pre-flight flow; C7 deserializes the same TensorRT engines; C13 writes a real FDR for the replay run (a real flight record, just driven by historical inputs). Production C1C5 components remain **mode-agnostic** — replay-aware logic lives ONLY in the composition root branch, the strategies named above, the `replay_input/` coordinator, and the CLI.
The user-visible result: a UI consumer tails the JSONL file and sees per-tick `(lat, lon, alt, horiz_accuracy)` exactly as the airborne binary would emit them in a real flight. Other MAVLink emits (FC GPS_INPUT, GCS STATUSTEXT, EKF source-set commands) are swallowed by `NoopMavlinkTransport` — the operator confirmed they don't need to be observable in replay (the contract above is the single source of truth for that decision).
This contract defines four Protocols, one coordinator class, and the replay-mode composition branch:
- **`FrameSource`** — formalised cross-cutting interface for camera-frame ingestion. Two strategies: `LiveCameraFrameSource` (live) and `VideoFileFrameSource` (replay; gated `BUILD_VIDEO_FILE_FRAME_SOURCE`).
- **`Clock`** — wall-clock vs. tlog-derived time abstraction (R-DEMO-4 mitigation). Two strategies: `WallClock` (live/research/operator/replay-realtime) and `TlogDerivedClock` (replay-asap).
- **`ReplaySink`** — offline `EstimatorOutput` consumer interface tapping C5's output stream. One strategy: `JsonlReplaySink` (one `EstimatorOutput` per JSONL line; gated `BUILD_REPLAY_SINK_JSONL`).
- **`TlogReplayFcAdapter`** — replay-only `FcAdapter` strategy (per AZ-261 `FcAdapter` Protocol from `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md`); parses pymavlink `.tlog` and emits `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` at tlog-timestamp cadence (or wall-clock-paced per `--pace`). Gated `BUILD_TLOG_REPLAY_ADAPTER`.
- **`NoopMavlinkTransport`** — replay-only outbound transport that swallows every byte the C8 encoders try to write. The C8 outbound encoder code path is **unchanged** between live and replay (Invariant 1); the transport layer is the only place the destination differs. Gated `BUILD_REPLAY_SINK_JSONL` (shares the build flag with `JsonlReplaySink` — both are "where does this binary send its outputs in replay" concerns).
- **`ReplayInputAdapter`** — Layer-4 coordinator class in `replay_input/` that owns `(video, tlog)` lifecycle, applies the time-offset (manual via `--time-offset-ms` or auto via AZ-405 IMU-take-off detection), instantiates `VideoFileFrameSource` + `TlogReplayFcAdapter` + chosen `Clock`, and hands the trio to the composition root. The composition root sees only standard `FrameSource` + `FcAdapter` + `Clock` after the coordinator is opened.
The shared `WgsConverter` (AZ-279) is constructor-injected into the tlog adapter for tlog-GPS → local-tangent-plane conversion.
The shared `WgsConverter` (AZ-279) is constructor-injected into the tlog adapter for tlog-GPS → local-tangent-plane conversion (unchanged from v1.0.0).
## Public API
### Protocol: `FrameSource`
### Protocol: `FrameSource` (unchanged from v1.0.0)
```python
@runtime_checkable
@@ -36,7 +55,7 @@ class FrameSource(Protocol):
def close(self) -> None: ...
```
### Protocol: `Clock`
### Protocol: `Clock` (unchanged from v1.0.0)
```python
@runtime_checkable
@@ -46,7 +65,7 @@ class Clock(Protocol):
def sleep_until_ns(self, target_ns: int) -> None: ... # honoured in --pace realtime; no-op in --pace asap
```
### Protocol: `ReplaySink`
### Protocol: `ReplaySink` (unchanged from v1.0.0)
```python
@runtime_checkable
@@ -55,7 +74,7 @@ class ReplaySink(Protocol):
def close(self) -> None: ...
```
### Concrete: `TlogReplayFcAdapter`
### Concrete: `TlogReplayFcAdapter` (unchanged from v1.0.0)
```python
class TlogReplayFcAdapter(FcAdapter):
@@ -65,12 +84,81 @@ class TlogReplayFcAdapter(FcAdapter):
target_fc_dialect: FcKind, # ARDUPILOT_PLANE | INAV
clock: Clock,
wgs_converter: WgsConverter,
time_offset_ms: int = 0, # auto-detected by AZ-405 auto-sync task or set via --time-offset-ms
time_offset_ms: int = 0, # set by ReplayInputAdapter (auto-sync or --time-offset-ms)
pace: ReplayPace = ReplayPace.ASAP, # REALTIME | ASAP
): ...
```
The `TlogReplayFcAdapter` implements the full `FcAdapter` Protocol from AZ-261. `emit_external_position` raises `FcEmitError("replay adapter does not emit to FC")` (replay is read-only on the FC side; downstream consumers use `ReplaySink` instead). `request_source_set_switch` raises `SourceSetSwitchNotSupportedError`. `subscribe_telemetry` is the primary surface — fans out IMU/attitude/GPS-health/flight-state from the tlog at the configured pace.
The `TlogReplayFcAdapter` implements the **full** `FcAdapter` Protocol from AZ-261. `subscribe_telemetry` fans out IMU/attitude/GPS-health/flight-state from the tlog at the configured pace. `emit_external_position`, `emit_status_text`, and `request_source_set_switch` are implemented as **no-ops that delegate to the underlying transport** — in replay mode the transport is `NoopMavlinkTransport` (see below), so the bytes go nowhere; in live mode the same encoders shape the same bytes for a real wire. The encoder code path is identical; only the transport differs.
### Concrete: `NoopMavlinkTransport`
```python
class NoopMavlinkTransport(MavlinkTransport):
"""Outbound transport sink for replay mode.
Accepts every `write(payload: bytes)` and `close()` call without I/O.
Counts bytes written for observability (FDR + INFO log at close).
"""
def write(self, payload: bytes) -> None: ... # silent drop
def close(self) -> None: ...
def bytes_written(self) -> int: ... # observability
```
The C8 outbound encoders (per the v1.0.0 `FcAdapter` protocol — `emit_external_position`, `emit_status_text`, `request_source_set_switch`, and the `QgcTelemetryAdapter` 12 Hz GCS summary) operate over a constructor-injected `MavlinkTransport` interface (a new tiny Protocol introduced by AZ-401 to make this swap clean). In live mode the transport is `SerialMavlinkTransport` writing to the UART; in replay mode it is `NoopMavlinkTransport`. **The encoders themselves are unchanged** — they produce the same byte streams, including the MAVLink 2.0 signing handshake and per-flight key rotation. The signing key is mandatory in both modes (the operator supplies a dummy key for replay; the contract does not constrain the key's provenance).
This is the single architectural point that lets us say "replay is exactly like live, only the destination differs" without baking `if replay_mode:` branches into C8.
### Concrete: `ReplayInputAdapter`
```python
@dataclass(frozen=True, slots=True)
class ReplayInputBundle:
frame_source: FrameSource
fc_adapter: FcAdapter
clock: Clock
resolved_time_offset_ms: int
auto_sync_result: AutoSyncDecision | None # None when --time-offset-ms is provided
class ReplayInputAdapter:
"""Converges (video, tlog) into the standard FrameSource + FcAdapter + Clock surfaces.
Owns the time-alignment between video frames and tlog IMU/attitude ticks
(manual via --time-offset-ms or automatic via AZ-405 IMU-take-off detection).
Instantiates VideoFileFrameSource, TlogReplayFcAdapter, and the chosen Clock.
The composition root, after calling .open(), sees no replay-specific types.
"""
def __init__(
self,
*,
video_path: Path,
tlog_path: Path,
camera_calibration: CameraCalibration,
target_fc_dialect: FcKind,
wgs_converter: WgsConverter,
pace: ReplayPace,
manual_time_offset_ms: int | None, # None → auto-sync runs (AZ-405)
auto_sync_config: AutoSyncConfig,
) -> None: ...
def open(self) -> ReplayInputBundle:
"""Resolve time-offset (auto-sync or manual), build the strategies, return the bundle.
Raises:
ReplayInputAdapterError("tlog missing required message types: ...")
— R-DEMO-3 fail-fast at startup.
ReplayInputAdapterError("auto-sync hard-fail: ...")
— AC-8 of the epic (≤ 95 % frame-window match).
ReplayInputAdapterError("video file unreadable / unsupported codec / ...")
— VideoFileFrameSource opening failure surfaced at coordinator scope.
"""
...
def close(self) -> None: ... # closes both inputs; idempotent
```
### CLI surface
@@ -80,82 +168,103 @@ gps-denied-replay
--tlog PATH
--output results.jsonl
--camera-calibration calib.json
--config config.yaml
[--pace {realtime,asap}] # default asap
[--time-offset-ms N] # overrides auto-sync
--config config.yaml # same config schema as the airborne binary
--mavlink-signing-key PATH # mandatory; operator provides a dummy key for replay
[--pace {realtime,asap}] # default asap
[--time-offset-ms N] # overrides AZ-405 auto-sync
```
The CLI is a thin **mode-config wrapper**: it loads `config.yaml`, sets `config.mode = "replay"` and the replay-specific paths/flags, and calls the **same** entry point the live binary uses. The shared entry point calls `compose_root(config)` which returns a wired runtime; the runtime's per-frame loop is unchanged between live and replay.
### Composition root extension
```python
def compose_replay(config: Config) -> ReplayRoot: ...
```
`runtime_root/__init__.py` exposes a single `compose_root(config) -> Runtime` (no separate `compose_replay`). When `config.mode == "replay"`:
1. Build a `ReplayInputAdapter` from `config.replay.{video_path, tlog_path, pace, time_offset_ms, …}` + the same `CameraCalibration` and `WgsConverter` the live path already uses.
2. Call `replay_input.open()``ReplayInputBundle(frame_source, fc_adapter, clock, …)`.
3. Pick the `MavlinkTransport` strategy: `NoopMavlinkTransport` (replay) vs. `SerialMavlinkTransport` (live), based on `config.mode`.
4. Add a `JsonlReplaySink` subscriber to C5's `EstimatorOutput` stream (replay only). The live binary already emits to C8 outbound + QGC telemetry adapter; the JSONL sink is an additional listener, not a replacement.
5. Wire C1C5 + C6 + C7 + C13 exactly as in the live composition (Invariant 1 — components see the same interfaces).
6. Return the wired `Runtime` whose per-frame loop is the existing one (single source of truth — no per-mode loop).
`ReplayRoot` is a dataclass holding all wired components plus the `FrameSource`, `TlogReplayFcAdapter`, `ReplaySink`, and `Clock` chosen for the replay run. The runtime loop is:
```
loop:
frame = frame_source.next_frame()
frame = frame_source.next_frame() # VideoFileFrameSource in replay
if frame is None: break
c1 = vio.process(frame) # C1
candidates = vpr.lookup(c1) # C2
reranked = rerank.rerank(candidates) # C2.5
matched = matcher.match(reranked) # C3
refined = refiner.refine_if_needed(matched) # C3.5
pose = pose_estimator.estimate(refined) # C4
state.add_pose_anchor(pose) # C5
state.add_vio(c1.vio_output) # C5
c1 = vio.process(frame) # C1
candidates = vpr.lookup(c1) # C2 (uses real C6 DescriptorIndex)
reranked = rerank.rerank(candidates) # C2.5
matched = matcher.match(reranked) # C3
refined = refiner.refine_if_needed(matched) # C3.5
pose = pose_estimator.estimate(refined) # C4
state.add_pose_anchor(pose) # C5
state.add_vio(c1.vio_output) # C5
output = state.current_estimate()
replay_sink.emit(output)
replay_sink.close()
# multiple listeners, all wired by the composition root:
fc_adapter.emit_external_position(output) # → NoopMavlinkTransport in replay; SerialMavlinkTransport live
fdr.write(output) # C13: ALWAYS, both modes
if replay_sink is not None: # replay only
replay_sink.emit(output) # JsonlReplaySink → JSONL file → UI tails it
```
The tlog adapter's `subscribe_telemetry` callbacks are wired to C5's `add_fc_imu` and to C1's IMU prior on the same threads as in the live binary.
Side notes:
- The tlog adapter's `subscribe_telemetry` callbacks are wired to C5's `add_fc_imu` and to C1's IMU prior on the same threads as in the live binary (Invariant 1 — same threads, same callbacks, different source).
- `set_takeoff_origin` (AZ-490 / ADR-010) is invoked identically in replay: the operator's pre-flight C10 Manifest is the source of truth in both modes. The tlog's first GPS fix is the **fallback**, gated through the same Principle #11 bounded-delta check.
- `BUILD_FAISS_INDEX` is ON in the airborne binary (live and replay alike). C2 in replay queries the **real** C6 `FaissDescriptorIndex`, populated by the pre-flight C10 build. This is the architectural change vs. v1.0.0 of this contract.
## Invariants
1. **Mode-agnostic C1C5**: production components MUST NOT contain `if replay_mode:` branches. Mode-specific behaviour lives in the strategy (Frame source / FC adapter / Sink / Clock). Verified by an explicit grep guard in CI.
2. **Single `Clock` per process**: the composition root resolves `Clock` exactly once at startup. All time-driven logic (AC-5.2 fallback timer, STATUSTEXT rate-limits, key rotation logging) consumes the injected `Clock` via constructor — never `time.monotonic_ns()` directly. Verified by an AST scan in CI for direct `time.monotonic_ns` / `time.time_ns` references in components.
1. **Mode-agnostic C1C7, C13**: production components MUST NOT contain `if config.mode == "replay":` branches. Mode-specific behaviour lives in the strategies (FrameSource / FcAdapter / MavlinkTransport / ReplaySink / Clock). Verified by an explicit grep guard in CI (the AZ-404 E2E test owns this assertion).
2. **Single `Clock` per process**: `compose_root` resolves `Clock` exactly once at startup. All time-driven logic (AC-5.2 fallback timer, STATUSTEXT rate-limits, key rotation logging) consumes the injected `Clock` via constructor — never `time.monotonic_ns()` directly. Verified by an AST scan in CI for direct `time.monotonic_ns` / `time.time_ns` references in `components/**/*.py`.
3. **Frame source ordering**: `next_frame()` returns frames in monotonically non-decreasing `monotonic_ns` order. Out-of-order frames raise `FrameSourceError` (NOT silently dropped — replay must be deterministic).
4. **End-of-stream is None**: `next_frame()` returns `None` ONLY when the stream is permanently exhausted. Transient I/O failures raise `FrameSourceError`.
5. **TlogReplayFcAdapter emit-only-via-sink**: `emit_external_position` and `emit_status_text` raise `FcEmitError("replay adapter does not emit to FC")`. Downstream consumers MUST emit to `ReplaySink` instead.
5. **Outbound MAVLink encoders are mode-agnostic**: the C8 outbound encoders for `GPS_INPUT` / `MSP2_SENSOR_GPS` / `STATUSTEXT` / `NAMED_VALUE_FLOAT` / `MAV_CMD_SET_EKF_SOURCE_SET` produce identical byte streams in both modes. Only the `MavlinkTransport` strategy differs (Serial vs. Noop). The MAVLink 2.0 signing handshake runs in replay too (the operator provides a dummy signing key); the signing bytes are produced and then dropped by `NoopMavlinkTransport`. Verified by a unit test that captures the encoder output in both modes and diffs the byte streams.
6. **Pace mode honoured by Clock**: `pace=REALTIME``Clock.sleep_until_ns(target_ns)` blocks until wall-clock catches up; `pace=ASAP` → no-op. The pace flag is consumed ONLY by the `Clock` and the tlog adapter — components see only the `Clock` Protocol.
7. **JsonlReplaySink one-line-per-emit**: each `emit(output)` writes exactly one JSON object + newline; the file is fsync'd on `close()`. Schema matches `EstimatorOutput` (frozen dataclass serialised via `dataclasses.asdict` + `orjson.dumps`).
8. **Time-offset honoured**: when constructed with `time_offset_ms != 0`, the tlog adapter shifts every emitted timestamp by that offset before passing to subscribers. `time_offset_ms` is set ONCE at construction (no live re-tuning).
9. **Build-flag gating**: `VideoFileFrameSource`, `TlogReplayFcAdapter`, `JsonlReplaySink` MUST refuse construction when their respective `BUILD_*` flag is OFF (per ADR-002 — replay binary has them ON; airborne / research / operator have them OFF).
8. **Time-offset resolved before composition**: the `ReplayInputAdapter` resolves `time_offset_ms` (auto-sync or manual) and locks it into the `TlogReplayFcAdapter` constructor before `compose_root` returns the wired runtime. No live re-tuning.
9. **Build-flag gating**: `VideoFileFrameSource`, `TlogReplayFcAdapter`, `JsonlReplaySink`, `NoopMavlinkTransport` MUST refuse construction when their respective `BUILD_*` flag is OFF (per ADR-002). In the airborne binary all four flags are ON by default; setting any of them OFF in airborne disables replay mode (the binary still runs live mode normally).
10. **Determinism**: same `(video, tlog, config, time_offset_ms, pace=ASAP)` input → same JSONL output within ≤ 1e-6 float drift in position fields (AC-5).
11. **MAVLink signing key required in replay**: the airborne binary refuses to run without `--mavlink-signing-key PATH` in both modes. In replay the operator supplies a dummy file (well-formed key bytes; no real channel to verify against). This preserves Invariant 5 — the encoders' signing code path runs identically in both modes.
12. **Real C6 cache in replay**: the airborne binary in replay mode reads the same pre-built C6 tile cache the operator built via the normal pre-flight C10/C11/C12 flow. There is no replay-specific cache shape. Verified by the AZ-404 E2E fixture, which runs the operator's pre-flight flow before invoking the replay CLI.
## Producer / Consumer Split
| Task ID | Scope |
|---------|-------|
| AZ-398 (Producer) | `FrameSource` Protocol; `Clock` Protocol; `VideoFileFrameSource` (gated `BUILD_VIDEO_FILE_FRAME_SOURCE`); `LiveCameraFrameSource` retrofit (rename existing camera-ingest plumbing into the Protocol shape — no behaviour change); `WallClock` + `TlogDerivedClock` strategies; composition wiring in the existing `compose_root`/`compose_operator` (Clock = WallClock there). NO tlog parsing, NO sink, NO replay composition. |
| AZ-399 (Consumer 1) | `TlogReplayFcAdapter`: pymavlink stream-parser (DO NOT materialise; R-DEMO-2 throughput floor); maps tlog message types → `FcTelemetryFrame`; supports both AP and iNav dialects; `subscribe_telemetry` fan-out at the configured pace; respects `time_offset_ms`; honours `Clock` for pacing; fail-fast at startup if required message types absent (R-DEMO-3). |
| AZ-400 (Consumer 2) | `ReplaySink` Protocol + `JsonlReplaySink` (one JSON object per line; orjson serialiser; `close()` fsyncs). |
| AZ-401 (Consumer 3) | `compose_replay(config) -> ReplayRoot`: full strategy resolution for the replay binary; `Clock` strategy selection (TlogDerivedClock for ASAP, WallClock for REALTIME; documented per R-DEMO-4); `FrameSource` = `VideoFileFrameSource`; `FcAdapter` = `TlogReplayFcAdapter`; `Sink` = `JsonlReplaySink`; ALL of C1C5 wired with the same Public API as the live binary. NO C6/C10/C11/C12. Configuration loading + camera-calibration loading. |
| AZ-402 (Consumer 4) | `gps-denied-replay` CLI entrypoint: argparse, config + calibration loader, runtime loop (the loop body documented in this contract above), structured-error exit codes (0=success, 2=AC-8 sync-impossible, 1=any other error). |
| AZ-403 (Consumer 5) | `gps-denied-replay-cli` Dockerfile (multi-stage; Python + C1C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server) + GitHub Actions matrix entry + SBOM diff CI step verifying absence of excluded components per AC-4. |
| AZ-404 (Consumer 6) | E2E replay fixture test: `tests/e2e/replay/test_derkachi_1min.py` — runs the CLI against a 12 min Derkachi clip + matching tlog; asserts AC-3 (≤ 100 m for ≥ 80 % of ticks); gated by `RUN_REPLAY_E2E=1` in CI. |
| AZ-405 (Consumer 7) | Auto-sync of video ↔ tlog via IMU take-off detection (AC-7 / AC-8). Take-off pattern: sustained vertical accel > 0.5 g + change in attitude rate > 1 rad/s lasting ≥ 0.5 s (typical quadcopter signature). Confidence-scored; falls back to WARN + best-guess if < 80 %; `--time-offset-ms` always overrides; AC-8 hard-fail (exit 2) if neither auto-detect nor manual offset produces > 95 % frame-window match. |
| AZ-398 (Producer) | `FrameSource` Protocol; `Clock` Protocol; `VideoFileFrameSource` (gated `BUILD_VIDEO_FILE_FRAME_SOURCE`); `LiveCameraFrameSource` retrofit (rename existing camera-ingest plumbing into the Protocol shape — no behaviour change); `WallClock` + `TlogDerivedClock` strategies; composition wiring in `compose_root` (Clock = WallClock in live, picked per-pace in replay). NO tlog parsing, NO sink, NO replay coordinator. |
| AZ-399 (Consumer 1) | `TlogReplayFcAdapter`: pymavlink stream-parser (DO NOT materialise; R-DEMO-2 throughput floor); maps tlog message types → `FcTelemetryFrame`; supports both AP and iNav dialects; `subscribe_telemetry` fan-out at the configured pace; respects `time_offset_ms`; honours `Clock` for pacing; outbound `emit_*` methods delegate to constructor-injected `MavlinkTransport` (Invariant 5); fail-fast at startup if required message types absent (R-DEMO-3). |
| AZ-400 (Consumer 2) | `ReplaySink` Protocol + `JsonlReplaySink` (one JSON object per line; orjson serialiser; `close()` fsyncs). **Also**: `MavlinkTransport` Protocol cut-out + `NoopMavlinkTransport` strategy + `SerialMavlinkTransport` retrofit (rename the existing C8 transport code into the Protocol shape — no behaviour change). |
| AZ-401 (Consumer 3) | Extend `compose_root(config)` with a `config.mode = "live" \| "replay"` branch: in replay mode, builds the `ReplayInputAdapter`, picks `NoopMavlinkTransport`, adds the `JsonlReplaySink` listener on C5's `EstimatorOutput` stream, and otherwise wires C1C7 + C13 identically to live. Build-flag check at startup. NO separate `compose_replay` function (replay is a configuration of the single composition root). |
| AZ-402 (Consumer 4) | `gps-denied-replay` CLI: argparse, config + calibration loader, sets `config.mode = "replay"`, dispatches into the same companion entry point as live; structured-error exit codes (0=success, 2=AC-8 sync-impossible from `ReplayInputAdapter.open()`, 1=any other error). |
| AZ-404 (Consumer 6) | E2E replay fixture test: `tests/e2e/replay/test_derkachi_1min.py` — runs the CLI against a 12 min Derkachi clip + matching tlog; asserts AC-3 (≤ 100 m for ≥ 80 % of ticks); gated by `RUN_REPLAY_E2E=1` in CI. Asserts Invariant 1 (no `if config.mode == "replay"` branches in components) via an AST scan. |
| AZ-405 (Consumer 7) | Auto-sync of video ↔ tlog via IMU take-off detection. Lives **inside `replay_input/`** (this task creates the module): take-off pattern (sustained vertical accel > 0.5 g + change in attitude rate > 1 rad/s lasting ≥ 0.5 s) + video motion-onset; confidence-scored; falls back to WARN + best-guess if < 80 %; `--time-offset-ms` always overrides; AC-8 hard-fail (exit 2) if neither auto-detect nor manual offset produces > 95 % frame-window match. The `ReplayInputAdapter` coordinator is also defined and implemented by this task (it is the natural home for the auto-sync logic — the coordinator owns the time-alignment concern, and auto-sync is one of the two ways the offset is resolved). |
**AZ-403 (formerly: replay-cli Dockerfile + SBOM diff CI step) is CANCELLED**: the replay-cli Docker image no longer exists under v2.0.0. The airborne Docker image IS the replay image; no SBOM diff is needed because there are no components to assert as absent. See `_docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md` (cancellation banner) and the ADR-011 amendment in `architecture.md`.
## Constraints
- `@runtime_checkable` on all Protocols; DTOs `frozen=True, slots=True`.
- Lazy-import per ADR-002 with the new `BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL` flags.
- C1C5 components MUST remain mode-agnostic (Invariant 1).
- Lazy-import per ADR-002 with the new `BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL` flags. All three flags are ON in the airborne binary (production-default); OFF in the operator-orchestrator binary; the research binary mirrors airborne (ON).
- C1C7 + C13 components MUST remain mode-agnostic (Invariant 1).
- All time-driven logic in components MUST consume the injected `Clock` (Invariant 2).
- No HTTP server in the replay binary (parent-suite UI shells out to the CLI; defer until subprocess shape is proven insufficient).
- No HTTP server in the airborne binary regardless of mode (parent-suite UI shells out to the CLI and tails the JSONL file; defer until the subprocess shape is proven insufficient).
- pymavlink bundled unmodified per D-C8-3.
- The tlog parser MUST stream-parse — never materialise the entire tlog into memory (R-DEMO-2; multi-GB tlogs).
- MAVLink 2.0 signing key is mandatory in both modes (Invariant 11). The replay run reuses the live binary's per-flight key-load code path; the operator supplies a dummy key file.
## Risks / Mitigations
- **R-DEMO-1** (tlog ↔ video timestamp drift / unsynchronised recordings): auto-sync via IMU take-off detection (AC-7) + `--time-offset-ms` manual override. Fixed-wing hand-launch fallback documented.
- **R-DEMO-1** (tlog ↔ video timestamp drift / unsynchronised recordings): auto-sync via IMU take-off detection (AC-7) + `--time-offset-ms` manual override. Fixed-wing hand-launch fallback documented. Owned by `replay_input/` per AZ-405.
- **R-DEMO-2** (pymavlink slow on multi-GB tlogs): stream-parse, never materialise. Throughput floor benchmarked + documented in CI.
- **R-DEMO-3** (demo footage missing required FC messages): `TlogReplayFcAdapter.open(...)` fails fast at startup, listing missing message types and the components that need them.
- **R-DEMO-4** (production C1C5 paths bake real-time-cadence assumptions): `Clock` injection (Invariants 1, 2). Documented as ADR amendment in next architecture-doc cycle.
- **R-DEMO-3** (demo footage missing required FC messages): `ReplayInputAdapter.open(...)` fails fast at startup, listing missing message types and the components that need them.
- **R-DEMO-4** (production C1C5 paths bake real-time-cadence assumptions): `Clock` injection (Invariants 1, 2). Captured in ADR-011 (architecture.md).
- **R-DEMO-5 (new in v2.0.0)** (live and replay diverge silently because the modes share a composition root): mitigated by Invariant 1 (no mode-aware branches in components) + Invariant 5 (encoders are byte-identical) + the AZ-404 E2E test asserting both invariants on every PR. The single composition root is the single point of mode awareness.
## Notes for the Implementer
- The `LiveCameraFrameSource` retrofit is a no-op restructure: the existing camera-ingest thread becomes a class implementing `FrameSource`. Its behaviour is unchanged. This is what allows C1 to consume `FrameSource` via constructor without becoming replay-aware.
- The `TlogReplayFcAdapter`'s `subscribe_telemetry` fan-out runs on a dedicated thread (mirroring the live `PymavlinkArdupilotAdapter` decode-thread semantics). This way C1 and C5 see identical thread boundaries in live and replay.
- The `SerialMavlinkTransport` retrofit (introduced by AZ-400) is a no-op restructure: the existing pymavlink transport code becomes a class implementing the new tiny `MavlinkTransport` Protocol. Its behaviour is unchanged. This is what allows C8 outbound encoders to remain identical between live and replay.
- The `TlogReplayFcAdapter`'s `subscribe_telemetry` fan-out runs on a dedicated thread (mirroring the live `PymavlinkArdupilotAdapter` decode-thread semantics). C1 and C5 see identical thread boundaries in live and replay (Invariant 1).
- The `Clock` Protocol is the SAME interface in live and replay — only the strategy differs. This is the single Liskov-clean line that lets components consume `Clock` without knowing the mode.
- The `ReplayInputAdapter` lives at `src/gps_denied_onboard/replay_input/__init__.py` (public) + `tlog_video_adapter.py` (concrete) + `auto_sync.py` (AZ-405 logic). It is a Layer-4 module per `module-layout.md` (it imports from Layer 1 `frame_source/` and `clock/` interfaces, and instantiates Layer-4 strategies from `c8_fc_adapter/`). The composition root imports the **public API** of `replay_input/` only; it does not reach into the coordinator's internals.
- The parent-suite UI demo flow: operator plans a route in the suite UI → C12 builds the cache → operator runs `gps-denied-replay --video ... --tlog ... --output results.jsonl` → UI tails `results.jsonl` and renders per-tick `(lat, lon, alt, horiz_accuracy)`. The operator's pre-flight workflow is **identical** to a live flight up until the final "fly" step. This is the user-confirmed design intent.
+91 -49
View File
@@ -38,7 +38,7 @@ Row 20 (E-CC-HELPERS / AZ-264) was added during Decompose Step 2 to comply with
| 18 | E-C8 | C8 FC + GCS Adapter | component | AZ-261 | L | 2134 | E-C5, E-CC-CONF, E-CC-LOG |
| 19 | E-BBT | Blackbox Tests (FT/NFT scenarios) | tests | AZ-262 | M | 1321 | every component epic ships its component-internal tests under its own epic; this one parents the suite-level FT/NFT scenarios in `_docs/02_document/tests/*.md` |
| 20 | E-CC-HELPERS | Cross-Cutting: Common Helpers (8 shared utilities) | cross-cutting | AZ-264 | M | 1321 | E-BOOT, E-CC-LOG (added in Decompose Step 2 — supersedes per-component helper child-issues from cycle 1) |
| 21 | E-DEMO-REPLAY | Offline replay mode (video + tlog → per-tick coordinate stream) | feature | AZ-265 | M | 2227 | E-C1, E-C2, E-C2.5, E-C3, E-C3.5, E-C4, E-C5, E-C8, E-CC-CONF (added in Decompose Step 2 — enables parent-suite UI demo via subprocess + JSONL streaming) |
| 21 | E-DEMO-REPLAY | Offline replay mode (video + tlog → per-tick coordinate stream) — configuration of the airborne binary (ADR-011), NOT a separate image | feature | AZ-265 | M | 1924 | E-C1, E-C2, E-C2.5, E-C3, E-C3.5, E-C4, E-C5, E-C6, E-C8, E-CC-CONF (added in Decompose Step 2 — enables parent-suite UI demo via subprocess + JSONL streaming) |
## High-level component dependency diagram
@@ -2091,34 +2091,46 @@ This epic IS the testing strategy for system-level scenarios. Per-component test
**Tracker**: AZ-265
**Type**: feature (deployment-adjacent)
**T-shirt**: M | **Story points**: 2732
**Added**: Decompose Step 2 (cycle 1, 2026-05-10)
**T-shirt**: M | **Story points**: 1924
**Added**: Decompose Step 2 (cycle 1, 2026-05-10)**revised 2026-05-14** per ADR-011 (replay-as-configuration; replaces the v1.0.0 four-binary design)
**Source notes**: `_docs/how_to_test.md` (user-written demo requirements — auto-sync incorporated as child task #8)
### System context
Demonstrate the GPS-denied positioning pipeline against historical flight data: a video file from the nav camera + a `.tlog` file from the FC. The replay mode runs the **same C1C5 inference pipeline** the airborne binary runs; only the input transport (live camera → video file; live MAVLink → tlog) and output sink (FC MAVLink emit → JSONL) differ. NO ROS dependency is added — replay reuses the existing C8 `FcAdapter` interface via the strategy pattern.
Demonstrate the GPS-denied positioning pipeline against historical flight data: a video file from the nav camera + a `.tlog` file from the FC. **Per ADR-011, replay is a configuration of the airborne binary, NOT a separate image.** The replay configuration runs the **same C1C7 + C13 pipeline** the airborne binary runs in live mode; only three strategies differ at startup (chosen by `config.mode = "replay"`):
- `FrameSource`: `VideoFileFrameSource` instead of `LiveCameraFrameSource`.
- `FcAdapter`: `TlogReplayFcAdapter` instead of `PymavlinkArdupilotAdapter` / `Msp2InavAdapter`.
- `MavlinkTransport`: `NoopMavlinkTransport` instead of `SerialMavlinkTransport` — the C8 outbound encoders run unchanged (the MAVLink bytes are produced and dropped; the user-confirmed design intent is that the only UI-visible output in replay is per-tick `EstimatorOutput` via `JsonlReplaySink`, see below).
Additionally, the composition root attaches a `JsonlReplaySink` as an extra listener on C5's `EstimatorOutput` stream — the parent-suite UI tails the resulting JSONL file for the per-tick coordinate display. C13 (FDR) still writes a real flight record (just driven by historical inputs); C8 outbound encoders still run their signing handshake + per-flight key rotation (the operator supplies a dummy signing key); C6 reads the same pre-built tile cache the operator built via the normal pre-flight C10/C11/C12 flow.
NO ROS dependency is added — replay reuses the existing C8 `FcAdapter` interface via the strategy pattern.
```mermaid
flowchart LR
subgraph LIVE[Airborne mode — unchanged]
CAM[Live camera] --> C1L[C1 VIO]
FCL[Live FC MAVLink] --> C8L[C8 inbound]
C8L --> C1L
C1L --> C2L[C2..C5]
C2L --> C8OL[C8 outbound] --> FCL
subgraph LIVE[Airborne mode — config.mode = "live"]
CAM[Live camera] --> FS1[LiveCameraFrameSource] --> C1L[C1 VIO]
FCL[Live FC MAVLink wire] --> SMTL[SerialMavlinkTransport in] --> FCAL[PymavlinkArdupilotAdapter] --> C1L
C1L --> C2C5L[C2..C5]
C2C5L --> C8OL[C8 outbound encoders] --> SMTLOUT[SerialMavlinkTransport out] --> FCL
C2C5L --> FDR[C13 FDR]
end
subgraph REPLAY[Replay mode — this epic]
VID[Video file .mp4/.h264] --> VFFS[VideoFileFrameSource] --> C1R[C1 VIO]
TLOG[tlog file] --> TLR[TlogReplayFcAdapter] --> C1R
C1R --> C2R[C2..C5]
C2R --> RSINK[JsonlReplaySink] --> JSONL[results.jsonl - one EstimatorOutput per tick]
subgraph REPLAY[Replay mode — config.mode = "replay"]
VID[Video file .mp4/.h264] --> RIA1[ReplayInputAdapter]
TLOG[tlog file] --> RIA1
RIA1 --> FS2[VideoFileFrameSource] --> C1R[C1 VIO]
RIA1 --> FCAR[TlogReplayFcAdapter] --> C1R
C1R --> C2C5R[C2..C5]
C2C5R --> C8OR[C8 outbound encoders] --> NMTOUT[NoopMavlinkTransport out — bytes dropped]
C2C5R --> RSINK[JsonlReplaySink] --> JSONL[results.jsonl — UI tails this]
C2C5R --> FDR2[C13 FDR]
end
```
### Problem / Context
The parent-suite UI (in `ui/` workspace, out of scope for this repo) needs to demo the GPS-denied positioning end-to-end. Per-component fixtures or simulators would not give the demo end-to-end fidelity. Instead, replay mode runs the production pipeline against historical inputs — demo confidence equals field test confidence on the same footage.
The parent-suite UI (in `ui/` workspace, out of scope for this repo) needs to demo the GPS-denied positioning end-to-end. Per-component fixtures or simulators would not give the demo end-to-end fidelity. Instead, replay mode runs the production pipeline against historical inputs — demo confidence equals field test confidence on the same footage. **ADR-011 makes this fidelity structural**: the same binary runs in both contexts, so any drift between them is a behavioural-test failure that any unit/integration test can catch, not an SBOM-diff failure between two separate source trees.
ROS as the input transport was considered and rejected: the system is MAVLink-native; introducing ROS would (a) add a major new dependency, (b) split production vs. demo code paths, and (c) duplicate code. Reusing the existing C8 `FcAdapter` interface with a tlog-replay strategy is strictly better.
@@ -2128,24 +2140,29 @@ ROS as the input transport was considered and rejected: the system is MAVLink-na
- `FrameSource` interface (formalised cross-cutting; previously implicit "camera ingest thread") + `VideoFileFrameSource` strategy + `LiveCameraFrameSource` retrofit (no-op restructure of existing camera plumbing).
- `TlogReplayFcAdapter` strategy (new C8 `FcAdapter` impl) parsing pymavlink `.tlog` files and emitting `ImuWindow` / `AttitudeWindow` / `GpsHealth` / `FlightStateSignal` at tlog timestamp cadence.
- `ReplaySink` interface + `JsonlReplaySink` impl (one `EstimatorOutput` per line).
- `compose_replay(config) -> ReplayRoot` composition root extending E-CC-CONF (AZ-246).
- `Clock` injection (per R-DEMO-4) so timer-driven logic in C1C5 works in both wall-clock (live) and tlog-simulated (replay) modes.
- `gps-denied-replay` CLI: `--video PATH --tlog PATH --output results.jsonl --camera-calibration calib.json --config config.yaml --pace {realtime,asap} [--time-offset-ms N]`.
- Fourth Docker image `gps-denied-replay-cli` (Python + C1C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server).
- E2E replay test on a 12 min Derkachi clip + matching tlog asserting estimated track within ≤ 100 m of ground-truth GPS for ≥ 80 % of ticks.
- `MavlinkTransport` Protocol seam in `c8_fc_adapter/` + `SerialMavlinkTransport` retrofit (no-op restructure of existing live MAVLink transport code) + `NoopMavlinkTransport` strategy — together they keep the C8 outbound encoders byte-identical between live and replay (per replay protocol Invariant 5).
- `replay_input/` Layer-4 cross-cutting coordinator (`ReplayInputAdapter`) that owns `(video, tlog)` lifecycle, applies the time-offset (manual or auto), and instantiates the three replay strategies above. Composition root sees only standard `FrameSource` + `FcAdapter` + `Clock` after the coordinator is opened.
- `Clock` injection (per R-DEMO-4) so timer-driven logic in C1C5 works in both wall-clock (live + replay-realtime) and tlog-simulated (replay-asap) modes.
- Extension of `compose_root(config)` with a `config.mode == "replay"` branch (NO separate `compose_replay` function; ADR-011).
- `gps-denied-replay` CLI: thin console-script wrapper that loads `config.yaml`, sets `config.mode = "replay"`, applies the replay-specific paths/flags, and dispatches into the same companion entry point as `gps-denied-onboard`.
- E2E replay test on a 12 min Derkachi clip + matching tlog asserting estimated track within ≤ 100 m of ground-truth GPS for ≥ 80 % of ticks. Asserts mode-agnosticism (replay protocol Invariant 1) via AST scan.
**Out of scope**:
- ROS / ROS2 dependency.
- HTTP wrapper microservice (parent-suite UI backend shells out to the CLI; defer until subprocess-shape is proven insufficient).
- Modifying any C1C5 component to be replay-aware — they MUST remain mode-agnostic.
- C6 mid-flight write path (replay reads a pre-built tile cache; doesn't write).
- Modifying any C1C7 + C13 component to be replay-aware — they MUST remain mode-agnostic (replay protocol Invariant 1).
- C6 mid-flight write path (replay reads a pre-built tile cache via the same pre-flight C10/C11/C12 flow; doesn't write).
- A fourth Docker image (`gps-denied-replay-cli`) — **dropped per ADR-011**; the airborne image IS the replay image; AZ-403 is cancelled.
- An SBOM-diff CI step for the replay binary — **dropped per ADR-011**; no separate binary exists to diff.
### Architecture notes
- ADR-001 / ADR-002 / ADR-009 all apply unchanged.
- New `BUILD_*` flags: `BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL`. Default ON for the new replay-cli binary; OFF for airborne, research, and operator-orchestrator.
- ADR-001 / ADR-002 / ADR-009 / **ADR-011** all apply. ADR-011 is the design-defining decision for this epic — read it first.
- New `BUILD_*` flags: `BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL` (the last one gates BOTH `JsonlReplaySink` and `NoopMavlinkTransport`). **All three default ON for the airborne and research binaries; OFF for operator-orchestrator.** The airborne binary serves both `config.mode = "live"` and `config.mode = "replay"` from a single image.
- New cross-cutting `FrameSource` interface lives at `src/gps_denied_onboard/frame_source/` (Layer 1 Foundation per `module-layout.md` § layering).
- `compose_replay` lives in `runtime_root.py` alongside `compose_root` and `compose_operator`.
- New cross-cutting `Clock` interface lives at `src/gps_denied_onboard/clock/` (Layer 1 Foundation).
- New cross-cutting `replay_input/` coordinator lives at `src/gps_denied_onboard/replay_input/` (Layer 4 Adapters — it instantiates Layer-4 strategies).
- `compose_root(config)` in `runtime_root/__init__.py` gains a `config.mode` branch. **No separate `compose_replay` function.**
### Interface specification
@@ -2157,8 +2174,19 @@ class FrameSource(Protocol):
class VideoFileFrameSource(FrameSource):
def __init__(self, video_path: Path, frame_rate_hz: float, camera_id: str): ...
class TlogReplayFcAdapter(FcAdapter): # FcAdapter from AZ-261 / E-C8
def __init__(self, tlog_path: Path, target_fc_dialect: enum {ARDUPILOT, INAV}): ...
class TlogReplayFcAdapter(FcAdapter): # FcAdapter from AZ-261 / E-C8; outbound emits delegate to MavlinkTransport
def __init__(self, tlog_path: Path, target_fc_dialect: enum {ARDUPILOT, INAV},
clock: Clock, wgs_converter: WgsConverter,
mavlink_transport: MavlinkTransport, # NoopMavlinkTransport in replay
time_offset_ms: int = 0, pace: ReplayPace = ReplayPace.ASAP): ...
class MavlinkTransport(Protocol): # new tiny Protocol seam introduced by AZ-400
def write(self, payload: bytes) -> None: ...
def close(self) -> None: ...
class NoopMavlinkTransport(MavlinkTransport):
def __init__(self) -> None: ...
def bytes_written(self) -> int: ... # observability (FDR + INFO log at close)
class ReplaySink(Protocol):
def emit(self, output: EstimatorOutput) -> None: ...
@@ -2167,72 +2195,86 @@ class ReplaySink(Protocol):
class JsonlReplaySink(ReplaySink):
def __init__(self, output_path: Path): ...
def compose_replay(config: Config) -> ReplayRoot: ...
class ReplayInputAdapter: # cross-cutting coordinator in replay_input/
def __init__(self, *, video_path: Path, tlog_path: Path,
camera_calibration: CameraCalibration, target_fc_dialect: FcKind,
wgs_converter: WgsConverter, pace: ReplayPace,
manual_time_offset_ms: int | None,
auto_sync_config: AutoSyncConfig) -> None: ...
def open(self) -> ReplayInputBundle: ... # FrameSource + FcAdapter + Clock + resolved offset
def close(self) -> None: ...
def compose_root(config: Config) -> Runtime: ... # branches on config.mode internally
```
### Data flow
Startup → load config / calibration → process tlog + video timestamp-aligned → for each frame: camera-ingest → C1 → C2 → C2.5 → C3 → C3.5 → C4 → C5 → emit `EstimatorOutput` to `JsonlReplaySink`. End of input → close sink → exit.
Startup → load config / calibration → if `config.mode == "replay"`: build `ReplayInputAdapter``.open()` → wire its bundle into the same C1C5 graph as live + add `JsonlReplaySink` listener + pick `NoopMavlinkTransport`. Per-frame loop is identical to live: `FrameSource → C1 → C2 → C2.5 → C3 → C3.5 → C4 → C5 → emit_external_position (encoder bytes → noop transport in replay) + fdr.write + replay_sink.emit (replay only)`. End of input → close sink → exit.
`--pace realtime` paces frames at wall-clock; `--pace asap` runs uncapped (default). The injected `Clock` is wall-clock-derived in `realtime` mode and tlog-timestamp-derived in `asap` mode so component fallback timers (e.g., AC-5.2 3 s no-estimate fallback) trigger consistently in both.
### Dependencies
- E-C1, E-C2, E-C2.5, E-C3, E-C3.5, E-C4, E-C5, E-C8 (every per-frame component).
- **E-C6** — replay uses the real C6 `FaissDescriptorIndex` to query tiles, identically to live. (This is the architectural change vs. the v1.0.0 epic spec, which excluded C6 from the replay binary.)
- E-CC-CONF (AZ-246) for `compose_root` extension.
- E-CC-HELPERS (AZ-264) for `WgsConverter` (tlog GPS → local-tangent-plane).
- Does NOT depend on E-C6 / E-C10 / E-C11 / E-C12 (replay reads pre-built cache; no operator-side workflows).
- Does NOT depend on E-C10 / E-C11 / E-C12 — these are operator-side concerns; the operator runs the normal pre-flight C10/C11/C12 flow against the operator-orchestrator binary BEFORE the replay run on the airborne binary.
### Acceptance criteria
- AC-1: CLI exits 0 on a valid 1-min fixture and produces JSONL with one `EstimatorOutput` line per tlog tick (within ±5 % of `GLOBAL_POSITION_INT` count).
- AC-1: `gps-denied-replay` exits 0 on a valid 1-min fixture and produces JSONL with one `EstimatorOutput` line per tlog tick (within ±5 % of `GLOBAL_POSITION_INT` count).
- AC-2: Each line is a valid JSON object matching the `EstimatorOutput` schema.
- AC-3: For a fixture with known ground-truth GPS, the L2 horizontal distance ≤ 100 m for ≥ 80 % of ticks (matches AC-1.3 cumulative-drift bound).
- AC-4: Replay binary contains C1C5 + replay strategies; SBOM diff CI step verifies absence of C6/C10/C11/C12.
- AC-4 (revised per ADR-011): The airborne binary running in `config.mode == "replay"` is byte-identical to the airborne binary running in `config.mode == "live"` for the C1C7 + C13 components and the C8 outbound encoders. Verified via Invariant 1 (no-mode-branches AST scan in components) + Invariant 5 (encoder-byte-stream diff in unit tests) in AZ-404. **No SBOM diff** — there is only one binary.
- AC-5: Same input → same output (deterministic) within ≤ 1e-6 float drift in position fields.
- AC-6: `--pace realtime` runs the 1-min fixture in 60 ± 5 s; `--pace asap` in ≤ 30 s on Tier-1 hardware.
- AC-7: Without `--time-offset-ms`, the CLI auto-detects the video ↔ tlog offset by correlating video motion-onset (or first-frame timestamp) with the tlog IMU take-off pattern (sustained vertical accel > 0.5 g + change in attitude rate > 1 rad/s lasting ≥ 0.5 s, matching the typical quadcopter take-off signature). On a fixture with known correct offset, the auto-detected offset is within ± 200 ms of ground truth. If auto-detect confidence is < 80 % the CLI logs a WARN and proceeds with the best-guess offset; `--time-offset-ms N` always overrides the auto-detect.
- AC-8: If neither auto-detect nor manual offset can produce > 95 % of frames with at least one matching IMU window within ± 100 ms, the CLI exits with code 2 and prints both the auto-detected offset (if any) and the percentage of frames-with-IMU-window so the operator can debug.
- AC-9 (new per ADR-011): The operator's pre-flight workflow for a replay run is identical to a live flight up to the final "fly" step — plan route in suite UI → C12 build cache from real `satellite-provider` → confirm content-hash → run `gps-denied-replay` instead of running the airborne binary on the UAV. Verified by the AZ-404 E2E fixture's setup (which runs the operator pre-flight flow before invoking the replay CLI).
- AC-10 (new per ADR-011): The `--mavlink-signing-key PATH` CLI arg is mandatory in replay mode (the operator supplies a dummy key file); the C8 outbound signing handshake runs in replay and its bytes are dropped by `NoopMavlinkTransport`. Verified by a unit test asserting `NoopMavlinkTransport.bytes_written() > 0` after a replay run.
### Non-functional requirements
- Cold-start ≤ 5 s (not subject to AC-NEW-1's 30 s budget — that's airborne-only).
- Cold-start ≤ 5 s (not subject to AC-NEW-1's 30 s budget — that's live-airborne-only).
- Throughput ≥ 5 × real time on Jetson AGX Orin for `--pace asap`.
- Memory ≤ 4 GB resident (lean image; no FAISS index unless tile lookup is needed).
- Memory ≤ 4 GB resident (note: the airborne image's nominal memory budget is 8 GB shared on Jetson Orin Nano Super; replay has the same memory headroom as live).
### Risks & mitigations
- **R-DEMO-1**: Tlog ↔ video timestamp drift across long flights, AND the more-common case that recordings on the operator workstation are not synchronised at all (camera and FC start independently, often minutes apart). **Mitigation**: auto-sync via IMU take-off detection (AC-7) is the default; `--time-offset-ms N` is the manual override. If take-off pattern is ambiguous (e.g., fixed-wing hand-launch instead of quadcopter, or tlog includes pre-arm motion), CLI WARNs and falls back to the manual override.
- **R-DEMO-2**: Pymavlink slow on multi-GB tlogs. **Mitigation**: stream-parse, never materialise; benchmark + document throughput floor.
- **R-DEMO-3**: Demo footage missing required FC messages (HIL mode etc.). **Mitigation**: CLI fails fast at startup listing missing message types and the components that need them.
- **R-DEMO-4**: Production C1C5 paths bake real-time-cadence assumptions (e.g., 5 s fallback timer). **Mitigation**: `Clock` injection (wall-clock for live, tlog-derived for replay); documented as ADR amendment in next architecture-doc cycle.
- **R-DEMO-3**: Demo footage missing required FC messages (HIL mode etc.). **Mitigation**: `ReplayInputAdapter.open()` fails fast at startup, listing missing message types and the components that need them.
- **R-DEMO-4**: Production C1C5 paths bake real-time-cadence assumptions (e.g., 5 s fallback timer). **Mitigation**: `Clock` injection (wall-clock for live + replay-realtime, tlog-derived for replay-asap); captured in ADR-011.
- **R-DEMO-5 (new per ADR-011)**: Live and replay diverge silently because they share one composition root. **Mitigation**: replay protocol Invariant 1 (no mode-aware branches in components) enforced by AST scan in AZ-404 + Invariant 5 (encoder byte streams identical between modes) enforced by unit-test diff. Any drift becomes a test failure, not a silent dependency-set divergence as it would have been under the v1.0.0 four-binary design.
### Effort
T-shirt M; 2732 points across 8 child tasks.
T-shirt M; 1924 points across 7 child tasks (was 2732 across 8; AZ-403 dropped per ADR-011; AZ-401 shrank from 3 → 2 points).
### Child issues
| # | Title | Pts |
|---|-------|-----|
| 1 | `FrameSource` interface (cross-cutting) + `VideoFileFrameSource` strategy + `LiveCameraFrameSource` retrofit | 3 |
| 2 | `TlogReplayFcAdapter` strategy (pymavlink stream parser → inbound DTOs) | 5 |
| 3 | `ReplaySink` interface + `JsonlReplaySink` impl | 3 |
| 4 | `compose_replay(config)` + `Clock` injection (per R-DEMO-4) | 3 |
| 5 | `gps-denied-replay` CLI entrypoint + arg parser + camera-calibration loader | 3 |
| 6 | `gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff (excludes C6/C10/C11/C12) | 3 |
| 7 | E2E replay fixture test (Derkachi 12 min clip + tlog; AC-3 ≤100 m ≥ 80 % assertion) | 5 |
| 8 | Auto-sync of video ↔ tlog via IMU take-off detection (AC-7 / AC-8; `--time-offset-ms` remains the manual override) | 5 |
| 2 | `TlogReplayFcAdapter` strategy (pymavlink stream parser → inbound DTOs; outbound emits via injected `MavlinkTransport`) | 5 |
| 3 | `ReplaySink` interface + `JsonlReplaySink` impl + `MavlinkTransport` Protocol seam + `SerialMavlinkTransport` retrofit + `NoopMavlinkTransport` | 3 |
| 4 | Extend `compose_root(config)` with `config.mode == "replay"` branch (NO separate composition root); wire JSONL sink + `NoopMavlinkTransport` | 2 |
| 5 | `gps-denied-replay` console-script wrapper (mode-config dispatcher) | 3 |
| ~~6~~ | ~~`gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff~~ | ~~CANCELLED per ADR-011~~ |
| 7 | E2E replay fixture test (Derkachi 12 min clip + tlog; AC-3 ≤ 100 m ≥ 80 % assertion + AC-4 mode-agnosticism + AC-9 operator workflow) | 5 |
| 8 | Auto-sync of video ↔ tlog via IMU take-off detection (AC-7 / AC-8) + the `ReplayInputAdapter` coordinator under `replay_input/` | 5 |
### Key constraints
- ADR-001 / ADR-002 / ADR-009.
- C1C5 components MUST remain mode-agnostic; replay-aware logic lives only in the composition root, the new strategies, and the CLI.
- No HTTP server in any companion binary (airborne or replay); HTTP wrapper, if added later, lives in operator-orchestrator per `module-layout.md` Layer-4 placement.
- ADR-001 / ADR-002 / ADR-009 / **ADR-011**.
- C1C7 + C13 components MUST remain mode-agnostic; replay-aware logic lives only in the composition root branch, the new strategies (FrameSource / FcAdapter / MavlinkTransport / ReplaySink / Clock), the `replay_input/` coordinator, and the CLI wrapper.
- No HTTP server in the airborne binary regardless of mode; HTTP wrapper, if added later, lives in operator-orchestrator per `module-layout.md` Layer-4 placement.
- MAVLink 2.0 signing key is mandatory in both modes (replay protocol Invariant 11).
### Testing strategy
Unit tests under `tests/unit/frame_source/`, `tests/unit/c8_fc_adapter/test_tlog_replay_adapter.py`, `tests/unit/c8_fc_adapter/test_replay_sink.py`, `tests/unit/cli/test_replay_cli.py`. E2E under `tests/e2e/replay/` running the CLI against the Derkachi fixture (Tier-1 capable; gated by `RUN_REPLAY_E2E=1` in CI). No FT/NFT scenarios at this epic — those live in E-BBT.
Unit tests under `tests/unit/frame_source/`, `tests/unit/c8_fc_adapter/test_tlog_replay_adapter.py`, `tests/unit/c8_fc_adapter/test_replay_sink.py`, `tests/unit/c8_fc_adapter/test_noop_mavlink_transport.py`, `tests/unit/replay_input/`, `tests/unit/cli/test_replay_cli.py`. E2E under `tests/e2e/replay/` running the CLI against the Derkachi fixture (Tier-1 capable; gated by `RUN_REPLAY_E2E=1` in CI). No FT/NFT scenarios at this epic — those live in E-BBT.
---
+54 -41
View File
@@ -13,7 +13,7 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec
1. The single top-level Python package is `src/gps_denied_onboard/`. All imports are rooted there. No sibling packages live under `src/`.
2. Each component owns ONE folder under `src/gps_denied_onboard/components/`. Folder name = component slug (lowercase, snake_case, e.g. `c1_vio`, `c2_vpr`, `c2_5_rerank`).
3. Cross-cutting concerns own ONE folder each directly under `src/gps_denied_onboard/`: `_types/`, `helpers/`, `config/`, `logging/`, `fdr_client/`, `frame_source/`, `clock/`. Plus `runtime_root.py` and `healthcheck.py` at the package root.
3. Cross-cutting concerns own ONE folder each directly under `src/gps_denied_onboard/`: `_types/`, `helpers/`, `config/`, `logging/`, `fdr_client/`, `frame_source/`, `clock/`, `replay_input/`. Plus `runtime_root.py` and `healthcheck.py` at the package root.
4. Native (C++) libraries live under `cpp/` (parallel to `src/`, NOT nested), built by CMake; per-component pybind11 wrappers live at `src/gps_denied_onboard/components/<component>/_native/<name>.py` and import the resulting `.so` from a CMake-known path.
5. **Public API surface per component** = the files listed in each component's `Public API` list below. Anything not listed is internal and MUST NOT be imported from another component.
6. The composition root is `src/gps_denied_onboard/runtime_root.py`. It is the ONLY place that may import concrete strategy implementations across components — every other cross-component dependency is constructor-injected against an interface (ADR-009).
@@ -187,20 +187,22 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec
### Component: c8_fc_adapter
- **Epic**: AZ-261 (E-C8 FC + GCS Adapter)
- **Replay extensions epic**: AZ-265 (E-DEMO-REPLAY) — adds `tlog_replay_adapter.py` + `replay_sink.py` as gated strategies
- **Replay extensions epic**: AZ-265 (E-DEMO-REPLAY) — adds `tlog_replay_adapter.py` + `replay_sink.py` + `noop_mavlink_transport.py` as gated strategies; live transport code is retrofitted as `SerialMavlinkTransport` behind a new `MavlinkTransport` Protocol seam (no behaviour change) so the C8 outbound encoders are byte-identical between live and replay (replay protocol Invariant 5)
- **Directory**: `src/gps_denied_onboard/components/c8_fc_adapter/`
- **Public API**:
- `__init__.py` (re-exports `FcAdapter`, `GcsAdapter`, `ReplaySink`, `EmittedExternalPosition`)
- `interface.py` (`FcAdapter`, `GcsAdapter` Protocols; `ReplaySink` Protocol lives in `replay_sink.py` per the replay contract)
- `__init__.py` (re-exports `FcAdapter`, `GcsAdapter`, `ReplaySink`, `MavlinkTransport`, `EmittedExternalPosition`)
- `interface.py` (`FcAdapter`, `GcsAdapter`, `MavlinkTransport` Protocols; `ReplaySink` Protocol lives in `replay_sink.py` per the replay contract)
- **Internal**:
- `pymavlink_ardupilot_adapter.py` (ArduPilot Plane via pymavlink)
- `msp2_inav_adapter.py` (iNav via MSP2)
- `mavlink_gcs_adapter.py` (12 Hz downsampled summary to QGroundControl)
- `tlog_replay_adapter.py` (replay-only `FcAdapter`; gated `BUILD_TLOG_REPLAY_ADAPTER`; AZ-265)
- `replay_sink.py` (`ReplaySink` interface + `JsonlReplaySink` impl; gated `BUILD_REPLAY_SINK_JSONL`; AZ-265)
- `tlog_replay_adapter.py` (replay-mode `FcAdapter`; gated `BUILD_TLOG_REPLAY_ADAPTER`; ON in airborne per ADR-011; AZ-265)
- `replay_sink.py` (`ReplaySink` interface + `JsonlReplaySink` impl; gated `BUILD_REPLAY_SINK_JSONL`; ON in airborne per ADR-011; AZ-265)
- `noop_mavlink_transport.py` (`NoopMavlinkTransport` for replay-mode outbound bytes; gated `BUILD_REPLAY_SINK_JSONL`; ON in airborne; AZ-265 / AZ-400)
- `serial_mavlink_transport.py` (`SerialMavlinkTransport` retrofit of the existing live-mode UART transport; AZ-265 / AZ-400 no-op restructure)
- **Owns**: `src/gps_denied_onboard/components/c8_fc_adapter/**`, `tests/unit/c8_fc_adapter/**`
- **Imports from**: `_types` (`EstimatorOutput` DTO lives here), `helpers.wgs_converter`, `helpers.se3_utils`, `config`, `logging`, `fdr_client`, `clock` (for replay timer-injection). NEVER `from gps_denied_onboard.components.c5_state import ...` inside `c8_fc_adapter/*.py` — the `EstimatorOutput` DTO is consumed exclusively via `_types`.
- **Consumed by**: `c1_vio` (back-channel: ImuSample, AttitudeWindow), `c5_state` (back-channel: ImuSample, FlightStateSignal, GpsHealth), `runtime_root` (live + operator + replay binaries)
- **Consumed by**: `c1_vio` (back-channel: ImuSample, AttitudeWindow), `c5_state` (back-channel: ImuSample, FlightStateSignal, GpsHealth), `runtime_root` (live + operator binaries; replay is a mode of the airborne binary per ADR-011, not a separate composition root)
> **Back-channel note**: C8 is the source of inbound IMU / attitude / GPS-health signals from the FC. C1 and C5 receive these via constructor-injected `FcAdapter` (typed against the interface, not the concrete adapter). This is NOT a layering violation — C8's role spans both the outbound emit path AND the inbound telemetry source.
@@ -377,23 +379,35 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec
### shared/clock
- **Directory**: `src/gps_denied_onboard/clock/`
- **Purpose**: `Clock` interface + `WallClock` (live) + `TlogDerivedClock` (replay). Per R-DEMO-4: production C1C5 paths bake real-time-cadence assumptions (e.g., AC-5.2 3 s no-estimate fallback timer); injected `Clock` lets replay mode trip those timers consistently against tlog timestamps rather than wall-clock.
- **Owned by**: AZ-265 (E-DEMO-REPLAY) — child task #4 (`compose_replay` + `Clock` injection).
- **Consumed by**: `c1_vio`, `c5_state`, `c8_fc_adapter`, any component with timer-driven fallback logic; `runtime_root` (selects WallClock for live/research/operator, TlogDerivedClock for replay).
- **Purpose**: `Clock` interface + `WallClock` (live + replay-realtime) + `TlogDerivedClock` (replay-asap). Per R-DEMO-4: production C1C5 paths bake real-time-cadence assumptions (e.g., AC-5.2 3 s no-estimate fallback timer); injected `Clock` lets replay mode trip those timers consistently against tlog timestamps rather than wall-clock.
- **Owned by**: AZ-265 (E-DEMO-REPLAY) — task AZ-398 (`FrameSource` + `Clock`).
- **Consumed by**: `c1_vio`, `c5_state`, `c8_fc_adapter`, any component with timer-driven fallback logic; `runtime_root` (selects the strategy per `config.mode` + `config.replay.pace`).
### shared/replay_input
- **Directory**: `src/gps_denied_onboard/replay_input/`
- **Purpose**: Layer-4 cross-cutting coordinator that converges `(video, tlog)` inputs into the standard `FrameSource` + `FcAdapter` + `Clock` surfaces the airborne composition root consumes. Owns the time-alignment between video frames and tlog IMU/attitude ticks (manual via `--time-offset-ms` or automatic via the AZ-405 IMU-take-off detector). The composition root, in replay mode, builds a `ReplayInputAdapter`, calls `.open()`, and wires the returned `ReplayInputBundle` into the same C1C5 pipeline as live. New under ADR-011 (replaces the v1.0.0 design where replay was a separate composition root).
- `__init__.py` (re-exports `ReplayInputAdapter`, `ReplayInputBundle`, `AutoSyncDecision`, `AutoSyncConfig`)
- `interface.py` (`ReplayInputAdapter` class declaration + `ReplayInputBundle` DTO)
- `tlog_video_adapter.py` (concrete `ReplayInputAdapter` that instantiates `VideoFileFrameSource` + `TlogReplayFcAdapter` + chosen `Clock`)
- `auto_sync.py` (AZ-405 IMU-take-off / video-motion-onset detectors + combined offset computation + AC-8 frame-window-match validator)
- `tests/`
- **Owned by**: AZ-265 (E-DEMO-REPLAY) — task AZ-405 (auto-sync + coordinator).
- **Consumed by**: `runtime_root` (replay-mode branch of `compose_root`); `cli/replay.py`. Layer-4 module: imports from Layer 1 (`frame_source` interface, `clock` interface, `_types`, `config`, `logging`, `fdr_client`, `helpers.wgs_converter`) and instantiates Layer-4 strategies (`c8_fc_adapter.tlog_replay_adapter`, `frame_source.video_file_frame_source`). Does NOT import from Layer 3 (no component-level dependencies).
### shared/runtime_root
- **File**: `src/gps_denied_onboard/runtime_root.py`
- **Purpose**: Composition root — config → strategy resolution → graph wiring (ADR-009). The ONLY place that may import concrete strategy classes across components. Per-binary CMake `BUILD_*` flags + composition root validator enforce ADR-002 build-time exclusion. Hosts `compose_root(config)` (airborne), `compose_operator(config)` (operator), and `compose_replay(config)` (replay-cli).
- **Owned by**: AZ-263 (Bootstrap stub); per-component additions that wire a new strategy are owned jointly by the bootstrap epic and the consuming component task (touching `runtime_root.py` is allowed only via the explicit "wire-in" task in each component's epic). The `compose_replay` extension is owned by AZ-265 child task #4.
- **Consumed by**: the airborne binary entrypoint + the operator-orchestrator binary entrypoint + the research/comparative binary entrypoint + the replay-cli binary entrypoint.
- **Purpose**: Composition root — config → strategy resolution → graph wiring (ADR-009). The ONLY place that may import concrete strategy classes across components. Per-binary CMake `BUILD_*` flags + composition root validator enforce ADR-002 build-time exclusion. Hosts `compose_root(config)` (airborne; serves both `config.mode == "live"` and `config.mode == "replay"` per ADR-011) and `compose_operator(config)` (operator-orchestrator). No separate `compose_replay` function — replay is a configuration of `compose_root`, not a sibling composition root.
- **Owned by**: AZ-263 (Bootstrap stub); per-component additions that wire a new strategy are owned jointly by the bootstrap epic and the consuming component task (touching `runtime_root.py` is allowed only via the explicit "wire-in" task in each component's epic). The replay-mode branch of `compose_root` is owned by AZ-401.
- **Consumed by**: the airborne binary entrypoint (live + replay modes), the operator-orchestrator binary entrypoint, and the research/comparative binary entrypoint.
### shared/cli/replay
- **File**: `src/gps_denied_onboard/cli/replay.py`
- **Purpose**: `gps-denied-replay` CLI entrypoint. Args: `--video PATH --tlog PATH --output results.jsonl --camera-calibration calib.json --config config.yaml --pace {realtime,asap} [--time-offset-ms N]`.
- **Purpose**: `gps-denied-replay` console-script wrapper around the airborne entrypoint. Args: `--video PATH --tlog PATH --output results.jsonl --camera-calibration calib.json --config config.yaml --mavlink-signing-key PATH --pace {realtime,asap} [--time-offset-ms N]`. Loads the config, sets `config.mode = "replay"` and the replay-specific paths, and dispatches into the SAME companion entry point as the live `gps-denied-onboard` CLI. No standalone composition root, no separate process model — just a mode-config wrapper per ADR-011.
- **Owned by**: AZ-265 (E-DEMO-REPLAY) — child task #5.
- **Consumed by**: the `gps-denied-replay-cli` Docker image entrypoint; parent-suite UI backend (subprocess shell-out per AZ-265 architecture decision).
- **Consumed by**: the parent-suite UI backend (subprocess shell-out per AZ-265 architecture decision; the operator runs the same airborne Docker image with `gps-denied-replay` as the entry command).
### shared/healthcheck
@@ -409,7 +423,7 @@ Read top-to-bottom; an upper layer may import from a lower layer but NEVER the r
| Layer | Components / Modules | May import from |
|-------|---------------------|-----------------|
| 5. Entry / Composition | `runtime_root`, `cli/replay`, `healthcheck` | 1, 2, 3, 4 |
| 4. Adapters | c8_fc_adapter (incl. `tlog_replay_adapter` + `replay_sink`), c11_tile_manager, c10_provisioning, c12_operator_orchestrator, `frame_source/VideoFileFrameSource` + `frame_source/LiveCameraFrameSource` | 1, 2, 3 (limited — see notes) |
| 4. Adapters | c8_fc_adapter (incl. `tlog_replay_adapter` + `replay_sink` + `noop_mavlink_transport` + `serial_mavlink_transport`), c11_tile_manager, c10_provisioning, c12_operator_orchestrator, `frame_source/VideoFileFrameSource` + `frame_source/LiveCameraFrameSource`, `replay_input` | 1, 2, 3 (limited — see notes) |
| 3. Domain (runtime path) | c1_vio, c2_vpr, c2_5_rerank, c3_matcher, c3_5_adhop, c4_pose, c5_state, c13_fdr | 1, 2 |
| 2. Infrastructure | c6_tile_cache, c7_inference | 1 |
| 1. Foundation (shared) | `_types`, `config`, `logging`, `fdr_client`, `helpers/*`, `frame_source` (interface only), `clock` | (none) |
@@ -420,35 +434,34 @@ Read top-to-bottom; an upper layer may import from a lower layer but NEVER the r
- **C3 → C2.5 is BANNED at runtime** (R14): both must import `helpers.lightglue_runtime` instead. Enforced by the absence of any `from gps_denied_onboard.components.c2_5_rerank import ...` line inside `c3_matcher/`.
- **`runtime_root.py` may import any component's concrete impl**; everywhere else, cross-component imports go through the consumed component's Public API only.
## Build-Time Exclusion Map (ADR-002)
## Build-Time Exclusion Map (ADR-002 + ADR-011)
Four binaries are built from this codebase: **airborne** (Tier-1 + Tier-2 production), **research** (IT-12 comparative-study, links every strategy), **operator-orchestrator** (pre-flight workflows on operator workstation), **replay-cli** (offline `gps-denied-replay` against video + tlog; AZ-265).
Three binaries are built from this codebase: **airborne** (Tier-1 + Tier-2 production; runs BOTH live and replay modes from a single image per ADR-011), **research** (IT-12 comparative-study, links every strategy + the same replay strategies as airborne), **operator-orchestrator** (pre-flight workflows on operator workstation). There is no separate replay-cli binary.
| CMake flag | Components / native libs gated | Airborne | Research | Operator-tooling | Replay-cli |
|-----------|-------------------------------|----------|----------|------------------|------------|
| `BUILD_OKVIS2` | c1_vio/okvis2, cpp/okvis2 | ON | ON | OFF | ON |
| `BUILD_VINS_MONO` | c1_vio/vins_mono, cpp/vins_mono | OFF | ON | OFF | OFF |
| `BUILD_KLT_RANSAC` | c1_vio/klt_ransac, cpp/klt_ransac | ON (mandatory baseline) | ON | OFF | ON |
| `BUILD_VPR_<variant>` (UltraVPR, MegaLoc, MixVPR, SelaVPR, EigenPlaces, NetVLAD, SALAD) | c2_vpr/<variant> | UltraVPR ON, others OFF | all ON | OFF | UltraVPR ON, others OFF |
| `BUILD_TENSORRT_RUNTIME` | c7_inference/tensorrt_runtime | ON | ON | ON (operator pre-compiles engines) | ON |
| `BUILD_PYTORCH_RUNTIME` | c7_inference/pytorch_fp16_runtime | OFF | ON | OFF | OFF |
| `BUILD_C10_PROVISIONING` | c10_provisioning | OFF | OFF | ON | OFF |
| `BUILD_C11_TILE_MANAGER` | c11_tile_manager | OFF | OFF | ON | OFF |
| `BUILD_C12_OPERATOR_ORCHESTRATOR` | c12_operator_orchestrator | OFF | OFF | ON | OFF |
| `BUILD_GTSAM_BINDINGS` | cpp/gtsam_bindings (used by c4_pose + c5_state) | ON | ON | OFF | ON |
| `BUILD_FAISS_INDEX` | c6_tile_cache `FaissDescriptorIndex` (faiss-cpu wheel; runtime gate at `runtime_root.storage_factory` — no native target) | ON | ON | ON | OFF (replay reads pre-built cache only) |
| `BUILD_VIDEO_FILE_FRAME_SOURCE` | `frame_source/VideoFileFrameSource` (AZ-265) | OFF | OFF | OFF | ON |
| `BUILD_TLOG_REPLAY_ADAPTER` | `c8_fc_adapter/tlog_replay_adapter` (AZ-265) | OFF | OFF | OFF | ON |
| `BUILD_REPLAY_SINK_JSONL` | `c8_fc_adapter/replay_sink` (AZ-265) | OFF | OFF | OFF | ON |
| `BUILD_REPLAY_CLI` | `cli/replay.py` entrypoint + `compose_replay` wiring (AZ-265) | OFF | OFF | OFF | ON |
| `BUILD_LIVE_CAMERA_FRAME_SOURCE` | `frame_source/LiveCameraFrameSource` (AZ-265 retrofit) | ON | ON | OFF | OFF |
| CMake flag | Components / native libs gated | Airborne | Research | Operator-tooling |
|-----------|-------------------------------|----------|----------|------------------|
| `BUILD_OKVIS2` | c1_vio/okvis2, cpp/okvis2 | ON | ON | OFF |
| `BUILD_VINS_MONO` | c1_vio/vins_mono, cpp/vins_mono | OFF | ON | OFF |
| `BUILD_KLT_RANSAC` | c1_vio/klt_ransac, cpp/klt_ransac | ON (mandatory baseline) | ON | OFF |
| `BUILD_VPR_<variant>` (UltraVPR, MegaLoc, MixVPR, SelaVPR, EigenPlaces, NetVLAD, SALAD) | c2_vpr/<variant> | UltraVPR ON, others OFF | all ON | OFF |
| `BUILD_TENSORRT_RUNTIME` | c7_inference/tensorrt_runtime | ON | ON | ON (operator pre-compiles engines) |
| `BUILD_PYTORCH_RUNTIME` | c7_inference/pytorch_fp16_runtime | OFF | ON | OFF |
| `BUILD_C10_PROVISIONING` | c10_provisioning | OFF | OFF | ON |
| `BUILD_C11_TILE_MANAGER` | c11_tile_manager | OFF | OFF | ON |
| `BUILD_C12_OPERATOR_ORCHESTRATOR` | c12_operator_orchestrator | OFF | OFF | ON |
| `BUILD_GTSAM_BINDINGS` | cpp/gtsam_bindings (used by c4_pose + c5_state) | ON | ON | OFF |
| `BUILD_FAISS_INDEX` | c6_tile_cache `FaissDescriptorIndex` (faiss-cpu wheel; runtime gate at `runtime_root.storage_factory` — no native target) | ON | ON | ON |
| `BUILD_VIDEO_FILE_FRAME_SOURCE` | `frame_source/VideoFileFrameSource` (AZ-265) | ON (replay mode) | ON (replay mode) | OFF |
| `BUILD_TLOG_REPLAY_ADAPTER` | `c8_fc_adapter/tlog_replay_adapter` (AZ-265) | ON (replay mode) | ON (replay mode) | OFF |
| `BUILD_REPLAY_SINK_JSONL` | `c8_fc_adapter/replay_sink` + `c8_fc_adapter/noop_mavlink_transport` (AZ-265) | ON (replay mode) | ON (replay mode) | OFF |
| `BUILD_LIVE_CAMERA_FRAME_SOURCE` | `frame_source/LiveCameraFrameSource` (AZ-265 retrofit) | ON | ON | OFF |
The composition root validator at startup refuses to wire a strategy whose `BUILD_*` flag is OFF (raises `ConfigurationError` pointing at the offending strategy name + the missing flag).
The composition root validator at startup refuses to wire a strategy whose `BUILD_*` flag is OFF (raises `ConfigurationError` pointing at the offending strategy name + the missing flag). In airborne, all three replay-mode `BUILD_*` flags default ON so the same image serves both live and replay modes; an operator deployment that wishes to remove replay capability can flip them OFF at build time (the resulting binary will still run live mode normally).
Build-time exclusion is enforced by:
- CMake reading `cmake/build_options.cmake` per binary target.
- Per-binary CI matrix entry in `.github/workflows/ci.yml` (4 parallel build jobs).
- `ci/sbom_diff.py` step asserting each binary's SBOM contains exactly the expected component set (e.g., the airborne SBOM MUST NOT contain `c11_tile_manager`; the replay-cli SBOM MUST contain C1C5 + replay strategies and MUST NOT contain `c10_provisioning`).
- Per-binary CI matrix entry in `.github/workflows/ci.yml` (3 parallel build jobs: airborne, research, operator-orchestrator).
- `ci/sbom_diff.py` step asserting each binary's SBOM contains exactly the expected component set (e.g., the airborne SBOM MUST NOT contain `c11_tile_manager` or `c12_operator_orchestrator`; the operator-orchestrator SBOM MUST NOT contain `c1_vio` or any replay strategy). Note: there is no per-replay SBOM diff under ADR-011 — replay runs from the airborne image, which is already SBOM-diffed.
## Layout Conventions (reference)
@@ -464,12 +477,12 @@ Build-time exclusion is enforced by:
## Self-Verification Checklist
- [x] Every component in `_docs/02_document/components/` has a Per-Component Mapping entry (14 components: c1_vio, c2_vpr, c2_5_rerank, c3_matcher, c3_5_adhop, c4_pose, c5_state, c6_tile_cache, c7_inference, c8_fc_adapter, c10_provisioning, c11_tile_manager, c12_operator_orchestrator, c13_fdr).
- [x] Every shared / cross-cutting concern has a Shared section entry (_types, config, logging, fdr_client, frame_source, clock, helpers/* × 8, runtime_root, cli/replay, healthcheck).
- [x] Every shared / cross-cutting concern has a Shared section entry (_types, config, logging, fdr_client, frame_source, clock, replay_input, helpers/* × 8, runtime_root, cli/replay, healthcheck).
- [x] Layering table covers every component; foundation at Layer 1.
- [x] No component's `Imports from` list points at a component in a higher layer (back-channel exception for C8 → C1/C5 documented as interface-at-producer pattern).
- [x] Paths follow Python `src/`-layout convention with single top-level package `gps_denied_onboard/`.
- [x] No two components own overlapping paths. Joint native ownership of `cpp/gtsam_bindings/` resolved: c5_state is primary owner; c4_pose READ-ONLY.
- [x] Replay-mode additions (AZ-265) covered: new `frame_source/` and `clock/` cross-cuttings, new C8 strategies (`tlog_replay_adapter`, `replay_sink`), new `cli/replay.py` entrypoint, and a fourth `replay-cli` binary added to the Build-Time Exclusion Map.
- [x] Replay-mode additions (AZ-265 / ADR-011) covered: new `frame_source/` + `clock/` + `replay_input/` cross-cuttings; new C8 strategies (`tlog_replay_adapter`, `replay_sink`, `noop_mavlink_transport`, `serial_mavlink_transport`); new `cli/replay.py` console-script wrapper; replay-mode `BUILD_*` flags default ON in the airborne and research binaries (no separate replay-cli binary).
## How the implement skill consumes this