# Open-loop ESKF composition profile for the airborne binary

**Task**: AZ-776_eskf_open_loop_composition_profile
**Name**: Make the c5_state=eskf path runnable end-to-end by allowing c4_pose to be excluded from the airborne composition
**Description**: Introduce an explicit `c4_pose.enabled: bool` config flag (default True). When False, the airborne bootstrap skips C4 wiring entirely, the C5 ESKF estimator composes alongside C1 (and any other configured components) with no iSAM2 graph handle, and `gps-denied-replay` exits 0 on the Derkachi fixture with one EstimatorOutput per video frame. Unblocks five of the seven `@xfail`-masked Derkachi e2e tests on Jetson.
**Complexity**: 3 points
**Dependencies**: None
**Component**: runtime_root / c4_pose / c5_state
**Tracker**: AZ-776
**Epic**: AZ-602

## Problem

The c5_state strategy `eskf` is registered (`_STRATEGY_REGISTRY`),
documented as the IT-12 mandatory simple baseline (`eskf_baseline.py`
line 30), and has unit tests
(`tests/unit/c5_state/test_az386_eskf_baseline.py`). It has NEVER been
composition-tested end-to-end. When the airborne binary is configured
with `config.components.c5_state.strategy = eskf`, the bootstrap fails
at compose time:

```
PoseEstimatorConfigError: build_pose_estimator: isam2_graph_handle does
  not satisfy the C4 ISam2GraphHandle Protocol (missing get_pose_key /
  update / compute_marginals / last_anchor_age_ms?)
```

Root cause: `EskfStateEstimator._build_eskf_state_estimator` returns
`(estimator, handle=None)` by design — the ESKF has no GTSAM graph.
`airborne_bootstrap._build_c5_state_estimator_pair` (line 779) seeds
`pre_constructed['c5_isam2_graph_handle'] = None`.
`_c4_pose_wrapper` (line 371) extracts the None.
`pose_factory.build_pose_estimator` (line 127) rejects it.

The IT-12 "ESKF is the mandatory simple baseline" mandate has no
executable production path today — the baseline exists at the component
layer but not at the system layer. Every Derkachi e2e attempt with
`c5_state=eskf` exits non-zero at compose time, producing 0 JSONL rows
and masking every downstream behaviour the heavy ACs are supposed to
exercise.

## Outcome

- The airborne bootstrap can compose a binary with `c1_vio` + `c5_state(strategy=eskf)` end-to-end against a real `gps-denied-replay` invocation without composing `c4_pose` and without a stub or no-op pose estimator masking the absence.
- The composition-mode selection is explicit in YAML — operator sets `config.components.c4_pose.enabled = False` (or omits the block entirely) to opt into the open-loop profile.
- `_run_replay_loop` in `runtime_root/__init__.py` is exercised end-to-end on Jetson by a non-`xfail` integration test (AC-1, AC-2, AC-5, AC-6 realtime, AC-6 asap in `tests/e2e/replay/test_derkachi_1min.py` un-xfail and pass).
- The replay protocol document records the new open-loop ESKF variant alongside the existing full-GTSAM path so future readers do not have to reverse-engineer it from the code.
- AZ-602 (E2E Tier-1 harness rehabilitation epic) advances by 5/7 of the Derkachi xfails removed; the remaining 2 (AC-3 and AZ-699) are AZ-777 territory.

## Scope

### Included

- Add an `enabled: bool = True` field to `C4PoseConfig` (and the corresponding YAML schema in `config/schema.py`).
- In `airborne_bootstrap.build_pre_constructed`: when `_replay_omits_component_block(config, "c4_pose")` OR `config.components["c4_pose"].enabled is False`, skip `_build_c5_state_estimator_pair`'s iSAM2-handle requirement; the C5 ESKF estimator still builds, but the handle slot is absent from the returned dict.
- In `airborne_bootstrap._AIRBORNE_REGISTRATIONS` (or wherever `compose_root` walks the component slugs): exclude `c4_pose` from the topological walk when the config flag says so. The downstream edge `_C5_STATE_DEPENDS_ON = ("c1_vio", "c4_pose")` becomes `("c1_vio",)` for the open-loop profile (or the dependency-walker treats a deselected slug as a no-op edge).
- Validation in `compose_root`: when `c4_pose.enabled is False`, refuse `c5_state.strategy = gtsam_isam2` with a clear `CompositionError` — the gtsam_isam2 estimator needs a real handle, so the open-loop profile is only valid against ESKF. Symmetric refusal when `c4_pose.enabled is True` AND `c5_state.strategy = eskf` (the ESKF still returns `handle=None`, so c4 would still reject).
- `tests/unit/runtime_root/`: composition test that exercises the open-loop profile end-to-end with `compose_root`, asserting (i) the returned components dict contains `c1_vio` and `c5_state` but NOT `c4_pose`; (ii) `gps-denied-replay --config <open-loop yaml>` exits 0 against a minimal fixture and emits one EstimatorOutput per video frame.
- `tests/unit/runtime_root/`: regression test that the live (full-GTSAM) path still composes identically after the flag is added — `c4_pose.enabled` unset defaults to True and the existing topological walk is unchanged.
- `_docs/02_document/contracts/replay/replay_protocol.md`: add an `## Open-loop ESKF variant` section documenting the per-frame loop pseudocode (mirrors the existing GTSAM one minus C2–C4 stages) plus the YAML shape that selects it.
- `_docs/02_document/adr/`: create the ADR folder if absent, then write `ADR-012_open_loop_eskf_composition.md` recording the `c4_pose.enabled` flag, the strategy-pairing rules, and why the per-component flag was chosen over a top-level `composition_profile` knob.
- `tests/e2e/replay/test_derkachi_1min.py`: remove the `@pytest.mark.xfail` decorators on AC-1 (line 61), AC-2 (line 138), AC-5 (line 413), AC-6 realtime (line 453), AC-6 asap (line 479). Update the test conftest or fixture to drive the open-loop YAML profile.
- `_docs/02_document/components/06_c4_pose.md`: amend the component doc to document the `enabled` flag.

