# 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.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.

## 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`:
  - `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.

## 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.

### 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).

## 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_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.

## 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*: `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.

## 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.
