Closes out greenfield Step 6 (Decompose) for all 14 components (C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446 plus the _dependencies_table.md and component contract documents. State file updated to greenfield Step 7 (Implement), not_started. Co-authored-by: Cursor <cursoragent@cursor.com>
8.3 KiB
Replay — compose_replay(config) + Clock injection (R-DEMO-4)
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
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.pycomposition root location._docs/02_document/architecture.md— ADR-001 / ADR-002 / ADR-009._docs/02_document/contracts/c5_state/state_estimator_protocol.md—EstimatorOutputconsumed by the sink.
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.
Outcome
src/gps_denied_onboard/runtime_root/replay.py:ReplayPaceenum (REALTIME / ASAP).ReplayRootdataclass (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
ReplayCompositionErrorwith 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.
Scope
Included
compose_replaybody.ReplayRootdataclass.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.
Excluded
- CLI argparse + entrypoint — owned by CLI task.
- Auto-sync IMU take-off detection — owned by AZ-405 (this task accepts
time_offset_msfromconfigor 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).
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-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-3: ASAP → TlogDerivedClock; REALTIME → WallClock — pace=ASAP resolves Clock = TlogDerivedClock; pace=REALTIME resolves Clock = WallClock. Verify via isinstance(replay_root.clock, ...).
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-5: One EstimatorOutput per frame — drive 10 frames; assert JsonlReplaySink.emit was called exactly 10 times with EstimatorOutput instances.
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-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-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_tooling (per epic scope).
AC-9: Configuration + calibration loading — compose_replay(config_with_invalid_calib_path) → ReplayCompositionError("camera-calibration not found at ...").
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.
Non-Functional Requirements
compose_replayp99 ≤ 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.
Constraints
- ADR-001 / ADR-002 / ADR-009 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).
Risks & Mitigation
- R-DEMO-4 (production C1–C5 paths bake real-time-cadence assumptions) — Mitigation:
Clockinjection (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.
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).
Contract
Implements _docs/02_document/contracts/replay/replay_protocol.md — replay composition + runtime loop.