From 5adf3dd04f92f981bddf70d3cebe50d8a314b99d Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Thu, 14 May 2026 09:01:04 +0300 Subject: [PATCH] [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 --- _docs/02_document/architecture.md | 50 +++- .../contracts/replay/replay_protocol.md | 229 +++++++++++++----- _docs/02_document/epics.md | 140 +++++++---- _docs/02_document/module-layout.md | 95 ++++---- _docs/02_tasks/_dependencies_table.md | 35 ++- .../done/AZ-403_replay_dockerfile_ci.md | 33 +++ _docs/02_tasks/todo/AZ-401_replay_compose.md | 136 ++++++----- _docs/02_tasks/todo/AZ-402_replay_cli.md | 127 ++++++---- .../todo/AZ-403_replay_dockerfile_ci.md | 95 -------- .../todo/AZ-404_replay_e2e_fixture.md | 77 +++--- .../02_tasks/todo/AZ-405_replay_auto_sync.md | 116 ++++++--- _docs/_autodev_state.md | 1 + ...-14_az_403_cancellation_pending_tracker.md | 49 ++++ 13 files changed, 765 insertions(+), 418 deletions(-) create mode 100644 _docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md delete mode 100644 _docs/02_tasks/todo/AZ-403_replay_dockerfile_ci.md create mode 100644 _docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md diff --git a/_docs/02_document/architecture.md b/_docs/02_document/architecture.md index 422bec6..47e13a8 100644 --- a/_docs/02_document/architecture.md +++ b/_docs/02_document/architecture.md @@ -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. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/_docs/02_document/contracts/replay/replay_protocol.md b/_docs/02_document/contracts/replay/replay_protocol.md index 5a3a637..0c422be 100644 --- a/_docs/02_document/contracts/replay/replay_protocol.md +++ b/_docs/02_document/contracts/replay/replay_protocol.md @@ -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 C1–C5 pipeline against historical inputs (1–2 min Derkachi-style clip + matching pymavlink `.tlog`) so the parent-suite UI demo has end-to-end fidelity equal to a live flight. Production C1–C5 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 C1–C5 + 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 C1–C5 pipeline (with the full C6 tile cache + the same C7 inference runtime + the same C13 FDR) against historical inputs (1–2 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 C1–C5 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` 1–2 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 C1–C5 + 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 C1–C5**: 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 C1–C7, 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 C1–C5 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 + C1–C5 + 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 1–2 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 C1–C7 + 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 1–2 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. -- C1–C5 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). +- C1–C7 + 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 C1–C5 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 C1–C5 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. diff --git a/_docs/02_document/epics.md b/_docs/02_document/epics.md index 2e3e275..a52d4f0 100644 --- a/_docs/02_document/epics.md +++ b/_docs/02_document/epics.md @@ -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 | 21–34 | E-C5, E-CC-CONF, E-CC-LOG | | 19 | E-BBT | Blackbox Tests (FT/NFT scenarios) | tests | AZ-262 | M | 13–21 | 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 | 13–21 | 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 | 22–27 | 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 | 19–24 | 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**: 27–32 -**Added**: Decompose Step 2 (cycle 1, 2026-05-10) +**T-shirt**: M | **Story points**: 19–24 +**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 C1–C5 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 C1–C7 + 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 C1–C5 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 + C1–C5 + cpp/* + replay strategies; NO C6/C10/C11/C12; NO HTTP server). -- E2E replay test on a 1–2 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 C1–C5 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 1–2 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 C1–C5 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 C1–C7 + 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 C1–C5 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 C1–C5 + 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 C1–C7 + 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 C1–C5 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 C1–C5 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; 27–32 points across 8 child tasks. +T-shirt M; 19–24 points across 7 child tasks (was 27–32 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 1–2 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 1–2 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. -- C1–C5 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**. +- C1–C7 + 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. --- diff --git a/_docs/02_document/module-layout.md b/_docs/02_document/module-layout.md index 8b4684e..34588bd 100644 --- a/_docs/02_document/module-layout.md +++ b/_docs/02_document/module-layout.md @@ -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//_native/.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` (1–2 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 C1–C5 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 C1–C5 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 C1–C5 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_` (UltraVPR, MegaLoc, MixVPR, SelaVPR, EigenPlaces, NetVLAD, SALAD) | c2_vpr/ | 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_` (UltraVPR, MegaLoc, MixVPR, SelaVPR, EigenPlaces, NetVLAD, SALAD) | c2_vpr/ | 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 C1–C5 + 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 diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index 1c01e30..538a30a 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -107,12 +107,12 @@ are all declared and documented below under **Cycle Check**. | AZ-397 | C8 QgcTelemetryAdapter — downsampled 1–2 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 C1–C5 | 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 1–2 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 1–2 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 C1–C5 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 C1–C5 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). diff --git a/_docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md b/_docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md new file mode 100644 index 0000000..c57614b --- /dev/null +++ b/_docs/02_tasks/done/AZ-403_replay_dockerfile_ci.md @@ -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/_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 + C1–C5 + 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) diff --git a/_docs/02_tasks/todo/AZ-401_replay_compose.md b/_docs/02_tasks/todo/AZ-401_replay_compose.md index 94e10bb..debd179 100644 --- a/_docs/02_tasks/todo/AZ-401_replay_compose.md +++ b/_docs/02_tasks/todo/AZ-401_replay_compose.md @@ -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 C1–C5 -**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 C1–C5 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 C1–C5 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 C1–C7 + 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 C1–C7 + 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 C1–C5 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 C1–C5; 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 C1–C8 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 C1–C7 + 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 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 C1–C5 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`). -- C1–C5 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). +- C1–C7 + 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 C1–C5 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 C1–C5 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. diff --git a/_docs/02_tasks/todo/AZ-402_replay_cli.md b/_docs/02_tasks/todo/AZ-402_replay_cli.md index 2f7d64c..a92f8c6 100644 --- a/_docs/02_tasks/todo/AZ-402_replay_cli.md +++ b/_docs/02_tasks/todo/AZ-402_replay_cli.md @@ -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 `` 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:
")` + 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:
")` + 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. diff --git a/_docs/02_tasks/todo/AZ-403_replay_dockerfile_ci.md b/_docs/02_tasks/todo/AZ-403_replay_dockerfile_ci.md deleted file mode 100644 index eac1f78..0000000 --- a/_docs/02_tasks/todo/AZ-403_replay_dockerfile_ci.md +++ /dev/null @@ -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 + C1–C5 + 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 + C1–C5 + 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 gps-denied-replay --help` prints the argparse usage. - -**AC-2: Image scope: C1–C5 present** — `docker run --rm 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 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 ` shows NO exposed ports (`ExposedPorts: null`). `docker run --rm 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. diff --git a/_docs/02_tasks/todo/AZ-404_replay_e2e_fixture.md b/_docs/02_tasks/todo/AZ-404_replay_e2e_fixture.md index dd38280..48a4323 100644 --- a/_docs/02_tasks/todo/AZ-404_replay_e2e_fixture.md +++ b/_docs/02_tasks/todo/AZ-404_replay_e2e_fixture.md @@ -1,55 +1,71 @@ -# Replay — E2E replay fixture test (Derkachi 1–2 min clip + tlog) +# Replay — E2E replay fixture test (Derkachi 1–2 min clip + tlog) + mode-agnosticism + operator workflow **Task**: AZ-404_replay_e2e_fixture -**Name**: E2E replay fixture test — Derkachi 1–2 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 1–2 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 60–120 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 1–2 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 1–2 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 C1–C7 + 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 60–120 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 1–2 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. diff --git a/_docs/02_tasks/todo/AZ-405_replay_auto_sync.md b/_docs/02_tasks/todo/AZ-405_replay_auto_sync.md index f79bacd..a66ab48 100644 --- a/_docs/02_tasks/todo/AZ-405_replay_auto_sync.md +++ b/_docs/02_tasks/todo/AZ-405_replay_auto_sync.md @@ -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 video ↔ tlog 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 video↔tlog 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 (50–200 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**: video↔tlog 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. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 2ef11cb..0036754 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -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" diff --git a/_docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md b/_docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md new file mode 100644 index 0000000..7bc6061 --- /dev/null +++ b/_docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md @@ -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).