Wires the airborne composition root for replay-as-configuration (ADR-011):
- compose_root(config) branches on config.mode in {"live", "replay"}.
Live behaviour is unchanged; replay builds ReplayInputAdapter,
attaches JsonlReplaySink, and injects NoopMavlinkTransport.
- New private module runtime_root/_replay_branch.py holds the
replay-only strategy graph + build-flag gate + calibration loader.
- Config gains Config.mode (Literal["live","replay"]) plus
Config.replay sub-block with nested ReplayAutoSyncConfig that mirrors
the AZ-405 AutoSyncConfig DTO; YAML loader + ENV map updated.
Absorbs the AZ-400 transport-seam retrofit that AZ-401 strictly
required but AZ-400 had not delivered:
- New MavlinkTransport Protocol (write/bytes_written/close).
- NoopMavlinkTransport (replay; build-flag gated, idempotent close,
thread-safe byte counter).
- SerialMavlinkTransport (live, no-op restructure of existing pymavlink
byte path; encoder retrofit to actually USE it is the AZ-558
follow-up).
AZ-401 AC-9 (NoopMavlinkTransport.bytes_written > 0 after C8 encoders
run) is BLOCKED on AZ-558 — the encoder routing retrofit is out of
the AZ-401 task envelope (FORBIDDEN files: pymavlink_ardupilot_adapter,
msp2_inav_adapter). AZ-558 spec, batch_61_review.md, and the test's
@pytest.mark.skip rationale all carry the deferral reason.
Tests: 22 compose_root replay-branch tests + 17 transport tests.
Full regression: 2063 passed, 86 environment-skips, 1 documented
skip (AC-9 / AZ-558), 1 pre-existing flaky perf test deselected.
Co-authored-by: Cursor <cursoragent@cursor.com>
14 KiB
Replay — compose_root extension for config.mode == "replay" + JSONL sink + NoopMavlinkTransport wiring
Task: AZ-401_replay_compose
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":
- Build a
ReplayInputAdapterfromconfig.replay.{video_path, tlog_path, pace, time_offset_ms, target_fc_dialect, …}using the sameCameraCalibrationandWgsConverterthe live path already constructs. - Call
replay_input.open()→ReplayInputBundle(frame_source, fc_adapter, clock, …)and use the three returned strategies as the standardFrameSource+FcAdapter+Clockfor the rest of the graph (no further mode awareness past this point). - Pick
NoopMavlinkTransport(replay) instead ofSerialMavlinkTransport(live) as theMavlinkTransportinjected into the C8 outbound encoders. The encoders are unchanged — they produce the same byte streams in both modes (replay protocol Invariant 5). - Attach a
JsonlReplaySinkas an additional listener on C5'sEstimatorOutputstream. 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. - Wire C1–C7 + C13 exactly as in the live composition (replay protocol Invariant 1 — components see the same interfaces).
- Refuse construction if any of
BUILD_VIDEO_FILE_FRAME_SOURCE,BUILD_TLOG_REPLAY_ADAPTER,BUILD_REPLAY_SINK_JSONLis OFF in replay mode (raiseCompositionErrorpointing 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(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-modeBUILD_*flags default ON in airborne);runtime_rootcross-cutting entry._docs/02_document/contracts/c5_state/state_estimator_protocol.md—EstimatorOutputconsumed by the JSONL sink._docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.md—FcAdapterProtocol + the new tinyMavlinkTransportseam introduced by AZ-400.
Problem
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/__init__.py(or the factory module it delegates to) exposes onecompose_root(config) -> Runtimethat branches internally onconfig.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: buildsReplayInputAdapter, calls.open(), picksNoopMavlinkTransportfor C8 outbound, attachesJsonlReplaySinkto C5'sEstimatorOutputstream, and otherwise wires C1–C7 + C13 identically.
- In
Config.mode: Literal["live", "replay"] = "live"field on the config DTO (default live; replay opt-in). Plus aConfig.replaysub-config holdingvideo_path,tlog_path,output_path,pace,time_offset_ms,target_fc_dialect,auto_syncsub-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 atsrc/gps_denied_onboard/config/).- Build-flag check at startup: when
config.mode == "replay"and any of the three replay-modeBUILD_*flags is OFF, raisesCompositionError("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 legacycompose_replayexport (if present) are deleted as part of this task; replay is a configuration ofcompose_root, not a sibling composition root. The deletion is justified by the dead-code rule incoderule.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.readylog unchanged. - Unit tests:
test_compose_root_live_unchanged: withconfig.mode == "live", the returnedRuntimehas the same shape (same strategy classes for FrameSource/FcAdapter/MavlinkTransport/Sink set) as today.test_compose_root_replay_wires_replay_strategies: withconfig.mode == "replay",isinstance(runtime.frame_source, VideoFileFrameSource),isinstance(runtime.fc_adapter, TlogReplayFcAdapter),isinstance(runtime.mavlink_transport, NoopMavlinkTransport), and aJsonlReplaySinkis attached to C5'sEstimatorOutputstream.test_compose_root_replay_rejects_off_flag: withBUILD_VIDEO_FILE_FRAME_SOURCE=OFFandconfig.mode == "replay",compose_root(config)raisesCompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay mode requires it").test_compose_root_replay_single_clock: the sameClockinstance 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_replayraisesImportError(the legacy function is deleted).test_compose_root_replay_jsonl_sink_emits_per_tick: drive 10 frames through the wired runtime; assertJsonlReplaySink.emitwas called exactly 10 times withEstimatorOutputinstances.test_compose_root_replay_noop_transport_swallows_emits: drive a known sequence of EstimatorOutput through the runtime; assertNoopMavlinkTransport.bytes_written() > 0(C8 encoders still produce bytes) AND the bytes never reach any wire-attached transport.
Scope
Included
- The
config.modebranch insidecompose_root. Config.mode+Config.replayschema additions (if not already present).- Deletion of
runtime_root/replay.py+ thecompose_replayexport. - Build-flag check at startup for replay mode.
- INFO logs.
- All unit tests listed above.
Excluded
- CLI argparse + entrypoint — owned by AZ-402.
ReplayInputAdapteritself — owned by AZ-405.JsonlReplaySink/NoopMavlinkTransport/MavlinkTransportProtocol seam /SerialMavlinkTransportretrofit — owned by AZ-400.TlogReplayFcAdapter— owned by AZ-399.VideoFileFrameSource/LiveCameraFrameSource/Clockstrategies — owned by AZ-398.- E2E replay fixture test — owned by AZ-404.
- Auto-sync logic — owned by AZ-405.
Acceptance Criteria
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: 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: Replay mode wires replay strategies — with config.mode == "replay", the returned Runtime has:
frame_source: VideoFileFrameSourcefc_adapter: TlogReplayFcAdaptermavlink_transport: NoopMavlinkTransport- A
JsonlReplaySinkregistered as a listener on C5'sEstimatorOutputstream
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: 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: 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: 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: 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: 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: 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_rootp99 ≤ 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 / ADR-011 unchanged.
- Public API discipline (Layer-3 / Layer-4 from
module-layout.md). - 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:
Clockinjection (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.pybreaks consumers we forgot to update — Mitigation: AC-1 explicitly asserts the import fails; before this task lands, grep forcompose_replayacross the repo and update each call site tocompose_root(config_with_mode_replay). The grep is part of the implementation step; the test assertion catches any miss. - Risk:
Config.modedefault 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: single composition root that resolves
config.modeto 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_replayfunction "for clarity" (defeats ADR-011 — the single composition root IS the architectural mechanism for mode-agnosticism); branching onconfig.modeinside component code (defeats replay protocol Invariant 1).
Contract
Implements _docs/02_document/contracts/replay/replay_protocol.md (v2.0.0) — composition-root extension + Invariants 1, 5, 9, 11, 12. Operationalises ADR-011.