mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:31:12 +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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user