Files
gps-denied-onboard/_docs/02_tasks/done/AZ-401_replay_compose.md
T
Oleksandr Bezdieniezhnykh 17a0d074af [AZ-401] [AZ-400] Replay — compose_root replay-mode branch + transport seam
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>
2026-05-14 11:55:33 +03:00

14 KiB
Raw Blame History

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 C1C7 + C13 graph as live mode Description: Add a single mode-aware branch inside the existing compose_root(config) in src/gps_denied_onboard/runtime_root/__init__.py (or the factory module it delegates to). When config.mode == "live" the function behaves exactly as today. When config.mode == "replay":

  1. Build a ReplayInputAdapter from config.replay.{video_path, tlog_path, pace, time_offset_ms, target_fc_dialect, …} using the same CameraCalibration and WgsConverter the live path already constructs.
  2. Call replay_input.open()ReplayInputBundle(frame_source, fc_adapter, clock, …) and use the three returned strategies as the standard FrameSource + FcAdapter + Clock for the rest of the graph (no further mode awareness past this point).
  3. Pick NoopMavlinkTransport (replay) instead of SerialMavlinkTransport (live) as the MavlinkTransport injected into the C8 outbound encoders. The encoders are unchanged — they produce the same byte streams in both modes (replay protocol Invariant 5).
  4. Attach a JsonlReplaySink as an additional listener on C5's EstimatorOutput stream. The live binary's existing downstream observers (C8 outbound to FC, QGC telemetry adapter, C13 FDR) all stay wired in replay — only the C8 outbound transport differs (NoopMavlinkTransport in replay) and only the JsonlReplaySink is added.
  5. Wire C1C7 + C13 exactly as in the live composition (replay protocol Invariant 1 — components see the same interfaces).
  6. Refuse construction if any of BUILD_VIDEO_FILE_FRAME_SOURCE, BUILD_TLOG_REPLAY_ADAPTER, BUILD_REPLAY_SINK_JSONL is OFF in replay mode (raise CompositionError pointing at the OFF flag).

Out of scope: there is no separate compose_replay function under ADR-011 — replay is a configuration of the single compose_root. If a stub compose_replay exists today in the codebase (from the v1.0.0 design), it MUST be deleted as part of this task; the runtime_root __init__.py exposes only compose_root and compose_operator. Complexity: 2 points (was 3 in the v1.0.0 spec; shrinks because there is no new composition function, only a config-driven branch + two strategy swaps + one observer attachment) Dependencies: AZ-398 (FrameSource + Clock); AZ-399 (TlogReplayFcAdapter); AZ-400 (JsonlReplaySink + MavlinkTransport Protocol seam + NoopMavlinkTransport + SerialMavlinkTransport retrofit); AZ-405 (ReplayInputAdapter); AZ-269 / AZ-270 (config — Config.mode field + Config.replay sub-config); AZ-263 (runtime_root bootstrap); AZ-266 (logging); AZ-272 (FDR record schema); AZ-279 (WgsConverter); AZ-390 (E-C8 FcAdapter Protocol the tlog adapter implements); the C1C5 component factory APIs (already exist). Component: replay-composition (epic AZ-265 / E-DEMO-REPLAY) — branch lives in runtime_root/__init__.py (or the factory module the composition root already delegates to). Tracker: AZ-401 Epic: AZ-265 (E-DEMO-REPLAY)

