mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 03:51:14 +00:00
[AZ-265] Replay as configuration of airborne binary (ADR-011)
Re-design replay mode per user direction: replay is no longer a fourth Docker image with a reduced component set, but a `config.mode = "replay"` branch of the single airborne binary. The pre-flight workflow (route in suite UI -> C12 tile download via real satellite-provider -> C10 manifest+engines build) is identical between live and replay; only three strategies swap at compose time: FrameSource: Live <-> Video FcAdapter: Pymavlink/MSP2 <-> TlogReplay MavlinkTransport: Serial <-> Noop The C8 outbound MAVLink encoders run unchanged in both modes; their bytes hit `NoopMavlinkTransport` in replay and disappear. A new `JsonlReplaySink` taps C5's `EstimatorOutput` stream so the parent-suite UI sees per-tick coordinates by tailing `results.jsonl`. MAVLink 2.0 signing key remains mandatory (operator supplies a dummy file). A new `replay_input/` Layer-4 cross-cutting coordinator owns `(video, tlog) -> (FrameSource, FcAdapter, Clock)` convergence; the composition root sees only standard interfaces past `.open()`. Docs: - architecture.md: new ADR-011 with full rationale; ADR-002 binary narrative updated. - contracts/replay/replay_protocol.md: bumped to v2.0.0; 12 invariants (notably mode-agnosticism + encoder byte-equality + signing key mandatory + real C6 cache in replay). - module-layout.md: Build-Time Exclusion Map dropped from 4 to 3 binary columns; replay-mode `BUILD_*` flags default ON in airborne; `shared/replay_input` cross-cutting entry added. - epics.md: E-DEMO-REPLAY scope reframed; story points 27-32 -> 19-24. Task respecs: - AZ-401: shrunk 3 -> 2 pts; `compose_root` mode branch + JSONL sink + NoopMavlinkTransport wiring; legacy `compose_replay` export deleted. - AZ-402: console-script wrapper that mutates `config.mode = "replay"` and dispatches into the shared airborne main; `--mavlink-signing-key` mandatory. - AZ-403: CANCELLED. Moved to done/ with banner; Jira transition deferred via `_docs/_process_leftovers/2026-05-14_az_403_cancellation_pending_tracker.md`. - AZ-404: AC-4 reworded as mode-agnosticism AST scan + encoder byte-equality test; new AC-8 operator-workflow rehearsal. - AZ-405: also owns the `replay_input/` module + `ReplayInputAdapter`. _dependencies_table.md updated: AZ-401 gains AZ-405 dep; AZ-404 drops AZ-403 dep; AZ-403 row marked CANCELLED. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Replay — gps-denied-replay-cli Dockerfile + GitHub Actions matrix + SBOM diff — **CANCELLED per ADR-011 (2026-05-14)**
|
||||
|
||||
> **Status**: CANCELLED. Do NOT implement.
|
||||
>
|
||||
> **Cancelled by**: `_docs/02_document/architecture.md` § ADR-011 (replay is a configuration of the airborne binary, not a separate image) + `_docs/02_document/contracts/replay/replay_protocol.md` v2.0.0.
|
||||
>
|
||||
> **Reason**: Under ADR-011 there is no separate `gps-denied-replay-cli` Docker image — the airborne image IS the replay image, running the same components from a single source tree with `config.mode = "replay"` chosen at startup. The SBOM-diff CI step this task specified existed to enforce the exclusion of `c6_tile_cache` / `c10_provisioning` / `c11_tilemanager` / `c12_operator_orchestrator` from the replay binary. None of those exclusions hold any more:
|
||||
>
|
||||
> - **C6 IS required in replay** (epic AZ-265 AC-3 — ≤ 100 m horizontal accuracy — depends on C2's tile retrieval via the C6 `FaissDescriptorIndex`; v1.0.0's `BUILD_C6=OFF` flag for replay was the contradiction that prompted the ADR-011 rewrite).
|
||||
> - **C10/C11/C12 are already excluded from the airborne image** by ADR-002 + ADR-004 — that exclusion is enforced by the existing `ci/sbom_diff.py` step on the airborne image, NOT by a separate replay-specific SBOM diff.
|
||||
>
|
||||
> Therefore: no fourth Docker image, no `docker/replay-cli/Dockerfile`, no `ci/sbom_diff_replay.py` script, no GitHub Actions matrix entry for `replay-cli`. The work originally tracked under this task is replaced by zero work on the binary topology — the airborne image already does everything this task would have produced.
|
||||
>
|
||||
> **Replacement**: none required. The replay-mode entry point (`gps-denied-replay` console-script) ships from the airborne image via AZ-402.
|
||||
>
|
||||
> **Tracker action**: transition the Jira ticket `AZ-403` to **Cancelled** with a comment pointing at ADR-011. If the Jira MCP is unavailable at execution time, record the transition in `_docs/_process_leftovers/<YYYY-MM-DD>_az_403_cancellation.md` for replay on the next autodev start (per `.cursor/rules/tracker.mdc`).
|
||||
>
|
||||
> **Affected dependencies**: AZ-404 (E2E replay fixture test) previously listed AZ-403 as a hard dependency for "tests run via Docker image" (its old AC-8). AC-8 is reworded in the AZ-404 respec to test the airborne image instead. AZ-404's dependency on AZ-403 is removed from `_docs/02_tasks/_dependencies_table.md`.
|
||||
>
|
||||
> The original specification below is preserved for traceability only. Do not implement.
|
||||
|
||||
---
|
||||
|
||||
# (Cancelled) Original task spec — preserved for traceability
|
||||
|
||||
**Task**: AZ-403_replay_dockerfile_ci
|
||||
**Name**: `gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff (excludes C6/C10/C11/C12)
|
||||
**Description**: Add the fourth Docker image `gps-denied-replay-cli`: multi-stage build (Python + 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)
|
||||
@@ -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 <name> is OFF in this build")`. In live mode the flags are not checked (the OFF setting on a replay flag does not block live mode).
|
||||
- The legacy stub `runtime_root/replay.py` + the legacy `compose_replay` export (if present) are deleted as part of this task; replay is a configuration of `compose_root`, not a sibling composition root. The deletion is justified by the dead-code rule in `coderule.mdc` (no remaining usages once this task lands).
|
||||
- INFO log on startup, in replay mode: `kind="replay.compose_root.ready"` with `{config_path, calib_path, pace, time_offset_ms, video_path, tlog_path, output_path}`.
|
||||
- INFO log on startup, in live mode: existing live `compose_root.ready` log unchanged.
|
||||
- Unit tests:
|
||||
- `test_compose_root_live_unchanged`: with `config.mode == "live"`, the returned `Runtime` has the same shape (same strategy classes for FrameSource/FcAdapter/MavlinkTransport/Sink set) as today.
|
||||
- `test_compose_root_replay_wires_replay_strategies`: with `config.mode == "replay"`, `isinstance(runtime.frame_source, VideoFileFrameSource)`, `isinstance(runtime.fc_adapter, TlogReplayFcAdapter)`, `isinstance(runtime.mavlink_transport, NoopMavlinkTransport)`, and a `JsonlReplaySink` is attached to C5's `EstimatorOutput` stream.
|
||||
- `test_compose_root_replay_rejects_off_flag`: with `BUILD_VIDEO_FILE_FRAME_SOURCE=OFF` and `config.mode == "replay"`, `compose_root(config)` raises `CompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay mode requires it")`.
|
||||
- `test_compose_root_replay_single_clock`: the same `Clock` instance is injected into all components that need one (replay protocol Invariant 2 — `id()` equality across consumers).
|
||||
- `test_compose_root_no_compose_replay_export`: `from gps_denied_onboard.runtime_root import compose_replay` raises `ImportError` (the legacy function is deleted).
|
||||
- `test_compose_root_replay_jsonl_sink_emits_per_tick`: drive 10 frames through the wired runtime; assert `JsonlReplaySink.emit` was called exactly 10 times with `EstimatorOutput` instances.
|
||||
- `test_compose_root_replay_noop_transport_swallows_emits`: drive a known sequence of EstimatorOutput through the runtime; assert `NoopMavlinkTransport.bytes_written() > 0` (C8 encoders still produce bytes) AND the bytes never reach any wire-attached transport.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- `compose_replay` body.
|
||||
- `ReplayRoot` dataclass.
|
||||
- `runtime_loop()` driving the per-frame loop documented in the contract.
|
||||
- Build-flag check at startup.
|
||||
- Configuration + calibration loading (re-uses existing config loader from AZ-269/AZ-270).
|
||||
- Unit tests including build-flag rejection + frame-by-frame loop.
|
||||
- The `config.mode` branch inside `compose_root`.
|
||||
- `Config.mode` + `Config.replay` schema additions (if not already present).
|
||||
- Deletion of `runtime_root/replay.py` + the `compose_replay` export.
|
||||
- Build-flag check at startup for replay mode.
|
||||
- INFO logs.
|
||||
- All unit tests listed above.
|
||||
|
||||
### Excluded
|
||||
- CLI argparse + entrypoint — owned by CLI task.
|
||||
- Auto-sync IMU take-off detection — owned by AZ-405 (this task accepts `time_offset_ms` from `config` or CLI override).
|
||||
- Dockerfile + CI — owned by Docker task.
|
||||
- E2E replay fixture test — owned by E2E task.
|
||||
- C6/C10/C11/C12 wiring — explicitly NOT included (per epic scope).
|
||||
- CLI argparse + entrypoint — owned by AZ-402.
|
||||
- `ReplayInputAdapter` itself — owned by AZ-405.
|
||||
- `JsonlReplaySink` / `NoopMavlinkTransport` / `MavlinkTransport` Protocol seam / `SerialMavlinkTransport` retrofit — owned by AZ-400.
|
||||
- `TlogReplayFcAdapter` — owned by AZ-399.
|
||||
- `VideoFileFrameSource` / `LiveCameraFrameSource` / `Clock` strategies — owned by AZ-398.
|
||||
- E2E replay fixture test — owned by AZ-404.
|
||||
- Auto-sync logic — owned by AZ-405.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: ReplayRoot returned with all components wired** — `compose_replay(valid_config)` returns a `ReplayRoot` with non-None values for all fields (`frame_source`, `fc_adapter`, `replay_sink`, `clock`, `vio`, `vpr`, `rerank`, `matcher`, `refiner`, `pose_estimator`, `state_estimator`).
|
||||
**AC-1: Single composition root** — `from gps_denied_onboard.runtime_root import compose_root, compose_operator` works; `from gps_denied_onboard.runtime_root import compose_replay` raises `ImportError`. There is only one entry-point function per binary track.
|
||||
|
||||
**AC-2: Build-flag check** — with `BUILD_VIDEO_FILE_FRAME_SOURCE=OFF`, `compose_replay(...)` → `ReplayCompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay binary requires it")`.
|
||||
**AC-2: Live mode unchanged** — with `config.mode == "live"` (or omitted; default is live), `compose_root(config)` produces a `Runtime` whose strategy classes match the pre-task baseline (snapshot test: serialise the class names of all wired strategies; baseline file checked into the repo; this test fails if the live wiring changed inadvertently).
|
||||
|
||||
**AC-3: ASAP → TlogDerivedClock; REALTIME → WallClock** — `pace=ASAP` resolves `Clock = TlogDerivedClock`; `pace=REALTIME` resolves `Clock = WallClock`. Verify via `isinstance(replay_root.clock, ...)`.
|
||||
**AC-3: Replay mode wires replay strategies** — with `config.mode == "replay"`, the returned `Runtime` has:
|
||||
- `frame_source: VideoFileFrameSource`
|
||||
- `fc_adapter: TlogReplayFcAdapter`
|
||||
- `mavlink_transport: NoopMavlinkTransport`
|
||||
- A `JsonlReplaySink` registered as a listener on C5's `EstimatorOutput` stream
|
||||
|
||||
**AC-4: Runtime loop terminates on EOS** — wire a `FakeFrameSource` returning 10 frames + None; call `runtime_loop()`; assert it returns 0 after exactly 10 frame cycles.
|
||||
**AC-4: Replay-mode build-flag check** — with `BUILD_VIDEO_FILE_FRAME_SOURCE=OFF` and `config.mode == "replay"`, `compose_root(config)` raises `CompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay mode requires it")`. Same for the other two flags. With the SAME `BUILD_*` flag OFF but `config.mode == "live"`, `compose_root(config)` succeeds (live mode does not require the replay flags).
|
||||
|
||||
**AC-5: One EstimatorOutput per frame** — drive 10 frames; assert `JsonlReplaySink.emit` was called exactly 10 times with `EstimatorOutput` instances.
|
||||
**AC-5: Clock injection** — `config.mode == "replay"` with `pace == "asap"` injects `TlogDerivedClock`; with `pace == "realtime"` injects `WallClock`. The SAME `Clock` instance is injected into every component that consumes one (`id()` equality across the C1, C5, C8 consumers; replay protocol Invariant 2).
|
||||
|
||||
**AC-6: AC-8 sync-impossible exit code 2** — wire a tlog adapter that reports < 95 % frame-window match (auto-sync hard-fail per AC-8 of the epic); `runtime_loop()` returns 2.
|
||||
**AC-6: JSONL sink emits per tick** — drive 10 frames through the wired runtime (using a `FakeFrameSource` + fake `TlogReplayFcAdapter` from test fixtures); assert `JsonlReplaySink.emit` is called exactly 10 times with `EstimatorOutput` instances.
|
||||
|
||||
**AC-7: Composition uses Public APIs only** — assert that `compose_replay` imports ONLY `__init__.py` re-exports of each component (per `module-layout.md` Layer-3 / Layer-4 rules). CI-style check via AST scan in the unit test.
|
||||
**AC-7: No mode-aware imports in components** — AST scan asserts that `compose_root` is the ONLY file that imports BOTH `LiveCameraFrameSource` AND `VideoFileFrameSource` (i.e., no component sees both strategy classes). Replay-aware logic is confined to the composition root + the replay strategies + the `replay_input/` coordinator.
|
||||
|
||||
**AC-8: No C6/C10/C11/C12 imports** — assert that `compose_replay` does NOT import any symbol from `components.c6_tile_cache`, `components.c10_provisioning`, `components.c11_tilemanager`, `components.c12_operator_orchestrator` (per epic scope).
|
||||
**AC-8: Public APIs only across components** — assert that the replay-mode branch imports ONLY `__init__.py` re-exports of each component (per `module-layout.md` Layer-3 / Layer-4 rules). CI-style AST scan in the unit test.
|
||||
|
||||
**AC-9: Configuration + calibration loading** — `compose_replay(config_with_invalid_calib_path)` → `ReplayCompositionError("camera-calibration not found at ...")`.
|
||||
**AC-9: NoopMavlinkTransport swallows C8 outbound bytes** — drive a known EstimatorOutput sequence through the runtime in replay mode; assert `NoopMavlinkTransport.bytes_written() > 0` (the C8 encoders run their signing handshake + GPS_INPUT encoding) AND no I/O reached any wire-attached transport (verified by the absence of any open file descriptor / serial port mock activity).
|
||||
|
||||
**AC-10: Single-Clock invariant** — assert that the same `Clock` instance is injected into all components that need one (no two distinct Clock instances per process); check via `id()` comparison across consumers.
|
||||
**AC-10: Operator pre-flight C6 cache reused identically** — wire a stub C6 `FaissDescriptorIndex` populated with a known descriptor; run replay mode against the wired runtime; assert C2's `lookup()` returns the expected tile ID. Demonstrates that C6 is fully wired in replay (replay protocol Invariant 12 — no replay-specific cache shape).
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- `compose_replay` p99 ≤ 1 s (one-time startup cost; epic NFT cold-start ≤ 5 s).
|
||||
- `runtime_loop()` per-frame overhead (NOT counting 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.
|
||||
|
||||
@@ -1,101 +1,140 @@
|
||||
# Replay — gps-denied-replay CLI entrypoint + arg parser + calibration loader
|
||||
# Replay — `gps-denied-replay` console-script wrapper (mode-config dispatcher)
|
||||
|
||||
**Task**: AZ-402_replay_cli
|
||||
**Name**: `gps-denied-replay` CLI entrypoint + argparse + camera-calibration loader
|
||||
**Description**: Implement the `gps-denied-replay` console script: `argparse`-based CLI accepting `--video PATH --tlog PATH --output results.jsonl --camera-calibration calib.json --config config.yaml [--pace {realtime,asap}] [--time-offset-ms N]`. Load and validate the camera-calibration JSON (project's standard pinhole + distortion-coefficients schema, reusable via the config loader from AZ-269/AZ-270 if practical, otherwise a small dedicated loader); construct the `Config` object; invoke `compose_replay(config) -> ReplayRoot`; call `replay_root.runtime_loop()`; map the returned exit code to the process exit code (0 = success per AC-1 of the epic; 2 = sync-impossible per AC-8; 1 = any other error). Set up structured logging (stdout JSON per project convention) and FDR client. Exit-code mapping documented inline. CLI registered as a console_script entrypoint in pyproject.toml under `[project.scripts]` (or equivalent build-config).
|
||||
**Complexity**: 3 points
|
||||
**Dependencies**: AZ-401 (`compose_replay` + `ReplayRoot.runtime_loop`); AZ-269 / AZ-270 (config); AZ-263; AZ-266; AZ-272 (FDR record schema); AZ-273 (`FdrClient`)
|
||||
**Component**: replay-cli (epic AZ-265 / E-DEMO-REPLAY) — CLI entrypoint at `src/gps_denied_onboard/cli/replay.py`
|
||||
**Name**: `gps-denied-replay` console-script — thin mode-config wrapper that builds a replay-mode `Config` and dispatches into the shared airborne entry point
|
||||
**Description**: Implement the `gps-denied-replay` console-script in `src/gps_denied_onboard/cli/replay.py`. Per ADR-011, this is **not a standalone CLI** with its own composition root — it is a thin wrapper around the live airborne entry point that loads `config.yaml`, sets `config.mode = "replay"`, applies the replay-specific CLI args (`--video`, `--tlog`, `--output`, `--time-offset-ms`, `--pace`, `--mavlink-signing-key`), and calls the same `main()` function the live `gps-denied-onboard` binary calls. The shared main entry point calls `compose_root(config)` (which branches on `config.mode` per AZ-401) and runs the per-frame loop; the runtime loop is unchanged between live and replay.
|
||||
|
||||
CLI surface (argparse):
|
||||
```
|
||||
gps-denied-replay
|
||||
--video PATH # required
|
||||
--tlog PATH # required
|
||||
--output results.jsonl # required
|
||||
--camera-calibration calib.json # required
|
||||
--config config.yaml # required (same schema as airborne)
|
||||
--mavlink-signing-key PATH # required (operator supplies a dummy key for replay; signing handshake still runs)
|
||||
[--pace {realtime,asap}] # default asap
|
||||
[--time-offset-ms N] # overrides AZ-405 auto-sync inside replay_input/
|
||||
```
|
||||
|
||||
The CLI:
|
||||
1. Parses arguments + validates file existence (video, tlog, calib, config, signing key).
|
||||
2. Loads `config.yaml` via the existing `config/` loader.
|
||||
3. Loads the camera-calibration JSON (small dedicated loader; pinhole + distortion-coefficients schema).
|
||||
4. Mutates the loaded config: `config.mode = "replay"`, `config.replay.video_path = ...`, `config.replay.tlog_path = ...`, `config.replay.output_path = ...`, `config.replay.pace = ...`, `config.replay.time_offset_ms = ...` (None if not provided — `ReplayInputAdapter` will auto-detect via AZ-405).
|
||||
5. Calls the SAME `main(config, camera_calibration, signing_key_path)` function the live `gps-denied-onboard` binary already calls. The shared main wires everything via `compose_root(config)` and runs the per-frame loop.
|
||||
6. Maps the runtime exit code to the process exit code (0 = success; 2 = `ReplayInputAdapter.open()` auto-sync hard-fail per AC-8 of the epic; 1 = any other error).
|
||||
7. Top-level try/except logs the FULL traceback via `logger.exception` and exits 1 on any unhandled exception.
|
||||
|
||||
**Complexity**: 3 points (unchanged from v1.0.0 — the CLI shape is the same; what changed is that the CLI does NOT host the composition logic; it just builds a config and dispatches).
|
||||
**Dependencies**: AZ-401 (`compose_root` extension with `config.mode == "replay"` branch + the `Config.mode` + `Config.replay` schema additions); AZ-269 / AZ-270 (config loader); AZ-263 (the shared airborne `main()` entry point); AZ-266 (logging); AZ-272 (FDR record schema); AZ-273 (`FdrClient`).
|
||||
**Component**: replay-cli (epic AZ-265 / E-DEMO-REPLAY) — `src/gps_denied_onboard/cli/replay.py`.
|
||||
**Tracker**: AZ-402
|
||||
**Epic**: AZ-265 (E-DEMO-REPLAY)
|
||||
|
||||
### Document Dependencies
|
||||
|
||||
- `_docs/02_document/contracts/replay/replay_protocol.md` — CLI surface specification.
|
||||
- `_docs/02_document/architecture.md` — § 5 (binary topology; replay-cli is the fourth Docker image).
|
||||
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — CLI surface specification + Invariant 11 (signing key mandatory in replay).
|
||||
- `_docs/02_document/architecture.md` — **ADR-011** (replay-as-configuration) + § 5 (binary topology; replay runs from the airborne image).
|
||||
- `_docs/02_document/module-layout.md` — `cli/replay` cross-cutting entry (the console-script wrapper, not a standalone CLI).
|
||||
|
||||
## Problem
|
||||
|
||||
Without this task, the `compose_replay` composition root has no entrypoint — the parent-suite UI cannot shell out to a replay run. The CLI is the user-facing surface (and CI-test surface) of the replay binary.
|
||||
Without this task, the operator has no entry point to invoke `config.mode == "replay"` against an arbitrary `(video, tlog)` pair — they would need to manually edit a config file with the replay-mode flag and the per-file paths, then invoke the airborne entry point. The CLI is the user-facing surface (and CI-test surface) for the replay mode.
|
||||
|
||||
## Outcome
|
||||
|
||||
- `src/gps_denied_onboard/cli/replay.py` — `main()` entrypoint:
|
||||
- argparse setup with all 7 args + the 2 optional ones.
|
||||
- calibration loader (small JSON loader; pinhole + distortion-coefficients schema) — module-internal.
|
||||
- config loader invocation (re-use AZ-269 / AZ-270 plumbing).
|
||||
- `compose_replay(config)` invocation.
|
||||
- `replay_root.runtime_loop()` invocation; exit code propagated.
|
||||
- Structured logging + FDR client setup.
|
||||
- Top-level try/except: logs the error class + message + suggested next step before exiting 1.
|
||||
- `pyproject.toml` (or equivalent) registers `gps-denied-replay = "gps_denied_onboard.cli.replay:main"`.
|
||||
- INFO log at startup: `kind="replay.cli.started"` with all CLI args (sanitised — no key bytes per E-C8 signing invariants, but replay has no signing).
|
||||
- INFO log at exit: `kind="replay.cli.exited"` with `{exit_code, frames_processed, lines_written}`.
|
||||
- Unit tests: argparse defaults + overrides, calibration loader rejects malformed JSON, config loader passes-through to `compose_replay`, exit-code mapping on each known runtime_loop return value.
|
||||
- argparse setup with all 6 required args + the 2 optional ones.
|
||||
- File-existence validation for all required-file args (video, tlog, calib, config, signing key); fails fast with `ReplayCliError` + exit 1 on missing files.
|
||||
- Calibration loader (small JSON loader; pinhole + distortion-coefficients schema) — module-internal helper.
|
||||
- Config loader invocation (re-use AZ-269 / AZ-270 plumbing).
|
||||
- Mode-config mutation: `config.mode = "replay"` + `config.replay.{video_path, tlog_path, output_path, pace, time_offset_ms}` populated from CLI args.
|
||||
- Dispatch into the shared airborne `main(config, camera_calibration, signing_key_path)` entry point.
|
||||
- Exit-code mapping: shared main returns 0 / 1 / 2 → CLI exits with the same code.
|
||||
- Structured logging setup + FDR client setup happen inside the shared main (NOT duplicated here).
|
||||
- Top-level try/except: logs the FULL traceback via `logger.exception` + exits 1 on any unhandled exception.
|
||||
- `pyproject.toml` `[project.scripts]` registers `gps-denied-replay = "gps_denied_onboard.cli.replay:main"`.
|
||||
- INFO log at CLI startup (BEFORE config load, since logging is not yet bootstrapped): a single `print(f"gps-denied-replay starting with args: {sanitised_args}")` via stderr; the shared main then bootstraps structured logging properly. `--mavlink-signing-key` value is replaced by `<redacted>` in the printed args.
|
||||
- Unit tests:
|
||||
- `test_argparse_all_args`: all 6 required + 2 optional args parsed correctly; defaults applied.
|
||||
- `test_argparse_missing_video_exits_2`: argparse exits 2 when `--video` is omitted (stdlib argparse default).
|
||||
- `test_argparse_missing_signing_key_exits_2`: same for `--mavlink-signing-key`.
|
||||
- `test_calibration_loader_malformed`: corrupt calib.json → `ReplayCliError("camera-calibration JSON malformed: <details>")` + exit 1.
|
||||
- `test_calibration_loader_schema`: calib.json missing `intrinsics` → `ReplayCliError("camera-calibration schema invalid: missing 'intrinsics'")`.
|
||||
- `test_config_mode_set_to_replay`: parse args + invoke the CLI; capture the `Config` passed to the shared main; assert `config.mode == "replay"` + `config.replay.video_path` etc. populated.
|
||||
- `test_dispatch_to_shared_main`: assert the shared main is called exactly once with the mutated config; assert no separate composition logic is invoked inside `cli/replay.py`.
|
||||
- `test_exit_code_pass_through`: with a FakeMain returning 0 / 1 / 2, the CLI exits 0 / 1 / 2 respectively.
|
||||
- `test_top_level_exception_logged_and_exits_1`: an unhandled exception inside the shared main is logged with full traceback (verified via `logger.exception` mock) and the CLI exits 1.
|
||||
- `test_console_script_registered`: install the package in a fresh venv (via `tox` or `pytest-virtualenv`); assert `gps-denied-replay --help` runs and prints the argparse usage.
|
||||
|
||||
## Scope
|
||||
|
||||
### Included
|
||||
- argparse + arg-validation (file existence, output-parent existence).
|
||||
- camera-calibration JSON loader + schema validation.
|
||||
- `compose_replay` invocation + runtime_loop dispatch.
|
||||
- Exit-code mapping.
|
||||
- Top-level error handling (catch + log + exit 1 on unexpected exception).
|
||||
- argparse + arg-validation (file existence).
|
||||
- camera-calibration JSON loader + schema validation (module-internal helper).
|
||||
- Config-mode mutation (`config.mode = "replay"` + replay sub-config population).
|
||||
- Dispatch into the shared airborne `main()` entry point.
|
||||
- Exit-code mapping (pass-through).
|
||||
- Top-level error handling.
|
||||
- Console-script registration in pyproject.toml.
|
||||
- Unit tests for argparse + calibration loader + exit-code mapping.
|
||||
- All unit tests listed above.
|
||||
|
||||
### Excluded
|
||||
- Auto-sync IMU take-off detection — owned by AZ-405 (this task accepts `--time-offset-ms` and forwards to config/replay; the auto-sync TASK computes the default value).
|
||||
- Dockerfile + CI matrix — owned by Docker task.
|
||||
- E2E replay fixture test — owned by E2E task.
|
||||
- Auto-sync IMU take-off detection — owned by AZ-405 (the `ReplayInputAdapter` inside `replay_input/` consumes `--time-offset-ms` from config OR auto-detects when None).
|
||||
- The `compose_root` branch + the JSONL sink + the NoopMavlinkTransport wiring — owned by AZ-401.
|
||||
- E2E replay fixture test — owned by AZ-404.
|
||||
- The shared airborne `main()` function itself — owned by AZ-263 / the existing airborne entry-point task. This task assumes the shared main exists and is callable with `(config, camera_calibration, signing_key_path)`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
**AC-1: All required args parsed** — invoke with `--video v.mp4 --tlog t.tlog --output o.jsonl --camera-calibration c.json --config conf.yaml`; assert all five values reach `compose_replay`'s `Config` object.
|
||||
**AC-1: All required args parsed** — invoke with `--video v.mp4 --tlog t.tlog --output o.jsonl --camera-calibration c.json --config conf.yaml --mavlink-signing-key key.bin`; assert all six values reach the shared main (or the `Config` mutation phase) intact.
|
||||
|
||||
**AC-2: --pace default ASAP** — invoke without `--pace`; assert config has `pace=ReplayPace.ASAP`.
|
||||
**AC-2: `--pace` default ASAP** — invoke without `--pace`; assert `config.replay.pace == "asap"`.
|
||||
|
||||
**AC-3: --pace realtime** — invoke with `--pace realtime`; assert config has `pace=ReplayPace.REALTIME`.
|
||||
**AC-3: `--pace realtime`** — invoke with `--pace realtime`; assert `config.replay.pace == "realtime"`.
|
||||
|
||||
**AC-4: --time-offset-ms forwarded** — invoke with `--time-offset-ms 5000`; assert config has `time_offset_ms=5000`.
|
||||
**AC-4: `--time-offset-ms` forwarded** — invoke with `--time-offset-ms 5000`; assert `config.replay.time_offset_ms == 5000`. Without `--time-offset-ms`, assert `config.replay.time_offset_ms is None` (and `ReplayInputAdapter` will auto-detect).
|
||||
|
||||
**AC-5: Missing required arg → exit 2 + helpful message** — invoke without `--video`; assert exit code 2 (argparse default) + stderr message names the missing arg.
|
||||
**AC-5: `--mavlink-signing-key` required** — invoke without `--mavlink-signing-key`; assert argparse exits 2 with stderr message naming the missing arg. Per replay protocol Invariant 11.
|
||||
|
||||
**AC-6: Calibration loader rejects malformed JSON** — pass a corrupt calib.json; assert `ReplayCliError("camera-calibration JSON malformed: <details>")` + exit 1.
|
||||
|
||||
**AC-7: Calibration schema validation** — pass a calib.json missing `intrinsics` key; assert `ReplayCliError("camera-calibration schema invalid: missing 'intrinsics'")`.
|
||||
|
||||
**AC-8: Output parent dir validation** — `--output /nonexistent/out.jsonl` → `ReplayCliError("output parent directory does not exist")` + exit 1 (consistent with `JsonlReplaySink` behaviour).
|
||||
**AC-8: Mode set to replay** — capture the `Config` object passed to the shared main; assert `config.mode == "replay"`.
|
||||
|
||||
**AC-9: Exit-code mapping** — wire a `FakeReplayRoot` whose `runtime_loop` returns 0 / 1 / 2; assert process exit code matches each.
|
||||
**AC-9: Exit-code pass-through** — wire a FakeMain returning 0 / 1 / 2; assert the CLI exits 0 / 1 / 2 respectively. Exit code 2 is reserved for `ReplayInputAdapter.open()` auto-sync hard-fail (set by the shared main / `compose_root`), NOT for argparse missing-arg (which uses argparse's default exit 2 but with a distinguishable stderr message).
|
||||
|
||||
**AC-10: Console script registered** — install the package in a fresh venv; assert `gps-denied-replay --help` runs and prints the argparse usage.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- CLI startup p99 ≤ 5 s (cold-start NFT from the epic).
|
||||
- argparse + calibration loading p99 ≤ 100 ms (excluding `compose_replay` itself).
|
||||
- CLI startup p99 ≤ 5 s (cold-start NFT from the epic, including config + calibration loading).
|
||||
- argparse + calibration loading p99 ≤ 100 ms (excluding `compose_root` itself).
|
||||
|
||||
## Constraints
|
||||
|
||||
- argparse (stdlib) — no new CLI framework.
|
||||
- JSON for calibration (already the project convention).
|
||||
- Exit codes: 0 = success; 2 = AC-8 sync-impossible (or argparse missing-arg); 1 = any other error.
|
||||
- Exit codes: 0 = success; 2 = AC-8 sync-impossible (set by the shared main from `ReplayInputAdapter`) OR argparse missing-arg (stdlib default); 1 = any other error.
|
||||
- Console-script registration in pyproject.toml `[project.scripts]`.
|
||||
- The CLI MUST NOT call `compose_root` directly — it mutates the config and dispatches into the shared main, which calls `compose_root`. This keeps the live and replay code paths converged at the same entry point per ADR-011.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- **Risk: argparse exit code 2 conflicts with epic AC-8 exit code 2** — *Mitigation*: documented; argparse exit 2 is for "missing-required-arg / arg-parse-error" — operator can distinguish via stderr; AC-8 exit 2 is for runtime-sync-impossible.
|
||||
- **Risk: argparse exit code 2 conflicts with epic AC-8 exit code 2** — *Mitigation*: documented; the argparse path emits a `usage:` line + a "the following arguments are required: …" line to stderr (stdlib default), whereas the AC-8 path emits a `replay.auto_sync.ac8_validation_failed` structured log line with the auto-detected offset + match percentage. Operators distinguish via stderr inspection.
|
||||
- **Risk: calibration JSON schema drift** — *Mitigation*: schema-validate at load time; AC-7 enforces.
|
||||
- **Risk: top-level error swallowing makes debugging hard** — *Mitigation*: top-level except logs the FULL traceback (via `logger.exception`); the exit code is 1 but the operator sees the traceback in stderr.
|
||||
- **Risk: the CLI accidentally re-implements composition logic** — *Mitigation*: AC-8 (`config.mode == "replay"` set) + dispatch-to-shared-main test together prevent any composition logic from sneaking into `cli/replay.py`. Code-review checklist on the PR.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: `gps-denied-replay` CLI.
|
||||
- **Production code**: real argparse, real calibration loader, real `compose_replay` dispatch, real exit-code propagation.
|
||||
- **Named capability**: `gps-denied-replay` console-script that activates replay mode on the airborne binary.
|
||||
- **Production code**: real argparse, real calibration loader, real config-mode mutation, real dispatch to the shared main, real exit-code pass-through.
|
||||
- **Allowed external stubs**: test fakes only.
|
||||
- **Unacceptable substitutes**: a click-based or typer-based CLI (adds a dependency for no gain over stdlib argparse).
|
||||
- **Unacceptable substitutes**: a click-based or typer-based CLI (adds a dependency for no gain over stdlib argparse); calling `compose_root` directly from the CLI (bypasses the shared main and defeats ADR-011's "same entry point for both modes" property).
|
||||
|
||||
## Contract
|
||||
|
||||
Implements `_docs/02_document/contracts/replay/replay_protocol.md` — CLI surface.
|
||||
Implements `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0) — CLI surface + Invariant 11 (signing key mandatory). Operationalises ADR-011.
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Replay — gps-denied-replay-cli Dockerfile + GitHub Actions matrix + SBOM diff
|
||||
|
||||
**Task**: AZ-403_replay_dockerfile_ci
|
||||
**Name**: `gps-denied-replay-cli` Dockerfile + GitHub Actions matrix entry + SBOM diff (excludes C6/C10/C11/C12)
|
||||
**Description**: Add the fourth Docker image `gps-denied-replay-cli`: multi-stage build (Python + 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 <image> gps-denied-replay --help` prints the argparse usage.
|
||||
|
||||
**AC-2: Image scope: C1–C5 present** — `docker run --rm <image> python -c "import gps_denied_onboard.components.c1_vio; import gps_denied_onboard.components.c2_vpr; import gps_denied_onboard.components.c2_5_rerank; import gps_denied_onboard.components.c3_matcher; import gps_denied_onboard.components.c3_5_adhop; import gps_denied_onboard.components.c4_pose; import gps_denied_onboard.components.c5_state; import gps_denied_onboard.components.c8_fc_adapter"` exits 0.
|
||||
|
||||
**AC-3: Image scope: NO C6/C10/C11/C12** — `docker run --rm <image> python -c "import gps_denied_onboard.components.c6_tile_cache"` exits non-zero (ImportError); same for c10, c11, c12.
|
||||
|
||||
**AC-4: SBOM-diff script passes on a clean image** — script run against the built image exits 0.
|
||||
|
||||
**AC-5: SBOM-diff script fails on a polluted image** — synthetic test where the image is rebuilt with `BUILD_C6=ON`; script exits 1 + prints `LEAK: c6_tile_cache present in SBOM`.
|
||||
|
||||
**AC-6: GitHub Actions matrix entry includes replay-cli** — `.github/workflows/build-images.yml` includes a matrix entry building+pushing `replay-cli`. Verify by syntax-checking the YAML + visual review.
|
||||
|
||||
**AC-7: NO HTTP server** — image inspection: `docker inspect <image>` shows NO exposed ports (`ExposedPorts: null`). `docker run --rm <image> ss -tlnp` (after a 5 s sleep) shows no listening sockets.
|
||||
|
||||
**AC-8: Image size sanity** — replay-cli image size ≤ 1.5× live-image size (replay re-uses live's CUDA + GTSAM + opencv layers). If exceeded, investigate.
|
||||
|
||||
**AC-9: README accuracy** — `docker/replay-cli/README.md` documents the entrypoint command, the volume mounts (e.g., `-v /host/data:/data`), and the build-args.
|
||||
|
||||
**AC-10: SBOM-diff script standalone testable** — invoke `python ci/sbom_diff_replay.py --sbom test-fixtures/clean-sbom.json` returns 0; with `polluted-sbom.json` returns 1.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- Image build p99 ≤ 10 min on Tier-1 CI hardware (mirrors live image).
|
||||
- SBOM-diff script p99 ≤ 30 s.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Re-use existing Dockerfile patterns (stage names, base images, layer ordering) for cache locality.
|
||||
- `syft` (or equivalent) is the SBOM tool; pinned version in CI.
|
||||
- The SBOM-diff script does NOT modify the image; read-only inspection.
|
||||
|
||||
## Risks & Mitigation
|
||||
|
||||
- **Risk: SBOM-diff false-positives if a dep transitively pulls in c6_tile_cache** — *Mitigation*: AC-5 fails fast; in practice, components do not depend on each other so transitive pull-in is impossible.
|
||||
- **Risk: Image bloat from copying cpp/* libs that aren't needed** — *Mitigation*: build-time exclusion in the cmake config (per `module-layout.md`); review image layer size in AC-8.
|
||||
- **Risk: CI matrix YAML drift breaks all 4 image builds** — *Mitigation*: matrix entry follows the same shape as the existing 3 entries; visual review in PR.
|
||||
|
||||
## Runtime Completeness
|
||||
|
||||
- **Named capability**: replay-cli Docker image + CI build + SBOM verification.
|
||||
- **Production code**: real Dockerfile, real CI matrix entry, real SBOM-diff script.
|
||||
- **Unacceptable substitutes**: skipping the SBOM diff (defeats AC-4 of the epic — the binary scope cannot be verified).
|
||||
|
||||
## Contract
|
||||
|
||||
Operationalises `_docs/02_document/contracts/replay/replay_protocol.md` — replay binary scope (NO C6/C10/C11/C12) + epic AC-4 SBOM diff.
|
||||
@@ -1,55 +1,71 @@
|
||||
# Replay — E2E replay fixture test (Derkachi 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user