[AZ-776] Open-loop ESKF composition profile via c4_pose.enabled

ADR-012: add c4_pose.enabled (default True) and enforce the
(c4_pose.enabled, c5_state.strategy) 2x2 pairing matrix at compose
time. When enabled=false, compose_root removes c4_pose from the
selection map and build_pre_constructed omits c5_isam2_graph_handle.
Replay protocol Invariant 13 owns the gate. Tier-2 conftest YAML
writes the open-loop profile; un-xfails AC-1/2/5 and both AC-6
variants in Derkachi (AC-3 stays xfailed for AZ-777). 319/319
runtime_root + c4_pose + c5_state tests green.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-21 13:40:01 +03:00
parent 6044a33197
commit 8de2716500
10 changed files with 687 additions and 83 deletions
+15 -11
View File
@@ -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"
)
+7 -67
View File
@@ -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")
@@ -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