### Excluded

- Building the Derkachi C6 reference tile cache + descriptor index (AZ-777 territory).
- Implementing C2/C3/C4 anchoring against Derkachi imagery (depends on AZ-777).
- Un-xfailing `test_ac3_within_100m_80pct_of_ticks` (`test_derkachi_1min.py` line 174) — that test needs satellite anchoring, requires AZ-777.
- Un-xfailing `test_az699_real_flight_validation_emits_verdict_and_report` (`test_derkachi_real_tlog.py` line 174) — also requires AZ-777 for accuracy gate.
- Changing the production default — this task makes ESKF *runnable*, not *the default*.
- Renaming any existing config field, YAML block, or factory function (per AZ-618 umbrella's "MUST NOT touch any per-component factory signature" constraint).

## Acceptance Criteria

**AC-1: Open-loop profile composes**
Given a YAML config with `config.components.c4_pose.enabled = False` and `config.components.c5_state.strategy = eskf`
When `compose_root(config, pre_constructed=build_pre_constructed(config))` runs
Then it returns a components dict containing `c1_vio` and `c5_state` but NOT `c4_pose`, and no `PoseEstimatorConfigError` is raised

**AC-2: Full GTSAM profile unchanged**
Given a YAML config with `config.components.c4_pose` absent or `enabled = True` and `config.components.c5_state.strategy = gtsam_isam2`
When `compose_root` runs
Then the returned components dict contains `c1_vio`, `c4_pose`, `c5_state` exactly as before this task

**AC-3: Invalid pairing rejected**
Given a YAML config that pairs `c4_pose.enabled = True` with `c5_state.strategy = eskf`, OR `c4_pose.enabled = False` with `c5_state.strategy = gtsam_isam2`
When `compose_root` runs
Then it raises `CompositionError` naming both the conflicting fields and the valid pairings

**AC-4: Replay binary exits 0 on Derkachi open-loop**
Given the Derkachi 1-min fixture and a YAML config selecting the open-loop ESKF profile
When `gps-denied-replay --config <open-loop.yaml> --video <derkachi> --tlog <derkachi> --output <out.jsonl>` runs on Jetson
Then it exits with code 0 and `<out.jsonl>` contains one EstimatorOutput line per video frame (±10 % to allow for VIO INIT-state skips on the first few frames)

**AC-5: Replay protocol documents the variant**
Given the updated `_docs/02_document/contracts/replay/replay_protocol.md`
When a reader looks for the open-loop ESKF composition
Then there is a dedicated `## Open-loop ESKF variant` section with per-frame loop pseudocode and the YAML shape that selects it

**AC-6: ADR records the design choice**
Given the new `_docs/02_document/adr/ADR-012_open_loop_eskf_composition.md`
When a reader looks for why the per-component flag was chosen
Then ADR-012 records the alternatives considered (per-component flag vs. top-level profile vs. implicit rule) and the rationale for the per-component flag

**AC-7: Five Derkachi e2e xfails removed**
Given `tests/e2e/replay/test_derkachi_1min.py` after this task
When the file is read
Then the `@pytest.mark.xfail` decorators on AC-1 (line 61), AC-2 (line 138), AC-5 (line 413), AC-6 realtime (line 453), AC-6 asap (line 479) are removed and the underlying tests pass on the Jetson harness

## Non-Functional Requirements

**Performance**
- No measurable latency regression on the full-GTSAM path (the new flag check is O(1) at compose time, zero overhead per-frame).
- Open-loop profile per-frame latency budget remains the same 400 ms p95 (the path is strictly shorter — fewer components — so this should improve, not regress).

**Compatibility**
- Default `c4_pose.enabled = True` so every existing config file behaves identically without modification.
- No changes to `compose_root` public signature, `build_pre_constructed` public signature, or any per-component factory signature (per AZ-618 umbrella constraint).

**Reliability**
- Invalid YAML pairings (open-loop + gtsam_isam2 OR full-GTSAM + eskf) MUST fail loud at compose time, never silently fall back. An operator who misconfigures the profile sees a clear error referencing both conflicting fields, not a downstream `KeyError` or stack trace.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-1   | `compose_root` with open-loop YAML → returned components dict | Contains `c1_vio`, `c5_state`; no `c4_pose` key |
| AC-1   | `build_pre_constructed` with open-loop YAML → no `c5_isam2_graph_handle` key in result | Key absent from dict |
| AC-2   | `compose_root` with default full-GTSAM YAML → returned components dict | Contains `c1_vio`, `c4_pose`, `c5_state` (regression guard) |
| AC-3   | `compose_root` with `c4_pose.enabled=False` + `c5_state.strategy=gtsam_isam2` | `CompositionError` raised naming both fields |
| AC-3   | `compose_root` with `c4_pose.enabled=True` + `c5_state.strategy=eskf` | `CompositionError` raised naming both fields |
| C4PoseConfig | `enabled` field defaults to `True` when unset in YAML | Round-trips through `load_config` |

## Blackbox Tests

| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|--------------|-------------------|----------------|
| AC-4   | Derkachi 1-min fixture + open-loop YAML profile | `gps-denied-replay` invoked on Jetson via `scripts/run-tests-jetson.sh` with `RUN_REPLAY_E2E=1 GPS_DENIED_TIER=2` | Exit 0; one EstimatorOutput line per video frame ±10 % | Perf, Compat |
| AC-7   | `test_derkachi_1min.py` AC-1, AC-2, AC-5, AC-6 realtime, AC-6 asap | Tests run on Jetson after this task | All five pass (xfail decorators removed) | Reliability |

## Constraints

- The flag MUST be `c4_pose.enabled: bool`, not a top-level `composition_profile` knob and not an implicit ADR-only rule. User chose this shape during AZ-776 scoping (cycle-3 Step 9, 2026-05-21). Rationale recorded in ADR-012.
- The flag MUST NOT change the C4 estimator's own runtime behaviour — it ONLY affects whether the component is wired into the composition graph. The C4 estimator itself remains a fully functional component.
- The `_run_replay_loop` warning at `runtime_root/__init__.py` (`replay_loop.satellite_anchoring_not_wired`, emitted every 25 frames) is the existing honest signal for the open-loop path. Do NOT remove it; AZ-777 will replace the wiring it warns about. The warning remains visible in the JSONL output during AZ-776 → AZ-777 transition.
- ADR file naming follows the existing pattern (`ADR-NNN_<slug>.md`). Increment past the highest current ADR number; if `_docs/02_document/adr/` is empty (as today), start at ADR-012 to leave room for the ADR-001..ADR-011 references already in code+docs.

## Risks & Mitigation

**Risk 1: Topological-walk regression on the live path**
- *Risk*: Modifying `_AIRBORNE_REGISTRATIONS` or the walker logic could silently break the live GTSAM composition.
- *Mitigation*: AC-2 is a regression guard. Run the full `tests/unit/runtime_root/` suite before declaring done.

**Risk 2: ESKF estimator surfaces a latent bug under the open-loop wiring**
- *Risk*: Until this task lands, `EskfStateEstimator` has never been driven by a real composition root + replay binary. Latent bugs (state initialization, IMU preintegration handoff, FDR write paths) may surface only at AC-4.
- *Mitigation*: Reproduce on Tier-1 Colima first (no GPU needed for ESKF). Use the Tier-2 Jetson run only as the AC-4 confirmation, not as the debug environment. Any non-trivial ESKF fix surfaced this way becomes a sibling ticket — do not in-scope creep.

**Risk 3: Replay protocol doc inconsistency**
- *Risk*: The new `## Open-loop ESKF variant` section may contradict the existing "Wire C1–C5 + C6 + C7 + C13 exactly as in the live composition" line at line 188.
- *Mitigation*: Amend line 188 to say "Wire C1–C5 + C6 + C7 + C13 exactly as in the live composition **for the full-GTSAM profile**; see `## Open-loop ESKF variant` for the open-loop case". One sentence; no ambiguity.

### ADR Impact

> Affects ADR-001 (composition root is single registration site): unchanged — still one registration site. The new flag selects *what* is registered, not *where*.
> Affects ADR-002 (build-flag gate is the lazy-loading boundary): unchanged — `BUILD_*` flags still gate physical linkage; `c4_pose.enabled` is a *runtime* selection on top of a built strategy.
> Affects ADR-009 (interface-first DI): unchanged — the open-loop path still resolves through interfaces, just with the C4 interface unbound.
> Affects ADR-011 (replay is a configuration, not a separate composition root): unchanged — open-loop is a configuration of the same airborne composition root; the new flag is orthogonal to the live/replay split.
