# Batch 78 Report — FT-P-01 vertical slice (cycle 1, batch 12 of test phase)

**Batch**: 78
**Date**: 2026-05-17
**Context**: Test implementation (greenfield Step 10 — Implement Tests)
**Tasks**: AZ-598 (5 cp) — 1 task (FT-P-01 vertical slice)
**Cycle**: 1
**Verdict**: COMPLETE — PASS (self-reviewed + cumulative-reviewed; see `reviews/batch_78_review.md` + `reviews/cumulative_76_78_review.md`)

## Summary

Two distinct concerns shipped under one ticket because they unblock
each other:

1. **Observer extension** — `sitl_observer._FdrReplayObserver.wait_for_outbound`
   was missing despite being called by FT-P-01 + FT-P-05. The b78
   audit caught this; the implementation adds cursor-based replay
   from `outbound_messages_<fc_kind>_<host>.json` plus an
   `OutboundMessage` dataclass + the two scenario kwarg fixes
   (`fc_adapter=` → `fc_kind=`).
2. **FT-P-01 fixture builder** — a vertical-slice tool that produces
   the two fixture files (`outbound_messages_*` + `observer_*`) for
   the FT-P-01 scenario. Pivoted from the original "live SITL
   docker capture" design (would have needed ~13+ cp of new SUT-side
   frame-ingestion code) to a "drive `gps-denied-replay` against a
   1 fps MP4 + synthetic stationary tlog" approach that reuses the
   existing production `ReplayInputAdapter`. No new SUT code; one
   subprocess call instead of a multi-container compose.

### Direction-correction surfaced mid-batch

During b78 scoping I told the user incorrectly that the
"upload-tlog+video" feature wasn't implemented. Discovery during
scope analysis showed `src/gps_denied_onboard/replay_input/` exists
exactly for that use case (CLI = `gps-denied-replay`, coordinator
= `ReplayInputAdapter`, auto-sync = AZ-405). I corrected the user
immediately, surfaced the direction options, and the user chose to
stay the course on b78 (FT-P-01 vertical slice). The discovery also
enabled the pivot from live-SITL-capture to "reuse the
`gps-denied-replay` CLI" — turning the impossible-in-one-batch
phase 2 into a tractable one.

### AZ-598 — observer extension + FT-P-01 builder (5 cp)

#### Phase 1 — observer extension

* **`e2e/runner/helpers/sitl_observer.py`** (extended):
  * New `OutboundMessage(lat_deg, lon_deg, image_id=None)` frozen
    dataclass.
  * `_FdrReplayObserver` unfrozen (cursor state is now meaningful);
    `_outbound_cursor: int = 0` + lazy `_outbound_messages` cache.
  * New `wait_for_outbound(timeout_s: float | None = None)` method
    with three outcomes: `OutboundMessage` / `TimeoutError` /
    `RuntimeError`.
  * New module-level `_load_outbound_messages(fc_kind, host)` helper
    that validates the entire `messages` list at first read.
* **`e2e/tests/positive/test_ft_p_01_still_image_accuracy.py`**:
  `get_observer(fc_adapter=...)` → `get_observer(fc_kind=...)`.
* **`e2e/tests/positive/test_ft_p_05_sat_anchor.py`**: same kwarg
  fix.
* **`e2e/_unit_tests/helpers/test_sitl_observer.py`**: +11 tests
  covering cursor advance, timeout, exhaustion, missing file,
  missing env, malformed schema (list/object/keys), optional
  `image_id`, and cursor independence between observers.

#### Phase 2 — FT-P-01 fixture builder

* **`e2e/fixtures/sitl_replay_builder/__init__.py`** (new): minimal
  package docstring; deliberately no symbol re-exports (avoid the
  `build_p01_fixtures` function/submodule name-shadow pitfall —
  documented in the docstring).
* **`e2e/fixtures/sitl_replay_builder/build_p01_fixtures.py`** (new):
  * `BuilderConfig` frozen dataclass.
  * `encode_stills_to_mp4(image_paths, output, fps=1.0)` — OpenCV;
    accepts `_video_writer_factory` / `_imread` for testability.
  * `generate_stationary_tlog(output, duration_s=120, hz=200)` —
    pymavlink; writes zero-motion `RAW_IMU` + `ATTITUDE` pairs.
  * `run_gps_denied_replay(video, tlog, fdr_out, time_offset_ms=0,
    extra_args=...)` — subprocess to the production CLI; bypasses
    auto-sync because the synthetic tlog has no take-off.
  * `parse_fdr_for_outbound_estimates(fdr_path, fdr_kind=...,
    lat_key=..., lon_key=...)` — JSONL walk; configurable
    record-kind + field-key projection.
  * `write_outbound_messages_fixture(output, image_ids, estimates)`
    — schema writer; preserves `None` → JSON `null` for timeouts.
  * `write_observer_fixture(output)` — minimal observer config so
    `get_observer` succeeds.
  * `build_p01_fixtures(cfg, *, _runner=None, ...)` — orchestrator.
  * `_main(argv=None) -> int` — argparse CLI entry point.
* **`e2e/fixtures/sitl_replay_builder/README.md`** (new): strategy,
  usage, output structure, limitations.
* **`e2e/_unit_tests/fixtures/test_sitl_replay_builder.py`** (new):
  +24 tests — 3 for `encode_stills_to_mp4`, 4 for
  `generate_stationary_tlog` (incl. one real-pymavlink round-trip),
  3 for `run_gps_denied_replay`, 6 for
  `parse_fdr_for_outbound_estimates`, 3 for
  `write_outbound_messages_fixture`, 1 for
  `write_observer_fixture`, 4 for end-to-end orchestration.
* **`e2e/_unit_tests/test_directory_layout.py`**: registers the
  three new files in the layout invariant.

## Out of scope (deferred)

* **Live capture EXECUTION** — the builder runs `gps-denied-replay`
  as a subprocess; that subprocess requires `pip install -e .` at
  repo root plus access to the input images. Not executed in this
  batch; documented as a manual operator step. A future ticket can
  add a CI job that runs the live capture + commits the resulting
  fixtures.
* **Other scenarios** (FT-P-02 through FT-N-04) — each needs its
  own fixture-builder flow (continuous video + IMU CSV replay,
  blackout/spoof setup, etc.).
* **iNav adapter** — only ArduPilot supported in this batch.
* **`fc_kind` ↔ `fc_adapter` naming convergence** — kwarg-fix only;
  a future cleanup ticket should converge the vocabulary.

## Test Results

* New unit tests: **35** (11 `wait_for_outbound` + 24 builder).
* Full `e2e/_unit_tests` suite: **664 passed in 137 s** (previous
  cumulative: 637 → +27 net).
* No new linter errors.
* `grep raise NotImplementedError` under `e2e/tests/` returns
  **zero** matches (b77 invariant preserved).

## State

* Spec moved: `_docs/02_tasks/todo/AZ-598_ft_p_01_vertical_slice.md`
  → `_docs/02_tasks/done/`.
* `_docs/_autodev_state.md` advanced to `last_completed_batch: 78`,
  `last_cumulative_review: batches_76-78` (K=3 cumulative shipped
  alongside the batch review).
