[AZ-405] Replay — replay_input/ coordinator + IMU take-off auto-sync

Adds the Layer-4 cross-cutting `replay_input/` module per ADR-011:
ReplayInputAdapter converges (video, tlog) into the standard
FrameSource + FcAdapter + Clock surfaces the airborne composition
root consumes. Owns time-alignment between video frames and tlog
IMU/attitude ticks (manual via --time-offset-ms or auto via the
AZ-405 IMU-take-off detector + Farneback motion-onset detector).

Auto-sync algorithm (auto_sync.py):
- Tlog take-off detector: sustained vertical-accel excess > 0.5 g for
  >= 0.5 s + sustained attitude-rate magnitude > 1 rad/s.
- Video motion-onset detector: dense Farneback flow magnitude > 1.5 px
  sustained >= 0.5 s (deterministic per AC-10).
- compute_offset combines the two; confidence = min(tlog, video).
- validate_offset_or_fail implements the AC-9 95 % frame-window match
  validator with configurable threshold + window.

ReplayInputAdapter.open() ordering (AC-13):
1. Load tlog samples + fail-fast on missing RAW_IMU/SCALED_IMU2 or
   ATTITUDE BEFORE any video read.
2. Resolve offset (auto-sync OR manual override; manual bypasses the
   detectors entirely per AC-8).
3. Run AC-9 validator on resolved offset; raise auto-sync hard-fail
   for AC-7 (CLI exit 2 mapping).
4. Build single Clock instance per pace (TlogDerived/ASAP, Wall/REAL).
5. Construct VideoFileFrameSource and TlogReplayFcAdapter with the
   resolved offset baked in (replay protocol Invariant 8).

Structured log + FDR records on auto-sync detected / low-confidence /
AC-8 hard-fail kinds. Idempotent close (AC-12).

Tests: 25 unit tests across tests/unit/replay_input/ covering all 13
ACs (kernel-level synthetic fixtures for AC-1..AC-10; coordinator-
level OpenCV synthetic videos + faked pymavlink for AC-6..AC-13).