Document Dependencies

  • _docs/02_document/contracts/replay/replay_protocol.md (v2.0.0) — Invariants 1, 5, 9, 11, 12 + the composition-root extension narrative.
  • _docs/02_document/architecture.mdADR-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.mdEstimatorOutput consumed by the JSONL sink.
  • _docs/02_document/contracts/c8_fc_adapter/fc_adapter_protocol.mdFcAdapter Protocol + the new tiny MavlinkTransport seam 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 one compose_root(config) -> Runtime that branches internally on config.mode in {"live", "replay"}. No new public function. The branch:
    • In live: behaves exactly as today (no behaviour change for the live path).
    • In replay: builds ReplayInputAdapter, calls .open(), picks NoopMavlinkTransport for C8 outbound, attaches JsonlReplaySink to C5's EstimatorOutput stream, and otherwise wires C1C7 + C13 identically.
  • Config.mode: Literal["live", "replay"] = "live" field on the config DTO (default live; replay opt-in). Plus a Config.replay sub-config holding video_path, tlog_path, output_path, pace, time_offset_ms, target_fc_dialect, auto_sync sub-block. Owned by the AZ-269 / AZ-270 config schema task — this task adds the fields if AZ-269 / AZ-270 haven't landed them yet (the config schema is a coordinate of this and the AZ-269 / AZ-270 / AZ-405 tasks; the schema lives at src/gps_denied_onboard/config/).
  • Build-flag check at startup: when config.mode == "replay" and any of the three replay-mode BUILD_* flags is OFF, raises CompositionError("replay mode requires BUILD_VIDEO_FILE_FRAME_SOURCE / BUILD_TLOG_REPLAY_ADAPTER / BUILD_REPLAY_SINK_JSONL to be ON; flag <name> is OFF in this build"). In live mode the flags are not checked (the OFF setting on a replay flag does not block live mode).
  • The legacy stub runtime_root/replay.py + the legacy compose_replay export (if present) are deleted as part of this task; replay is a configuration of compose_root, not a sibling composition root. The deletion is justified by the dead-code rule in coderule.mdc (no remaining usages once this task lands).
  • INFO log on startup, in replay mode: kind="replay.compose_root.ready" with {config_path, calib_path, pace, time_offset_ms, video_path, tlog_path, output_path}.
  • INFO log on startup, in live mode: existing live compose_root.ready log unchanged.
  • Unit tests:
    • test_compose_root_live_unchanged: with config.mode == "live", the returned Runtime has the same shape (same strategy classes for FrameSource/FcAdapter/MavlinkTransport/Sink set) as today.
    • test_compose_root_replay_wires_replay_strategies: with config.mode == "replay", isinstance(runtime.frame_source, VideoFileFrameSource), isinstance(runtime.fc_adapter, TlogReplayFcAdapter), isinstance(runtime.mavlink_transport, NoopMavlinkTransport), and a JsonlReplaySink is attached to C5's EstimatorOutput stream.
    • test_compose_root_replay_rejects_off_flag: with BUILD_VIDEO_FILE_FRAME_SOURCE=OFF and config.mode == "replay", compose_root(config) raises CompositionError("BUILD_VIDEO_FILE_FRAME_SOURCE is OFF; replay mode requires it").
    • test_compose_root_replay_single_clock: the same Clock instance is injected into all components that need one (replay protocol Invariant 2 — id() equality across consumers).
    • test_compose_root_no_compose_replay_export: from gps_denied_onboard.runtime_root import compose_replay raises ImportError (the legacy function is deleted).
    • test_compose_root_replay_jsonl_sink_emits_per_tick: drive 10 frames through the wired runtime; assert JsonlReplaySink.emit was called exactly 10 times with EstimatorOutput instances.
    • test_compose_root_replay_noop_transport_swallows_emits: drive a known sequence of EstimatorOutput through the runtime; assert NoopMavlinkTransport.bytes_written() > 0 (C8 encoders still produce bytes) AND the bytes never reach any wire-attached transport.

Scope

Included

  • 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 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: Single composition rootfrom 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: VideoFileFrameSource
  • fc_adapter: TlogReplayFcAdapter
  • mavlink_transport: NoopMavlinkTransport
  • A JsonlReplaySink registered as a listener on C5's EstimatorOutput stream

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 injectionconfig.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_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 / ADR-011 unchanged.
  • Public API discipline (Layer-3 / Layer-4 from module-layout.md).
  • C1C7 + C13 components MUST remain mode-agnostic (replay protocol Invariant 1; AST scan enforces in AZ-404).
  • All time-driven logic uses injected Clock (replay protocol Invariant 2).
  • No HTTP server in the airborne binary regardless of mode.
  • NO standalone composition root for replay (replay protocol + ADR-011).

Risks & Mitigation

  • R-DEMO-4 (production C1C5 paths bake real-time-cadence assumptions)Mitigation: Clock injection (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 updateMitigation: 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 fieldMitigation: 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.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 (v2.0.0) — composition-root extension + Invariants 1, 5, 9, 11, 12. Operationalises ADR-011.