diff --git a/_docs/02_document/architecture.md b/_docs/02_document/architecture.md index 32291d4..56c3573 100644 --- a/_docs/02_document/architecture.md +++ b/_docs/02_document/architecture.md @@ -713,4 +713,41 @@ Two facts surfaced during the Step 7 (Implement) batch loop that contradicted th - AZ-405 grows slightly: it now also owns the `replay_input/` coordinator (the natural home for the auto-sync logic + the time-offset application). - AZ-404 (E2E replay test) is unchanged in scope but reworded: it asserts mode-agnosticism (Invariant 1) and runs against the unified airborne image — no fourth-image entrypoint to verify. - C8 gains a thin `MavlinkTransport` Protocol seam introduced by AZ-400: `SerialMavlinkTransport` (live) and `NoopMavlinkTransport` (replay) implement it. This is a no-op restructure of the existing C8 transport code; the encoders are unchanged. The Protocol seam is the architectural mechanism for Invariant 5 (encoders are byte-identical). -- Demo↔field fidelity is now structurally guaranteed: the same binary runs in both contexts; any drift between them is a behavioural-test failure, not an SBOM-diff failure. \ No newline at end of file +- Demo↔field fidelity is now structurally guaranteed: the same binary runs in both contexts; any drift between them is a behavioural-test failure, not an SBOM-diff failure. + +### ADR-012 — Open-loop ESKF composition profile via `c4_pose.enabled = false` (AZ-776) + +**Context**: ADR-009 wires the C4 pose estimator and the C5 state estimator through a shared GTSAM iSAM2 substrate — C4 adds its PnP factor directly to C5's iSAM2 graph (ADR-003). The `c4_pose` slot in `runtime_root/airborne_bootstrap.py` lists `c5_isam2_graph_handle` as a required `pre_constructed` key (AZ-625), and the `OpenCVGtsamPoseEstimator` constructor consumes that handle. This wiring was sound for the steady-state GTSAM-iSAM2 build of C5. + +When C5 ships a second strategy — `eskf` (ESKF baseline, AZ-588) — the substrate is **not** an iSAM2 graph: ESKF integrates an IMU-driven covariance forward closed-form, with no factor graph behind it. Its `create()` factory returns `(estimator, None)` for the second tuple element (the iSAM2 handle slot). Two facts surfaced from this: + +1. **`c4_pose` cannot be the gate.** C4 owns satellite-anchored pose estimation. ESKF runs satellite-free open-loop. Forcing `c4_pose` into the composition when no satellite anchoring is wired means C4 either crashes at construction (no iSAM2 handle) or, worse, gets a fake handle that pretends to anchor poses that nothing produces — a silent passthrough that violates the "Real Results, Not Simulated Ones" meta-rule. +2. **The replay Tier-2 smoke profile needs an honest minimum.** The AZ-265 replay path's mandatory simple baseline is KLT/RANSAC VIO + ESKF state estimator without any satellite re-anchoring (AZ-777 will add the satellite path on top via the Derkachi C6 reference tile cache). Without an explicit composition profile that excludes C4, every Tier-2 test that wants to exercise the simple baseline either crashes at compose time or has to monkey-patch the registry — both are anti-patterns for an architectural seam. + +**Decision**: + +1. **`C4PoseConfig.enabled: bool = True` is the user-facing switch for the open-loop ESKF profile.** Default ON preserves the ADR-003 steady-state airborne path. Setting `enabled=False` instructs `compose_root` to remove `c4_pose` from the selection map before topological ordering — the wrapper never runs, the consumer never sees a handle, and the wiring stays honest. +2. **`compose_root` enforces the C4↔C5 pairing matrix at compose time.** The validation gate lives in `_validate_c4_c5_composition_profile` (called from `compose_root` before `_compose`) and rejects the two off-diagonal cells of the 2×2 (`c4_pose.enabled`, `c5_state.strategy`) matrix with a `CompositionError` naming both blocks. The two valid combinations are: + - `c4_pose.enabled=True` + `c5_state.strategy="gtsam_isam2"` — the ADR-003 / ADR-009 steady-state airborne path. + - `c4_pose.enabled=False` + `c5_state.strategy="eskf"` — the open-loop ESKF profile (Tier-2 smoke baseline; satellite anchoring deferred to AZ-777). + The two **invalid** combinations are rejected with explicit error text: + - `enabled=False` + `gtsam_isam2` (an iSAM2 graph with no PnP anchors converges to drift-prone visual-only odometry; the production deployment intent is that gtsam_isam2 always coexists with C4). + - `enabled=True` + `eskf` (ESKF has no graph for C4 to anchor against; this is the AZ-776 root-cause pairing the user reported). +3. **`build_pre_constructed` honours `c4_pose.enabled`.** When disabled, `c5_isam2_graph_handle` is **omitted** from the `pre_constructed` dict — the handle is a C4 consumer requirement, and removing C4 from the selection map removes the requirement. The ESKF estimator itself is still built and cached in the internal `_c5_prebuilt_estimator` slot (so the C5 wrapper short-circuits onto the prebuilt instance), but the iSAM2-shaped seam disappears from the cross-component contract. +4. **Component selection is the only thing that changes.** The composition root's existing `_compose` mechanics — topological ordering, lazy strategy resolution, build-flag gating — are unchanged. The new `skip_slugs` parameter (a `frozenset[str]`) is the minimal seam that lets `compose_root` instruct `_compose` to drop the disabled component(s); there is no second composition path, no `compose_eskf` function, no mode-aware branch outside the validation gate. + +**Alternatives considered**: + +1. **Make `c4_pose` a "soft" dependency of C5 (introspect the strategy at C5 construction time, skip C4 wiring only when `strategy == "eskf"`).** Rejected: this leaks C5-strategy specifics into C4's interface (`PoseEstimator` would have to grow a "you may not be wired" affordance), violates ADR-009 interface-first, and re-introduces the very mode-aware branches Invariant 1 of the replay protocol forbids. +2. **Make `compose_root` derive `c4_pose.enabled` automatically from `c5_state.strategy` (no user-facing flag).** Rejected: the C4↔C5 coupling is a deliberate design pairing, not a mechanical derivation. Future research strategies (e.g. a non-iSAM2 GTSAM variant, or a satellite-anchored ESKF) may want different combinations; the explicit flag keeps the configuration honest and audit-able. +3. **Keep the wiring as-is and rely on the registry mechanism to skip C4.** Rejected: `C4PoseConfig` registers itself with the global config registry at module import (via `register_component_block` in `components/c4_pose/__init__.py`), which means even an empty `c4_pose:` block in YAML instantiates the block with defaults and pulls C4 into the selection map. The flag is the only honest opt-out without removing the registration call (which would break the steady-state path). +4. **Build a synthetic `NullIsam2GraphHandle` that satisfies the Protocol but no-ops on update.** Rejected as the textbook example of the "Real Results, Not Simulated Ones" anti-pattern: it would let C4 run on top of ESKF with no anchoring, producing pose estimates that look real but have no factor-graph grounding. The composition-time gate is the honest answer. + +**Consequences**: + +- `tests/e2e/replay/conftest.py` writes `c4_pose: { enabled: false }` into the Tier-2 replay `config.yaml`, alongside the existing `c1_vio: klt_ransac` + `c5_state: eskf` block. This is the open-loop profile the replay binary uses for the AZ-265 / AZ-776 simple-baseline tests. +- `tests/e2e/replay/test_derkachi_1min.py` un-xfails AC-1 (clean exit + per-frame JSONL), AC-2 (schema), AC-5 (determinism), AC-6 realtime, and AC-6 ASAP — these tests only required compose-time success to pass and AZ-776 lands that. AC-3 (≤ 100 m for ≥ 80 % of ticks) **remains** xfailed for AZ-777: ESKF integrates open-loop and drifts unbounded without C2/C3/C4 satellite re-anchoring; the ≤ 100 m threshold cannot be met by physics until the Derkachi C6 reference tile cache lands. +- `_docs/02_document/contracts/replay/replay_protocol.md` gains a new "Open-loop ESKF composition profile" sub-section in **Composition root extension** plus a new **Invariant 13** ("C4↔C5 pairing matrix is enforced at compose time") that the AZ-776 unit tests own. +- `_docs/02_document/components/06_c4_pose/description.md` gains an "Enabled flag" sub-section that points at this ADR; the rest of the component contract is unchanged. +- The unit-test surface at `tests/unit/runtime_root/test_az776_open_loop_eskf_composition.py` owns the seven invariants AZ-776 introduces: `C4PoseConfig.enabled` default-true, AC-1 (open-loop ESKF composes without C4), AC-2 (default GTSAM profile still includes C4), AC-3a + AC-3b (the two forbidden pairings raise `CompositionError`), and the two `pre_constructed` behaviours (`c5_isam2_graph_handle` omitted when C4 disabled, present when C4 enabled). The full suite passes in ~4 s. +- The composition root's contract surface in `runtime_root/__init__.py` gains one public helper (`CompositionError` was already public; the new `skip_slugs` parameter to `_compose` is module-private). No public CLI flag is added — operators set `c4_pose.enabled = false` in YAML. \ No newline at end of file diff --git a/_docs/02_document/components/06_c4_pose/description.md b/_docs/02_document/components/06_c4_pose/description.md index a16bc71..d3cfda7 100644 --- a/_docs/02_document/components/06_c4_pose/description.md +++ b/_docs/02_document/components/06_c4_pose/description.md @@ -8,6 +8,8 @@ **Cycle-1 operational reality**: the airborne binary wires C4 through `_STRATEGY_REGISTRY` + `register_airborne_strategies()` (AZ-591) with a single strategy slot (`opencv_gtsam` — `C4PoseConfig.KNOWN_POSE_STRATEGIES = {"opencv_gtsam"}`). Constructor injection flows through the `pre_constructed` dict passed to `compose_root(config, pre_constructed=...)` (AZ-618 umbrella → AZ-623 c5 helpers phase + AZ-625 eager iSAM2 handle phase). The `c4_pose` slot lists `("c282_ransac_filter", "c5_wgs_converter", "c5_se3_utils", "c5_isam2_graph_handle")` in `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`; `c13_fdr` and `clock` are optional. The `c5_isam2_graph_handle` slot is the **shared GTSAM substrate seam** — `build_pre_constructed` eagerly invokes `build_state_estimator` once (AZ-625 / Phase E.5) so the (`StateEstimator`, `ISam2GraphHandle`) tuple is constructed BEFORE either the C4 or C5 wrapper runs (C4 runs first in topo order via `_C4_POSE_DEPENDS_ON = ("c1_vio", "c3_matcher")`, then C5 short-circuits on the prebuilt estimator via the internal `_c5_prebuilt_estimator` key). The cross-seam identity invariant (`c4_pose._isam2_handle is c5_state._isam2_handle`) is verified by AC-625.3. Missing required keys raise `AirborneBootstrapError` at composition time, naming the consumer and missing key. +**Enabled flag (AZ-776 / ADR-012)**: `C4PoseConfig.enabled: bool = True` is the user-facing switch that controls C4's participation in the composition graph. Default ON preserves the ADR-003 steady-state airborne path. Setting `c4_pose.enabled = false` in YAML removes C4 from the component selection map at compose time — the wrapper never runs, the consumer never sees an iSAM2 handle, and `build_pre_constructed` omits `c5_isam2_graph_handle` from the `pre_constructed` dict. The flag exists to support the open-loop ESKF composition profile (the AZ-265 replay Tier-2 smoke baseline) where C5 runs as the `eskf` strategy with no factor graph for C4 to anchor against. `compose_root` enforces the 2×2 pairing matrix between `c4_pose.enabled` and `c5_state.strategy` at compose time and rejects the off-diagonal cells (`enabled=False` + `gtsam_isam2`, `enabled=True` + `eskf`) with a `CompositionError`. See ADR-012 (architecture.md) and Invariant 13 in `_docs/02_document/contracts/replay/replay_protocol.md`. + **Upstream dependencies**: - C3.5 → `MatchResult` (refined or passthrough). - C5 StateEstimator — supplies the GTSAM iSAM2 handle so C4 can add its factor in-graph (architecture principle: shared substrate per ADR-003). diff --git a/_docs/02_document/contracts/replay/replay_protocol.md b/_docs/02_document/contracts/replay/replay_protocol.md index 0f9097f..decc44d 100644 --- a/_docs/02_document/contracts/replay/replay_protocol.md +++ b/_docs/02_document/contracts/replay/replay_protocol.md @@ -213,6 +213,33 @@ Side notes: - `set_takeoff_origin` (AZ-490 / ADR-010) is invoked identically in replay: the operator's pre-flight C10 Manifest is the source of truth in both modes. The tlog's first GPS fix is the **fallback**, gated through the same Principle #11 bounded-delta check. - `BUILD_FAISS_INDEX` is ON in the airborne binary (live and replay alike). C2 in replay queries the **real** C6 `FaissDescriptorIndex`, populated by the pre-flight C10 build. This is the architectural change vs. v1.0.0 of this contract. +### Composition profile: open-loop ESKF (AZ-776 / ADR-012) + +The replay binary supports a second composition profile alongside the production GTSAM-iSAM2 path: **open-loop ESKF**. It is the Tier-2 smoke baseline for the AZ-265 replay flow and the only profile that can run end-to-end against the Derkachi clip today (the satellite-anchored profile waits on AZ-777's C6 reference tile cache). + +The user-facing switch is one YAML field on the C4 block: + +``` +c4_pose: + enabled: false +c5_state: + strategy: eskf +``` + +When `c4_pose.enabled = false`: +- `compose_root` removes `c4_pose` from the component selection map before topological ordering; the C4 wrapper never runs, the `OpenCVGtsamPoseEstimator` is never instantiated, and `pose_estimator.estimate(refined)` in the per-frame loop above is a no-op (C5 receives IMU + VIO inputs only, no PnP anchor). +- `build_pre_constructed` omits `c5_isam2_graph_handle` from the `pre_constructed` dict (no consumer requires it). The ESKF estimator is still pre-built and cached in the internal `_c5_prebuilt_estimator` slot exactly as in the gtsam_isam2 path; the C5 wrapper short-circuits onto the prebuilt instance per AZ-625. +- The replay per-frame loop is otherwise unchanged — C1 VIO still runs, C5 still ingests, the JSONL sink still emits per video frame. Position drifts open-loop without satellite re-anchoring; AZ-777 closes that half of the loop by adding C2/C3/C4 against the C6 tile cache. + +The 2×2 pairing matrix between `c4_pose.enabled` and `c5_state.strategy` is enforced at compose time (Invariant 13 below). The two valid cells are: + +| `c4_pose.enabled` | `c5_state.strategy` | Profile | Status | +|---|---|---|---| +| `true` | `gtsam_isam2` | Steady-state airborne (ADR-003 / ADR-009) | Production | +| `false` | `eskf` | Open-loop ESKF (this section) | Replay Tier-2 smoke baseline | + +The two **invalid** cells (`true` + `eskf` and `false` + `gtsam_isam2`) raise `CompositionError` from `compose_root` with explicit error text naming both blocks. The Tier-2 replay fixture (`tests/e2e/replay/conftest.py`) writes the second valid cell. The first cell is the live + airborne default. + ## Invariants 1. **Mode-agnostic C1–C7, C13**: production components MUST NOT contain `if config.mode == "replay":` branches. Mode-specific behaviour lives in the strategies (FrameSource / FcAdapter / MavlinkTransport / ReplaySink / Clock). Verified by an explicit grep guard in CI (the AZ-404 E2E test owns this assertion). @@ -227,6 +254,7 @@ Side notes: 10. **Determinism**: same `(video, tlog, config, time_offset_ms, pace=ASAP)` input → same JSONL output within ≤ 1e-6 float drift in position fields (AC-5). 11. **MAVLink signing key required in replay**: the airborne binary refuses to run without `--mavlink-signing-key PATH` in both modes. In replay the operator supplies a dummy file (well-formed key bytes; no real channel to verify against). This preserves Invariant 5 — the encoders' signing code path runs identically in both modes. 12. **Real C6 cache in replay**: the airborne binary in replay mode reads the same pre-built C6 tile cache the operator built via the normal pre-flight C10/C11/C12 flow. There is no replay-specific cache shape. Verified by the AZ-404 E2E fixture, which runs the operator's pre-flight flow before invoking the replay CLI. +13. **C4↔C5 pairing matrix is enforced at compose time** (AZ-776 / ADR-012): `compose_root` rejects the two off-diagonal cells of the (`c4_pose.enabled`, `c5_state.strategy`) matrix with a `CompositionError` naming both blocks. `enabled=False` + `gtsam_isam2` and `enabled=True` + `eskf` are forbidden. The two valid cells are `enabled=True` + `gtsam_isam2` (production steady-state per ADR-003 / ADR-009) and `enabled=False` + `eskf` (open-loop ESKF — replay Tier-2 smoke baseline; satellite anchoring deferred to AZ-777). Verified by `tests/unit/runtime_root/test_az776_open_loop_eskf_composition.py` AC-3a and AC-3b. ## Producer / Consumer Split diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 9cf0806..1644791 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -6,9 +6,9 @@ step: 10 name: Implement status: in_progress sub_step: - phase: 0 - name: awaiting-invocation - detail: "cycle 3 implement: AZ-776 first (no deps), then AZ-777 (depends on AZ-776)" + phase: 7 + name: batch-loop + detail: "batch 103 cycle3: AZ-776 implemented, awaiting commit + transition; AZ-777 next" retry_count: 0 cycle: 3 tracker: jira diff --git a/src/gps_denied_onboard/components/c4_pose/config.py b/src/gps_denied_onboard/components/c4_pose/config.py index 44a1687..edddf4d 100644 --- a/src/gps_denied_onboard/components/c4_pose/config.py +++ b/src/gps_denied_onboard/components/c4_pose/config.py @@ -27,6 +27,16 @@ class C4PoseConfig: Fields per the C4 contract §"Config-load-time validation": + * ``enabled`` — AZ-776 composition-graph flag. When ``False`` the + composition root strips ``c4_pose`` from the airborne component + graph entirely (open-loop ESKF profile per ADR-012). Default + ``True`` preserves the full GTSAM pipeline that ADR-003 mandates + as the steady-state airborne path. Forbidden pairings (rejected + by ``compose_root`` with :class:`CompositionError`): + ``enabled=False`` + ``c5_state.strategy="gtsam_isam2"`` (iSAM2 + requires a C4 anchor); ``enabled=True`` + + ``c5_state.strategy="eskf"`` (ESKF has no iSAM2 graph for C4 to + anchor against). * ``strategy`` — selects the concrete estimator. Currently only ``"opencv_gtsam"`` is defined. * ``ransac_iterations`` — OpenCV ``solvePnPRansac`` iteration @@ -56,6 +66,7 @@ class C4PoseConfig: handling would land in a future config extension). """ + enabled: bool = True strategy: str = "opencv_gtsam" ransac_iterations: int = 200 ransac_reprojection_threshold_px: float = 4.0 diff --git a/src/gps_denied_onboard/runtime_root/__init__.py b/src/gps_denied_onboard/runtime_root/__init__.py index 08e15ab..7c5f323 100644 --- a/src/gps_denied_onboard/runtime_root/__init__.py +++ b/src/gps_denied_onboard/runtime_root/__init__.py @@ -336,6 +336,7 @@ def _compose( allowed_tiers: frozenset[StrategyTier], extra_required_env: Iterable[str], pre_constructed: Mapping[str, Any] | None = None, + skip_slugs: frozenset[str] = frozenset(), ) -> tuple[dict[str, Any], tuple[str, ...]]: """Shared composition path used by ``compose_root`` / ``compose_operator``. @@ -345,9 +346,17 @@ def _compose( strategies (``frame_source``, ``fc_adapter``, ``clock``, ``mavlink_transport``, ``replay_sink``) so any C1-C7 factory that declares a dependency on one finds it already populated. + + ``skip_slugs`` removes component slugs from the composition graph + even when ``config.components`` contains a block for them. AZ-776 + uses this to drop ``c4_pose`` from the airborne open-loop ESKF + profile; the slug is filtered out of the topological walk and the + corresponding wrapper factory is never invoked. """ _check_required_env(extra_required=extra_required_env) selections = _resolve_component_strategies(config, allowed_tiers) + for skipped in skip_slugs: + selections.pop(skipped, None) resolved: dict[str, _Registration] = { slug: _resolve_strategy(slug, strategy, allowed_tiers) for slug, strategy in selections.items() @@ -423,6 +432,92 @@ def _read_strategy_attr(block: Any) -> Any: return None +def _read_c4_pose_enabled(config: Config) -> bool: + """True iff the airborne ``c4_pose`` slot is enabled in the composition graph. + + AZ-776: ``c4_pose.enabled = False`` selects the open-loop ESKF + composition profile (ADR-012). Defaults to ``True`` when the + block is absent or carries no ``enabled`` attribute, preserving + the legacy GTSAM pipeline (ADR-003 steady-state path). + """ + components = getattr(config, "components", None) or {} + if not isinstance(components, Mapping): + return True + block = components.get("c4_pose") + if block is None: + return True + enabled = getattr(block, "enabled", True) + return bool(enabled) + + +def _read_c5_state_strategy(config: Config) -> str: + """Return ``config.components['c5_state'].strategy`` or the default. + + Defaults to ``"gtsam_isam2"`` when the block is absent — the same + fallback :func:`airborne_bootstrap._resolve_c5_state_strategy` + uses for the eager :func:`build_state_estimator` invocation. + """ + components = getattr(config, "components", None) or {} + if not isinstance(components, Mapping): + return "gtsam_isam2" + block = components.get("c5_state") + if block is None: + return "gtsam_isam2" + strategy = getattr(block, "strategy", "gtsam_isam2") + return str(strategy) if strategy else "gtsam_isam2" + + +def _validate_c4_c5_composition_profile(config: Config) -> None: + """Reject incompatible ``c4_pose.enabled`` / ``c5_state.strategy`` pairings. + + AZ-776 / ADR-012 defines two valid composition profiles for the + airborne binary: + + * **Full GTSAM** (steady-state, ADR-003): ``c4_pose.enabled=True`` + + ``c5_state.strategy="gtsam_isam2"``. C4 anchors land into C5's + iSAM2 graph; C5 produces smoothed estimates. + * **Open-loop ESKF** (mandatory simple baseline, ADR-012): + ``c4_pose.enabled=False`` + ``c5_state.strategy="eskf"``. C4 is + stripped from the graph; C5 ESKF integrates VIO + IMU + open-loop. No iSAM2 graph handle is produced or consumed. + + The two off-diagonal pairings are physically impossible: + + * ``c4_pose.enabled=False`` + ``c5_state.strategy="gtsam_isam2"``: + iSAM2 requires a C4 pose anchor to bound drift; without C4 the + graph never receives a pose factor and the smoother is unbounded. + * ``c4_pose.enabled=True`` + ``c5_state.strategy="eskf"``: C4 + needs the iSAM2 graph handle to inject pose factors, but ESKF + returns ``handle=None`` by design (no graph). The C4 wrapper + would crash on the ``None`` handle. + + Either off-diagonal raises :class:`CompositionError` with a clear + next-step naming both fields. + """ + c4_enabled = _read_c4_pose_enabled(config) + c5_strategy = _read_c5_state_strategy(config) + if not c4_enabled and c5_strategy == "gtsam_isam2": + raise CompositionError( + "compose_root: c4_pose.enabled=False is incompatible with " + "c5_state.strategy='gtsam_isam2' — iSAM2 requires C4 pose " + "anchors to bound smoother drift. Use either the full " + "GTSAM profile (c4_pose.enabled=true, c5_state.strategy=" + "'gtsam_isam2') or the open-loop ESKF profile " + "(c4_pose.enabled=false, c5_state.strategy='eskf'). See " + "ADR-012 (open-loop ESKF composition profile)." + ) + if c4_enabled and c5_strategy == "eskf": + raise CompositionError( + "compose_root: c4_pose.enabled=True is incompatible with " + "c5_state.strategy='eskf' — ESKF has no iSAM2 graph for " + "C4 to anchor against (handle=None by design), so the C4 " + "wrapper cannot run. Set c4_pose.enabled=false to select " + "the open-loop ESKF profile, or change c5_state.strategy " + "to 'gtsam_isam2' for the full GTSAM pipeline. See " + "ADR-012 (open-loop ESKF composition profile)." + ) + + def compose_root( config: Config, *, @@ -445,6 +540,14 @@ def compose_root( finds it already populated. C1-C7+C13 strategies are wired identically to live mode (replay protocol Invariant 1). + AZ-776 composition profile (ADR-012): when + ``config.components['c4_pose'].enabled`` is ``False`` the function + strips ``c4_pose`` from the airborne component graph (open-loop + ESKF profile). The forbidden off-diagonal pairings + (``enabled=False`` + ``c5_state.strategy='gtsam_isam2'``; + ``enabled=True`` + ``c5_state.strategy='eskf'``) raise + :class:`CompositionError` before any factory runs. + The ``pre_constructed`` kwarg (AZ-591) lets the caller seed ``constructed`` with infrastructure objects (e.g. fdr_client, descriptor_index, inference_runtime) before any registered factory @@ -461,6 +564,8 @@ def compose_root( have to satisfy the full OpenCV / pymavlink / FDR side-effects of the real strategies. """ + _validate_c4_c5_composition_profile(config) + skip_slugs = frozenset() if _read_c4_pose_enabled(config) else frozenset({"c4_pose"}) extra_env = ("MAVLINK_SIGNING_KEY",) if config.mode == "live" else () if config.mode == "replay": replay_factory = replay_components_factory or build_replay_components @@ -478,6 +583,7 @@ def compose_root( allowed_tiers=frozenset({"airborne", "shared"}), extra_required_env=extra_env, pre_constructed=seeded, + skip_slugs=skip_slugs, ) merged: dict[str, Any] = dict(replay_components) merged.update(components) diff --git a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py index a976b4d..67e9189 100644 --- a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py +++ b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py @@ -1135,6 +1135,33 @@ def _build_c3_feature_extractor(config: Config) -> FeatureExtractor: return OpenCvOrbExtractor() +def _c4_pose_disabled(config: Config) -> bool: + """True iff the airborne open-loop ESKF composition profile is active. + + AZ-776 / ADR-012: when ``config.components["c4_pose"].enabled`` is + explicitly ``False`` the composition root strips ``c4_pose`` from + the component graph and the C4 wrapper never runs. The eager + ``(estimator, handle)`` build inside :func:`build_pre_constructed` + therefore has no consumer for the iSAM2 graph handle slot + (``c5_isam2_graph_handle``); leaving the slot absent makes the + "no C4" property visible at the pre-bootstrap layer too. + + Returns ``False`` when the block is absent or when ``enabled`` is + missing / true — the full GTSAM profile (ADR-003 steady state) + remains the default airborne pipeline. Replay-mode component-block + omission is handled separately by + :func:`_replay_omits_component_block`. + """ + components = getattr(config, "components", None) or {} + if not isinstance(components, Mapping): + return False + block = components.get("c4_pose") + if block is None: + return False + enabled = getattr(block, "enabled", True) + return not bool(enabled) + + def _replay_omits_component_block(config: Config, block_name: str) -> bool: """True iff replay-mode :class:`Config` has no ``components[block_name]`` entry. @@ -1301,7 +1328,14 @@ def build_pre_constructed(config: Config) -> dict[str, Any]: fdr_client=constructed["c13_fdr"], tile_store=constructed.get("c6_tile_store"), ) - constructed["c5_isam2_graph_handle"] = handle + # AZ-776 / ADR-012 open-loop ESKF profile: c4_pose.enabled=False + # strips c4_pose from the composition graph, so the + # c5_isam2_graph_handle slot has no consumer. ESKF returns + # handle=None by design; omitting the slot makes the open-loop + # property visible at the pre_constructed layer and refuses the + # invariant "handle is None means C4 will crash on read". + if not _c4_pose_disabled(config): + constructed["c5_isam2_graph_handle"] = handle constructed[_C5_PREBUILT_ESTIMATOR_KEY] = estimator return constructed diff --git a/tests/e2e/replay/conftest.py b/tests/e2e/replay/conftest.py index fe7ebef..a087773 100644 --- a/tests/e2e/replay/conftest.py +++ b/tests/e2e/replay/conftest.py @@ -136,24 +136,28 @@ def derkachi_replay_inputs(tmp_path_factory: pytest.TempPathFactory) -> Derkachi # treats each top-level mapping as a block whose key is a # registry slug; nesting the slugs under a `components:` # wrapper makes the loader silently drop them (the wrapper - # is not a registered slug). See `_docs/_repo` notes on the - # ESKF compose-time blocker (AZ-776) for why this matters. + # is not a registered slug). # - # KLT/RANSAC + ESKF is the minimal pair that runs without - # native deps (cv2 + numpy only). The CLI currently exits - # non-zero at compose time for this configuration: c4_pose - # hard-requires an iSAM2 graph handle that ESKF does not - # provide (handle=None by design). AZ-776 tracks the fix. - # Until AZ-776 lands, every heavy AC test in - # `test_derkachi_1min.py` is xfailed with that ticket in - # the reason. C2/C3/C4 satellite anchoring additionally - # require AZ-777 (Derkachi C6 reference tile cache). + # Open-loop ESKF composition profile (AZ-776 / ADR-012): + # `c4_pose.enabled = false` strips C4 from the composition + # graph so the airborne binary can run the mandatory simple + # baseline (KLT/RANSAC VIO + ESKF state estimator) end-to-end + # without a C4 anchor. ESKF has no iSAM2 graph for C4 to + # anchor against; the `compose_root` validation gate rejects + # the off-diagonal pairings (`enabled=False` + `gtsam_isam2` + # or `enabled=True` + `eskf`) with a `CompositionError`. + # Position drifts open-loop without C2/C3/C4 satellite + # re-anchoring — AZ-777 (Derkachi C6 reference tile cache) + # is the follow-up that closes the satellite-anchoring half + # of the per-frame loop. "mode: replay\n" "replay:\n" " pace: asap\n" " target_fc_dialect: ardupilot_plane\n" "c1_vio:\n" " strategy: klt_ransac\n" + "c4_pose:\n" + " enabled: false\n" "c5_state:\n" " strategy: eskf\n" ) diff --git a/tests/e2e/replay/test_derkachi_1min.py b/tests/e2e/replay/test_derkachi_1min.py index f3d4882..f1c02ec 100644 --- a/tests/e2e/replay/test_derkachi_1min.py +++ b/tests/e2e/replay/test_derkachi_1min.py @@ -58,22 +58,6 @@ _HEAVY_SKIP = pytest.mark.skipif( @pytest.mark.tier2 @_HEAVY_SKIP -@pytest.mark.xfail( - reason=( - "Blocked by AZ-776: the replay compose root cannot wire " - "c5_state=eskf because c4_pose hard-requires an iSAM2 graph " - "handle that ESKF does not provide (handle=None by design). " - "The CLI exits non-zero at compose time before the per-frame " - "loop runs, so this test cannot pass against the current " - "runtime. Once AZ-776 ships, an open-loop C1+C5(ESKF) " - "composition will allow the CLI to exit 0 and this AC-1 " - "test (emit one EstimatorOutput per video frame) can pass. " - "Full-pipeline accuracy still requires AZ-777 (Derkachi C6 " - "reference tile cache) but AC-1 only needs successful exit, " - "not anchor-quality, so AZ-776 alone is sufficient." - ), - strict=False, -) def test_ac1_exits_0_jsonl_count_match(replay_runner, derkachi_replay_inputs) -> None: """Real loop emits one EstimatorOutput per video frame, not per GPS fix. @@ -135,17 +119,6 @@ _ESTIMATOR_OUTPUT_KEYS = frozenset( @pytest.mark.tier2 @_HEAVY_SKIP -@pytest.mark.xfail( - reason=( - "Blocked by AZ-776 (replay compose root cannot use " - "c5_state=eskf). The CLI exits non-zero before any JSONL " - "rows are written, so the schema cannot be validated against " - "the current runtime. Schema lives in EstimatorOutput and is " - "stable; AC-2 can pass as soon as AZ-776 makes the loop " - "actually emit rows." - ), - strict=False, -) def test_ac2_jsonl_schema_match(replay_runner) -> None: # Act result = replay_runner(pace="asap") @@ -174,18 +147,13 @@ def test_ac2_jsonl_schema_match(replay_runner) -> None: @pytest.mark.xfail( reason=( "AC-3 requires the C1+C2+C3+C4+C5 satellite-re-anchoring " - "pipeline. Two blockers, both tracked: " - "(1) AZ-776 — the replay compose root cannot currently wire " - "c5_state=eskf at all (c4_pose hard-requires an iSAM2 " - "handle ESKF does not provide); the CLI exits non-zero " - "before any tick is emitted. " - "(2) AZ-777 — once AZ-776 lands, the open-loop C1+C5(ESKF) " - "composition will run end-to-end but with NO satellite " - "anchoring (no C2/C3/C4) because the Derkachi fixture has " - "no reference C6 tile cache. ESKF integrates open-loop, so " - "position drifts unbounded over the 8-min flight and the " - "≤100m threshold cannot be met by physics. " - "AC-3 stays xfail until BOTH AZ-776 and AZ-777 ship." + "pipeline. Blocked by AZ-777: with AZ-776 landed, the " + "open-loop C1+C5(ESKF) composition now runs end-to-end but " + "with NO satellite anchoring (no C2/C3/C4) because the " + "Derkachi fixture has no reference C6 tile cache. ESKF " + "integrates open-loop, so position drifts unbounded over " + "the 8-min flight and the ≤100 m threshold cannot be met " + "by physics until the reference tile cache (AZ-777) lands." ), strict=False, ) @@ -410,17 +378,6 @@ def test_ac4_encoder_byte_equality_via_transport_seam() -> None: @pytest.mark.tier2 @_HEAVY_SKIP -@pytest.mark.xfail( - reason=( - "Blocked by AZ-776: with the compose root failing for " - "c5_state=eskf the CLI exits non-zero on both runs, so " - "determinism cannot be observed. Once AZ-776 ships, the " - "open-loop C1+C5 path is deterministic by construction " - "(KLT/RANSAC uses fixed seeds, ESKF is closed-form) and " - "AC-5 should pass." - ), - strict=False, -) def test_ac5_determinism_two_runs_diff(replay_runner) -> None: # Act r1 = replay_runner(pace="asap") @@ -450,14 +407,6 @@ def test_ac5_determinism_two_runs_diff(replay_runner) -> None: @pytest.mark.tier2 @_HEAVY_SKIP -@pytest.mark.xfail( - reason=( - "Blocked by AZ-776: the CLI exits non-zero at compose time, " - "so the realtime pacing loop is never reached. Once AZ-776 " - "ships, AC-6 realtime can pace the open-loop C1+C5 path." - ), - strict=False, -) def test_ac6_pace_realtime_60s_within_5pct(replay_runner) -> None: # Act — cap to 60 s so a full 490-second flight doesn't pin the test # to an 8-minute realtime run; the pacing correctness is validated @@ -476,15 +425,6 @@ def test_ac6_pace_realtime_60s_within_5pct(replay_runner) -> None: @pytest.mark.tier2 @_HEAVY_SKIP -@pytest.mark.xfail( - reason=( - "Blocked by AZ-776: the CLI exits non-zero at compose time, " - "so the ASAP pacing loop is never reached. Once AZ-776 " - "ships, AC-6 ASAP can run the open-loop C1+C5 path " - "to completion." - ), - strict=False, -) def test_ac6_pace_asap_under_30s(replay_runner) -> None: # Act result = replay_runner(pace="asap") diff --git a/tests/unit/runtime_root/test_az776_open_loop_eskf_composition.py b/tests/unit/runtime_root/test_az776_open_loop_eskf_composition.py new file mode 100644 index 0000000..3b59931 --- /dev/null +++ b/tests/unit/runtime_root/test_az776_open_loop_eskf_composition.py @@ -0,0 +1,442 @@ +"""AZ-776 / ADR-012 — Open-loop ESKF composition profile (`c4_pose.enabled`). + +Verifies the contract at +``_docs/02_tasks/todo/AZ-776_eskf_open_loop_composition_profile.md``: + +* AC-1 — open-loop profile composes: with + ``c4_pose.enabled = False`` + ``c5_state.strategy = "eskf"``, + :func:`compose_root` produces a runtime whose components dict + contains ``c1_vio`` and ``c5_state`` but NOT ``c4_pose``, and the + topological walk completes without + :class:`CompositionError` / :class:`StrategyNotLinkedError` / + :class:`AirborneBootstrapError`. + +* AC-2 — full GTSAM profile unchanged: with default + ``c4_pose.enabled = True`` + ``c5_state.strategy = "gtsam_isam2"``, + :func:`compose_root` still composes ``c4_pose`` exactly as before + AZ-776 (no behaviour change for the steady-state airborne path + documented by ADR-003). + +* AC-3a — forbidden pairing ``c4_pose.enabled=False`` + + ``c5_state.strategy="gtsam_isam2"`` raises :class:`CompositionError` + naming both fields and ADR-012. + +* AC-3b — forbidden pairing ``c4_pose.enabled=True`` + + ``c5_state.strategy="eskf"`` raises :class:`CompositionError` + naming both fields and ADR-012. + +* AC-Config — :class:`C4PoseConfig.enabled` defaults to ``True`` + (preserves ADR-003 steady-state path when the operator does not + set the flag explicitly). + +* AC-Bootstrap — :func:`build_pre_constructed` omits the + ``c5_isam2_graph_handle`` slot from the returned dict when + ``c4_pose.enabled = False`` (the slot has no consumer in the + open-loop profile). + +The composition tests stub every per-component wrapper at the +:mod:`gps_denied_onboard.runtime_root.airborne_bootstrap` module +boundary so the unit suite does not pull in gtsam / opencv / +lightglue / FAISS / TensorRT — the same isolation pattern AZ-591 +established in :mod:`tests.unit.runtime_root.test_az591_airborne_bootstrap`. +The :func:`build_pre_constructed` test also stubs +:func:`_build_c5_state_estimator_pair` to avoid registering / loading +either gtsam or the ESKF NumPy estimator at unit-test time. +""" + +from __future__ import annotations + +import dataclasses +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any + +import pytest + +from gps_denied_onboard.components.c4_pose.config import C4PoseConfig +from gps_denied_onboard.config import Config +from gps_denied_onboard.fdr_client.client import _reset_for_tests as _reset_fdr_client_cache +from gps_denied_onboard.runtime_root import ( + CompositionError, + clear_strategy_registry, + compose_root, +) +from gps_denied_onboard.runtime_root import airborne_bootstrap +from gps_denied_onboard.runtime_root.airborne_bootstrap import ( + build_pre_constructed, + clear_imu_preintegrator_cache, + register_airborne_strategies, +) + + +# ---------------------------------------------------------------------- +# Fixtures + helpers + + +@dataclass(frozen=True) +class _C1Block: + strategy: str = "klt_ransac" + + +@dataclass(frozen=True) +class _C4Block: + enabled: bool = True + strategy: str = "opencv_gtsam" + + +@dataclass(frozen=True) +class _C5Block: + strategy: str = "gtsam_isam2" + + +@pytest.fixture(autouse=True) +def _isolated_registry() -> Iterator[None]: + clear_strategy_registry() + clear_imu_preintegrator_cache() + _reset_fdr_client_cache() + yield + clear_strategy_registry() + clear_imu_preintegrator_cache() + _reset_fdr_client_cache() + + +@pytest.fixture +def _airborne_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name, value in ( + ("GPS_DENIED_FC_PROFILE", "ardupilot_plane"), + ("GPS_DENIED_TIER", "1"), + ("DB_URL", "postgresql+psycopg://gps_denied:dev@db:5432/gps_denied"), + ("CAMERA_CALIBRATION_PATH", "/etc/gps-denied/calib.yml"), + ("LOG_LEVEL", "INFO"), + ("LOG_SINK", "console"), + ("INFERENCE_BACKEND", "pytorch_fp16"), + ("FDR_PATH", "/var/lib/gps-denied/fdr"), + ("TILE_CACHE_PATH", "/var/lib/gps-denied/tiles"), + ("MAVLINK_SIGNING_KEY", "ZZZZZZZZ"), + ): + monkeypatch.setenv(name, value) + + +def _stub_wrappers(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[str]]: + """Stub every airborne wrapper to return a sentinel + record invocations. + + Mirrors :func:`tests.unit.runtime_root.test_az591_airborne_bootstrap.\ +test_ac2_compose_root_reaches_completion_with_pre_constructed_infra` + so the AZ-776 tests do not pull in gtsam / opencv / lightglue. + + Returns: + ``invocations``: a dict mapping component slug → list of + invocation order (read after :func:`compose_root` returns). + """ + invocations: dict[str, list[str]] = {"order": []} + + def _make_stub(slug: str) -> Any: + def _stub(config: Any, constructed: dict[str, Any]) -> str: + del config, constructed + invocations["order"].append(slug) + return f"<{slug}>" + + return _stub + + monkeypatch.setattr(airborne_bootstrap, "_c1_vio_wrapper", _make_stub("c1_vio")) + monkeypatch.setattr(airborne_bootstrap, "_c2_vpr_wrapper", _make_stub("c2_vpr")) + monkeypatch.setattr(airborne_bootstrap, "_c2_5_rerank_wrapper", _make_stub("c2_5_rerank")) + monkeypatch.setattr(airborne_bootstrap, "_c3_matcher_wrapper", _make_stub("c3_matcher")) + monkeypatch.setattr(airborne_bootstrap, "_c3_5_adhop_wrapper", _make_stub("c3_5_adhop")) + monkeypatch.setattr(airborne_bootstrap, "_c4_pose_wrapper", _make_stub("c4_pose")) + monkeypatch.setattr(airborne_bootstrap, "_c5_state_wrapper", _make_stub("c5_state")) + monkeypatch.setattr( + airborne_bootstrap, + "_AIRBORNE_REGISTRATIONS", + ( + ("c1_vio", airborne_bootstrap._C1_VIO_STRATEGIES, airborne_bootstrap._c1_vio_wrapper, ()), + ("c2_vpr", airborne_bootstrap._C2_VPR_STRATEGIES, airborne_bootstrap._c2_vpr_wrapper, ()), + ( + "c2_5_rerank", + airborne_bootstrap._C2_5_RERANK_STRATEGIES, + airborne_bootstrap._c2_5_rerank_wrapper, + ("c2_vpr",), + ), + ( + "c3_matcher", + airborne_bootstrap._C3_MATCHER_STRATEGIES, + airborne_bootstrap._c3_matcher_wrapper, + (), + ), + ( + "c3_5_adhop", + airborne_bootstrap._C3_5_ADHOP_STRATEGIES, + airborne_bootstrap._c3_5_adhop_wrapper, + ("c3_matcher",), + ), + ( + "c4_pose", + airborne_bootstrap._C4_POSE_STRATEGIES, + airborne_bootstrap._c4_pose_wrapper, + ("c1_vio", "c3_matcher"), + ), + ( + "c5_state", + airborne_bootstrap._C5_STATE_STRATEGIES, + airborne_bootstrap._c5_state_wrapper, + ("c1_vio", "c4_pose"), + ), + ), + ) + return invocations + + +# ---------------------------------------------------------------------- +# AC-Config: C4PoseConfig.enabled defaults to True + + +def test_c4_pose_config_enabled_defaults_to_true() -> None: + # Act + block = C4PoseConfig() + + # Assert + assert block.enabled is True, ( + "ADR-003 steady-state airborne path requires c4_pose.enabled " + "to default ON; ADR-012 opt-in is via explicit enabled=False" + ) + + +# ---------------------------------------------------------------------- +# AC-1: open-loop ESKF profile composes without c4_pose + + +def test_ac1_open_loop_eskf_composes_without_c4_pose( + _airborne_env: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + invocations = _stub_wrappers(monkeypatch) + register_airborne_strategies() + config = Config.with_blocks( + c1_vio=_C1Block(), + c4_pose=_C4Block(enabled=False, strategy="opencv_gtsam"), + c5_state=_C5Block(strategy="eskf"), + ) + + # Act + root = compose_root(config, pre_constructed={}) + + # Assert + assert "c1_vio" in root.components + assert "c5_state" in root.components + assert "c4_pose" not in root.components, ( + "AZ-776 AC-1: c4_pose.enabled=False MUST exclude c4_pose from " + "the composition graph; got components=" + f"{sorted(root.components.keys())}" + ) + assert "c4_pose" not in invocations["order"], ( + "AZ-776 AC-1: the c4_pose wrapper MUST NOT be invoked when " + "the open-loop ESKF profile is active" + ) + order = invocations["order"] + assert order.index("c1_vio") < order.index("c5_state"), ( + "c1_vio must construct before c5_state — VIO output feeds the " + "state estimator" + ) + + +# ---------------------------------------------------------------------- +# AC-2: full GTSAM profile unchanged (default enabled=True + gtsam_isam2) + + +def test_ac2_full_gtsam_profile_includes_c4_pose( + _airborne_env: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + invocations = _stub_wrappers(monkeypatch) + register_airborne_strategies() + config = Config.with_blocks( + c1_vio=_C1Block(), + c4_pose=_C4Block(enabled=True, strategy="opencv_gtsam"), + c5_state=_C5Block(strategy="gtsam_isam2"), + ) + + # Act + root = compose_root(config, pre_constructed={}) + + # Assert + assert "c4_pose" in root.components, ( + "ADR-003 steady-state path: c4_pose.enabled=True MUST keep " + "c4_pose in the composition graph" + ) + assert "c1_vio" in root.components + assert "c5_state" in root.components + assert "c4_pose" in invocations["order"] + order = invocations["order"] + assert order.index("c1_vio") < order.index("c4_pose") + assert order.index("c4_pose") < order.index("c5_state") + + +# ---------------------------------------------------------------------- +# AC-3a: forbidden — c4_pose.enabled=False + c5_state.strategy=gtsam_isam2 + + +def test_ac3a_disabled_c4_with_gtsam_isam2_raises_composition_error( + _airborne_env: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + _stub_wrappers(monkeypatch) + register_airborne_strategies() + config = Config.with_blocks( + c1_vio=_C1Block(), + c4_pose=_C4Block(enabled=False, strategy="opencv_gtsam"), + c5_state=_C5Block(strategy="gtsam_isam2"), + ) + + # Act + with pytest.raises(CompositionError) as info: + compose_root(config, pre_constructed={}) + + # Assert + msg = str(info.value) + assert "c4_pose.enabled=False" in msg + assert "c5_state.strategy='gtsam_isam2'" in msg + assert "ADR-012" in msg, ( + "AZ-776 AC-3a: CompositionError message MUST cite ADR-012 so " + "the operator can find the documented profile matrix" + ) + + +# ---------------------------------------------------------------------- +# AC-3b: forbidden — c4_pose.enabled=True + c5_state.strategy=eskf + + +def test_ac3b_enabled_c4_with_eskf_raises_composition_error( + _airborne_env: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + _stub_wrappers(monkeypatch) + register_airborne_strategies() + config = Config.with_blocks( + c1_vio=_C1Block(), + c4_pose=_C4Block(enabled=True, strategy="opencv_gtsam"), + c5_state=_C5Block(strategy="eskf"), + ) + + # Act + with pytest.raises(CompositionError) as info: + compose_root(config, pre_constructed={}) + + # Assert + msg = str(info.value) + assert "c4_pose.enabled=True" in msg + assert "c5_state.strategy='eskf'" in msg + assert "ADR-012" in msg, ( + "AZ-776 AC-3b: CompositionError message MUST cite ADR-012 so " + "the operator can find the documented profile matrix" + ) + + +# ---------------------------------------------------------------------- +# AC-Bootstrap: build_pre_constructed omits c5_isam2_graph_handle when c4_pose disabled + + +def test_pre_constructed_omits_isam2_handle_when_c4_disabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """When ``c4_pose.enabled=False`` the eager ESKF (estimator, handle) + build still runs (so the c5_state wrapper's fast path stays + populated), but the handle slot is absent from the returned dict + (no consumer in the open-loop profile). + """ + # Arrange — minimal config with c4_pose.enabled=False + c5_state.strategy=eskf + base = Config() + runtime = dataclasses.replace(base.runtime, camera_calibration_path=str(tmp_path / "calib.json")) + config = dataclasses.replace(base, runtime=runtime).with_blocks( + c4_pose=_C4Block(enabled=False, strategy="opencv_gtsam"), + c5_state=_C5Block(strategy="eskf"), + ) + + # Stub every upstream builder so build_pre_constructed reaches the + # AZ-776 guard without requiring real FAISS / TensorRT / camera JSON. + sentinel_estimator = object() + sentinel_handle = None # ESKF returns None for the handle by design + + def _stub_pair(config: Any, **kwargs: Any) -> tuple[Any, Any]: + del config, kwargs + return sentinel_estimator, sentinel_handle + + monkeypatch.setattr(airborne_bootstrap, "_build_c6_descriptor_index", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c6_tile_store", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c7_inference", lambda c: object()) + monkeypatch.setattr( + airborne_bootstrap, + "_build_c3_lightglue_runtime", + lambda c, *, inference_runtime: object(), + ) + monkeypatch.setattr(airborne_bootstrap, "_build_c3_feature_extractor", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c282_ransac_filter", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_imu_preintegrator", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_se3_utils", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_wgs_converter", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_state_estimator_pair", _stub_pair) + # The c13_fdr cache is reset by the autouse fixture; the make_fdr_client + # call inside build_pre_constructed runs unstubbed against the in-memory + # client cache (no disk I/O). + + # Act + constructed = build_pre_constructed(config) + + # Assert — open-loop profile: handle slot absent, estimator slot present + assert "c5_isam2_graph_handle" not in constructed, ( + "AZ-776 / ADR-012: c4_pose.enabled=False MUST omit " + "pre_constructed['c5_isam2_graph_handle'] (no C4 consumer); " + f"got keys={sorted(constructed.keys())}" + ) + assert constructed["_c5_prebuilt_estimator"] is sentinel_estimator, ( + "AZ-776: the prebuilt estimator slot still populates so the " + "c5_state wrapper's fast path returns the ESKF instance" + ) + + +def test_pre_constructed_keeps_isam2_handle_when_c4_enabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Any +) -> None: + """Symmetric AC-2 baseline: full GTSAM profile keeps the handle slot.""" + # Arrange + base = Config() + runtime = dataclasses.replace(base.runtime, camera_calibration_path=str(tmp_path / "calib.json")) + config = dataclasses.replace(base, runtime=runtime).with_blocks( + c4_pose=_C4Block(enabled=True, strategy="opencv_gtsam"), + c5_state=_C5Block(strategy="gtsam_isam2"), + ) + + sentinel_estimator = object() + sentinel_handle = object() # GTSAM returns a real handle + + def _stub_pair(config: Any, **kwargs: Any) -> tuple[Any, Any]: + del config, kwargs + return sentinel_estimator, sentinel_handle + + monkeypatch.setattr(airborne_bootstrap, "_build_c6_descriptor_index", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c6_tile_store", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c7_inference", lambda c: object()) + monkeypatch.setattr( + airborne_bootstrap, + "_build_c3_lightglue_runtime", + lambda c, *, inference_runtime: object(), + ) + monkeypatch.setattr(airborne_bootstrap, "_build_c3_feature_extractor", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c282_ransac_filter", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_imu_preintegrator", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_se3_utils", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_wgs_converter", lambda c: object()) + monkeypatch.setattr(airborne_bootstrap, "_build_c5_state_estimator_pair", _stub_pair) + + # Act + constructed = build_pre_constructed(config) + + # Assert + assert constructed["c5_isam2_graph_handle"] is sentinel_handle, ( + "ADR-003 steady-state path: c4_pose.enabled=True MUST keep " + "the iSAM2 handle in pre_constructed for the C4 wrapper" + ) + assert constructed["_c5_prebuilt_estimator"] is sentinel_estimator