[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
+24 -11
View File
@@ -107,12 +107,12 @@ are all declared and documented below under **Cycle Check**.
| AZ-397 | C8 QgcTelemetryAdapter — downsampled 12 Hz summary out + operator command in | 3 | AZ-390, AZ-392, AZ-279, AZ-273, AZ-263, AZ-269, AZ-266 | AZ-261 |
| AZ-398 | FrameSource Protocol + Clock Protocol + LiveCameraFrameSource retrofit + VideoFileFrameSource| 3 | AZ-263, AZ-269, AZ-270, AZ-266, AZ-272 | AZ-265 |
| AZ-399 | TlogReplayFcAdapter — replay-only FcAdapter parsing pymavlink .tlog | 5 | AZ-398, AZ-390, AZ-391, AZ-279, AZ-273, AZ-263, AZ-269, AZ-266, AZ-272 | AZ-265 |
| AZ-400 | ReplaySink Protocol + JsonlReplaySink impl | 3 | AZ-263, AZ-269, AZ-270, AZ-381, AZ-266, AZ-272 | AZ-265 |
| AZ-401 | compose_replay(config) -> ReplayRoot + Clock injection across C1C5 | 3 | AZ-398, AZ-399, AZ-400, AZ-269, AZ-270, AZ-263, AZ-266, AZ-272, AZ-390 | AZ-265 |
| AZ-402 | gps-denied-replay CLI entrypoint + argparse + camera-calibration loader | 3 | AZ-401, AZ-269, AZ-270, AZ-263, AZ-266, AZ-272, AZ-273 | AZ-265 |
| AZ-403 | gps-denied-replay-cli Dockerfile + GitHub Actions matrix entry + SBOM diff | 3 | AZ-402, AZ-398, AZ-399, AZ-400, AZ-401, AZ-263, AZ-269, AZ-266 | AZ-265 |
| AZ-404 | E2E replay fixture test — Derkachi 12 min clip + tlog | 5 | AZ-402, AZ-403, AZ-401, AZ-263, AZ-269, AZ-266, AZ-272, AZ-273 | AZ-265 |
| AZ-405 | Auto-sync of video ↔ tlog via IMU take-off detection | 5 | AZ-402, AZ-399, AZ-398, AZ-263, AZ-269, AZ-266, AZ-272 | AZ-265 |
| AZ-400 | ReplaySink + JsonlReplaySink + MavlinkTransport seam + Noop/Serial transports | 3 | AZ-263, AZ-269, AZ-270, AZ-381, AZ-266, AZ-272, AZ-390 | AZ-265 |
| AZ-401 | compose_root replay-mode branch — JSONL sink + NoopMavlinkTransport wiring | 2 | AZ-398, AZ-399, AZ-400, AZ-405, AZ-269, AZ-270, AZ-263, AZ-266, AZ-272, AZ-390 | AZ-265 |
| AZ-402 | gps-denied-replay console-script wrapper (mode-config dispatcher) | 3 | AZ-401, AZ-269, AZ-270, AZ-263, AZ-266, AZ-272, AZ-273 | AZ-265 |
| AZ-403 | (CANCELLED per ADR-011 — replay is a configuration of the airborne binary; no fourth image) | — | — | AZ-265 |
| AZ-404 | E2E replay fixture test — Derkachi 12 min clip + mode-agnosticism + operator workflow | 5 | AZ-402, AZ-401, AZ-405, AZ-263, AZ-269, AZ-266, AZ-272, AZ-273 | AZ-265 |
| AZ-405 | replay_input/ coordinator + auto-sync of video ↔ tlog via IMU take-off detection | 5 | AZ-399, AZ-398, AZ-263, AZ-269, AZ-266, AZ-272, AZ-279 | AZ-265 |
| AZ-406 | Blackbox Test Infrastructure Bootstrap (Tier-1 + Tier-2 harness scaffold) | 5 | AZ-263 | AZ-262 |
| AZ-407 | Static fixture builders — tile-cache, age-injector, cold-boot, MAVLink passkey, CVE JPEG | 3 | AZ-406 | AZ-262 |
| AZ-408 | Runtime synthetic-injection fixture builders — outlier, blackout-spoof, multi-segment | 3 | AZ-406, AZ-407 | AZ-262 |
@@ -180,10 +180,23 @@ are all declared and documented below under **Cycle Check**.
(AZ-391) and `QgcTelemetryAdapter` (AZ-397); AZ-388 depends on
AZ-390 / AZ-397; AZ-396 depends on AZ-385. Each side ships against
the AZ-390 Protocol contract until the consumer task lands.
- **AZ-401 (compose_replay)** intentionally depends on the C1C5 epic
IDs (AZ-254 … AZ-260) at the documentation level — concrete strategy
task IDs flow in through each component's composition factory, not
through this composition root directly.
- **AZ-401 (compose_root replay-mode branch, per ADR-011)** intentionally
depends on the C1C5 epic IDs (AZ-254 … AZ-260) at the documentation
level — concrete strategy task IDs flow in through each component's
composition factory, not through this composition root directly. Under
ADR-011 there is NO separate `compose_replay` function; replay is a
`config.mode = "replay"` branch inside the single `compose_root`. The
legacy v1.0.0 fourth-binary design (AZ-403) is cancelled — see the
cancellation banner in `_docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md`
and the pending tracker leftover at
`_docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md`.
- **AZ-405 (replay_input/ coordinator + auto-sync)** is the architectural
seam between `(video, tlog)` and the rest of the system under ADR-011.
Its consumers are AZ-401 (composition-root branch builds the
coordinator) and AZ-404 (E2E uses the populated coordinator via the
CLI). It depends on AZ-398 / AZ-399 / AZ-279 but NOT on AZ-402 (the CLI
consumes the coordinator's CLI-arg surface, but the coordinator itself
is CLI-agnostic).
- **E-BBT (AZ-262) forward dependencies on AZ-444 (Tier-2 harness)**:
AZ-428, AZ-430, AZ-440, AZ-443 declare hard forward deps on AZ-444;
AZ-439 declares an optional forward dep on AZ-444 (Tier-2 ASan-fuzz
@@ -300,7 +313,7 @@ are all declared and documented below under **Cycle Check**.
normaliser) → AZ-276..AZ-283
- Frame source + Clock → AZ-398
- Replay sink → AZ-400
- Replay composition + CLI + auto-sync → AZ-401/402/405
- Replay composition branch + CLI wrapper + replay_input/ coordinator → AZ-401/402/405 (AZ-403 cancelled per ADR-011)
- **No unresolved `AZ-?` placeholders** in any task file (verified by grep on Step 4 close-out).
@@ -0,0 +1,33 @@
# Replay — gps-denied-replay-cli Dockerfile + GitHub Actions matrix + SBOM diff — **CANCELLED per ADR-011 (2026-05-14)**
> **Status**: CANCELLED. Do NOT implement.
>
> **Cancelled by**: `_docs/02_document/architecture.md` § ADR-011 (replay is a configuration of the airborne binary, not a separate image) + `_docs/02_document/contracts/replay/replay_protocol.md` v2.0.0.
>
> **Reason**: Under ADR-011 there is no separate `gps-denied-replay-cli` Docker image — the airborne image IS the replay image, running the same components from a single source tree with `config.mode = "replay"` chosen at startup. The SBOM-diff CI step this task specified existed to enforce the exclusion of `c6_tile_cache` / `c10_provisioning` / `c11_tilemanager` / `c12_operator_orchestrator` from the replay binary. None of those exclusions hold any more:
>
> - **C6 IS required in replay** (epic AZ-265 AC-3 — ≤ 100 m horizontal accuracy — depends on C2's tile retrieval via the C6 `FaissDescriptorIndex`; v1.0.0's `BUILD_C6=OFF` flag for replay was the contradiction that prompted the ADR-011 rewrite).
> - **C10/C11/C12 are already excluded from the airborne image** by ADR-002 + ADR-004 — that exclusion is enforced by the existing `ci/sbom_diff.py` step on the airborne image, NOT by a separate replay-specific SBOM diff.
>
> Therefore: no fourth Docker image, no `docker/replay-cli/Dockerfile`, no `ci/sbom_diff_replay.py` script, no GitHub Actions matrix entry for `replay-cli`. The work originally tracked under this task is replaced by zero work on the binary topology — the airborne image already does everything this task would have produced.
>
> **Replacement**: none required. The replay-mode entry point (`gps-denied-replay` console-script) ships from the airborne image via AZ-402.
>
> **Tracker action**: transition the Jira ticket `AZ-403` to **Cancelled** with a comment pointing at ADR-011. If the Jira MCP is unavailable at execution time, record the transition in `_docs/_process_leftovers/<YYYY-MM-DD>_az_403_cancellation.md` for replay on the next autodev start (per `.cursor/rules/tracker.mdc`).
>
> **Affected dependencies**: AZ-404 (E2E replay fixture test) previously listed AZ-403 as a hard dependency for "tests run via Docker image" (its old AC-8). AC-8 is reworded in the AZ-404 respec to test the airborne image instead. AZ-404's dependency on AZ-403 is removed from `_docs/02_tasks/_dependencies_table.md`.
>
> The original specification below is preserved for traceability only. Do not implement.
---
# (Cancelled) Original task spec — preserved for traceability
**Task**: AZ-403_replay_dockerfile_ci
**Name**: `gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff (excludes C6/C10/C11/C12)
**Description**: Add the fourth Docker image `gps-denied-replay-cli`: multi-stage build (Python + C1C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server). Add a GitHub Actions matrix entry building and pushing this image alongside the existing 3 images (live / research / operator). Add an **SBOM diff CI step** that builds the SBOM (via `syft` or the project's existing SBOM tooling), parses it, and asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_orchestrator` packages — verifies AC-4 of the epic. The SBOM diff fails the CI job if any excluded component leaks into the replay image. Image base: same Python + CUDA base as the live image (consistency with TensorRT engines from C7) but with `BUILD_C6=OFF`, `BUILD_C10=OFF`, `BUILD_C11=OFF`, `BUILD_C12=OFF`, `BUILD_VIDEO_FILE_FRAME_SOURCE=ON`, `BUILD_TLOG_REPLAY_ADAPTER=ON`, `BUILD_REPLAY_SINK_JSONL=ON` build args.
**Complexity**: 3 points
**Dependencies**: AZ-402 (CLI entrypoint registered in pyproject); AZ-398 / AZ-399 / AZ-400 / AZ-401 (replay strategies); existing Dockerfile + CI plumbing for the live image (pattern to mirror); `module-layout.md` build-flag table; AZ-263, AZ-269, AZ-266
**Component**: replay-cicd (epic AZ-265 / E-DEMO-REPLAY) — Dockerfile at `docker/replay-cli/Dockerfile`; CI at `.github/workflows/build-images.yml` (or equivalent); SBOM-diff script at `ci/sbom_diff_replay.py`
**Tracker**: AZ-403
**Epic**: AZ-265 (E-DEMO-REPLAY)
+80 -56
View File
@@ -1,103 +1,127 @@
# Replay — compose_replay(config) + Clock injection (R-DEMO-4)
# Replay — `compose_root` extension for `config.mode == "replay"` + JSONL sink + NoopMavlinkTransport wiring
**Task**: AZ-401_replay_compose
**Name**: `compose_replay(config) -> ReplayRoot` + `Clock` injection across C1C5
**Description**: Implement `compose_replay(config: Config) -> ReplayRoot` at `src/gps_denied_onboard/runtime_root/replay.py` (alongside the existing `compose_root` and `compose_operator`). Resolves ALL strategies for the replay binary: `FrameSource` = `VideoFileFrameSource`; `FcAdapter` = `TlogReplayFcAdapter`; `Sink` = `JsonlReplaySink`; `Clock` = `TlogDerivedClock` (when `pace=ASAP`) OR `WallClock` (when `pace=REALTIME`); ALL of C1C5 wired with the SAME Public API as the live `compose_root` (per Invariant 1 — no replay-aware branches in components). NO C6/C10/C11/C12 (replay reads pre-built tile cache; no operator-side workflows). Configuration loading (config.yaml) + camera-calibration loading (calib.json) handled here. The `ReplayRoot` dataclass holds: `frame_source`, `fc_adapter`, `replay_sink`, `clock`, `vio` (C1), `vpr` (C2), `rerank` (C2.5), `matcher` (C3), `refiner` (C3.5), `pose_estimator` (C4), `state_estimator` (C5), and `runtime_loop()` method that drives the per-frame loop documented in the contract. Build-flag check at startup: refuses to run if any of `BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL` is OFF — these are mandatory for the replay binary.
**Complexity**: 3 points
**Dependencies**: AZ-398 (`FrameSource` + `Clock`); AZ-399 (`TlogReplayFcAdapter`); AZ-400 (`JsonlReplaySink`); AZ-269 / AZ-270 (config); AZ-263; AZ-266; AZ-272; AZ-390 (E-C8 `FcAdapter` Protocol the tlog adapter implements); all C1C5 epics composed at runtime via their Public APIs: AZ-254 (C1), AZ-255 (C2), AZ-256 (C2.5), AZ-257 (C3), AZ-258 (C3.5), AZ-259 (C4), AZ-260 (C5) — concrete strategy task IDs flow in through each component's composition factory, not through this composition root directly
**Component**: replay-composition (epic AZ-265 / E-DEMO-REPLAY) — lives in `runtime_root/replay.py`
**Name**: Extend `compose_root(config)` with a `config.mode == "replay"` branch — wire `ReplayInputAdapter`, `JsonlReplaySink`, and `NoopMavlinkTransport` into the same C1C7 + C13 graph as live mode
**Description**: Add a single mode-aware branch inside the existing `compose_root(config)` in `src/gps_denied_onboard/runtime_root/__init__.py` (or the factory module it delegates to). When `config.mode == "live"` the function behaves exactly as today. When `config.mode == "replay"`:
1. Build a `ReplayInputAdapter` from `config.replay.{video_path, tlog_path, pace, time_offset_ms, target_fc_dialect, …}` using the same `CameraCalibration` and `WgsConverter` the live path already constructs.
2. Call `replay_input.open()``ReplayInputBundle(frame_source, fc_adapter, clock, …)` and use the three returned strategies as the standard `FrameSource` + `FcAdapter` + `Clock` for the rest of the graph (no further mode awareness past this point).
3. Pick `NoopMavlinkTransport` (replay) instead of `SerialMavlinkTransport` (live) as the `MavlinkTransport` injected into the C8 outbound encoders. The encoders are unchanged — they produce the same byte streams in both modes (replay protocol Invariant 5).
4. Attach a `JsonlReplaySink` as an additional listener on C5's `EstimatorOutput` stream. The live binary's existing downstream observers (C8 outbound to FC, QGC telemetry adapter, C13 FDR) all stay wired in replay — only the C8 outbound transport differs (NoopMavlinkTransport in replay) and only the JsonlReplaySink is added.
5. Wire C1C7 + C13 exactly as in the live composition (replay protocol Invariant 1 — components see the same interfaces).
6. Refuse construction if any of `BUILD_VIDEO_FILE_FRAME_SOURCE`, `BUILD_TLOG_REPLAY_ADAPTER`, `BUILD_REPLAY_SINK_JSONL` is OFF in replay mode (raise `CompositionError` pointing at the OFF flag).
**Out of scope**: there is **no** separate `compose_replay` function under ADR-011 — replay is a configuration of the single `compose_root`. If a stub `compose_replay` exists today in the codebase (from the v1.0.0 design), it MUST be deleted as part of this task; the runtime_root `__init__.py` exposes only `compose_root` and `compose_operator`.
**Complexity**: 2 points (was 3 in the v1.0.0 spec; shrinks because there is no new composition function, only a config-driven branch + two strategy swaps + one observer attachment)
**Dependencies**: AZ-398 (`FrameSource` + `Clock`); AZ-399 (`TlogReplayFcAdapter`); AZ-400 (`JsonlReplaySink` + `MavlinkTransport` Protocol seam + `NoopMavlinkTransport` + `SerialMavlinkTransport` retrofit); AZ-405 (`ReplayInputAdapter`); AZ-269 / AZ-270 (config — `Config.mode` field + `Config.replay` sub-config); AZ-263 (`runtime_root` bootstrap); AZ-266 (logging); AZ-272 (FDR record schema); AZ-279 (`WgsConverter`); AZ-390 (E-C8 `FcAdapter` Protocol the tlog adapter implements); the C1C5 component factory APIs (already exist).
**Component**: replay-composition (epic AZ-265 / E-DEMO-REPLAY) — branch lives in `runtime_root/__init__.py` (or the factory module the composition root already delegates to).
**Tracker**: AZ-401
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` — replay composition + runtime loop body.
- `_docs/02_document/module-layout.md``runtime_root.py` composition root location.
- `_docs/02_document/architecture.md` — ADR-001 / ADR-002 / ADR-009.
- `_docs/02_document/contracts/c5_state/state_estimator_protocol.md``EstimatorOutput` consumed by the sink.
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — Invariants 1, 5, 9, 11, 12 + the composition-root extension narrative.
- `_docs/02_document/architecture.md`**ADR-011** (replay-as-configuration; THE design-defining decision for this task) + ADR-001 / ADR-002 / ADR-009.
- `_docs/02_document/module-layout.md` — Build-Time Exclusion Map (the three replay-mode `BUILD_*` flags default ON in airborne); `runtime_root` cross-cutting entry.
- `_docs/02_document/contracts/c5_state/state_estimator_protocol.md``EstimatorOutput` consumed by the JSONL sink.
- `_docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md``FcAdapter` Protocol + the new tiny `MavlinkTransport` seam introduced by AZ-400.
## Problem
Without this task, the replay-only strategies (FrameSource + Clock + TlogReplayFcAdapter + JsonlReplaySink) have no composition root that wires them with C1C5; the per-frame runtime loop is undefined; the CLI has nothing to invoke. This is the integration point where replay strategies meet production components.
Without this task, the replay-only strategies (`ReplayInputAdapter` from `replay_input/`, `JsonlReplaySink`, `NoopMavlinkTransport`) have no integration point with the airborne composition root; `config.mode == "replay"` is parsed by the config loader but not acted upon; the per-frame runtime loop is identical to live but no UI-tailable JSONL output is produced. This is the single point of mode awareness in the codebase (replay protocol Invariants 1 + 5).
## Outcome
- `src/gps_denied_onboard/runtime_root/replay.py`:
- `ReplayPace` enum (REALTIME / ASAP).
- `ReplayRoot` dataclass (frozen + slots; holds all wired components).
- `compose_replay(config: Config) -> ReplayRoot`.
- `ReplayRoot.runtime_loop() -> int` (returns exit code; 0 on success, 2 on AC-8 sync-impossible, 1 on any other error).
- The composition root invokes `build_*` factories from each component's existing factory module (no new factory APIs in scope here — they all exist from the C1C8 epics).
- Build-flag check at startup: refuses to run if any mandatory replay-only flag is OFF; raises `ReplayCompositionError` with the OFF-flag list.
- INFO log on startup: `kind="replay.compose_root.ready"` with `{config_path, calib_path, pace, time_offset_ms, video_path, tlog_path, output_path}`.
- DEBUG log per loop iteration: `kind="replay.loop.tick"` (every 100 frames).
- Unit tests: composition resolves + returns ReplayRoot, build-flag check rejects on missing flag, runtime_loop terminates on `next_frame() -> None`, runtime_loop emits one EstimatorOutput per processed frame, AC-8 sync-impossible exit code 2.
- `src/gps_denied_onboard/runtime_root/__init__.py` (or the factory module it delegates to) exposes one `compose_root(config) -> Runtime` that branches internally on `config.mode in {"live", "replay"}`. No new public function. The branch:
- In `live`: behaves exactly as today (no behaviour change for the live path).
- In `replay`: builds `ReplayInputAdapter`, calls `.open()`, picks `NoopMavlinkTransport` for C8 outbound, attaches `JsonlReplaySink` to C5's `EstimatorOutput` stream, and otherwise wires C1C7 + C13 identically.
- `Config.mode: Literal["live", "replay"] = "live"` field on the config DTO (default live; replay opt-in). Plus a `Config.replay` sub-config holding `video_path`, `tlog_path`, `output_path`, `pace`, `time_offset_ms`, `target_fc_dialect`, `auto_sync` sub-block. Owned by the AZ-269 / AZ-270 config schema task — this task adds the fields if AZ-269 / AZ-270 haven't landed them yet (the config schema is a coordinate of this and the AZ-269 / AZ-270 / AZ-405 tasks; the schema lives at `src/gps_denied_onboard/config/`).
- Build-flag check at startup: when `config.mode == "replay"` and any of the three replay-mode `BUILD_*` flags is OFF, raises `CompositionError("replay mode requires BUILD_VIDEO_FILE_FRAME_SOURCE / BUILD_TLOG_REPLAY_ADAPTER / BUILD_REPLAY_SINK_JSONL to be ON; flag <name> is OFF in this build")`. In live mode the flags are not checked (the OFF setting on a replay flag does not block live mode).
- The legacy stub `runtime_root/replay.py` + the legacy `compose_replay` export (if present) are deleted as part of this task; replay is a configuration of `compose_root`, not a sibling composition root. The deletion is justified by the dead-code rule in `coderule.mdc` (no remaining usages once this task lands).
- INFO log on startup, in replay mode: `kind="replay.compose_root.ready"` with `{config_path, calib_path, pace, time_offset_ms, video_path, tlog_path, output_path}`.
- INFO log on startup, in live mode: existing live `compose_root.ready` log unchanged.
- Unit tests:
- `test_compose_root_live_unchanged`: with `config.mode == "live"`, the returned `Runtime` has the same shape (same strategy classes for FrameSource/FcAdapter/MavlinkTransport/Sink set) as today.
- `test_compose_root_replay_wires_replay_strategies`: with `config.mode == "replay"`, `isinstance(runtime.frame_source, VideoFileFrameSource)`, `isinstance(runtime.fc_adapter, TlogReplayFcAdapter)`, `isinstance(runtime.mavlink_transport, NoopMavlinkTransport)`, and a `JsonlReplaySink` is attached to C5's `EstimatorOutput` stream.
- `test_compose_root_replay_rejects_off_flag`: with `BUILD_VIDEO_FILE_FRAME_SOURCE=OFF` and `config.mode == "replay"`, `compose_root(config)` raises `CompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay mode requires it")`.
- `test_compose_root_replay_single_clock`: the same `Clock` instance is injected into all components that need one (replay protocol Invariant 2 — `id()` equality across consumers).
- `test_compose_root_no_compose_replay_export`: `from gps_denied_onboard.runtime_root import compose_replay` raises `ImportError` (the legacy function is deleted).
- `test_compose_root_replay_jsonl_sink_emits_per_tick`: drive 10 frames through the wired runtime; assert `JsonlReplaySink.emit` was called exactly 10 times with `EstimatorOutput` instances.
- `test_compose_root_replay_noop_transport_swallows_emits`: drive a known sequence of EstimatorOutput through the runtime; assert `NoopMavlinkTransport.bytes_written() > 0` (C8 encoders still produce bytes) AND the bytes never reach any wire-attached transport.
## Scope
### Included
- `compose_replay` body.
- `ReplayRoot` dataclass.
- `runtime_loop()` driving the per-frame loop documented in the contract.
- Build-flag check at startup.
- Configuration + calibration loading (re-uses existing config loader from AZ-269/AZ-270).
- Unit tests including build-flag rejection + frame-by-frame loop.
- The `config.mode` branch inside `compose_root`.
- `Config.mode` + `Config.replay` schema additions (if not already present).
- Deletion of `runtime_root/replay.py` + the `compose_replay` export.
- Build-flag check at startup for replay mode.
- INFO logs.
- All unit tests listed above.
### Excluded
- CLI argparse + entrypoint — owned by CLI task.
- Auto-sync IMU take-off detection — owned by AZ-405 (this task accepts `time_offset_ms` from `config` or CLI override).
- Dockerfile + CI — owned by Docker task.
- E2E replay fixture test — owned by E2E task.
- C6/C10/C11/C12 wiring — explicitly NOT included (per epic scope).
- CLI argparse + entrypoint — owned by AZ-402.
- `ReplayInputAdapter` itself — owned by AZ-405.
- `JsonlReplaySink` / `NoopMavlinkTransport` / `MavlinkTransport` Protocol seam / `SerialMavlinkTransport` retrofit — owned by AZ-400.
- `TlogReplayFcAdapter` — owned by AZ-399.
- `VideoFileFrameSource` / `LiveCameraFrameSource` / `Clock` strategies — owned by AZ-398.
- E2E replay fixture test — owned by AZ-404.
- Auto-sync logic — owned by AZ-405.
## Acceptance Criteria
**AC-1: ReplayRoot returned with all components wired** — `compose_replay(valid_config)` returns a `ReplayRoot` with non-None values for all fields (`frame_source`, `fc_adapter`, `replay_sink`, `clock`, `vio`, `vpr`, `rerank`, `matcher`, `refiner`, `pose_estimator`, `state_estimator`).
**AC-1: Single composition root** — `from gps_denied_onboard.runtime_root import compose_root, compose_operator` works; `from gps_denied_onboard.runtime_root import compose_replay` raises `ImportError`. There is only one entry-point function per binary track.
**AC-2: Build-flag check** — with `BUILD_VIDEO_FILE_FRAME_SOURCE=OFF`, `compose_replay(...)``ReplayCompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay binary requires it")`.
**AC-2: Live mode unchanged** — with `config.mode == "live"` (or omitted; default is live), `compose_root(config)` produces a `Runtime` whose strategy classes match the pre-task baseline (snapshot test: serialise the class names of all wired strategies; baseline file checked into the repo; this test fails if the live wiring changed inadvertently).
**AC-3: ASAP → TlogDerivedClock; REALTIME → WallClock** — `pace=ASAP` resolves `Clock = TlogDerivedClock`; `pace=REALTIME` resolves `Clock = WallClock`. Verify via `isinstance(replay_root.clock, ...)`.
**AC-3: Replay mode wires replay strategies** — with `config.mode == "replay"`, the returned `Runtime` has:
- `frame_source: VideoFileFrameSource`
- `fc_adapter: TlogReplayFcAdapter`
- `mavlink_transport: NoopMavlinkTransport`
- A `JsonlReplaySink` registered as a listener on C5's `EstimatorOutput` stream
**AC-4: Runtime loop terminates on EOS** — wire a `FakeFrameSource` returning 10 frames + None; call `runtime_loop()`; assert it returns 0 after exactly 10 frame cycles.
**AC-4: Replay-mode build-flag check** — with `BUILD_VIDEO_FILE_FRAME_SOURCE=OFF` and `config.mode == "replay"`, `compose_root(config)` raises `CompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay mode requires it")`. Same for the other two flags. With the SAME `BUILD_*` flag OFF but `config.mode == "live"`, `compose_root(config)` succeeds (live mode does not require the replay flags).
**AC-5: One EstimatorOutput per frame** — drive 10 frames; assert `JsonlReplaySink.emit` was called exactly 10 times with `EstimatorOutput` instances.
**AC-5: Clock injection** — `config.mode == "replay"` with `pace == "asap"` injects `TlogDerivedClock`; with `pace == "realtime"` injects `WallClock`. The SAME `Clock` instance is injected into every component that consumes one (`id()` equality across the C1, C5, C8 consumers; replay protocol Invariant 2).
**AC-6: AC-8 sync-impossible exit code 2** — wire a tlog adapter that reports < 95 % frame-window match (auto-sync hard-fail per AC-8 of the epic); `runtime_loop()` returns 2.
**AC-6: JSONL sink emits per tick** — drive 10 frames through the wired runtime (using a `FakeFrameSource` + fake `TlogReplayFcAdapter` from test fixtures); assert `JsonlReplaySink.emit` is called exactly 10 times with `EstimatorOutput` instances.
**AC-7: Composition uses Public APIs only** — assert that `compose_replay` imports ONLY `__init__.py` re-exports of each component (per `module-layout.md` Layer-3 / Layer-4 rules). CI-style check via AST scan in the unit test.
**AC-7: No mode-aware imports in components** — AST scan asserts that `compose_root` is the ONLY file that imports BOTH `LiveCameraFrameSource` AND `VideoFileFrameSource` (i.e., no component sees both strategy classes). Replay-aware logic is confined to the composition root + the replay strategies + the `replay_input/` coordinator.
**AC-8: No C6/C10/C11/C12 imports** — assert that `compose_replay` does NOT import any symbol from `components.c6_tile_cache`, `components.c10_provisioning`, `components.c11_tilemanager`, `components.c12_operator_orchestrator` (per epic scope).
**AC-8: Public APIs only across components** — assert that the replay-mode branch imports ONLY `__init__.py` re-exports of each component (per `module-layout.md` Layer-3 / Layer-4 rules). CI-style AST scan in the unit test.
**AC-9: Configuration + calibration loading** — `compose_replay(config_with_invalid_calib_path)``ReplayCompositionError("camera-calibration not found at ...")`.
**AC-9: NoopMavlinkTransport swallows C8 outbound bytes** — drive a known EstimatorOutput sequence through the runtime in replay mode; assert `NoopMavlinkTransport.bytes_written() > 0` (the C8 encoders run their signing handshake + GPS_INPUT encoding) AND no I/O reached any wire-attached transport (verified by the absence of any open file descriptor / serial port mock activity).
**AC-10: Single-Clock invariant** — assert that the same `Clock` instance is injected into all components that need one (no two distinct Clock instances per process); check via `id()` comparison across consumers.
**AC-10: Operator pre-flight C6 cache reused identically** — wire a stub C6 `FaissDescriptorIndex` populated with a known descriptor; run replay mode against the wired runtime; assert C2's `lookup()` returns the expected tile ID. Demonstrates that C6 is fully wired in replay (replay protocol Invariant 12 — no replay-specific cache shape).
## Non-Functional Requirements
- `compose_replay` p99 ≤ 1 s (one-time startup cost; epic NFT cold-start ≤ 5 s).
- `runtime_loop()` per-frame overhead (NOT counting C1C5 work) p99 ≤ 1 ms.
- `compose_root` p99 ≤ 1 s in either mode (one-time startup cost; epic NFT cold-start ≤ 5 s).
- The branch logic itself adds ≤ 50 ms p99 to live-mode startup (since the live branch should not pay any replay tax).
## Constraints
- ADR-001 / ADR-002 / ADR-009 unchanged.
- ADR-001 / ADR-002 / ADR-009 / **ADR-011** unchanged.
- Public API discipline (Layer-3 / Layer-4 from `module-layout.md`).
- C1C5 components MUST remain mode-agnostic (Invariant 1 enforced by AST scan in AZ-404).
- All time-driven logic uses injected `Clock` (Invariant 2).
- NO HTTP server in the replay binary (per epic scope).
- C1C7 + C13 components MUST remain mode-agnostic (replay protocol Invariant 1; AST scan enforces in AZ-404).
- All time-driven logic uses injected `Clock` (replay protocol Invariant 2).
- No HTTP server in the airborne binary regardless of mode.
- NO standalone composition root for replay (replay protocol + ADR-011).
## Risks & Mitigation
- **R-DEMO-4 (production C1C5 paths bake real-time-cadence assumptions)***Mitigation*: `Clock` injection (Invariant 2). Documented as ADR amendment in next architecture-doc cycle.
- **Risk: composition root is the single biggest churn surface for new components***Mitigation*: re-use existing per-component `build_*` factories; this task does NOT introduce new factory APIs.
- **Risk: builders fail in subtle ways under build-flag combinations***Mitigation*: AC-2 + AC-7 + AC-8 cover the failure modes; unit-test-grade build-flag matrix on every PR.
- **R-DEMO-4 (production C1C5 paths bake real-time-cadence assumptions)***Mitigation*: `Clock` injection (replay protocol Invariant 2); inherits from AZ-398.
- **R-DEMO-5 (live and replay diverge silently because they share one composition root)***Mitigation*: AC-2 (live snapshot test) + AC-7 (no mode-aware imports outside compose_root) + AZ-404's AST scan on Invariant 1. Any drift becomes a test failure.
- **Risk: deleting `runtime_root/replay.py` breaks consumers we forgot to update***Mitigation*: AC-1 explicitly asserts the import fails; before this task lands, grep for `compose_replay` across the repo and update each call site to `compose_root(config_with_mode_replay)`. The grep is part of the implementation step; the test assertion catches any miss.
- **Risk: `Config.mode` default of "live" silently breaks an existing config file that lacked the field***Mitigation*: the default is "live", which is also the legacy behaviour; no existing config file needs to change.
## Runtime Completeness
- **Named capability**: replay-binary composition root + per-frame runtime loop.
- **Production code**: real strategy resolution, real ReplayRoot dataclass, real runtime loop, real build-flag check.
- **Allowed external stubs**: test fakes only (FakeFrameSource, FakeFcAdapter, FakeReplaySink) for unit tests.
- **Unacceptable substitutes**: hardcoding strategies in the loop body (defeats ADR-009); embedding component-construction logic in the loop (defeats single-responsibility).
- **Named capability**: single composition root that resolves `config.mode` to the correct strategy set + observer wiring.
- **Production code**: real config-mode branch, real strategy wiring, real JSONL sink attachment, real noop-transport injection, real build-flag check.
- **Allowed external stubs**: test fakes only (FakeFrameSource, FakeFcAdapter, FakeReplaySink, FakeMavlinkTransport, FakeC6DescriptorIndex) for unit tests.
- **Unacceptable substitutes**: keeping a separate `compose_replay` function "for clarity" (defeats ADR-011 — the single composition root IS the architectural mechanism for mode-agnosticism); branching on `config.mode` inside component code (defeats replay protocol Invariant 1).
## Contract
Implements `_docs/02_document/contracts/replay/replay_protocol.md` — replay composition + runtime loop.
Implements `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — composition-root extension + Invariants 1, 5, 9, 11, 12. Operationalises ADR-011.
+83 -44
View File
@@ -1,101 +1,140 @@
# Replay — gps-denied-replay CLI entrypoint + arg parser + calibration loader
# Replay — `gps-denied-replay` console-script wrapper (mode-config dispatcher)
**Task**: AZ-402_replay_cli
**Name**: `gps-denied-replay` CLI entrypoint + argparse + camera-calibration loader
**Description**: Implement the `gps-denied-replay` console script: `argparse`-based CLI accepting `--video PATH --tlog PATH --output results.jsonl --camera-calibration calib.json --config config.yaml [--pace {realtime,asap}] [--time-offset-ms N]`. Load and validate the camera-calibration JSON (project's standard pinhole + distortion-coefficients schema, reusable via the config loader from AZ-269/AZ-270 if practical, otherwise a small dedicated loader); construct the `Config` object; invoke `compose_replay(config) -> ReplayRoot`; call `replay_root.runtime_loop()`; map the returned exit code to the process exit code (0 = success per AC-1 of the epic; 2 = sync-impossible per AC-8; 1 = any other error). Set up structured logging (stdout JSON per project convention) and FDR client. Exit-code mapping documented inline. CLI registered as a console_script entrypoint in pyproject.toml under `[project.scripts]` (or equivalent build-config).
**Complexity**: 3 points
**Dependencies**: AZ-401 (`compose_replay` + `ReplayRoot.runtime_loop`); AZ-269 / AZ-270 (config); AZ-263; AZ-266; AZ-272 (FDR record schema); AZ-273 (`FdrClient`)
**Component**: replay-cli (epic AZ-265 / E-DEMO-REPLAY) — CLI entrypoint at `src/gps_denied_onboard/cli/replay.py`
**Name**: `gps-denied-replay` console-script — thin mode-config wrapper that builds a replay-mode `Config` and dispatches into the shared airborne entry point
**Description**: Implement the `gps-denied-replay` console-script in `src/gps_denied_onboard/cli/replay.py`. Per ADR-011, this is **not a standalone CLI** with its own composition root — it is a thin wrapper around the live airborne entry point that loads `config.yaml`, sets `config.mode = "replay"`, applies the replay-specific CLI args (`--video`, `--tlog`, `--output`, `--time-offset-ms`, `--pace`, `--mavlink-signing-key`), and calls the same `main()` function the live `gps-denied-onboard` binary calls. The shared main entry point calls `compose_root(config)` (which branches on `config.mode` per AZ-401) and runs the per-frame loop; the runtime loop is unchanged between live and replay.
CLI surface (argparse):
```
gps-denied-replay
--video PATH # required
--tlog PATH # required
--output results.jsonl # required
--camera-calibration calib.json # required
--config config.yaml # required (same schema as airborne)
--mavlink-signing-key PATH # required (operator supplies a dummy key for replay; signing handshake still runs)
[--pace {realtime,asap}] # default asap
[--time-offset-ms N] # overrides AZ-405 auto-sync inside replay_input/
```
The CLI:
1. Parses arguments + validates file existence (video, tlog, calib, config, signing key).
2. Loads `config.yaml` via the existing `config/` loader.
3. Loads the camera-calibration JSON (small dedicated loader; pinhole + distortion-coefficients schema).
4. Mutates the loaded config: `config.mode = "replay"`, `config.replay.video_path = ...`, `config.replay.tlog_path = ...`, `config.replay.output_path = ...`, `config.replay.pace = ...`, `config.replay.time_offset_ms = ...` (None if not provided — `ReplayInputAdapter` will auto-detect via AZ-405).
5. Calls the SAME `main(config, camera_calibration, signing_key_path)` function the live `gps-denied-onboard` binary already calls. The shared main wires everything via `compose_root(config)` and runs the per-frame loop.
6. Maps the runtime exit code to the process exit code (0 = success; 2 = `ReplayInputAdapter.open()` auto-sync hard-fail per AC-8 of the epic; 1 = any other error).
7. Top-level try/except logs the FULL traceback via `logger.exception` and exits 1 on any unhandled exception.
**Complexity**: 3 points (unchanged from v1.0.0 — the CLI shape is the same; what changed is that the CLI does NOT host the composition logic; it just builds a config and dispatches).
**Dependencies**: AZ-401 (`compose_root` extension with `config.mode == "replay"` branch + the `Config.mode` + `Config.replay` schema additions); AZ-269 / AZ-270 (config loader); AZ-263 (the shared airborne `main()` entry point); AZ-266 (logging); AZ-272 (FDR record schema); AZ-273 (`FdrClient`).
**Component**: replay-cli (epic AZ-265 / E-DEMO-REPLAY) — `src/gps_denied_onboard/cli/replay.py`.
**Tracker**: AZ-402
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` — CLI surface specification.
- `_docs/02_document/architecture.md` — § 5 (binary topology; replay-cli is the fourth Docker image).
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — CLI surface specification + Invariant 11 (signing key mandatory in replay).
- `_docs/02_document/architecture.md` **ADR-011** (replay-as-configuration) + § 5 (binary topology; replay runs from the airborne image).
- `_docs/02_document/module-layout.md``cli/replay` cross-cutting entry (the console-script wrapper, not a standalone CLI).
## Problem
Without this task, the `compose_replay` composition root has no entrypoint — the parent-suite UI cannot shell out to a replay run. The CLI is the user-facing surface (and CI-test surface) of the replay binary.
Without this task, the operator has no entry point to invoke `config.mode == "replay"` against an arbitrary `(video, tlog)` pair — they would need to manually edit a config file with the replay-mode flag and the per-file paths, then invoke the airborne entry point. The CLI is the user-facing surface (and CI-test surface) for the replay mode.
## Outcome
- `src/gps_denied_onboard/cli/replay.py``main()` entrypoint:
- argparse setup with all 7 args + the 2 optional ones.
- calibration loader (small JSON loader; pinhole + distortion-coefficients schema) — module-internal.
- config loader invocation (re-use AZ-269 / AZ-270 plumbing).
- `compose_replay(config)` invocation.
- `replay_root.runtime_loop()` invocation; exit code propagated.
- Structured logging + FDR client setup.
- Top-level try/except: logs the error class + message + suggested next step before exiting 1.
- `pyproject.toml` (or equivalent) registers `gps-denied-replay = "gps_denied_onboard.cli.replay:main"`.
- INFO log at startup: `kind="replay.cli.started"` with all CLI args (sanitised — no key bytes per E-C8 signing invariants, but replay has no signing).
- INFO log at exit: `kind="replay.cli.exited"` with `{exit_code, frames_processed, lines_written}`.
- Unit tests: argparse defaults + overrides, calibration loader rejects malformed JSON, config loader passes-through to `compose_replay`, exit-code mapping on each known runtime_loop return value.
- argparse setup with all 6 required args + the 2 optional ones.
- File-existence validation for all required-file args (video, tlog, calib, config, signing key); fails fast with `ReplayCliError` + exit 1 on missing files.
- Calibration loader (small JSON loader; pinhole + distortion-coefficients schema) — module-internal helper.
- Config loader invocation (re-use AZ-269 / AZ-270 plumbing).
- Mode-config mutation: `config.mode = "replay"` + `config.replay.{video_path, tlog_path, output_path, pace, time_offset_ms}` populated from CLI args.
- Dispatch into the shared airborne `main(config, camera_calibration, signing_key_path)` entry point.
- Exit-code mapping: shared main returns 0 / 1 / 2 → CLI exits with the same code.
- Structured logging setup + FDR client setup happen inside the shared main (NOT duplicated here).
- Top-level try/except: logs the FULL traceback via `logger.exception` + exits 1 on any unhandled exception.
- `pyproject.toml` `[project.scripts]` registers `gps-denied-replay = "gps_denied_onboard.cli.replay:main"`.
- INFO log at CLI startup (BEFORE config load, since logging is not yet bootstrapped): a single `print(f"gps-denied-replay starting with args: {sanitised_args}")` via stderr; the shared main then bootstraps structured logging properly. `--mavlink-signing-key` value is replaced by `<redacted>` in the printed args.
- Unit tests:
- `test_argparse_all_args`: all 6 required + 2 optional args parsed correctly; defaults applied.
- `test_argparse_missing_video_exits_2`: argparse exits 2 when `--video` is omitted (stdlib argparse default).
- `test_argparse_missing_signing_key_exits_2`: same for `--mavlink-signing-key`.
- `test_calibration_loader_malformed`: corrupt calib.json → `ReplayCliError("camera-calibration JSON malformed: <details>")` + exit 1.
- `test_calibration_loader_schema`: calib.json missing `intrinsics``ReplayCliError("camera-calibration schema invalid: missing 'intrinsics'")`.
- `test_config_mode_set_to_replay`: parse args + invoke the CLI; capture the `Config` passed to the shared main; assert `config.mode == "replay"` + `config.replay.video_path` etc. populated.
- `test_dispatch_to_shared_main`: assert the shared main is called exactly once with the mutated config; assert no separate composition logic is invoked inside `cli/replay.py`.
- `test_exit_code_pass_through`: with a FakeMain returning 0 / 1 / 2, the CLI exits 0 / 1 / 2 respectively.
- `test_top_level_exception_logged_and_exits_1`: an unhandled exception inside the shared main is logged with full traceback (verified via `logger.exception` mock) and the CLI exits 1.
- `test_console_script_registered`: install the package in a fresh venv (via `tox` or `pytest-virtualenv`); assert `gps-denied-replay --help` runs and prints the argparse usage.
## Scope
### Included
- argparse + arg-validation (file existence, output-parent existence).
- camera-calibration JSON loader + schema validation.
- `compose_replay` invocation + runtime_loop dispatch.
- Exit-code mapping.
- Top-level error handling (catch + log + exit 1 on unexpected exception).
- argparse + arg-validation (file existence).
- camera-calibration JSON loader + schema validation (module-internal helper).
- Config-mode mutation (`config.mode = "replay"` + replay sub-config population).
- Dispatch into the shared airborne `main()` entry point.
- Exit-code mapping (pass-through).
- Top-level error handling.
- Console-script registration in pyproject.toml.
- Unit tests for argparse + calibration loader + exit-code mapping.
- All unit tests listed above.
### Excluded
- Auto-sync IMU take-off detection — owned by AZ-405 (this task accepts `--time-offset-ms` and forwards to config/replay; the auto-sync TASK computes the default value).
- Dockerfile + CI matrix — owned by Docker task.
- E2E replay fixture test — owned by E2E task.
- Auto-sync IMU take-off detection — owned by AZ-405 (the `ReplayInputAdapter` inside `replay_input/` consumes `--time-offset-ms` from config OR auto-detects when None).
- The `compose_root` branch + the JSONL sink + the NoopMavlinkTransport wiring — owned by AZ-401.
- E2E replay fixture test — owned by AZ-404.
- The shared airborne `main()` function itself — owned by AZ-263 / the existing airborne entry-point task. This task assumes the shared main exists and is callable with `(config, camera_calibration, signing_key_path)`.
## Acceptance Criteria
**AC-1: All required args parsed** — invoke with `--video v.mp4 --tlog t.tlog --output o.jsonl --camera-calibration c.json --config conf.yaml`; assert all five values reach `compose_replay`'s `Config` object.
**AC-1: All required args parsed** — invoke with `--video v.mp4 --tlog t.tlog --output o.jsonl --camera-calibration c.json --config conf.yaml --mavlink-signing-key key.bin`; assert all six values reach the shared main (or the `Config` mutation phase) intact.
**AC-2: --pace default ASAP** — invoke without `--pace`; assert config has `pace=ReplayPace.ASAP`.
**AC-2: `--pace` default ASAP** — invoke without `--pace`; assert `config.replay.pace == "asap"`.
**AC-3: --pace realtime** — invoke with `--pace realtime`; assert config has `pace=ReplayPace.REALTIME`.
**AC-3: `--pace realtime`** — invoke with `--pace realtime`; assert `config.replay.pace == "realtime"`.
**AC-4: --time-offset-ms forwarded** — invoke with `--time-offset-ms 5000`; assert config has `time_offset_ms=5000`.
**AC-4: `--time-offset-ms` forwarded** — invoke with `--time-offset-ms 5000`; assert `config.replay.time_offset_ms == 5000`. Without `--time-offset-ms`, assert `config.replay.time_offset_ms is None` (and `ReplayInputAdapter` will auto-detect).
**AC-5: Missing required arg → exit 2 + helpful message** — invoke without `--video`; assert exit code 2 (argparse default) + stderr message names the missing arg.
**AC-5: `--mavlink-signing-key` required** — invoke without `--mavlink-signing-key`; assert argparse exits 2 with stderr message naming the missing arg. Per replay protocol Invariant 11.
**AC-6: Calibration loader rejects malformed JSON** — pass a corrupt calib.json; assert `ReplayCliError("camera-calibration JSON malformed: <details>")` + exit 1.
**AC-7: Calibration schema validation** — pass a calib.json missing `intrinsics` key; assert `ReplayCliError("camera-calibration schema invalid: missing 'intrinsics'")`.
**AC-8: Output parent dir validation** — `--output /nonexistent/out.jsonl``ReplayCliError("output parent directory does not exist")` + exit 1 (consistent with `JsonlReplaySink` behaviour).
**AC-8: Mode set to replay** — capture the `Config` object passed to the shared main; assert `config.mode == "replay"`.
**AC-9: Exit-code mapping** — wire a `FakeReplayRoot` whose `runtime_loop` returns 0 / 1 / 2; assert process exit code matches each.
**AC-9: Exit-code pass-through** — wire a FakeMain returning 0 / 1 / 2; assert the CLI exits 0 / 1 / 2 respectively. Exit code 2 is reserved for `ReplayInputAdapter.open()` auto-sync hard-fail (set by the shared main / `compose_root`), NOT for argparse missing-arg (which uses argparse's default exit 2 but with a distinguishable stderr message).
**AC-10: Console script registered** — install the package in a fresh venv; assert `gps-denied-replay --help` runs and prints the argparse usage.
## Non-Functional Requirements
- CLI startup p99 ≤ 5 s (cold-start NFT from the epic).
- argparse + calibration loading p99 ≤ 100 ms (excluding `compose_replay` itself).
- CLI startup p99 ≤ 5 s (cold-start NFT from the epic, including config + calibration loading).
- argparse + calibration loading p99 ≤ 100 ms (excluding `compose_root` itself).
## Constraints
- argparse (stdlib) — no new CLI framework.
- JSON for calibration (already the project convention).
- Exit codes: 0 = success; 2 = AC-8 sync-impossible (or argparse missing-arg); 1 = any other error.
- Exit codes: 0 = success; 2 = AC-8 sync-impossible (set by the shared main from `ReplayInputAdapter`) OR argparse missing-arg (stdlib default); 1 = any other error.
- Console-script registration in pyproject.toml `[project.scripts]`.
- The CLI MUST NOT call `compose_root` directly — it mutates the config and dispatches into the shared main, which calls `compose_root`. This keeps the live and replay code paths converged at the same entry point per ADR-011.
## Risks & Mitigation
- **Risk: argparse exit code 2 conflicts with epic AC-8 exit code 2***Mitigation*: documented; argparse exit 2 is for "missing-required-arg / arg-parse-error" — operator can distinguish via stderr; AC-8 exit 2 is for runtime-sync-impossible.
- **Risk: argparse exit code 2 conflicts with epic AC-8 exit code 2***Mitigation*: documented; the argparse path emits a `usage:` line + a "the following arguments are required: …" line to stderr (stdlib default), whereas the AC-8 path emits a `replay.auto_sync.ac8_validation_failed` structured log line with the auto-detected offset + match percentage. Operators distinguish via stderr inspection.
- **Risk: calibration JSON schema drift***Mitigation*: schema-validate at load time; AC-7 enforces.
- **Risk: top-level error swallowing makes debugging hard***Mitigation*: top-level except logs the FULL traceback (via `logger.exception`); the exit code is 1 but the operator sees the traceback in stderr.
- **Risk: the CLI accidentally re-implements composition logic***Mitigation*: AC-8 (`config.mode == "replay"` set) + dispatch-to-shared-main test together prevent any composition logic from sneaking into `cli/replay.py`. Code-review checklist on the PR.
## Runtime Completeness
- **Named capability**: `gps-denied-replay` CLI.
- **Production code**: real argparse, real calibration loader, real `compose_replay` dispatch, real exit-code propagation.
- **Named capability**: `gps-denied-replay` console-script that activates replay mode on the airborne binary.
- **Production code**: real argparse, real calibration loader, real config-mode mutation, real dispatch to the shared main, real exit-code pass-through.
- **Allowed external stubs**: test fakes only.
- **Unacceptable substitutes**: a click-based or typer-based CLI (adds a dependency for no gain over stdlib argparse).
- **Unacceptable substitutes**: a click-based or typer-based CLI (adds a dependency for no gain over stdlib argparse); calling `compose_root` directly from the CLI (bypasses the shared main and defeats ADR-011's "same entry point for both modes" property).
## Contract
Implements `_docs/02_document/contracts/replay/replay_protocol.md` — CLI surface.
Implements `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — CLI surface + Invariant 11 (signing key mandatory). Operationalises ADR-011.
@@ -1,95 +0,0 @@
# Replay — gps-denied-replay-cli Dockerfile + GitHub Actions matrix + SBOM diff
**Task**: AZ-403_replay_dockerfile_ci
**Name**: `gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff (excludes C6/C10/C11/C12)
**Description**: Add the fourth Docker image `gps-denied-replay-cli`: multi-stage build (Python + C1C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server). Add a GitHub Actions matrix entry building and pushing this image alongside the existing 3 images (live / research / operator). Add an **SBOM diff CI step** that builds the SBOM (via `syft` or the project's existing SBOM tooling), parses it, and asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_orchestrator` packages — verifies AC-4 of the epic. The SBOM diff fails the CI job if any excluded component leaks into the replay image. Image base: same Python + CUDA base as the live image (consistency with TensorRT engines from C7) but with `BUILD_C6=OFF`, `BUILD_C10=OFF`, `BUILD_C11=OFF`, `BUILD_C12=OFF`, `BUILD_VIDEO_FILE_FRAME_SOURCE=ON`, `BUILD_TLOG_REPLAY_ADAPTER=ON`, `BUILD_REPLAY_SINK_JSONL=ON` build args.
**Complexity**: 3 points
**Dependencies**: AZ-402 (CLI entrypoint registered in pyproject); AZ-398 / AZ-399 / AZ-400 / AZ-401 (replay strategies); existing Dockerfile + CI plumbing for the live image (pattern to mirror); `module-layout.md` build-flag table; AZ-263, AZ-269, AZ-266
**Component**: replay-cicd (epic AZ-265 / E-DEMO-REPLAY) — Dockerfile at `docker/replay-cli/Dockerfile`; CI at `.github/workflows/build-images.yml` (or equivalent); SBOM-diff script at `ci/sbom_diff_replay.py`
**Tracker**: AZ-403
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` — replay binary scope (NO C6/C10/C11/C12).
- `_docs/02_document/architecture.md` — § 5 binary topology; build-flag matrix.
- `_docs/02_document/module-layout.md` — Build-Time Exclusion Map for the new BUILD_* flags.
## Problem
Without this task, the replay binary cannot ship — there's no CI matrix entry to build the image, no Dockerfile, no SBOM verification that the binary is actually free of operator-side components. AC-4 (SBOM diff verification) is a gating item.
## Outcome
- `docker/replay-cli/Dockerfile`:
- Multi-stage: builder stage (compiles cpp/*) + runtime stage (Python + C1C5 + replay strategies).
- Build-args: `BUILD_C6=OFF BUILD_C10=OFF BUILD_C11=OFF BUILD_C12=OFF BUILD_VIDEO_FILE_FRAME_SOURCE=ON BUILD_TLOG_REPLAY_ADAPTER=ON BUILD_REPLAY_SINK_JSONL=ON`.
- Entrypoint: `gps-denied-replay`.
- No HTTP server (no exposed ports; CLI only).
- `.github/workflows/build-images.yml` matrix entry for `replay-cli` (image tag, build args, push to registry).
- `ci/sbom_diff_replay.py` — generates the SBOM via `syft packages dir:./ -o spdx-json` (or equivalent) on the built image, parses it, asserts the absence of `c6_tile_cache`, `c10_provisioning`, `c11_tilemanager`, `c12_operator_orchestrator` Python packages. Exit 0 on clean SBOM; exit 1 on leak (with the leaking package name printed).
- CI step `replay-cli-sbom-diff` invokes the script after the image build; fails the job on script exit 1.
- Documentation: `docker/replay-cli/README.md` documents the image scope + build-args.
- Unit / smoke tests: `docker buildx build` of the Dockerfile succeeds locally; SBOM-diff script runs against a pre-built test image fixture.
## Scope
### Included
- Dockerfile.
- GitHub Actions matrix entry.
- SBOM-diff script + CI step.
- README for the image.
- Local smoke tests.
### Excluded
- Image push credentials / registry config — assumed inherited from the existing CI infrastructure.
- E2E replay fixture test — owned by E2E task.
## Acceptance Criteria
**AC-1: Dockerfile builds locally** — `docker buildx build -f docker/replay-cli/Dockerfile .` succeeds; final image exists and `docker run --rm <image> gps-denied-replay --help` prints the argparse usage.
**AC-2: Image scope: C1C5 present** — `docker run --rm <image> python -c "import gps_denied_onboard.components.c1_vio; import gps_denied_onboard.components.c2_vpr; import gps_denied_onboard.components.c2_5_rerank; import gps_denied_onboard.components.c3_matcher; import gps_denied_onboard.components.c3_5_adhop; import gps_denied_onboard.components.c4_pose; import gps_denied_onboard.components.c5_state; import gps_denied_onboard.components.c8_fc_adapter"` exits 0.
**AC-3: Image scope: NO C6/C10/C11/C12** — `docker run --rm <image> python -c "import gps_denied_onboard.components.c6_tile_cache"` exits non-zero (ImportError); same for c10, c11, c12.
**AC-4: SBOM-diff script passes on a clean image** — script run against the built image exits 0.
**AC-5: SBOM-diff script fails on a polluted image** — synthetic test where the image is rebuilt with `BUILD_C6=ON`; script exits 1 + prints `LEAK: c6_tile_cache present in SBOM`.
**AC-6: GitHub Actions matrix entry includes replay-cli** — `.github/workflows/build-images.yml` includes a matrix entry building+pushing `replay-cli`. Verify by syntax-checking the YAML + visual review.
**AC-7: NO HTTP server** — image inspection: `docker inspect <image>` shows NO exposed ports (`ExposedPorts: null`). `docker run --rm <image> ss -tlnp` (after a 5 s sleep) shows no listening sockets.
**AC-8: Image size sanity** — replay-cli image size ≤ 1.5× live-image size (replay re-uses live's CUDA + GTSAM + opencv layers). If exceeded, investigate.
**AC-9: README accuracy** — `docker/replay-cli/README.md` documents the entrypoint command, the volume mounts (e.g., `-v /host/data:/data`), and the build-args.
**AC-10: SBOM-diff script standalone testable** — invoke `python ci/sbom_diff_replay.py --sbom test-fixtures/clean-sbom.json` returns 0; with `polluted-sbom.json` returns 1.
## Non-Functional Requirements
- Image build p99 ≤ 10 min on Tier-1 CI hardware (mirrors live image).
- SBOM-diff script p99 ≤ 30 s.
## Constraints
- Re-use existing Dockerfile patterns (stage names, base images, layer ordering) for cache locality.
- `syft` (or equivalent) is the SBOM tool; pinned version in CI.
- The SBOM-diff script does NOT modify the image; read-only inspection.
## Risks & Mitigation
- **Risk: SBOM-diff false-positives if a dep transitively pulls in c6_tile_cache***Mitigation*: AC-5 fails fast; in practice, components do not depend on each other so transitive pull-in is impossible.
- **Risk: Image bloat from copying cpp/* libs that aren't needed***Mitigation*: build-time exclusion in the cmake config (per `module-layout.md`); review image layer size in AC-8.
- **Risk: CI matrix YAML drift breaks all 4 image builds***Mitigation*: matrix entry follows the same shape as the existing 3 entries; visual review in PR.
## Runtime Completeness
- **Named capability**: replay-cli Docker image + CI build + SBOM verification.
- **Production code**: real Dockerfile, real CI matrix entry, real SBOM-diff script.
- **Unacceptable substitutes**: skipping the SBOM diff (defeats AC-4 of the epic — the binary scope cannot be verified).
## Contract
Operationalises `_docs/02_document/contracts/replay/replay_protocol.md` — replay binary scope (NO C6/C10/C11/C12) + epic AC-4 SBOM diff.
@@ -1,55 +1,71 @@
# Replay — E2E replay fixture test (Derkachi 12 min clip + tlog)
# Replay — E2E replay fixture test (Derkachi 12 min clip + tlog) + mode-agnosticism + operator workflow
**Task**: AZ-404_replay_e2e_fixture
**Name**: E2E replay fixture test — Derkachi 12 min clip + tlog; AC-3 ≤ 100 m for ≥ 80 % of ticks
**Description**: Implement `tests/e2e/replay/test_derkachi_1min.py` running the `gps-denied-replay` CLI against a 12 min Derkachi clip + matching pymavlink `.tlog` and asserting AC-3 of the epic: L2 horizontal distance ≤ 100 m for ≥ 80 % of ticks (matches AC-1.3 cumulative-drift bound). Also asserts AC-1 (CLI exits 0; JSONL line count within ±5 % of `GLOBAL_POSITION_INT` tlog count); AC-2 (each line is valid JSON matching `EstimatorOutput` schema); AC-5 (determinism: same input → same output within ≤ 1e-6 float drift in position fields, run twice and diff); AC-6 (`--pace realtime` runs in 60 ± 5 s; `--pace asap` in ≤ 30 s on Tier-1 hardware). Test fixture: re-uses the existing Derkachi corpus (`_docs/00_problem/input_data/flight_derkachi/`) — clip a 60120 s segment + matching tlog window. Test gated by `RUN_REPLAY_E2E=1` env var in CI (Tier-1 capable; not run on every PR by default per the project's existing E2E gating pattern).
**Complexity**: 5 points
**Dependencies**: AZ-402 (CLI entrypoint); AZ-403 (Docker image used by E2E in CI); AZ-401 (composition root); the Derkachi fixture (`_docs/00_problem/input_data/flight_derkachi/`); AZ-263, AZ-269, AZ-266, AZ-272, AZ-273
**Component**: replay-tests (epic AZ-265 / E-DEMO-REPLAY) — test at `tests/e2e/replay/`
**Name**: E2E replay fixture test — Derkachi 12 min clip + tlog; AC-3 ≤ 100 m for ≥ 80 % of ticks + mode-agnosticism enforcement + operator-workflow rehearsal
**Description**: Implement `tests/e2e/replay/test_derkachi_1min.py` running the `gps-denied-replay` console-script against a 12 min Derkachi clip + matching pymavlink `.tlog` and asserting AC-3 of the epic: L2 horizontal distance ≤ 100 m for ≥ 80 % of ticks (matches AC-1.3 cumulative-drift bound). Per ADR-011 the test runs against the **single airborne image** in replay mode — there is no separate replay-cli image to verify. Also asserts:
- AC-1 (CLI exits 0; JSONL line count within ±5 % of `GLOBAL_POSITION_INT` tlog count);
- AC-2 (each line is valid JSON matching `EstimatorOutput` schema);
- AC-4 — **revised per ADR-011** — mode-agnosticism of the C1C7 + C13 components + byte-equality of C8 outbound encoders between live and replay (the v1.0.0 SBOM-diff check is replaced by these two AST/byte assertions);
- AC-5 (determinism: same input → same output within ≤ 1e-6 float drift in position fields, run twice and diff);
- AC-6 (`--pace realtime` runs in 60 ± 5 s; `--pace asap` in ≤ 30 s on Tier-1 hardware);
- AC-9 (operator pre-flight workflow rehearsal: the test setup runs the operator's C10/C11/C12 pre-flight flow against a mock satellite-provider before invoking the replay CLI, demonstrating that the operator workflow is identical between live and replay modes).
Test fixture: re-uses the existing Derkachi corpus (`_docs/00_problem/input_data/flight_derkachi/`) — clip a 60120 s segment + matching tlog window. Test gated by `RUN_REPLAY_E2E=1` env var in CI (Tier-1 capable; not run on every PR by default per the project's existing E2E gating pattern).
**Complexity**: 5 points (unchanged from v1.0.0 — the test surface is the same; AC-4 is reworded but no smaller; AC-9 is added, AC-8 removed).
**Dependencies**: AZ-402 (CLI entrypoint); AZ-401 (compose_root replay branch); AZ-405 (`ReplayInputAdapter` + auto-sync inside replay_input/); the Derkachi fixture (`_docs/00_problem/input_data/flight_derkachi/`); the airborne Docker image (the same image the live binary ships in — no replay-specific image; ADR-011); AZ-263, AZ-269, AZ-266, AZ-272, AZ-273.
**Component**: replay-tests (epic AZ-265 / E-DEMO-REPLAY) — test at `tests/e2e/replay/`.
**Tracker**: AZ-404
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` — Invariants 7, 10 (determinism).
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — Invariants 1, 5, 7, 10, 12 (mode-agnosticism + encoder byte-equality + JSONL one-line-per-emit + determinism + real C6 cache in replay).
- `_docs/02_document/architecture.md`**ADR-011** (replay-as-configuration; the design-defining decision that AC-4 enforces).
- `_docs/02_document/components/07_c5_state/description.md``EstimatorOutput` schema.
- `_docs/00_problem/input_data/flight_derkachi/README.md` — fixture documentation.
- `_docs/00_problem/input_data/expected_results/position_accuracy.csv` — ground-truth GPS for the AC-3 assertion.
## Problem
Without this task, AC-3 (the epic's primary acceptance gate — demo confidence equals field test confidence on the same footage) is unverified. AC-5 (determinism) and AC-6 (pace timing) are similarly unverified at the system level.
Without this task, AC-3 (the epic's primary acceptance gate — demo confidence equals field test confidence on the same footage) is unverified. AC-5 (determinism) and AC-6 (pace timing) are similarly unverified at the system level. Under ADR-011, AC-4 (mode-agnosticism + byte-equality of C8 encoders) and AC-9 (operator workflow rehearsal) are now the structural guarantees that replace the v1.0.0 SBOM diff — without this task, the airborne and replay code paths can drift silently and nothing in CI catches it.
## Outcome
- `tests/e2e/replay/conftest.py`:
- Fixture `derkachi_replay_inputs` returning `(video_path, tlog_path, calib_path, ground_truth_csv)`.
- Fixture `replay_runner` invoking the CLI via `subprocess.run(["gps-denied-replay", ...])` (or equivalent) and returning the captured stdout/stderr + exit code + parsed JSONL output.
- Fixture `operator_pre_flight_setup` (NEW per AC-9): runs the operator C12 pre-flight flow against a `mock-suite-sat-service` fixture (per ADR-007) — plan route → download tiles → build C10 manifest+engines+descriptor index → assert the cache content hash matches the expected fixture. The fixture yields the populated cache directory + the manifest path.
- Fixture `replay_runner` invoking the CLI via `subprocess.run(["gps-denied-replay", ...])` (or equivalent) against the populated cache and returning the captured stdout/stderr + exit code + parsed JSONL output.
- `tests/e2e/replay/test_derkachi_1min.py`:
- `test_ac1_exits_0_jsonl_count_match`.
- `test_ac2_jsonl_schema_match`.
- `test_ac3_within_100m_80pct_of_ticks`.
- `test_ac4_mode_agnosticism_ast_scan` (NEW per ADR-011): AST scan asserts no `components/**/*.py` file contains `if config.mode` / `if mode == "replay"` / `is_replay` style branches. The scan is part of this E2E test for centralized ownership of the invariant; can be hoisted to a standalone lint later if useful.
- `test_ac4_encoder_byte_equality` (NEW per ADR-011): construct two identical `EstimatorOutput` instances; pass one through `compose_root(config_live).fc_adapter.emit_external_position(out)` (with `SerialMavlinkTransport` replaced by a `CapturingMavlinkTransport` test fixture); pass the other through `compose_root(config_replay).fc_adapter.emit_external_position(out)` (with `NoopMavlinkTransport` replaced by the same `CapturingMavlinkTransport`); assert the captured byte streams are byte-identical (replay protocol Invariant 5).
- `test_ac5_determinism_two_runs_diff`.
- `test_ac6_pace_realtime_60s_within_5pct`.
- `test_ac6_pace_asap_under_30s`.
- `test_ac9_operator_workflow` (NEW per ADR-011): use the `operator_pre_flight_setup` fixture; assert the cache directory's content hash matches the expected fixture hash; then invoke `replay_runner` against the populated cache; assert AC-3 passes. This is the integration proof that the operator workflow is identical between live and replay.
- Helper `tests/e2e/replay/_helpers.py`:
- JSONL parser → list of `EstimatorOutput`.
- L2 horizontal-distance computation (WGS84-aware; uses `WgsConverter` AZ-279 inside the test for ground-truth comparison).
- Match-percentage computation against ground-truth GPS.
- `CapturingMavlinkTransport` test fixture (used by `test_ac4_encoder_byte_equality`).
- CI gating: tests marked `@pytest.mark.skipif(not os.getenv("RUN_REPLAY_E2E"), reason="...")` per the project's E2E pattern.
- Documentation: `tests/e2e/replay/README.md` describes how to run locally + which env var enables in CI.
- Documentation: `tests/e2e/replay/README.md` describes how to run locally + which env var enables in CI + the operator-workflow rehearsal fixture.
## Scope
### Included
- All 6 test methods (one per epic AC except AC-7 / AC-8 — those are auto-sync, owned by AZ-405 — and AC-4 — owned by SBOM diff in AZ-403).
- Helper functions for JSONL parsing + ground-truth comparison.
- Conftest fixtures.
- All 8 test methods (AC-1, AC-2, AC-3, AC-4 mode-agnosticism, AC-4 byte-equality, AC-5, AC-6 realtime, AC-6 asap, AC-9 operator workflow).
- Helper functions for JSONL parsing + ground-truth comparison + `CapturingMavlinkTransport`.
- Conftest fixtures incl. `operator_pre_flight_setup`.
- README.
### Excluded
- AC-7 / AC-8 auto-sync tests — owned by AZ-405 (auto-sync task).
- AC-4 SBOM-diff verification — owned by AZ-403 (Dockerfile + CI task).
- AC-7 / AC-8 auto-sync detection unit tests — owned by AZ-405 (the E2E test uses the auto-sync via the CLI, but unit-level positive/ambiguous/hand-launch cases live with AZ-405).
- Test against a separate replay-cli Docker image — **dropped per ADR-011**; the test runs against the airborne image only.
## Acceptance Criteria
@@ -59,28 +75,33 @@ Without this task, AC-3 (the epic's primary acceptance gate — demo confidence
**AC-3: test_ac3_within_100m_80pct_of_ticks passes** — for the Derkachi fixture with known ground-truth GPS, ≥ 80 % of emitted `EstimatorOutput` records have L2 horizontal distance ≤ 100 m from ground truth.
**AC-4: test_ac5_determinism_two_runs_diff passes** — run the CLI twice with identical args; load both JSONL outputs; assert position fields differ by ≤ 1e-6 float (Invariant 10).
**AC-4a: test_ac4_mode_agnosticism_ast_scan passes** — AST scan over `src/gps_denied_onboard/components/**/*.py` asserts no file contains an `if config.mode` / `if mode == "replay"` / `if self._replay_mode` / `is_replay` style branch. Replay-mode logic is structurally confined to the composition root + the replay strategies + the `replay_input/` coordinator.
**AC-5: test_ac6_pace_realtime_60s_within_5pct passes** — run with `--pace realtime` on a 60 s clip; assert wall-clock duration is 60 s ± 3 s.
**AC-4b: test_ac4_encoder_byte_equality passes** — for a known `EstimatorOutput`, the C8 outbound encoder byte stream is byte-identical between `compose_root(config_live)` and `compose_root(config_replay)` (verified via `CapturingMavlinkTransport`). The MAVLink 2.0 signing handshake runs in both modes; the dummy signing key in replay produces a byte-equivalent encoded output.
**AC-6: test_ac6_pace_asap_under_30s passes** — run with `--pace asap` on the same 60 s clip; assert wall-clock duration ≤ 30 s on Tier-1 hardware.
**AC-5: test_ac5_determinism_two_runs_diff passes** — run the CLI twice with identical args; load both JSONL outputs; assert position fields differ by ≤ 1e-6 float (replay protocol Invariant 10).
**AC-7: All tests skip cleanly without RUN_REPLAY_E2E** — when the env var is unset, `pytest tests/e2e/replay/` reports all 6 tests as SKIPPED, not FAILED.
**AC-6a: test_ac6_pace_realtime_60s_within_5pct passes** — run with `--pace realtime` on a 60 s clip; assert wall-clock duration is 60 s ± 3 s.
**AC-8: Tests run via Docker image** — also verify the CLI works via `docker run --rm gps-denied-replay-cli gps-denied-replay ...` for at least one of the AC tests (AC-1) — proves the image entrypoint is functional.
**AC-6b: test_ac6_pace_asap_under_30s passes** — run with `--pace asap` on the same 60 s clip; assert wall-clock duration ≤ 30 s on Tier-1 hardware.
**AC-7: All tests skip cleanly without RUN_REPLAY_E2E** — when the env var is unset, `pytest tests/e2e/replay/` reports all 8 tests as SKIPPED, not FAILED.
**AC-8: test_ac9_operator_workflow passes** — the `operator_pre_flight_setup` fixture runs the operator C12 pre-flight flow against a mock satellite-provider; the resulting cache directory's content hash matches the expected fixture; the replay CLI then runs against the populated cache and AC-3 passes. Demonstrates replay protocol Invariant 12 (real C6 cache in replay) + epic AC-9 (operator workflow identity).
**AC-9: Helper L2 computation correct** — unit-level test of the WGS84 L2 helper against hand-computed expected distance for a known coord pair.
**AC-10: README accuracy** — `tests/e2e/replay/README.md` documents the env var, the fixture location, the expected runtime per pace, and the failure-mode cookbook (e.g., "if AC-3 fails, regenerate ground-truth via X").
**AC-10: README accuracy** — `tests/e2e/replay/README.md` documents the env var, the fixture location, the expected runtime per pace, the operator-workflow rehearsal fixture, and the failure-mode cookbook (e.g., "if AC-3 fails, regenerate ground-truth via X").
## Non-Functional Requirements
- E2E suite runtime ≤ 5 min on Tier-1 hardware (one realtime run + one asap run + two determinism asap runs + two more for AC-1/AC-2).
- E2E suite runtime ≤ 6 min on Tier-1 hardware (one operator pre-flight setup + one realtime run + one asap run + two determinism asap runs + AC-4 byte-equality + AST scan; the operator-workflow setup adds ~30 s vs. v1.0.0).
- E2E memory ≤ 4 GB resident (epic NFT).
## Constraints
- Re-use the Derkachi fixture (`_docs/00_problem/input_data/flight_derkachi/`); do NOT introduce new fixture data unless explicitly missing.
- Re-use the `mock-suite-sat-service` test fixture (per ADR-007) for the operator pre-flight rehearsal.
- pytest is the test runner.
- Tier-1 hardware assumed (Jetson AGX Orin or equivalent x86 with CUDA per the project's CI matrix).
- The 12 min clip is a sub-segment of the existing Derkachi flight; the segment range is documented in `tests/e2e/replay/README.md`.
@@ -90,14 +111,16 @@ Without this task, AC-3 (the epic's primary acceptance gate — demo confidence
- **Risk: AC-3 flake under non-deterministic ML inference***Mitigation*: AC-5 (determinism) covers the two-runs-equal case; AC-3 is the offline-replay-quality check; if the system is non-deterministic enough to flake AC-3, that's a deeper bug worth surfacing.
- **Risk: Derkachi fixture clip not yet trimmed***Mitigation*: this task includes producing the trimmed clip + tlog window as part of the fixture; the conftest fixture file holds the trim definition (start/end timestamps).
- **Risk: AC-6 realtime timing flakes on shared CI runners***Mitigation*: ± 3 s tolerance is generous; if flakes persist, the tolerance widens to ± 5 s in a follow-up.
- **Risk (new per ADR-011): mode-agnosticism AST scan false-positives***Mitigation*: the scan whitelist is owned by this test; legitimate uses of `config.mode` inside `runtime_root/*` are NOT scanned (only `components/**/*.py`); the test fails with the offending file path + line so the author can move the branch into `runtime_root` or into a replay strategy.
- **Risk (new per ADR-011): encoder byte-equality fails because the MAVLink signing nonce / counter differs between live and replay***Mitigation*: the test uses a `DeterministicSigningKey` fixture that seeds the per-flight nonce / counter to a known value; both `compose_root(config_live)` and `compose_root(config_replay)` use this seeded key. If the byte streams still differ after the deterministic-seeding fix, that is a genuine drift between live and replay encoders and is a P0 bug.
## Runtime Completeness
- **Named capability**: end-to-end replay regression test against the Derkachi fixture.
- **Production code**: real CLI invocation, real ground-truth comparison, real determinism diff.
- **Allowed external stubs**: NONE — this is the integration-fidelity test.
- **Unacceptable substitutes**: an in-process pytest harness that bypasses the CLI subprocess (defeats AC-1 + AC-8 — the deliverable is the CLI binary).
- **Named capability**: end-to-end replay regression test against the Derkachi fixture + mode-agnosticism enforcement + operator-workflow rehearsal.
- **Production code**: real CLI invocation, real ground-truth comparison, real determinism diff, real AST scan, real encoder byte-stream capture, real operator C12 pre-flight run.
- **Allowed external stubs**: `mock-suite-sat-service` (per ADR-007) for the operator pre-flight rehearsal only; no other stubs — this is the integration-fidelity test.
- **Unacceptable substitutes**: an in-process pytest harness that bypasses the CLI subprocess (defeats AC-1 — the deliverable is the console-script entrypoint); a separate replay-cli Docker image test (defeats ADR-011 — there is only one image).
## Contract
Verifies `_docs/02_document/contracts/replay/replay_protocol.md` — Invariants 7 + 10; epic ACs 1, 2, 3, 5, 6.
Verifies `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — Invariants 1, 5, 7, 10, 12; epic ACs 1, 2, 3, 4 (mode-agnosticism + byte-equality), 5, 6, 9.
+85 -31
View File
@@ -1,54 +1,99 @@
# Replay — Auto-sync video↔tlog via IMU take-off detection (AC-7 / AC-8)
# Replay — `replay_input/` coordinator + auto-sync video↔tlog via IMU take-off detection
**Task**: AZ-405_replay_auto_sync
**Name**: Auto-sync of videotlog via IMU take-off detection (AC-7 / AC-8; `--time-offset-ms` remains the manual override)
**Description**: Implement auto-detection of the video↔tlog timestamp offset for the replay CLI, mitigating R-DEMO-1 (recordings are often started independently — camera and FC may be minutes apart). Algorithm: (1) parse the tlog for the IMU take-off pattern — sustained vertical accel > 0.5 g for ≥ 0.5 s + change in attitude rate > 1 rad/s in the same window (typical quadcopter take-off signature); compute `tlog_takeoff_ns`. (2) Analyse the video for motion-onset — pyramidal optical flow magnitude crossing a configurable threshold sustained for ≥ 0.5 s; compute `video_motion_onset_ns`. (3) Offset = `tlog_takeoff_ns - video_motion_onset_ns` (positive offset = video starts before take-off recorded in tlog). Confidence-scoring: confidence is high (≥ 80 %) when both signals are well-defined; low when ambiguous (e.g., fixed-wing hand-launch — no clear vertical-accel-above-0.5g pulse). If confidence < 80 %, log WARN + use the best-guess offset and proceed. `--time-offset-ms` always overrides auto-detect (manual override per AC-7). AC-8 hard-fail (exit code 2): if the resulting offset produces ≤ 95 % of frames matching at least one IMU window within ± 100 ms, the CLI exits with code 2 and prints both the auto-detected offset (if any) and the per-frame match percentage so the operator can debug.
**Complexity**: 5 points
**Dependencies**: AZ-402 (CLI hosts the auto-sync logic at startup); AZ-399 (tlog parser); AZ-398 (VideoFileFrameSource for video-side analysis); AZ-263, AZ-269, AZ-266, AZ-272 (FDR for confidence + decision logging)
**Component**: replay-auto-sync (epic AZ-265 / E-DEMO-REPLAY) — auto-sync helper at `src/gps_denied_onboard/cli/replay_auto_sync.py`
**Name**: `replay_input/` Layer-4 cross-cutting coordinator (`ReplayInputAdapter`) + auto-sync of videotlog timestamp offset via IMU take-off detection (AC-7 / AC-8; `--time-offset-ms` is the manual override)
**Description**: Per ADR-011, replay is a configuration of the airborne binary; the architectural integration point is the new `replay_input/` Layer-4 cross-cutting module that converges `(video, tlog)` inputs into the standard `FrameSource` + `FcAdapter` + `Clock` surfaces the composition root already consumes. This task creates the `replay_input/` module and owns the time-alignment concern inside it (auto-sync + manual offset application).
The module:
1. Hosts the `ReplayInputAdapter` class in `src/gps_denied_onboard/replay_input/tlog_video_adapter.py` (public re-export in `__init__.py`). Constructor takes `(video_path, tlog_path, camera_calibration, target_fc_dialect, wgs_converter, pace, manual_time_offset_ms, auto_sync_config)`. `.open()` resolves the time-offset (auto-sync OR manual override), instantiates `VideoFileFrameSource` + `TlogReplayFcAdapter` + chosen `Clock` (`TlogDerivedClock` for pace=ASAP; `WallClock` for pace=REALTIME), and returns a `ReplayInputBundle(frame_source, fc_adapter, clock, resolved_time_offset_ms, auto_sync_result)` for the composition root to wire.
2. Hosts the auto-sync logic in `src/gps_denied_onboard/replay_input/auto_sync.py`:
- `detect_tlog_takeoff(tlog_path, target_fc_dialect) -> AutoSyncResult` — parses the tlog for the IMU take-off pattern (sustained vertical accel > 0.5 g for ≥ 0.5 s + change in attitude rate > 1 rad/s in the same window — typical quadcopter take-off signature); returns `(tlog_takeoff_ns, confidence)`.
- `detect_video_motion_onset(video_path, frame_rate_hz) -> AutoSyncResult` — analyses the video for motion-onset via pyramidal optical flow magnitude crossing a configurable threshold sustained for ≥ 0.5 s; returns `(video_motion_onset_ns, confidence)`.
- `compute_offset(tlog_result, video_result) -> AutoSyncOffset` — combines the two; offset = `tlog_takeoff_ns - video_motion_onset_ns` (positive offset = video starts before take-off recorded in tlog); confidence = combined.
- `validate_offset_or_fail(offset, tlog_path, video_path, frame_rate_hz, threshold_pct) -> int` — runs the AC-8 frame-window match-percentage check: for each video frame, find the nearest IMU window within ± 100 ms after applying the offset; return 0 if ≥ 95 % of frames have a match, 2 otherwise.
3. Confidence-scoring: confidence is high (≥ 80 %) when both signals are well-defined; low when ambiguous (e.g., fixed-wing hand-launch — no clear vertical-accel-above-0.5g pulse). If combined confidence < 80 %, `ReplayInputAdapter.open()` logs WARN + uses the best-guess offset and proceeds. `manual_time_offset_ms is not None` always overrides auto-detect.
4. AC-8 hard-fail: if `validate_offset_or_fail` returns 2 (either after auto-sync OR after manual override), `ReplayInputAdapter.open()` raises `ReplayInputAdapterError("auto-sync hard-fail: …")` which the shared main maps to CLI exit code 2.
The composition root's replay-mode branch (AZ-401) instantiates `ReplayInputAdapter`, calls `.open()`, and consumes the returned bundle. No replay-aware code lives outside this module + AZ-400's transport seam + AZ-401's composition-root branch.
**Complexity**: 5 points (unchanged from v1.0.0 — same algorithmic work; the coordinator class is a small addition since it just instantiates strategies the algorithm already needs).
**Dependencies**: AZ-402 (CLI provides the args that feed `ReplayInputAdapter`); AZ-399 (`TlogReplayFcAdapter` is instantiated by `ReplayInputAdapter.open()`); AZ-398 (`VideoFileFrameSource` + `Clock` strategies are instantiated by `ReplayInputAdapter.open()`); AZ-279 (`WgsConverter` constructor-injected); AZ-263 (`runtime_root` bootstrap); AZ-269 / AZ-270 (`Config.replay.auto_sync` sub-config); AZ-266 (logging); AZ-272 (FDR record schema for confidence + decision logging).
**Component**: replay-input (epic AZ-265 / E-DEMO-REPLAY) — module at `src/gps_denied_onboard/replay_input/`.
**Tracker**: AZ-405
**Epic**: AZ-265 (E-DEMO-REPLAY)
### Document Dependencies
- `_docs/02_document/contracts/replay/replay_protocol.md` `time_offset_ms` semantics (Invariant 8).
- `_docs/02_document/architecture.md` — R-DEMO-1 mitigation.
- Epic AZ-265 description in `_docs/02_document/epics.md` — AC-7 / AC-8.
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — `ReplayInputAdapter` API; `time_offset_ms` semantics (Invariant 8).
- `_docs/02_document/architecture.md` **ADR-011** (replay-as-configuration; ReplayInputAdapter is the architectural seam between (video, tlog) and the rest of the system) + R-DEMO-1 mitigation.
- `_docs/02_document/module-layout.md``shared/replay_input` cross-cutting entry.
- Epic AZ-265 description in `_docs/02_document/epics.md` — AC-7 / AC-8 / AC-9 / AC-10.
## Problem
Without this task, the replay CLI relies on the operator passing `--time-offset-ms N` manually, which is error-prone (operators often don't have a stopwatch on the moment of take-off; the camera and FC are routinely started at different times). R-DEMO-1 is a recurring real-world concern. AC-7 / AC-8 codify the auto-sync expectation.
Two problems:
1. **Without `replay_input/`** there is no module-level home for the `(video, tlog)``(FrameSource, FcAdapter, Clock)` convergence; the composition root would need to instantiate each strategy individually + know about auto-sync + apply the manual override — all replay-specific code leaking into `compose_root`. Per ADR-011 the composition root should see only standard `FrameSource` + `FcAdapter` + `Clock` instances after the coordinator is opened; this task creates the coordinator.
2. **Without auto-sync** the replay CLI relies on the operator passing `--time-offset-ms N` manually, which is error-prone (operators often don't have a stopwatch on the moment of take-off; the camera and FC are routinely started at different times). R-DEMO-1 is a recurring real-world concern. AC-7 / AC-8 codify the auto-sync expectation.
## Outcome
- `src/gps_denied_onboard/cli/replay_auto_sync.py`:
- `detect_tlog_takeoff(tlog_path, target_fc_dialect) -> AutoSyncResult` — returns `(tlog_takeoff_ns, confidence)`.
- `detect_video_motion_onset(video_path, frame_rate_hz) -> AutoSyncResult` — returns `(video_motion_onset_ns, confidence)`.
- `compute_offset(tlog_result, video_result) -> AutoSyncOffset` — combines the two; emits final confidence + offset.
- `validate_offset_or_fail(offset, tlog_path, video_path, ...) -> int` — runs the AC-8 frame-window match-percentage check; returns 0 if ≥ 95 %, 2 otherwise (caller maps to CLI exit code).
- CLI wiring (in `cli/replay.py`): when `--time-offset-ms` is NOT provided, the CLI invokes `detect_*` + `compute_offset` + `validate_offset_or_fail`; if validation returns 2, the CLI exits 2 with the diagnostic message per AC-8.
- `src/gps_denied_onboard/replay_input/__init__.py`:
- Re-exports `ReplayInputAdapter`, `ReplayInputBundle`, `AutoSyncDecision`, `AutoSyncConfig`, `ReplayInputAdapterError`.
- `src/gps_denied_onboard/replay_input/interface.py`:
- `ReplayInputBundle` frozen+slots dataclass.
- `AutoSyncDecision` frozen+slots dataclass.
- `AutoSyncConfig` frozen+slots dataclass (defaults + thresholds).
- `src/gps_denied_onboard/replay_input/tlog_video_adapter.py`:
- `ReplayInputAdapter` class with `open()` + `close()` (idempotent close).
- Inside `open()`: resolve time-offset (auto-sync OR manual) → instantiate strategies → return bundle.
- Fails fast if required tlog message types absent (R-DEMO-3); raises `ReplayInputAdapterError("tlog missing required message types: ...")`.
- `src/gps_denied_onboard/replay_input/auto_sync.py`:
- `detect_tlog_takeoff(tlog_path, target_fc_dialect) -> AutoSyncResult` — pymavlink stream-parse; sustained vertical-accel + attitude-rate detector.
- `detect_video_motion_onset(video_path, frame_rate_hz) -> AutoSyncResult` — OpenCV pyramidal optical flow.
- `compute_offset(tlog_result, video_result) -> AutoSyncOffset` — combination + confidence.
- `validate_offset_or_fail(offset, tlog_path, video_path, frame_rate_hz, threshold_pct) -> int` — AC-8 validator.
- `src/gps_denied_onboard/replay_input/tests/` — unit tests:
- `test_tlog_takeoff_detector_positive` (AC-1).
- `test_tlog_takeoff_detector_ambiguous` (AC-2).
- `test_tlog_takeoff_detector_hand_launch` (AC-3).
- `test_video_motion_onset_positive` (AC-4).
- `test_combined_offset_within_200ms` (AC-5).
- `test_combined_offset_low_confidence_warn_and_proceed` (AC-6).
- `test_ac8_validator_hard_fail` (AC-7).
- `test_manual_override_bypasses_auto_detect` (AC-8).
- `test_frame_window_match_validator_threshold` (AC-9).
- `test_confidence_score_deterministic` (AC-10).
- `test_replay_input_adapter_open_returns_bundle` (covers the coordinator wiring; AC-11 below).
- `test_replay_input_adapter_clock_strategy_pace_asap` (TlogDerivedClock).
- `test_replay_input_adapter_clock_strategy_pace_realtime` (WallClock).
- `test_replay_input_adapter_close_idempotent`.
- `test_replay_input_adapter_missing_tlog_messages_fails_fast` (R-DEMO-3).
- INFO log on auto-detect success: `kind="replay.auto_sync.detected"` with `{tlog_takeoff_ns, video_motion_onset_ns, offset_ms, tlog_confidence, video_confidence, combined_confidence}`.
- WARN log on low confidence: `kind="replay.auto_sync.low_confidence"` with the same fields + `proceeding_with_best_guess: true`.
- ERROR log on AC-8 fail: `kind="replay.auto_sync.ac8_validation_failed"` with `{frame_window_match_pct, threshold_pct: 95.0}`.
- FDR records mirror all three log kinds.
- Unit tests: tlog-takeoff detector against synthetic IMU traces (positive case + ambiguous case + hand-launch case); video-motion detector against synthetic video frames; combined offset within tolerance for synchronised inputs; AC-8 validation hard-fails on degenerate offsets.
## Scope
### Included
- `replay_input/` module structure (`__init__.py`, `interface.py`, `tlog_video_adapter.py`, `auto_sync.py`, `tests/`).
- `ReplayInputAdapter` class with `open()` + `close()`.
- Tlog-takeoff detector (sustained vertical accel + attitude rate).
- Video-motion-onset detector (pyramidal optical flow).
- Combined offset computation + confidence.
- AC-8 frame-window match-percentage validator.
- CLI wiring at startup.
- Manual override (`--time-offset-ms`) bypass path.
- Manual override (`manual_time_offset_ms is not None`) bypass path.
- Structured logging + FDR.
- Unit tests covering positive / ambiguous / hand-launch / hard-fail cases.
- All unit tests listed above.
### Excluded
- E2E test against the Derkachi fixture — owned by E2E task (this task ships unit tests; E2E task adds an integration assertion AC-7 / AC-8).
- The CLI argparse + entrypoint — owned by CLI task.
- Modifications to `TlogReplayFcAdapter` — this task consumes the adapter's tlog stream and the FrameSource's video frames; no API changes.
- E2E test against the Derkachi fixture — owned by AZ-404 (this task ships unit tests; AZ-404 adds the integration assertion AC-7 / AC-8 / AC-9).
- The CLI argparse + entrypoint — owned by AZ-402.
- The composition root branch on `config.mode` — owned by AZ-401.
- `VideoFileFrameSource` + `Clock` strategies themselves — owned by AZ-398.
- `TlogReplayFcAdapter` itself — owned by AZ-399.
## Acceptance Criteria
@@ -62,16 +107,22 @@ Without this task, the replay CLI relies on the operator passing `--time-offset-
**AC-5: Combined offset within ± 200 ms (epic AC-7)** — for a fixture with KNOWN ground-truth offset (e.g., constructed test case offset = 5000 ms), `compute_offset` returns within ± 200 ms of ground truth.
**AC-6: Low combined confidence WARN-and-proceed** — when `combined_confidence < 0.80`, `compute_offset` returns the best-guess offset + WARN log; the CLI proceeds (does NOT exit) — verified via the unit test of the CLI wiring.
**AC-6: Low combined confidence WARN-and-proceed** — when `combined_confidence < 0.80`, `ReplayInputAdapter.open()` returns the bundle with the best-guess offset + WARN log; does NOT raise — verified via the unit test of the coordinator.
**AC-7: AC-8 hard-fail exit 2** — wire a `validate_offset_or_fail` against a deliberately-bad offset (e.g., 60 s offset on a 60 s clip — every frame would be off the tlog window); function returns 2; CLI exit code 2; ERROR log + FDR fired.
**AC-7: AC-8 hard-fail raises** — wire a `validate_offset_or_fail` against a deliberately-bad offset (e.g., 60 s offset on a 60 s clip — every frame would be off the tlog window); `ReplayInputAdapter.open()` raises `ReplayInputAdapterError("auto-sync hard-fail: …")` so the shared main maps to CLI exit code 2; ERROR log + FDR fired.
**AC-8: Manual override bypasses auto-detect** — `--time-offset-ms 5000` passed → auto-detect functions are NOT invoked (verified via call-count assertion); the manual offset flows directly into `TlogReplayFcAdapter`.
**AC-8: Manual override bypasses auto-detect** — `ReplayInputAdapter(manual_time_offset_ms=5000, …).open()``detect_*` and `compute_offset` are NOT invoked (verified via call-count assertion); the manual offset flows directly into `TlogReplayFcAdapter`. AC-8 validator still runs (so a wildly wrong manual offset still fails fast).
**AC-9: Frame-window match-percentage validator** — for a known-good offset, validator computes ≥ 95 % match (returns 0); for a known-bad offset, computes ≤ 95 % (returns 2). Threshold is configurable via `config.replay.auto_sync_match_threshold_pct` (default 95.0).
**AC-10: Confidence-score determinism** — re-run the auto-sync against the same input twice; assert confidence values match within 1e-9 (algorithmic determinism).
**AC-11: ReplayInputAdapter.open() returns a complete bundle** — `bundle = adapter.open()` returns a `ReplayInputBundle` with `isinstance(bundle.frame_source, VideoFileFrameSource)`, `isinstance(bundle.fc_adapter, TlogReplayFcAdapter)`, and `bundle.clock` matching the pace (`TlogDerivedClock` for ASAP, `WallClock` for REALTIME). The `resolved_time_offset_ms` field equals either the manual override or the auto-sync result.
**AC-12: Close is idempotent** — `adapter.open(); adapter.close(); adapter.close()` does not raise; the second close is a no-op.
**AC-13: Missing tlog messages fail fast** — open against a tlog missing `RAW_IMU` (AP) or `MSP2_RAW_IMU` (iNav); assert `ReplayInputAdapterError("tlog missing required message types: ['RAW_IMU']")` is raised inside `open()` BEFORE any video read (R-DEMO-3).
## Non-Functional Requirements
- Auto-sync startup overhead p99 ≤ 3 s (within the epic's cold-start ≤ 5 s budget combined with composition).
@@ -85,21 +136,24 @@ Without this task, the replay CLI relies on the operator passing `--time-offset-
- The take-off pattern thresholds (0.5 g, 1 rad/s, 0.5 s sustained) are in `config.replay.auto_sync.takeoff_*` with documented defaults.
- The video-motion threshold is similarly configurable.
- AC-8's 95 % match threshold is configurable per `config.replay.auto_sync_match_threshold_pct`.
- `ReplayInputAdapter` is a Layer-4 module (per `module-layout.md`); it 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`); it does NOT import from Layer 3 (no component-level dependencies).
## Risks & Mitigation
- **R-DEMO-1 (drift / unsynchronised recordings)***Mitigation*: this task IS the mitigation; AC-1..AC-5 cover the positive cases; AC-6 covers the WARN-and-proceed regime; AC-8 covers the hard-fail regime.
- **R-DEMO-1 (drift / unsynchronised recordings)***Mitigation*: this task IS the mitigation; AC-1..AC-5 cover the positive cases; AC-6 covers the WARN-and-proceed regime; AC-7 covers the hard-fail regime.
- **R-DEMO-3 (demo footage missing required FC messages)***Mitigation*: AC-13 fails fast at startup with a clear message naming the missing types.
- **Risk: optical-flow false-positives on jitter-only video***Mitigation*: configurable threshold; sustained-for-0.5 s requirement matches the take-off semantics; AC-2 covers the ambiguous case.
- **Risk: fixed-wing hand-launch hits the WARN regime even on legitimate footage***Mitigation*: documented; operator can pass `--time-offset-ms` manually; AC-3 documents the expected confidence drop.
- **Risk: AC-8 95 % threshold too strict for short clips with sparse IMU***Mitigation*: threshold is configurable; default 95 % is calibrated for typical tlog rates (50200 Hz IMU).
- **Risk (new): the coordinator class adds a new architectural seam that might leak `if mode == replay` plumbing into `compose_root`***Mitigation*: AZ-401's AC-7 (AST scan) catches this; the coordinator's API surface (open() → bundle) is designed so the composition root sees only standard interfaces past `.open()`.
## Runtime Completeness
- **Named capability**: videotlog auto-sync via IMU take-off detection.
- **Production code**: real OpenCV optical flow, real pymavlink tlog scan, real confidence-scored combined offset, real AC-8 validator.
- **Named capability**: `replay_input/` Layer-4 coordinator that converges `(video, tlog)` into the standard `FrameSource` + `FcAdapter` + `Clock` surfaces, owning time-alignment between them.
- **Production code**: real OpenCV optical flow, real pymavlink tlog scan, real confidence-scored combined offset, real AC-8 validator, real strategy instantiation, real Clock-pace selection.
- **Allowed external stubs**: test fakes only.
- **Unacceptable substitutes**: a hardcoded `time_offset_ms = 0` default (defeats R-DEMO-1 mitigation).
- **Unacceptable substitutes**: a hardcoded `time_offset_ms = 0` default (defeats R-DEMO-1 mitigation); placing the coordinator inside `cli/replay.py` (defeats the Layer-4 separation and forces the CLI to know about strategy instantiation — that belongs in the composition root branch, which itself delegates to `replay_input/`).
## Contract
Implements epic AZ-265 ACs 7 + 8; mitigates R-DEMO-1.
Implements epic AZ-265 ACs 7 + 8; mitigates R-DEMO-1 + R-DEMO-3. Implements the `ReplayInputAdapter` surface specified in `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0). Operationalises the `replay_input/` cross-cutting module from ADR-011.
+1
View File
@@ -15,3 +15,4 @@ tracker: jira
last_completed_batch: 59
last_cumulative_review: batches_55-57
current_batch: 60
current_batch_tasks: "AZ-401, AZ-389"
@@ -0,0 +1,49 @@
# Leftover — AZ-403 Jira transition to Cancelled (pending tracker write)
**Timestamp**: 2026-05-14T08:35:00+03:00
**Author**: agent (autodev cycle 1, Step 7 batch loop, replay-redesign docs phase)
## What was blocked
A Jira `transitionJiraIssue` call to move `AZ-403` (gps-denied-replay-cli Dockerfile + SBOM diff CI step) from its current status to **Cancelled**, plus a comment explaining the cancellation reason.
The transition was NOT performed during this docs phase because the docs phase did not touch the tracker (the replay-redesign was a docs-only change; the tracker write properly belongs to the next time autodev's tracker-write path runs, which is the standard implement / batch-loop step where Jira transitions happen).
## Full payload (replay this on next tracker-write opportunity)
```yaml
tracker: jira
cloudId: denyspopov.atlassian.net
projectKey: AZ
issueIdOrKey: AZ-403
action: transitionJiraIssue
target_status: Cancelled
comment: |
Cancelled per ADR-011 (replay-as-configuration; see `_docs/02_document/architecture.md`
§ ADR-011). The replay binary no longer exists as a separate Docker image — the airborne
image serves both live and replay modes from a single source tree with `config.mode = "replay"`
chosen at startup. No SBOM-diff CI step is needed because there are no per-replay
exclusions to enforce (C10/C11/C12 are already excluded from the airborne image by
ADR-002 + ADR-004; C6 is REQUIRED in replay per epic AZ-265 AC-3).
Original task file moved to `_docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md` with a
cancellation banner; AZ-404's dependency on this ticket removed from
`_docs/02_tasks/_dependencies_table.md`. No replacement ticket — the replay-mode
console-script entry point ships from the airborne image via AZ-402.
```
## Reason for deferral
The current turn is a docs-only redesign; the tracker write properly belongs to the next tracker-touching step (autodev's standard implement / batch-loop tracker-transition step) so a single batched tracker pass picks up the AZ-403 cancellation alongside any other tracker transitions for AZ-401/402/404/405 if they accompany the implementation step.
This is a NON-user-input blocker (`.cursor/rules/tracker.mdc` Leftovers Mechanism qualifying case) — no user decision is pending; replaying the call is straightforward when the tracker is next touched.
## Replay obligation
At the next autodev start, OR at the next batch implementation step that performs Jira transitions, the agent MUST:
1. Read this file.
2. Issue the `transitionJiraIssue` call with the payload above.
3. On success → delete this file.
4. On failure → update the file timestamp + reason, surface to the user only if the failure shape requires user input (auth, missing transition target, missing permission).