mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 18:11:14 +00:00
[AZ-625] Phase E.5: airborne_bootstrap c5_isam2_graph_handle ordering
Wire the airborne bootstrap to seed pre_constructed['c5_isam2_graph_handle'] so c4_pose's compose-time lookup is satisfied (c4_pose runs before c5_state in topological order; the iSAM2 graph handle is built INSIDE the C5 estimator's constructor and so must be produced eagerly at bootstrap time). build_pre_constructed now invokes a new internal _build_c5_state_estimator_pair helper that calls state_factory.build_state_estimator once, captures the (estimator, handle) tuple, and seeds two slots: 'c5_isam2_graph_handle' for C4's lookup, and an internal '_c5_prebuilt_estimator' look-aside key for the C5 wrapper's short-circuit. _c5_state_wrapper checks the look-aside key first and returns the prebuilt instance as-is — the SAME object the handle was extracted from, so c4_pose._isam2_handle and c5_state._isam2_handle reference ONE object across the C4 / C5 seam (AC-625.3 cross-seam identity invariant). C5_STATE_BUILD_FLAGS mirrors state_factory._STATE_BUILD_FLAGS so the bootstrap can name the gating BUILD_STATE_* flag in operator errors before the lower level StateEstimatorConfigError fires (AC-625.2). When the factory itself rejects the configuration with the flag ON, the error wraps into AirborneBootstrapError with __cause__ preserved (matches AZ-621 / AZ-622 patterns). Constraints respected per AZ-618 umbrella: no per-component factory signature changed; additive on top of AZ-619..AZ-623; no edits under state_factory, pose_factory, or c5_state internals. Tests: tests/unit/runtime_root/test_az625_c5_isam2_graph_handle_ordering.py adds 8 tests covering AC-625.1..3 (presence + Protocol conformance, internal key invariant, BUILD-flag-OFF error, unknown-strategy error, factory error wrapping, cross-seam identity, wrapper short-circuit, wrapper fallback). Autouse stubs added to test_az619/620/621/622/623 so prior phase tests stay isolated from the new builder. Quality gates: ruff format clean, ruff lint clean, 32/32 phase tests pass, 255/255 runtime_root + c5_state regression suite passes. Code review verdict PASS (2 Low findings; full report in _docs/03_implementation/reviews/batch_95_review.md). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -91,6 +91,7 @@ __all__ = [
|
||||
"AIRBORNE_MAIN_PRODUCER_ID",
|
||||
"AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS",
|
||||
"C3_MATCHER_BUILD_FLAGS",
|
||||
"C5_STATE_BUILD_FLAGS",
|
||||
"C7_AIRBORNE_BUILD_FLAGS",
|
||||
"FAISS_BUILD_FLAG",
|
||||
"AirborneBootstrapError",
|
||||
@@ -155,6 +156,28 @@ flag would unblock the bootstrap with a different runtime selection.
|
||||
"""
|
||||
|
||||
|
||||
C5_STATE_BUILD_FLAGS: Final[Mapping[str, str]] = {
|
||||
"gtsam_isam2": "BUILD_STATE_GTSAM_ISAM2",
|
||||
"eskf": "BUILD_STATE_ESKF",
|
||||
}
|
||||
"""Per-strategy ``BUILD_STATE_*`` flag matrix consumed by the airborne
|
||||
c5_state estimator pair builder (AZ-625 / Phase E.5).
|
||||
|
||||
Mirrors :data:`gps_denied_onboard.runtime_root.state_factory._STATE_BUILD_FLAGS`
|
||||
verbatim — both this constant and the state factory's table read the same
|
||||
compile-time flags. ANY mutation of this matrix MUST be mirrored in
|
||||
``state_factory._STATE_BUILD_FLAGS`` (and vice versa).
|
||||
|
||||
Surfaced here so :func:`_build_c5_state_estimator_pair` can name the
|
||||
gating flag in an :class:`AirborneBootstrapError` (AC-625.2) when the
|
||||
configured C5 state strategy's flag is OFF in this binary, *before*
|
||||
:func:`build_state_estimator` has a chance to raise the lower-level
|
||||
:class:`StateEstimatorConfigError` (which is the
|
||||
state-factory-internal error type, not the operator-facing
|
||||
bootstrap-error contract this module owns).
|
||||
"""
|
||||
|
||||
|
||||
C3_MATCHER_BUILD_FLAGS: Final[Mapping[str, str]] = {
|
||||
"disk_lightglue": "BUILD_MATCHER_DISK_LIGHTGLUE",
|
||||
"aliked_lightglue": "BUILD_MATCHER_ALIKED_LIGHTGLUE",
|
||||
@@ -360,6 +383,18 @@ def _c4_pose_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
|
||||
|
||||
|
||||
def _c5_state_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any:
|
||||
# AZ-625 fast path: when build_pre_constructed has eagerly built the
|
||||
# (estimator, handle) pair, the estimator lives under the private
|
||||
# _c5_prebuilt_estimator key. Returning the prebuilt instance keeps
|
||||
# c4_pose's c5_isam2_graph_handle pointing at the SAME estimator's
|
||||
# _isam2_handle — the AC-625.3 cross-seam identity invariant.
|
||||
prebuilt = constructed.get(_C5_PREBUILT_ESTIMATOR_KEY)
|
||||
if prebuilt is not None:
|
||||
return prebuilt
|
||||
# Fallback path: tests / fixtures that bypass build_pre_constructed
|
||||
# (for example, the existing test_az401_compose_root_replay.py suite
|
||||
# which seeds pre_constructed manually) still drive the wrapper
|
||||
# through build_state_estimator directly.
|
||||
imu_preintegrator = _require(constructed, "c5_imu_preintegrator", "c5_state")
|
||||
se3_utils = _require(constructed, "c5_se3_utils", "c5_state")
|
||||
wgs_converter = _require(constructed, "c5_wgs_converter", "c5_state")
|
||||
@@ -594,6 +629,174 @@ def _resolve_c3_matcher_strategy(config: Config) -> str:
|
||||
return getattr(block, "strategy", "disk_lightglue")
|
||||
|
||||
|
||||
def _resolve_c5_state_strategy(config: Config) -> str:
|
||||
"""Return the configured C5 state strategy, defaulting to gtsam_isam2.
|
||||
|
||||
Mirrors :func:`_resolve_c3_matcher_strategy` for the C5 slot.
|
||||
Reuses :class:`gps_denied_onboard.components.c5_state.config.C5StateConfig`'s
|
||||
own default ``"gtsam_isam2"`` when ``config.components`` carries no
|
||||
``c5_state`` block (early-bootstrap tests with bare ``Config()``).
|
||||
"""
|
||||
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"
|
||||
return getattr(block, "strategy", "gtsam_isam2")
|
||||
|
||||
|
||||
_C5_PREBUILT_ESTIMATOR_KEY: Final[str] = "_c5_prebuilt_estimator"
|
||||
"""Internal coordination key under which :func:`build_pre_constructed` stores
|
||||
the pre-built :class:`StateEstimator` instance.
|
||||
|
||||
The C5 state estimator and its :class:`ISam2GraphHandle` are constructed as
|
||||
a single tuple by :func:`build_state_estimator`; the handle is the iSAM2
|
||||
graph wrapper held INSIDE the estimator. C4 (``c4_pose``) reaches into
|
||||
``pre_constructed['c5_isam2_graph_handle']`` at compose time — but the C4
|
||||
wrapper runs BEFORE the C5 wrapper in topological order
|
||||
(``_C5_STATE_DEPENDS_ON: ('c1_vio', 'c4_pose')``). The handle therefore
|
||||
MUST exist in ``pre_constructed`` before either wrapper runs, which means
|
||||
the bootstrap MUST build the (estimator, handle) pair eagerly (AZ-625).
|
||||
|
||||
Storing the prebuilt estimator under this internal key lets the C5 wrapper
|
||||
short-circuit on it and return the SAME instance the handle was extracted
|
||||
from, so ``c4_pose._isam2_handle`` and ``c5_state._isam2_handle`` reference
|
||||
ONE object across the C4 / C5 seam (AC-625.3 identity contract).
|
||||
|
||||
Deliberately NOT in :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` — it is
|
||||
an internal coordination key, not a Public API surface that any component
|
||||
queries directly (only the C5 wrapper consults it, and only as a fast
|
||||
path).
|
||||
"""
|
||||
|
||||
|
||||
def _build_c5_state_estimator_pair(
|
||||
config: Config,
|
||||
*,
|
||||
imu_preintegrator: Any,
|
||||
se3_utils: Any,
|
||||
wgs_converter: Any,
|
||||
fdr_client: Any,
|
||||
tile_store: Any | None = None,
|
||||
camera_calibration: CameraCalibration | None = None,
|
||||
flight_id: str | None = None,
|
||||
companion_id: str | None = None,
|
||||
) -> tuple[Any, Any]:
|
||||
"""Build the ``(StateEstimator, ISam2GraphHandle)`` tuple eagerly.
|
||||
|
||||
The C5 estimator and its iSAM2 graph handle are produced together by
|
||||
:func:`gps_denied_onboard.runtime_root.state_factory.build_state_estimator`
|
||||
— the handle is the wrapper around the estimator's internal
|
||||
``_isam2`` + ``_smoother`` substrate (see
|
||||
:class:`gps_denied_onboard.components.c5_state._isam2_handle.\
|
||||
ISam2GraphHandleImpl`). The handle's constructor takes the estimator
|
||||
as input, so the two cannot be separately constructed without a
|
||||
Protocol-seam change in C5 — explicitly forbidden by the AZ-618
|
||||
umbrella's "MUST NOT touch any per-component factory signature"
|
||||
constraint.
|
||||
|
||||
Building the pair eagerly at bootstrap time is the AZ-625 fix: the
|
||||
handle reaches ``pre_constructed['c5_isam2_graph_handle']`` so
|
||||
:func:`compose_root` can satisfy C4's lookup in topological order
|
||||
(``c4_pose`` runs before ``c5_state``); the estimator reaches a
|
||||
private coordination slot (``_c5_prebuilt_estimator``) so
|
||||
:func:`_c5_state_wrapper` can short-circuit and return the SAME
|
||||
instance the handle is bound to. The cross-seam identity invariant
|
||||
is verified by AC-625.3.
|
||||
|
||||
Validation order matches the rest of the airborne bootstrap:
|
||||
|
||||
1. Resolve the configured C5 state strategy
|
||||
(default ``"gtsam_isam2"``).
|
||||
2. Look it up in :data:`C5_STATE_BUILD_FLAGS`. An unknown strategy
|
||||
is an :class:`AirborneBootstrapError` naming the supported set
|
||||
(AC-625.2).
|
||||
3. Read the gating ``BUILD_STATE_*`` flag with the SAME default
|
||||
ladder the state factory uses
|
||||
(:func:`os.environ.get(flag, "ON").upper() == "OFF"`); an
|
||||
explicit OFF raises :class:`AirborneBootstrapError` naming the
|
||||
flag and the consuming component slug ``c5_state`` (AC-625.2).
|
||||
4. Lazily register the strategy via
|
||||
:func:`_ensure_state_strategy_registered` — same hook the C5
|
||||
wrapper uses, so a binary configured for the ESKF baseline does
|
||||
not import gtsam at bootstrap time.
|
||||
5. Delegate to :func:`build_state_estimator` with the
|
||||
infrastructure kwargs the wrapper would have passed; surface
|
||||
any :class:`StateEstimatorConfigError` as an
|
||||
:class:`AirborneBootstrapError` so the operator-facing error
|
||||
contract is uniform.
|
||||
|
||||
The optional kwargs ``tile_store`` / ``camera_calibration`` /
|
||||
``flight_id`` / ``companion_id`` exist for AZ-389 orthorectifier
|
||||
wiring; they are forwarded to :func:`build_state_estimator` which
|
||||
only consumes them when ``c5_state.orthorectifier.enabled`` is
|
||||
True. Until AZ-624 wires the operator-supplied flight metadata
|
||||
into ``pre_constructed``, callers pass the available defaults
|
||||
(today: ``tile_store=constructed['c6_tile_store']``, the rest
|
||||
``None``).
|
||||
|
||||
Raises:
|
||||
AirborneBootstrapError: when the configured strategy is not in
|
||||
:data:`C5_STATE_BUILD_FLAGS`, when the strategy's
|
||||
``BUILD_STATE_*`` flag is OFF, or when
|
||||
:func:`build_state_estimator` itself rejects the
|
||||
configuration (the original
|
||||
:class:`StateEstimatorConfigError` is preserved as
|
||||
``__cause__``).
|
||||
"""
|
||||
from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError
|
||||
|
||||
strategy = _resolve_c5_state_strategy(config)
|
||||
flag = C5_STATE_BUILD_FLAGS.get(strategy)
|
||||
if flag is None:
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: cannot construct "
|
||||
f"pre_constructed['c5_isam2_graph_handle'] because "
|
||||
f"config.components['c5_state'].strategy={strategy!r} is "
|
||||
f"not in the airborne BUILD-flag matrix "
|
||||
f"{sorted(C5_STATE_BUILD_FLAGS.keys())!r}. Consuming "
|
||||
f"component: c5_state. Reconfigure the C5 state strategy "
|
||||
f"to one of the supported strategies."
|
||||
)
|
||||
# Mirror state_factory._STATE_BUILD_FLAGS gate: default "ON" when
|
||||
# unset; only explicit "OFF" blocks. Keeping the default identical
|
||||
# to state_factory means AZ-625's pre-check fires before
|
||||
# build_state_estimator's own gate, so the operator sees the
|
||||
# bootstrap-error contract instead of the lower-level config error.
|
||||
if os.environ.get(flag, "ON").upper() == "OFF":
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: cannot construct "
|
||||
f"pre_constructed['c5_isam2_graph_handle'] because the "
|
||||
f"gating flag {flag}=ON is required for the configured "
|
||||
f"strategy={strategy!r}, but {flag} is OFF in this binary. "
|
||||
f"Consuming component: c5_state. Set {flag}=ON, or "
|
||||
f"reconfigure config.components['c5_state'].strategy to a "
|
||||
f"strategy whose BUILD_STATE_* flag is ON."
|
||||
)
|
||||
_ensure_state_strategy_registered(config)
|
||||
try:
|
||||
estimator, handle = build_state_estimator(
|
||||
config,
|
||||
imu_preintegrator=imu_preintegrator,
|
||||
se3_utils=se3_utils,
|
||||
wgs_converter=wgs_converter,
|
||||
fdr_client=fdr_client,
|
||||
tile_store=tile_store,
|
||||
camera_calibration=camera_calibration,
|
||||
flight_id=flight_id,
|
||||
companion_id=companion_id,
|
||||
)
|
||||
except StateEstimatorConfigError as exc:
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: cannot construct "
|
||||
f"pre_constructed['c5_isam2_graph_handle'] for "
|
||||
f"strategy={strategy!r} (gating flag {flag} is ON). "
|
||||
f"Consuming component: c5_state. Upstream error: {exc}"
|
||||
) from exc
|
||||
return estimator, handle
|
||||
|
||||
|
||||
def _is_build_flag_on(flag_name: str) -> bool:
|
||||
"""Read a compile-time ``BUILD_*`` flag from the environment.
|
||||
|
||||
@@ -945,7 +1148,7 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
instance, gated by :data:`C3_MATCHER_BUILD_FLAGS` per the
|
||||
configured strategy) + ``c3_feature_extractor`` (the shared
|
||||
:class:`gps_denied_onboard.helpers.feature_extractor.FeatureExtractor`
|
||||
used by C2.5). AZ-623 (Phase E) adds the four stateless / cached c5
|
||||
used by C2.5). AZ-623 (Phase E) added the four stateless / cached c5
|
||||
helpers: ``c282_ransac_filter`` (shared
|
||||
:class:`gps_denied_onboard.helpers.ransac_filter.RansacFilter`),
|
||||
``c5_imu_preintegrator`` (per-calibration-path-cached
|
||||
@@ -954,12 +1157,16 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
:mod:`gps_denied_onboard.helpers.se3_utils` module as a
|
||||
namespace handle), and ``c5_wgs_converter`` (shared
|
||||
:class:`gps_denied_onboard.helpers.wgs_converter.WgsConverter`).
|
||||
The ``c5_isam2_graph_handle`` slot is the special-case ordering
|
||||
work tracked separately in AZ-625 (split out of AZ-623 on
|
||||
2026-05-19 because Path 1 of the AZ-623 spec required a
|
||||
Protocol seam change forbidden by the AZ-618 umbrella). Phase F
|
||||
(AZ-624) will wire main() and verify AC-1..AC-5 once both AZ-623
|
||||
and AZ-625 land.
|
||||
AZ-625 (Phase E.5) adds ``c5_isam2_graph_handle`` and seeds an
|
||||
internal coordination key (``_c5_prebuilt_estimator``) by
|
||||
eagerly invoking :func:`build_state_estimator` once at bootstrap
|
||||
time and capturing the
|
||||
``(StateEstimator, ISam2GraphHandle)`` tuple — the handle reaches
|
||||
``c4_pose`` via ``pre_constructed`` (C4 runs before C5 in topo
|
||||
order), and the prebuilt estimator lets the C5 wrapper
|
||||
short-circuit without re-invoking the factory. Phase F (AZ-624)
|
||||
will wire ``runtime_root.main()`` and verify AC-1..AC-5
|
||||
end-to-end.
|
||||
|
||||
Returns a fresh dict on each call. The ``c13_fdr`` instance is cached
|
||||
inside :func:`make_fdr_client` (per-producer cache) so two calls within
|
||||
@@ -997,9 +1204,14 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
the strategy is unknown), or if the LightGlue engine load
|
||||
fails; OR (AZ-623) if ``config.runtime.camera_calibration_path``
|
||||
is empty / unreadable / malformed JSON, blocking the
|
||||
``c5_imu_preintegrator`` build. The message names the
|
||||
consuming component slug(s) and the relevant gating flag(s)
|
||||
or missing inputs.
|
||||
``c5_imu_preintegrator`` build; OR (AZ-625) if the
|
||||
configured C5 state strategy's
|
||||
:data:`C5_STATE_BUILD_FLAGS` flag is OFF (or the strategy
|
||||
is unknown), or if :func:`build_state_estimator` itself
|
||||
rejects the configuration when the
|
||||
``(StateEstimator, ISam2GraphHandle)`` pair is built
|
||||
eagerly. The message names the consuming component slug(s)
|
||||
and the relevant gating flag(s) or missing inputs.
|
||||
"""
|
||||
constructed: dict[str, Any] = {}
|
||||
constructed["c13_fdr"] = make_fdr_client(AIRBORNE_MAIN_PRODUCER_ID, config)
|
||||
@@ -1015,6 +1227,16 @@ def build_pre_constructed(config: Config) -> dict[str, Any]:
|
||||
constructed["c5_imu_preintegrator"] = _build_c5_imu_preintegrator(config)
|
||||
constructed["c5_se3_utils"] = _build_c5_se3_utils(config)
|
||||
constructed["c5_wgs_converter"] = _build_c5_wgs_converter(config)
|
||||
estimator, handle = _build_c5_state_estimator_pair(
|
||||
config,
|
||||
imu_preintegrator=constructed["c5_imu_preintegrator"],
|
||||
se3_utils=constructed["c5_se3_utils"],
|
||||
wgs_converter=constructed["c5_wgs_converter"],
|
||||
fdr_client=constructed["c13_fdr"],
|
||||
tile_store=constructed["c6_tile_store"],
|
||||
)
|
||||
constructed["c5_isam2_graph_handle"] = handle
|
||||
constructed[_C5_PREBUILT_ESTIMATOR_KEY] = estimator
|
||||
return constructed
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user