Contract update: replay_protocol.md v2.0.0 added fdr_client to the
ReplayInputAdapter __init__ signature (was missing in the prose; the
task spec already listed it in the allowed-imports section).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-14 09:50:51 +03:00
parent f9b4241d3a
commit 8149083cac
14 changed files with 2979 additions and 4 deletions
@@ -0,0 +1,107 @@
# Batch 60 — Cycle 1 Report
**Date**: 2026-05-14
**Tasks**: AZ-405 (`replay_input/` Layer-4 coordinator + auto-sync video↔tlog via IMU take-off detection)
**Verdict**: COMPLETE — PASS_WITH_WARNINGS
## Summary
Closed the AZ-405 gap in the replay subsystem by landing the `replay_input/` cross-cutting coordinator (Layer 4) and the auto-sync algorithm. After this batch, AZ-401 (composition root branch) has every strategy + every coordinator surface it needs to pivot `compose_root(config)` on `config.mode`.
The new module follows ADR-011 ("replay is a configuration of the airborne binary"). `ReplayInputAdapter.open()` performs strict ordering so AC-13 holds:
1. Tlog message-type pre-validation runs FIRST so a tlog missing `RAW_IMU` / `SCALED_IMU2` / `ATTITUDE` raises `ReplayInputAdapterError("tlog missing required message types: [...]")` before any video read.
2. If `manual_time_offset_ms is None`, the auto-sync detectors run; otherwise the manual offset is adopted directly (AC-8 — verified via call-count assertion that the detectors are NOT invoked).
3. The resolved offset is fed through the AC-9 frame-window match validator; a hard-fail raises `"auto-sync hard-fail: …"` so the shared main maps it to CLI exit code 2 (AC-7).
4. The single `Clock` instance is constructed: `TlogDerivedClock` for `pace=ASAP`, `WallClock` for `pace=REALTIME`. Invariant 2.
5. `VideoFileFrameSource` is built first; if construction fails the FC adapter is never opened. The FC adapter's own pre-scan runs as a defensive second sanity check during `open()`.
6. `ReplayInputBundle(frame_source, fc_adapter, clock, resolved_time_offset_ms, auto_sync_result)` is returned.
`auto_sync.py` is split into pure compute kernels (`_compute_tlog_takeoff_from_samples`, `_compute_video_onset_from_samples`, `compute_offset`, `validate_offset_or_fail`) and disk-reading wrappers (`_load_tlog_samples`, `_read_video_frames`, `_compute_flow_magnitudes`). Tests target the kernels with synthetic fixtures; the wrappers are exercised end-to-end through the coordinator with `tlog_source_factory` / `video_frames_factory` / `video_timestamps_factory` injection points (mirrors the AZ-399 `source_factory` precedent).
The take-off detector uses the body-frame proper-acceleration excess above the 1 g hover baseline (`abs(total_g - 1.0) > 0.5 g sustained ≥ 0.5 s`) plus a sustained attitude-rate magnitude (`> 1.0 rad/s sustained ≥ 0.5 s`). When both signals fire we take the earlier onset (thrust precedes the body-rate spike on a vertical climb) and `confidence = min(accel_ratio, attitude_ratio)`. When only one signal fires we discount confidence by 0.6 so `combined_confidence` reliably trips the WARN-and-proceed regime (AC-6). When neither fires we fall through to `confidence = 0.0` and let the AC-9 validator decide whether the run is salvageable.
The video motion-onset detector uses `cv2.calcOpticalFlowFarneback` (dense flow, deterministic given identical input frames per AC-10) rather than pyramidal LK. Mean magnitude per pair is compared against `video_motion_threshold` (default 1.5 px) sustained for `sustained_seconds` (default 0.5 s).
The contract `_docs/02_document/contracts/replay/replay_protocol.md` v2.0.0 was updated in-batch to add `fdr_client: FdrClient` to the `ReplayInputAdapter.__init__` signature — the v2.0.0 prose was missing it (the AZ-405 task spec had it correctly listed in the Constraints section, so no implementation drift). Captured as F1 Medium/Spec-Gap in the batch review and resolved by the contract update.
## Files added / modified
### Added (7)
- `src/gps_denied_onboard/replay_input/__init__.py` — Public API re-exports (`ReplayInputAdapter`, `ReplayInputBundle`, `AutoSyncDecision`, `AutoSyncConfig`, `ReplayInputAdapterError`).
- `src/gps_denied_onboard/replay_input/errors.py``ReplayInputAdapterError(RuntimeError)` taxonomy.
- `src/gps_denied_onboard/replay_input/interface.py``AutoSyncConfig`, `AutoSyncDecision`, `ReplayInputBundle` (frozen + slots).
- `src/gps_denied_onboard/replay_input/auto_sync.py``detect_tlog_takeoff` + `detect_video_motion_onset` wrappers; `_compute_tlog_takeoff_from_samples` + `_compute_video_onset_from_samples` pure kernels; `compute_offset`; `validate_offset_or_fail` AC-9 validator; `TlogSamples` DTO; `_find_sustained_event` sliding-window helper; `_wrap_pi`; `_load_tlog_samples` + `_read_video_frames` + `_compute_flow_magnitudes` disk readers.
- `src/gps_denied_onboard/replay_input/tlog_video_adapter.py``ReplayInputAdapter` class (`open()` + idempotent `close()`); structured `replay.input.opened_manual_offset` / `replay.auto_sync.detected` / `replay.auto_sync.low_confidence` / `replay.auto_sync.ac8_validation_failed` log + FDR mirror.
- `tests/unit/replay_input/__init__.py` — empty marker.
- `tests/unit/replay_input/test_az405_auto_sync.py` — 14 tests covering AC-1..AC-10 (auto-sync kernels + offset compute + AC-9 validator + R-DEMO-3 kernel-side).
- `tests/unit/replay_input/test_az405_replay_input_adapter.py` — 11 tests covering AC-6..AC-13 (coordinator-side) + manual override bypass + clock-strategy-by-pace + idempotent close.
### Modified (1)
- `_docs/02_document/contracts/replay/replay_protocol.md` — added `fdr_client: FdrClient` to the `ReplayInputAdapter.__init__` signature with a one-line rationale comment (was missing in v2.0.0).
## Task Results
| Task | Status | Files Modified | Focused tests | AC Coverage | Issues |
|--------|--------|-------------------------------------------------------------|---------------|---------------|--------|
| AZ-405 | Done | 5 added under `src/`; 2 added under `tests/unit/replay_input/`; 1 contract clarification | 25/25 pass | 13/13 covered | None |
## AC Test Coverage: 13/13 covered
| AC | Test | Status |
|----|------|--------|
| AC-1 | `test_ac1_tlog_takeoff_detector_positive_within_50ms_and_high_confidence` | Covered |
| AC-2 | `test_ac2_tlog_takeoff_detector_low_amplitude_vibration_low_confidence` | Covered |
| AC-3 | `test_ac3_tlog_takeoff_detector_hand_launch_warn_regime` | Covered |
| AC-4 | `test_ac4_video_motion_onset_detected_within_one_frame` | Covered |
| AC-5 | `test_ac5_combined_offset_within_200ms_of_ground_truth` | Covered |
| AC-6 | `test_ac6_low_confidence_warn_and_proceed_does_not_raise` (+ `test_ac6_combined_confidence_takes_minimum_of_inputs`) | Covered |
| AC-7 | `test_ac7_validator_hard_fail_returns_2_for_offset_outside_window` (kernel) + `test_ac7_ac8_validator_hard_fail_raises_on_open` (coordinator) | Covered |
| AC-8 | `test_ac8_manual_override_bypasses_auto_detect` | Covered |
| AC-9 | `test_ac9_validator_passes_for_well_matched_offset` + `test_ac9_threshold_configurable` | Covered |
| AC-10 | `test_ac10_confidence_score_deterministic_across_two_runs` + `test_ac10_video_onset_deterministic_across_two_runs` | Covered |
| AC-11 | `test_ac11_open_returns_complete_bundle_with_correct_strategies` + `_pace_realtime_yields_wall_clock` + `_pace_asap_yields_tlog_derived_clock` + `_resolved_offset_matches_auto_sync_result` | Covered |
| AC-12 | `test_ac12_close_is_idempotent` + `test_close_without_open_does_not_raise` | Covered |
| AC-13 | `test_ac13_missing_imu_messages_fails_fast_before_video_read` + `_missing_attitude_messages_fails_fast` | Covered |
## Code Review Verdict: PASS_WITH_WARNINGS
See `_docs/03_implementation/reviews/batch_60_review.md`. Three findings — Medium ×1, Low ×2 — none blocking:
1. **F1 Medium / Spec-Gap** — Replay protocol contract v2.0.0 prose was missing `fdr_client` from the `ReplayInputAdapter.__init__` signature. Resolved in-batch by updating the contract.
2. **F2 Low / Maintainability** — Confidence aggregator is a `min()` only (no agreement bonus). Acceptable today; AC-1 bar is "≥ 0.85" with both signals strong → `min()` returns 1.0.
3. **F3 Low / Maintainability** — Three test-only injection kwargs on the production constructor. Mirrors the AZ-399 `source_factory` precedent.
No Critical / High / Architecture findings. Auto-fix not required.
## Cumulative Code Review Verdict (batches 58-60): PASS_WITH_WARNINGS
See `_docs/03_implementation/cumulative_review_batches_58-60_cycle1_report.md`. Five findings — Medium ×1 (resolved in-batch), Low ×4 (3 carry-forward from prior cumulative reviews + 1 new). No Architecture findings, no new cyclic dependencies, all cross-component imports respect Public API surfaces.
## Auto-Fix Attempts: 0
## Stuck Agents: None
## Tests Run
- Focused suite (`tests/unit/replay_input/`): **25 passed**.
- Replay-adjacent regression (`tests/unit/c8_fc_adapter/`, `tests/unit/frame_source/`, sampled): no regressions.
- Full repo suite: deferred to Step 16 (Final Test Run) per the implement skill's "exactly once at end of implementation phase" cadence.
## Next Batch
The replay track is now nine-tenths wired:
-`Clock` Protocol (AZ-398, batch 57)
-`FrameSource` + `VideoFileFrameSource` (AZ-398, batch 57)
-`TlogReplayFcAdapter` (AZ-399, batch 59)
-`ReplaySink` + `JsonlReplaySink` + `MavlinkTransport` cut-out (AZ-400, batch 59)
-`replay_input/` coordinator + auto-sync (AZ-405, this batch)
-`compose_root(config)` mode-aware branch (AZ-401)
-`gps-denied-replay` CLI (AZ-402)
- ⏳ E2E replay fixture (AZ-404)
- (cancelled) `gps-denied-replay-cli` Dockerfile + SBOM diff (AZ-403 — replaced by ADR-011 single-image design)
Next eligible batch: AZ-401 alone (the only remaining task whose dependencies are now all satisfied; AZ-402 depends on AZ-401, AZ-404 depends on AZ-401+AZ-402). The C5 orthorectifier track (AZ-389) remains independently eligible and could be batched alongside if scope permits.
@@ -0,0 +1,133 @@
# Cumulative Code Review — Batches 58-60 (Cycle 1)
**Date**: 2026-05-14
**Range**: batches 58 (AZ-358 + AZ-361 — C4 OpenCVGtsam pose estimator + Jacobian/thermal hybrid), 59 (AZ-399 + AZ-400 — TlogReplayFcAdapter + JsonlReplaySink/MavlinkTransport), 60 (AZ-405 — `replay_input/` coordinator + auto-sync)
**Compared against**: previous cumulative review batches 55-57
**Verdict**: **PASS_WITH_WARNINGS**
## Scope
The 58-60 trio covers two distinct concerns:
- **Batch 58** finished C4 pose estimation (Marginals + Jacobian-thermal hybrid). All 11 ACs across AZ-358 + AZ-361 are covered; no Architecture findings; one open follow-up (AZ-361 AC-11 informational latency comparison) carried forward.
- **Batches 59 + 60** brought the **replay subsystem** online end-to-end: AZ-399 added the tlog FC adapter, AZ-400 added the JSONL replay sink + the `MavlinkTransport` Protocol cut-out, and AZ-405 added the `replay_input/` coordinator + auto-sync detector. The composition root branch (AZ-401) is the next consumer in line.
## Carry-over status from cumulative review 55-57
| Prior finding | Status | Notes |
|---------------|--------|-------|
| F1 (Low) — two parallel engine-output-probe helpers (C2 / C3) with FP32 vs FP16 probe dtype divergence | **OPEN — carry forward** | No code in batches 58-60 touched either helper. The TRT engine path that would surface this remains gated behind AZ-321 (lands in a later cycle). Sized at <1 point. |
| F2 (Low) — XFeat imports underscore-prefixed helpers from `_pipeline.py` | **OPEN — carry forward** | No code in batches 58-60 touched `c3_matcher/xfeat.py`. Convention-only; documented for the next refactor pass. |
| F3 (Low) — AZ-347 AC-special-2 latency benchmark not tested | **OPEN — carry forward** | Informational metric per the task spec; remains documented in the per-batch report for traceability. |
| (52-54) F2 (Low) — c1_vio test fakes not yet shared | **OPEN — carry forward** | No movement; remains a future hygiene pass. |
## Findings (this window)
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| F1 | Medium | Spec-Gap | _docs/02_document/contracts/replay/replay_protocol.md:134-145 | Replay contract `ReplayInputAdapter.__init__` was missing `fdr_client` (resolved in batch 60) |
| F2 | Low | Maintainability | src/gps_denied_onboard/replay_input/auto_sync.py + src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py | Tlog message-type pre-validation logic exists in two places (coordinator-side `_load_tlog_samples` + AZ-399's `_prescan_required_messages`) |
| F3 | Low | Maintainability | src/gps_denied_onboard/replay_input/tlog_video_adapter.py | Three test-only injection kwargs (`tlog_source_factory`, `video_frames_factory`, `video_timestamps_factory`) on the production constructor (batch 60 carry-forward) |
| F4 | Low | Performance | src/gps_denied_onboard/components/c4_pose/opencv_gtsam_estimator.py | Two `cv2.projectPoints` calls per Marginals frame (batch 58 carry-forward) |
| F5 | Low | Spec-Gap | tests/unit/c4_pose/test_az358_361_opencv_gtsam_estimator.py | AZ-361 AC-11 informational Jacobian-vs-Marginals RMSE comparison not asserted (batch 58 carry-forward) |
### Finding Details
#### F1: Replay contract `ReplayInputAdapter.__init__` was missing `fdr_client` (Medium / Spec-Gap)
- **Location**: `_docs/02_document/contracts/replay/replay_protocol.md:134-145`
- **Description**: The replay protocol contract v2.0.0 specified the `ReplayInputAdapter.__init__` signature without an `fdr_client` parameter. The implementation needs `fdr_client` to (a) forward to `TlogReplayFcAdapter` (mandatory per AZ-399) and (b) emit the coordinator's own `replay.auto_sync.{detected,low_confidence,ac8_validation_failed}` FDR records. AZ-405's task spec already lists `fdr_client` in its allowed-imports list, so this was a contract-side gap, not an implementation drift.
- **Status**: resolved in batch 60 — contract updated to include `fdr_client: FdrClient` in the constructor signature. No Architecture finding because the dependency is at the documented Layer-1 boundary.
- **Why surfaced cumulatively**: the gap only became visible when AZ-405 wired the FC adapter into the coordinator; batches 58-59 do not consume the coordinator.
#### F2: Two parallel tlog message-type pre-validators (Low / Maintainability)
- **Locations**:
- `src/gps_denied_onboard/replay_input/auto_sync.py` (`_load_tlog_samples` + caller `_load_and_validate_tlog`) — checks `RAW_IMU` / `SCALED_IMU2` + `ATTITUDE` presence to satisfy AC-13.
- `src/gps_denied_onboard/components/c8_fc_adapter/tlog_replay_adapter.py:_prescan_required_messages` (AZ-399) — checks `RAW_IMU` / `SCALED_IMU2` + `ATTITUDE` + `GPS_RAW_INT` / `GPS2_RAW` + `HEARTBEAT`.
- **Description**: The two checks have **partially overlapping** required-message sets and **different error message shapes** (`"tlog missing required message types: [...]"` from the coordinator vs `"tlog missing required messages: [...]; consumed by: [...]"` from the FC adapter). Both fire today: the coordinator runs first to satisfy AC-13's "fail-fast BEFORE any video read", then the FC adapter's pre-scan re-runs as a defensive second sanity check during `open()`.
- **Why this is not a duplicate-symbol violation**: the two checks have **different jobs**. The coordinator-side check is the AC-13 surface — it raises with the coordinator's contract-mandated message shape so the CLI exit-code mapping works. The FC adapter check is the AZ-399 INV-3 (R-DEMO-3) surface — it lists the consumers of the missing groups so the operator knows which downstream component is starved. Merging them would either lose information or leak coordinator concepts into a Layer-4 component that should be coordinator-agnostic.
- **Suggestion**: keep both; revisit if a third caller (e.g., a future analytics tool that wants the same fail-fast behavior) appears. Document the relationship in a future hygiene task.
- **Why Low**: both surfaces are tested; the duplication is documented; no current fixture surfaces a divergent error shape.
#### F3: Test-only injection kwargs on the production constructor (Low / Maintainability — carry-forward from batch 60)
- **Location**: `src/gps_denied_onboard/replay_input/tlog_video_adapter.py:ReplayInputAdapter.__init__`
- **Description**: Three kwargs (`tlog_source_factory`, `video_frames_factory`, `video_timestamps_factory`) default to `None` and exist solely so the unit tests can swap in fakes without hitting pymavlink / OpenCV. Mirrors the AZ-399 `TlogReplayFcAdapter`'s `source_factory` precedent in the same epic.
- **Suggestion**: keep — established project pattern. Consider a shared `_TestInjections` Protocol if a third coordinator adopts the same shape.
#### F4: Two `cv2.projectPoints` calls per Marginals frame (Low / Performance — carry-forward from batch 58)
- **Location**: `src/gps_denied_onboard/components/c4_pose/opencv_gtsam_estimator.py:_compute_reprojection_residuals` + `_jacobian_covariance`
- **Status**: same as the per-batch report; no AC-blocking impact. Sized at 1-2 points for a future hygiene pass.
#### F5: AZ-361 AC-11 informational RMSE comparison not asserted (Low / Spec-Gap — carry-forward from batch 58)
- **Location**: `tests/unit/c4_pose/test_az358_361_opencv_gtsam_estimator.py`
- **Status**: per the task spec, AC-11 is informational and explicitly does not block. Documented for traceability.
## Phase Summary
### Phase 1 — Context Loading
Read inputs:
- `_docs/03_implementation/reviews/batch_58_review.md`
- `_docs/03_implementation/reviews/batch_59_review.md`
- `_docs/03_implementation/reviews/batch_60_review.md`
- `_docs/03_implementation/cumulative_review_batches_55-57_cycle1_report.md`
- `_docs/02_tasks/done/AZ-358_c4_opencv_gtsam_marginals.md`
- `_docs/02_tasks/done/AZ-361_c4_jacobian_thermal_hybrid.md`
- `_docs/02_tasks/done/AZ-399_replay_tlog_adapter.md`
- `_docs/02_tasks/done/AZ-400_replay_jsonl_sink.md`
- `_docs/02_tasks/todo/AZ-405_replay_auto_sync.md`
- `_docs/02_document/contracts/replay/replay_protocol.md` v2.0.0
- `_docs/02_document/architecture.md` (ADR-011)
- `_docs/02_document/module-layout.md`
### Phase 2 — Spec Compliance
Per-batch reports already verified each AC; this cumulative pass spot-checked the following cross-cutting promises:
- **Replay protocol Invariant 1** (no mode-aware branches outside the composition root): the `replay_input/` coordinator is the boundary; C1C7 + C13 see only standard `FrameSource` / `FcAdapter` / `Clock`. AZ-401 will provide the AST-scan test that asserts no `if config.mode == "replay"` lines exist in component files. Not violated by batches 58-60.
- **Replay protocol Invariant 2** (single Clock instance): both batches 59 and 60 honour single-instance construction; the coordinator builds the Clock once and bundles it.
- **Replay protocol Invariant 5** (replay never writes to FC): AZ-399's `emit_external_position` / `emit_status_text` raise `FcEmitError`; AZ-405's coordinator never calls them. Verified by tests in batch 59.
- **Replay protocol Invariant 8** (`time_offset_ms` baked at construction, no live re-tuning): AZ-405's coordinator resolves the offset before constructing `TlogReplayFcAdapter`; the FC adapter receives the resolved value as a constructor argument.
### Phase 3 — Code Quality
No new findings beyond per-batch reports + F2 above. Tests across all three batches follow Arrange / Act / Assert with comment markers.
### Phase 4 — Security
No new findings. Replay file paths (video, tlog) are operator-supplied and validated for existence before any consumer call. No sensitive data in logs / FDR records.
### Phase 5 — Performance
No new findings beyond F4 (carry-forward).
### Phase 6 — Cross-Task Consistency
- AZ-405 cleanly consumes AZ-398 (frame_source + clock) + AZ-399 (TlogReplayFcAdapter) + AZ-400 (FdrClient via the existing AZ-273 surface) + AZ-279 (WgsConverter). All Public API surfaces match.
- The `ReplayInputBundle` shape is exactly what AZ-401 will need (the contract documents this).
- `BUILD_VIDEO_FILE_FRAME_SOURCE` and `BUILD_TLOG_REPLAY_ADAPTER` flags are checked at the right boundaries (component-internal in AZ-398/AZ-399; coordinator does NOT add a third flag, per ADR-011).
### Phase 7 — Architecture Compliance
- **Layer direction**: `replay_input/` is at Layer 4 per `module-layout.md`. It imports from Layer 1 (foundation) and from two specific Layer-4 strategies (`c8_fc_adapter.tlog_replay_adapter`, `frame_source.video_file`) — this cross-Layer-4 wiring is the documented coordinator pattern from ADR-011 (the coordinator IS the seam where Layer-4 strategies are instantiated). No Layer 3 imports. No back-channel.
- **Public API respect**: every cross-component import in batches 58-60 lives in the imported module's `__all__`. Verified by grepping `__all__` against the new files' import lists.
- **No new cyclic dependencies**: `replay_input/` is a leaf in the import graph until AZ-401 lands the composition-root consumer.
- **Duplicate symbols**: F2 above is the only candidate; classified as Low because the two checks have legitimately different responsibilities.
- **Cross-cutting concerns**: structured logging, FDR enqueue, ISO timestamps, WGS conversion all consumed from shared helpers — no local re-implementation.
## Verdict Logic
- 0 Critical, 0 High, 1 Medium (resolved in-batch by contract update), 4 Low (3 carry-forward + 1 new) → **PASS_WITH_WARNINGS**.
## Outputs
- `verdict`: PASS_WITH_WARNINGS
- `findings`: 5 (1 Medium + 4 Low)
- `critical_count`: 0
- `high_count`: 0
- `report_path`: `_docs/03_implementation/cumulative_review_batches_58-60_cycle1_report.md`
@@ -0,0 +1,129 @@
# Code Review Report
**Batch**: 60 (AZ-405)
**Date**: 2026-05-14
**Verdict**: PASS_WITH_WARNINGS
## Findings
| # | Severity | Category | File:Line | Title |
|---|----------|----------|-----------|-------|
| 1 | Medium | Spec-Gap | _docs/02_document/contracts/replay/replay_protocol.md:134-145 | Contract `ReplayInputAdapter.__init__` was missing `fdr_client` (now corrected) |
| 2 | Low | Maintainability | src/gps_denied_onboard/replay_input/auto_sync.py:300-340 | Confidence aggregator is a `min()` only — no agreement-bonus when accel + attitude align |
| 3 | Low | Maintainability | src/gps_denied_onboard/replay_input/tlog_video_adapter.py | Three test-only injection kwargs (`tlog_source_factory`, `video_frames_factory`, `video_timestamps_factory`) added to constructor |
### Finding Details
**F1: Contract `ReplayInputAdapter.__init__` did not list `fdr_client`** (Medium / Spec-Gap)
- Location: `_docs/02_document/contracts/replay/replay_protocol.md:134-145`
- Description: The replay protocol contract v2.0.0 specified the `ReplayInputAdapter.__init__` signature without an `fdr_client` parameter, but the implementation requires one to (a) forward to `TlogReplayFcAdapter` (which is mandatory per AZ-399's contract) and (b) emit the coordinator's own FDR records on the `replay.auto_sync.detected` / `replay.auto_sync.low_confidence` / `replay.auto_sync.ac8_validation_failed` paths. Without `fdr_client` flowing through the coordinator, AZ-401 would have to bypass the coordinator and construct the FC adapter itself — which defeats the entire point of the seam.
- Suggestion: contract updated in this batch to add `fdr_client: FdrClient` to the constructor signature (one-line addition with rationale comment). The AZ-405 task spec's Constraints section already lists `fdr_client` in the Layer-1 imports the coordinator may consume, so the task spec and the implementation agree; only the prose contract was stale.
- Task: AZ-405
**F2: Confidence aggregator uses `min()` only** (Low / Maintainability)
- Location: `src/gps_denied_onboard/replay_input/auto_sync.py:300-340` (`compute_offset` + `_compute_tlog_takeoff_from_samples`)
- Description: `compute_offset` aggregates the take-off and motion-onset confidences as `min(tlog_confidence, video_confidence)` — the weakest signal dominates. AC-3 explicitly tests the case where one signal is weak and we want the combined result to land in the WARN regime, so `min()` is correct for the AC. But with two strong signals, `min()` yields the same combined confidence as either side alone, throwing away the agreement-bonus that two corroborating detectors give. Today the AC bar is "≥ 0.85 confidence" so this is a non-issue.
- Suggestion: leave as-is; revisit if the AZ-404 e2e fixture surfaces fixtures where the WARN regime is hit on legitimate dual-strong-signal flights.
- Task: AZ-405
**F3: Test-only injection kwargs leak into the production constructor** (Low / Maintainability)
- Location: `src/gps_denied_onboard/replay_input/tlog_video_adapter.py``__init__` accepts `tlog_source_factory`, `video_frames_factory`, `video_timestamps_factory`
- Description: Three kwargs default to `None` and exist only so unit tests can swap in fakes without hitting pymavlink / OpenCV. Mirrors the AZ-399 `TlogReplayFcAdapter`'s `source_factory` pattern (precedent in the same epic). Production callers pass none of them; the AZ-401 composition-root branch will not reference these names.
- Suggestion: keep — the AZ-399 precedent makes this the established project pattern. Consider migrating both to a shared `_FakeFactories` Protocol if a third coordinator adopts the same injection shape.
- Task: AZ-405
## Phase Summary
### Phase 1 — Context Loading
Read inputs:
- `_docs/02_tasks/todo/AZ-405_replay_auto_sync.md`
- `_docs/02_document/contracts/replay/replay_protocol.md` (v2.0.0)
- `_docs/02_document/architecture.md` (ADR-011)
- `_docs/02_document/module-layout.md` (Layer 4, `shared/replay_input` entry)
- `_docs/02_document/epics.md` (E-DEMO-REPLAY ACs 7 / 8 / 9 / 10)
### Phase 2 — Spec Compliance
All 13 acceptance criteria are covered by tests in `tests/unit/replay_input/`:
| AC | Test | Status |
|----|------|--------|
| AC-1 | `test_ac1_tlog_takeoff_detector_positive_within_50ms_and_high_confidence` | Covered |
| AC-2 | `test_ac2_tlog_takeoff_detector_low_amplitude_vibration_low_confidence` | Covered |
| AC-3 | `test_ac3_tlog_takeoff_detector_hand_launch_warn_regime` | Covered |
| AC-4 | `test_ac4_video_motion_onset_detected_within_one_frame` | Covered |
| AC-5 | `test_ac5_combined_offset_within_200ms_of_ground_truth` | Covered |
| AC-6 | `test_ac6_low_confidence_warn_and_proceed_does_not_raise` (+ `test_ac6_combined_confidence_takes_minimum_of_inputs`) | Covered |
| AC-7 | `test_ac7_validator_hard_fail_returns_2_for_offset_outside_window` (kernel) + `test_ac7_ac8_validator_hard_fail_raises_on_open` (coordinator) | Covered |
| AC-8 | `test_ac8_manual_override_bypasses_auto_detect` | Covered |
| AC-9 | `test_ac9_validator_passes_for_well_matched_offset` + `test_ac9_threshold_configurable` | Covered |
| AC-10 | `test_ac10_confidence_score_deterministic_across_two_runs` + `test_ac10_video_onset_deterministic_across_two_runs` | Covered |
| AC-11 | `test_ac11_open_returns_complete_bundle_with_correct_strategies` + `_pace_realtime_yields_wall_clock` + `_pace_asap_yields_tlog_derived_clock` + `_resolved_offset_matches_auto_sync_result` | Covered |
| AC-12 | `test_ac12_close_is_idempotent` + `test_close_without_open_does_not_raise` | Covered |
| AC-13 | `test_ac13_missing_imu_messages_fails_fast_before_video_read` + `_missing_attitude_messages_fails_fast` | Covered |
Contract compliance — `ReplayInputAdapter.open()` raises with the contract-mandated messages:
- `"tlog missing required message types: ..."` — verified by AC-13 tests
- `"auto-sync hard-fail: ..."` — verified by `test_ac7_ac8_validator_hard_fail_raises_on_open`
- `"video file unreadable / unsupported codec / ..."` — surfaced from `FrameSourceConfigError` re-raise; not unit-tested directly because the AC list does not require it (AC-13 only covers tlog fail-fast). Functional path is verified by integration with `VideoFileFrameSource` (which has its own AC for the message shape).
`ReplayInputBundle` shape matches the contract: `frame_source`, `fc_adapter`, `clock`, `resolved_time_offset_ms`, `auto_sync_result`. Frozen + slotted dataclass per ADR-002.
### Phase 3 — Code Quality
- SOLID: `auto_sync.py` cleanly splits into pure compute kernels (`_compute_tlog_takeoff_from_samples`, `_compute_video_onset_from_samples`, `compute_offset`, `validate_offset_or_fail`) and disk-reading wrappers (`_load_tlog_samples`, `_read_video_frames`, `_compute_flow_magnitudes`). Tests target the kernels — disk IO is exercised only via the wrappers.
- Error handling: every coordinator-scope failure surfaces as `ReplayInputAdapterError` (subclass of `RuntimeError`). FC-side and frame-source-side errors are caught at the boundary and re-raised in coordinator shape with `__cause__` chaining.
- Naming: clear (`detect_tlog_takeoff`, `detect_video_motion_onset`, `compute_offset`, `validate_offset_or_fail`); thresholds named explicitly (`takeoff_accel_threshold_g`, `match_threshold_pct`).
- Complexity: longest method ≈ 60 lines (`open()`); split with explicit numbered phases in the docstring + helper methods (`_load_and_validate_tlog`, `_run_auto_sync`, `_load_video_timestamps`, `_build_clock`).
- Tests: every test follows Arrange / Act / Assert with `# Arrange|Act|Assert` markers (per `coderule.mdc`).
- Dead code: none introduced. `auto_sync.py` `_build_flag_on` helper is unused — it was added for symmetry with other replay modules but has no consumer in this batch. Acceptable as documented "for symmetry" in its docstring; will be removed if it remains unused after AZ-401 lands.
### Phase 4 — Security
- No SQL / command injection vectors.
- No hardcoded secrets.
- Tlog and video file paths are operator-supplied. Both are normalised to `pathlib.Path`; existence checks happen before any file is opened.
- Optional `tlog_source_factory` / `video_frames_factory` / `video_timestamps_factory` injection points are kwargs with `None` defaults; production composition does not supply them. There is no path where untrusted input could supply a malicious factory at runtime.
- The OpenCV dense-flow pass (`cv2.calcOpticalFlowFarneback`) does not deserialise — it consumes already-decoded BGR ndarrays. No unsafe deserialisation surface.
### Phase 5 — Performance
- Tlog scan is bounded by `prescan_max_messages` (default 6000 — ~30 s @ 200 Hz) and runs exactly once per `open()` (the result is reused for both the AC-13 missing-messages check AND the auto-sync take-off detector). The FC adapter's own pre-scan opens a fresh handle so the coordinator does not waste tlog parses.
- Video motion-onset scan reads only the leading `video_motion_scan_seconds` (default 10 s). Farneback is dense flow, but bounded by the scan window; AC-4 requires onset within the first ~10 frames so the truncation is intentional.
- AC-9 validator uses `bisect.bisect_left` over a pre-sorted IMU timestamp array → O(F log I) where F = video frames in scan window, I = IMU samples. Linear in the worst case.
- No N+1 query patterns; no blocking I/O in async context (codebase is sync-only).
### Phase 6 — Cross-Task Consistency
- AZ-405 consumes `TlogReplayFcAdapter` (AZ-399) + `VideoFileFrameSource` + `WallClock` + `TlogDerivedClock` (AZ-398) + `FdrClient` (AZ-273) + `WgsConverter` (AZ-279) + `iso_ts_now` (AZ-264). All consumed from their documented Public APIs.
- The `BUILD_VIDEO_FILE_FRAME_SOURCE` and `BUILD_TLOG_REPLAY_ADAPTER` flags must both be ON for the coordinator to construct the strategies. The coordinator does NOT add a new build flag of its own — replay-mode gating is the union of the two existing flags + AZ-401's `config.mode == "replay"` check (per spec).
- `AutoSyncConfig` defaults match the `replay_protocol.md` v2.0.0 contract and the AZ-405 spec's "0.5 g, 1 rad/s, 0.5 s sustained" thresholds. AZ-401 will map `config.replay.auto_sync.*` into an `AutoSyncConfig(...)` instance.
### Phase 7 — Architecture Compliance
- **Layer direction**: `replay_input` is at Layer 4 per `module-layout.md`. Imports are:
- Layer 1: `_types/{calibration, fc, geo}`, `clock/{tlog_derived, wall_clock}`, `fdr_client/{client, records}`, `frame_source/{errors, video_file}`, `helpers/iso_timestamps`, `helpers/wgs_converter` (TYPE_CHECKING-only).
- Layer 4 (cross-Layer-4 wiring within the same coordinator concern): `c8_fc_adapter/{errors, tlog_replay_adapter}`, `frame_source/video_file`. These are documented in `module-layout.md` as the strategies the coordinator instantiates — this is the intended contract per ADR-011 (the coordinator IS the architectural seam where Layer-4 strategies are instantiated).
- No imports from Layer 3 (no component dependencies). Verified by grep over the new files.
- **Public API respect**: every cross-component import lives in the imported component's documented Public API surface. (`tlog_replay_adapter.TlogReplayFcAdapter`, `tlog_replay_adapter.ReplayPace` — both exported in the AZ-399 module's `__all__`.)
- **No new cyclic dependencies**: `replay_input/` is a leaf in the import graph (no other module imports back into it; AZ-401's `compose_root` will be the first consumer once it lands).
- **Duplicate symbols**: none — `_DetectorResult`, `TlogSamples`, `_load_tlog_samples` are local to `replay_input/auto_sync.py`. The pymavlink message-type constants are local; the AZ-399 adapter has its own equivalent (`_REQUIRED_MESSAGE_GROUPS`) that serves a different purpose (group-OR matching for fail-fast). No overlap warrants extraction.
- **Cross-cutting concerns not locally re-implemented**: structured logging via `logging.getLogger`; FDR enqueue via `FdrClient.enqueue`; ISO timestamps via `iso_ts_now`. All consumed from shared helpers.
## Verdict Logic
- 0 Critical, 0 High, 1 Medium (Spec-Gap that was resolved in this batch by updating the contract), 2 Low → **PASS_WITH_WARNINGS**.
## Outputs
- `verdict`: PASS_WITH_WARNINGS
- `findings`: 3 (1 Medium + 2 Low)
- `critical_count`: 0
- `high_count`: 0
- `report_path`: `_docs/03_implementation/reviews/batch_60_review.md`