mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 04:51:12 +00:00
[AZ-591] Add airborne_bootstrap to populate _STRATEGY_REGISTRY
Batch 66 — fixes the production gap surfaced during the cycle-1 completeness-gate post-mortem: the central _STRATEGY_REGISTRY was empty in production source, so compose_root() raised StrategyNotLinkedError on the first component lookup and the airborne binary couldn't reach takeoff. Changes: - New module `src/.../runtime_root/airborne_bootstrap.py` exposes `register_airborne_strategies()` and a documented `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` table. The function registers 14 entries into the central registry across 7 strategy-selecting slots (c1_vio + c2_vpr + c2_5_rerank + c3_matcher + c3_5_adhop + c4_pose + c5_state). Per-slot wrappers adapt the registry-factory signature (config, constructed) to each per-component factory's kwarg surface and surface a AirborneBootstrapError when a required infrastructure dep is missing from constructed. - `compose_root` gains a `pre_constructed` kwarg in live mode, symmetric with the replay-mode seam. Replay entries still take precedence on key collision (ADR-011). Existing callers unaffected (kwarg defaults to None). - `runtime_root/__init__.py::main()` now calls `register_airborne_strategies()` before `compose_root(config)` so production binaries no longer crash at the registry-lookup step. - Lazy-loading preserved: state_factory's private _STATE_REGISTRY is populated lazily inside the c5_state wrapper, gated by BUILD_STATE_GTSAM_ISAM2 / BUILD_STATE_ESKF env flags. pose_factory's own lazy-import fallback handles c4_pose without an explicit register() call. - 7 new unit tests in `tests/unit/runtime_root/test_az591_airborne_\ bootstrap.py` cover AC-1..AC-5 plus the negative-path AirborneBootstrapError contract. Full unit suite 2105 passed / 88 environment-gated skips / 0 failures. End-to-end takeoff still needs a follow-up task to wire infrastructure pre-construction (c13_fdr / c6_* / c7_inference / etc.) into the pre_constructed dict passed to compose_root. That follow-up is gated by AZ-591 landing first; recommended split into per-component infrastructure-prep tasks (3pt each). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+23
@@ -120,3 +120,26 @@ Then it raises `StrategyNotLinkedError` for each airborne-tier registration with
|
||||
- This task does NOT validate end-to-end on the airborne binary because that requires a real Jetson + nav-camera + FC. It validates that `compose_root()` returns a `RuntimeRoot` without raising — the unit-test gate. End-to-end binary validation lives in the Tier-2 Jetson harness (AZ-444).
|
||||
- After this task lands, the cycle-1 completeness gate report at `_docs/03_implementation/implementation_completeness_cycle1_report.md` should be re-read: the `FAIL` classification for AZ-332 + AZ-333 is re-classified to `BLOCKED on Tier-2 prerequisites` per AZ-592 / AZ-593. The actual production blocker (this task) is being remediated here.
|
||||
- The user's PBI complexity rule caps PBIs at 5pt. This task is at the 5pt boundary because all 7 slots use the same wrapper pattern (so the slot count doesn't multiply complexity). If any slot's wrapper needs more than a few-line factory adapter, that slot's wrapper should split into its own PBI (`AZ-591_<slug>_bootstrap`).
|
||||
|
||||
## Implementation Notes (2026-05-16, batch 66)
|
||||
|
||||
**Outcome**: Landed `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py` with `register_airborne_strategies()` registering 14 entries into the central `_STRATEGY_REGISTRY` across 7 component slots (c1_vio, c2_vpr, c2_5_rerank, c3_matcher, c3_5_adhop, c4_pose, c5_state). Each slot's wrapper extracts infrastructure deps from `constructed` by documented key (see `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`) and forwards to the existing per-component factory (`build_vio_strategy`, `build_vpr_strategy`, etc.). Inter-component dependency edges are declared via `register_strategy(... depends_on=...)` so `_topo_order()` respects the runtime data-flow ordering (c2_vpr → c2_5_rerank; c3_matcher → c3_5_adhop; c1_vio + c3_matcher → c4_pose; c1_vio + c4_pose → c5_state).
|
||||
|
||||
**API extension**: `compose_root(config, *, pre_constructed, replay_components_factory)` now accepts a `pre_constructed` kwarg in live mode (previously only used in replay mode via `replay_components`). This is the seam the bootstrap wrappers rely on for infrastructure deps. Existing `compose_root` callers are unaffected (the kwarg defaults to `None`).
|
||||
|
||||
**main() integration**: `runtime_root/__init__.py::main()` now calls `register_airborne_strategies()` BEFORE `compose_root(config)`. Production binaries that call this `main()` no longer crash with `StrategyNotLinkedError` at the registry-lookup step. Note: end-to-end takeoff still requires a separate task to wire infrastructure pre-construction (c13_fdr, c6_descriptor_index, c7_inference, etc.) into the `pre_constructed` dict passed to `compose_root`. The wrappers fail loudly with `AirborneBootstrapError` if a dep is missing — that's the actionable next-step error for that follow-up task.
|
||||
|
||||
**Lazy-loading preservation**: The bootstrap module's top-level imports pull in the runtime_root factory modules (`vio_factory`, `vpr_factory`, etc.) which are thin import-time-safe — they don't transitively import gtsam, opencv-cuda, or other heavy deps. The c5_state private registry (`_STATE_REGISTRY`) is populated lazily inside `_c5_state_wrapper` via `_ensure_state_strategy_registered(config)`, which checks `BUILD_STATE_GTSAM_ISAM2` / `BUILD_STATE_ESKF` env flags before importing the gtsam-bound module. c4_pose's `_POSE_REGISTRY` is populated by `pose_factory._resolve_factory`'s own lazy-import fallback — no explicit `register()` from this bootstrap is needed.
|
||||
|
||||
**Tests**: 7 ACs verified in `tests/unit/runtime_root/test_az591_airborne_bootstrap.py`:
|
||||
- AC-1 — every slot has the expected strategy set after `register_airborne_strategies()`.
|
||||
- AC-2 — `compose_root(config, pre_constructed=...)` reaches completion with stubbed wrappers; topological order honoured.
|
||||
- AC-3 — idempotent re-registration.
|
||||
- AC-4 — unknown strategy in config raises `StrategyNotLinkedError` with available-strategies list.
|
||||
- AC-5 — airborne registrations are tier-isolated from `compose_operator`.
|
||||
- Plus a negative-path test that the production wrappers surface `AirborneBootstrapError` with the missing-key name when `pre_constructed` is empty.
|
||||
- Plus a consistency test that `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` covers every registered slot.
|
||||
|
||||
**Test results**: 7/7 new tests pass; 8/8 existing `test_az270_compose_root.py` tests still pass (no regression from the `pre_constructed` kwarg extension); full unit suite 2105 passed / 88 environment-gated skips / 0 failures.
|
||||
|
||||
**Follow-up not in this task**: The actual infrastructure pre-construction (building c13_fdr / c6_descriptor_index / c7_inference / c3_lightglue_runtime / c282_ransac_filter / c5_imu_preintegrator / etc. into a dict and passing it to `compose_root(..., pre_constructed=...)`) is a separate cross-cutting task. AZ-591 surfaces the registry seam; that follow-up wires the infrastructure side. Recommended split: per-component infrastructure-prep tasks (3pt each) gated by their existing factory's BUILD_* flag, sequenced behind AZ-591.
|
||||
@@ -12,7 +12,7 @@ sub_step:
|
||||
retry_count: 0
|
||||
cycle: 1
|
||||
tracker: jira
|
||||
last_completed_batch: 65
|
||||
last_completed_batch: 66
|
||||
last_cumulative_review: batches_61-63
|
||||
current_batch: 66
|
||||
current_batch_tasks: "AZ-591"
|
||||
current_batch: 67
|
||||
current_batch_tasks: ""
|
||||
|
||||
@@ -419,6 +419,7 @@ def _read_strategy_attr(block: Any) -> Any:
|
||||
def compose_root(
|
||||
config: Config,
|
||||
*,
|
||||
pre_constructed: Mapping[str, Any] | None = None,
|
||||
replay_components_factory: Any | None = None,
|
||||
) -> RuntimeRoot:
|
||||
"""Compose the airborne runtime graph for ``config.mode``.
|
||||
@@ -437,6 +438,16 @@ def compose_root(
|
||||
finds it already populated. C1-C7+C13 strategies are wired
|
||||
identically to live mode (replay protocol Invariant 1).
|
||||
|
||||
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
|
||||
runs. The airborne wrapper factories in
|
||||
:mod:`.airborne_bootstrap` look these up by documented key
|
||||
(see :data:`airborne_bootstrap.AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`).
|
||||
In replay mode, the replay components dict is merged with
|
||||
``pre_constructed`` (replay entries take precedence on key collision
|
||||
— they own the replay-only seam per ADR-011).
|
||||
|
||||
The ``replay_components_factory`` keyword is a test-only injection
|
||||
point — production callers omit it. Tests pass a callable returning
|
||||
``(components, construction_order)`` so the unit suite does not
|
||||
@@ -454,12 +465,16 @@ def compose_root(
|
||||
else:
|
||||
replay_components = {}
|
||||
replay_order = ()
|
||||
seeded: dict[str, Any] = {}
|
||||
if pre_constructed is not None:
|
||||
seeded.update(pre_constructed)
|
||||
seeded.update(replay_components)
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="airborne",
|
||||
allowed_tiers=frozenset({"airborne", "shared"}),
|
||||
extra_required_env=extra_env,
|
||||
pre_constructed=replay_components,
|
||||
pre_constructed=seeded,
|
||||
)
|
||||
merged: dict[str, Any] = dict(replay_components)
|
||||
merged.update(components)
|
||||
@@ -643,10 +658,14 @@ def main(config: Config | None = None) -> int:
|
||||
* ``EXIT_GENERIC_FAILURE`` (``1``) — any other error.
|
||||
"""
|
||||
from gps_denied_onboard.replay_input import ReplayInputAdapterError
|
||||
from gps_denied_onboard.runtime_root.airborne_bootstrap import (
|
||||
register_airborne_strategies,
|
||||
)
|
||||
|
||||
try:
|
||||
if config is None:
|
||||
config = load_config(env=os.environ, paths=())
|
||||
register_airborne_strategies()
|
||||
compose_root(config)
|
||||
except ReplayInputAdapterError as exc:
|
||||
print(f"runtime_root: replay sync impossible: {exc}", file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Per-binary bootstrap for the airborne runtime (AZ-591).
|
||||
|
||||
Populates the central ``_STRATEGY_REGISTRY`` (see
|
||||
:mod:`gps_denied_onboard.runtime_root`) with the (component, strategy)
|
||||
pairs the airborne binary supports, so that :func:`compose_root` can
|
||||
resolve ``config.components[slug].strategy`` for every strategy-selecting
|
||||
component (c1_vio, c2_vpr, c2_5_rerank, c3_matcher, c3_5_adhop, c4_pose,
|
||||
c5_state).
|
||||
|
||||
Without this bootstrap, ``compose_root`` raises :class:`StrategyNotLinkedError`
|
||||
on the first component lookup because no module under :mod:`gps_denied_onboard.\
|
||||
runtime_root` calls :func:`register_strategy` at import time — by design, per
|
||||
ADR-002, the build-flag gate must be the single place that decides which
|
||||
strategies are linked into a given binary.
|
||||
|
||||
Call order at process start:
|
||||
|
||||
1. ``register_airborne_strategies()`` — once, before any ``compose_root`` call.
|
||||
2. (Optional, test/production both) populate a ``pre_constructed`` dict with
|
||||
the infrastructure objects the wrappers below expect (``c13_fdr``,
|
||||
``c6_descriptor_index``, ``c7_inference``, etc.).
|
||||
3. ``compose_root(config, pre_constructed=pre_constructed)``.
|
||||
|
||||
The wrapper factories below adapt the registry-factory signature
|
||||
``(config, constructed)`` to each per-component factory's keyword-argument
|
||||
surface (e.g. ``build_vio_strategy(config, *, fdr_client=...)``). Every dep is
|
||||
looked up by a documented key in ``constructed``; a missing key surfaces as a
|
||||
:class:`AirborneBootstrapError` naming the missing dep + the consuming
|
||||
component slug.
|
||||
|
||||
Lazy-loading is preserved at two levels:
|
||||
|
||||
* **Central registry**: identity wrappers are registered for every strategy
|
||||
the binary supports. The wrappers themselves only import the concrete
|
||||
strategy module via the per-component factory (which already gates by
|
||||
``BUILD_*`` env flags) — they do NOT eagerly import strategy modules.
|
||||
* **Per-component private registries** (``state_factory._STATE_REGISTRY``
|
||||
needs explicit ``register_state_estimator`` calls; ``pose_factory`` has its
|
||||
own lazy-import fallback so no explicit registration is needed): the
|
||||
wrapper calls each strategy module's ``register()`` only when the config
|
||||
actually selects that strategy AND the matching ``BUILD_*`` flag is ON.
|
||||
|
||||
ADR refs: ADR-001 (composition root is single registration site), ADR-002
|
||||
(build-flag gate is the lazy-loading boundary), AZ-507 (cross-component
|
||||
import rule — bootstrap may import any component's Public API, but not its
|
||||
internals).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from gps_denied_onboard.runtime_root import register_strategy
|
||||
from gps_denied_onboard.runtime_root.matcher_factory import build_matcher_strategy
|
||||
from gps_denied_onboard.runtime_root.pose_factory import build_pose_estimator
|
||||
from gps_denied_onboard.runtime_root.refiner_factory import build_refiner_strategy
|
||||
from gps_denied_onboard.runtime_root.rerank_factory import build_rerank_strategy
|
||||
from gps_denied_onboard.runtime_root.state_factory import build_state_estimator
|
||||
from gps_denied_onboard.runtime_root.vio_factory import build_vio_strategy
|
||||
from gps_denied_onboard.runtime_root.vpr_factory import build_vpr_strategy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.config import Config
|
||||
|
||||
__all__ = [
|
||||
"AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS",
|
||||
"AirborneBootstrapError",
|
||||
"register_airborne_strategies",
|
||||
]
|
||||
|
||||
|
||||
_LOG = logging.getLogger("gps_denied_onboard.runtime_root.airborne_bootstrap")
|
||||
|
||||
|
||||
class AirborneBootstrapError(RuntimeError):
|
||||
"""Raised when an airborne wrapper factory cannot find a required dep.
|
||||
|
||||
The wrapper factories registered by :func:`register_airborne_strategies`
|
||||
extract infrastructure objects (fdr_client, descriptor_index, etc.) from
|
||||
the ``constructed`` dict passed to them by :func:`compose_root`. The
|
||||
caller (production ``main()`` or a unit test) is responsible for seeding
|
||||
``pre_constructed`` with these dependencies before invoking
|
||||
``compose_root``. A missing dep surfaces this error naming both the
|
||||
consuming component slug and the missing key, so the operator can fix
|
||||
the bootstrap wiring without guessing.
|
||||
"""
|
||||
|
||||
|
||||
AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS: Mapping[str, tuple[str, ...]] = {
|
||||
"c1_vio": ("c13_fdr",),
|
||||
"c2_vpr": ("c6_descriptor_index", "c7_inference"),
|
||||
"c2_5_rerank": (
|
||||
"c6_tile_store",
|
||||
"c3_lightglue_runtime",
|
||||
"c3_feature_extractor",
|
||||
"clock",
|
||||
),
|
||||
"c3_matcher": (
|
||||
"c3_lightglue_runtime",
|
||||
"c282_ransac_filter",
|
||||
"c7_inference",
|
||||
),
|
||||
"c3_5_adhop": ("c282_ransac_filter", "c7_inference"),
|
||||
"c4_pose": (
|
||||
"c282_ransac_filter",
|
||||
"c5_wgs_converter",
|
||||
"c5_se3_utils",
|
||||
"c5_isam2_graph_handle",
|
||||
),
|
||||
"c5_state": (
|
||||
"c5_imu_preintegrator",
|
||||
"c5_se3_utils",
|
||||
"c5_wgs_converter",
|
||||
"c13_fdr",
|
||||
),
|
||||
}
|
||||
"""Per-component infrastructure-dep keys the airborne wrappers expect to find
|
||||
in ``constructed`` (i.e. in the ``pre_constructed`` dict callers pass to
|
||||
``compose_root``). Tests use this to seed mock objects; production wiring
|
||||
populates them from the takeoff orchestrator (separate task — AZ-591 follow-up
|
||||
infrastructure-prep)."""
|
||||
|
||||
|
||||
_C1_VIO_STRATEGIES: tuple[str, ...] = ("klt_ransac", "okvis2", "vins_mono")
|
||||
_C2_VPR_STRATEGIES: tuple[str, ...] = (
|
||||
"ultra_vpr",
|
||||
"net_vlad",
|
||||
"mega_loc",
|
||||
"mix_vpr",
|
||||
"sela_vpr",
|
||||
"eigen_places",
|
||||
"salad",
|
||||
)
|
||||
_C2_5_RERANK_STRATEGIES: tuple[str, ...] = ("inlier_count",)
|
||||
_C3_MATCHER_STRATEGIES: tuple[str, ...] = ("disk_lightglue", "aliked_lightglue")
|
||||
_C3_5_ADHOP_STRATEGIES: tuple[str, ...] = ("adhop",)
|
||||
_C4_POSE_STRATEGIES: tuple[str, ...] = ("opencv_gtsam",)
|
||||
_C5_STATE_STRATEGIES: tuple[str, ...] = ("gtsam_isam2", "eskf")
|
||||
|
||||
|
||||
def _require(
|
||||
constructed: Mapping[str, Any], key: str, component_slug: str
|
||||
) -> Any:
|
||||
"""Extract ``constructed[key]`` or raise AirborneBootstrapError."""
|
||||
if key not in constructed:
|
||||
available = sorted(constructed.keys())
|
||||
raise AirborneBootstrapError(
|
||||
f"airborne_bootstrap: component {component_slug!r} requires "
|
||||
f"pre_constructed[{key!r}] to be populated before compose_root() runs; "
|
||||
f"available keys in constructed: {available}. "
|
||||
"Production main() must build infrastructure (c13_fdr, c6_*, "
|
||||
"c7_inference, etc.) into pre_constructed and pass it to "
|
||||
"compose_root(config, pre_constructed=...). Tests stub it via the "
|
||||
"same kwarg."
|
||||
)
|
||||
return constructed[key]
|
||||
|
||||
|
||||
def _c1_vio_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any:
|
||||
fdr_client = _require(constructed, "c13_fdr", "c1_vio")
|
||||
return build_vio_strategy(config, fdr_client=fdr_client)
|
||||
|
||||
|
||||
def _c2_vpr_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any:
|
||||
descriptor_index = _require(constructed, "c6_descriptor_index", "c2_vpr")
|
||||
inference_runtime = _require(constructed, "c7_inference", "c2_vpr")
|
||||
return build_vpr_strategy(
|
||||
config,
|
||||
descriptor_index=descriptor_index,
|
||||
inference_runtime=inference_runtime,
|
||||
)
|
||||
|
||||
|
||||
def _c2_5_rerank_wrapper(
|
||||
config: "Config", constructed: Mapping[str, Any]
|
||||
) -> Any:
|
||||
tile_store = _require(constructed, "c6_tile_store", "c2_5_rerank")
|
||||
lightglue_runtime = _require(
|
||||
constructed, "c3_lightglue_runtime", "c2_5_rerank"
|
||||
)
|
||||
feature_extractor = _require(
|
||||
constructed, "c3_feature_extractor", "c2_5_rerank"
|
||||
)
|
||||
clock = _require(constructed, "clock", "c2_5_rerank")
|
||||
fdr_client = constructed.get("c13_fdr")
|
||||
return build_rerank_strategy(
|
||||
config,
|
||||
tile_store=tile_store,
|
||||
lightglue_runtime=lightglue_runtime,
|
||||
feature_extractor=feature_extractor,
|
||||
clock=clock,
|
||||
fdr_client=fdr_client,
|
||||
)
|
||||
|
||||
|
||||
def _c3_matcher_wrapper(
|
||||
config: "Config", constructed: Mapping[str, Any]
|
||||
) -> Any:
|
||||
lightglue_runtime = _require(
|
||||
constructed, "c3_lightglue_runtime", "c3_matcher"
|
||||
)
|
||||
ransac_filter = _require(constructed, "c282_ransac_filter", "c3_matcher")
|
||||
inference_runtime = _require(constructed, "c7_inference", "c3_matcher")
|
||||
clock = constructed.get("clock")
|
||||
fdr_client = constructed.get("c13_fdr")
|
||||
return build_matcher_strategy(
|
||||
config,
|
||||
lightglue_runtime=lightglue_runtime,
|
||||
ransac_filter=ransac_filter,
|
||||
inference_runtime=inference_runtime,
|
||||
clock=clock,
|
||||
fdr_client=fdr_client,
|
||||
)
|
||||
|
||||
|
||||
def _c3_5_adhop_wrapper(
|
||||
config: "Config", constructed: Mapping[str, Any]
|
||||
) -> Any:
|
||||
ransac_filter = _require(constructed, "c282_ransac_filter", "c3_5_adhop")
|
||||
inference_runtime = _require(constructed, "c7_inference", "c3_5_adhop")
|
||||
clock = constructed.get("clock")
|
||||
fdr_client = constructed.get("c13_fdr")
|
||||
return build_refiner_strategy(
|
||||
config,
|
||||
ransac_filter=ransac_filter,
|
||||
inference_runtime=inference_runtime,
|
||||
clock=clock,
|
||||
fdr_client=fdr_client,
|
||||
)
|
||||
|
||||
|
||||
def _c4_pose_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any:
|
||||
ransac_filter = _require(constructed, "c282_ransac_filter", "c4_pose")
|
||||
wgs_converter = _require(constructed, "c5_wgs_converter", "c4_pose")
|
||||
se3_utils = _require(constructed, "c5_se3_utils", "c4_pose")
|
||||
isam2_graph_handle = _require(
|
||||
constructed, "c5_isam2_graph_handle", "c4_pose"
|
||||
)
|
||||
fdr_client = constructed.get("c13_fdr")
|
||||
clock = constructed.get("clock")
|
||||
return build_pose_estimator(
|
||||
config,
|
||||
ransac_filter=ransac_filter,
|
||||
wgs_converter=wgs_converter,
|
||||
se3_utils=se3_utils,
|
||||
isam2_graph_handle=isam2_graph_handle,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def _c5_state_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any:
|
||||
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")
|
||||
fdr_client = _require(constructed, "c13_fdr", "c5_state")
|
||||
tile_store = constructed.get("c6_tile_store")
|
||||
camera_calibration = constructed.get("camera_calibration")
|
||||
flight_id = constructed.get("flight_id")
|
||||
companion_id = constructed.get("companion_id")
|
||||
_ensure_state_strategy_registered(config)
|
||||
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,
|
||||
)
|
||||
return estimator
|
||||
|
||||
|
||||
def _ensure_state_strategy_registered(config: "Config") -> None:
|
||||
"""Register the c5_state strategy module if its build flag is on.
|
||||
|
||||
state_factory does NOT have a lazy-import fallback (unlike pose_factory).
|
||||
The configured strategy module's ``register()`` must be called before
|
||||
``build_state_estimator`` is invoked. We do this here, lazily, so the
|
||||
gtsam-bound code only loads when the airborne binary is actually
|
||||
configured for it AND the matching BUILD_* flag is ON.
|
||||
"""
|
||||
block = getattr(config, "components", None) or {}
|
||||
c5_block = block.get("c5_state") if isinstance(block, dict) else None
|
||||
strategy = (
|
||||
getattr(c5_block, "strategy", "gtsam_isam2")
|
||||
if c5_block is not None
|
||||
else "gtsam_isam2"
|
||||
)
|
||||
# state_factory._STATE_BUILD_FLAGS: gtsam_isam2 defaults ON-when-unset;
|
||||
# eskf defaults OFF-when-unset (mirror state_factory's own logic).
|
||||
if strategy == "gtsam_isam2":
|
||||
if os.environ.get("BUILD_STATE_GTSAM_ISAM2", "ON").upper() == "OFF":
|
||||
return
|
||||
from gps_denied_onboard.components.c5_state import gtsam_isam2_estimator
|
||||
|
||||
gtsam_isam2_estimator.register()
|
||||
elif strategy == "eskf":
|
||||
if os.environ.get("BUILD_STATE_ESKF", "OFF").upper() != "ON":
|
||||
return
|
||||
from gps_denied_onboard.components.c5_state import eskf_baseline
|
||||
|
||||
eskf_baseline.register()
|
||||
|
||||
|
||||
_C1_VIO_DEPENDS_ON: tuple[str, ...] = ()
|
||||
_C2_VPR_DEPENDS_ON: tuple[str, ...] = ()
|
||||
_C2_5_RERANK_DEPENDS_ON: tuple[str, ...] = ("c2_vpr",)
|
||||
_C3_MATCHER_DEPENDS_ON: tuple[str, ...] = ()
|
||||
_C3_5_ADHOP_DEPENDS_ON: tuple[str, ...] = ("c3_matcher",)
|
||||
_C4_POSE_DEPENDS_ON: tuple[str, ...] = ("c1_vio", "c3_matcher")
|
||||
_C5_STATE_DEPENDS_ON: tuple[str, ...] = ("c1_vio", "c4_pose")
|
||||
"""Inter-component dependency edges for ``_topo_order``. These are the runtime
|
||||
data-flow dependencies (NOT infrastructure deps in ``pre_constructed``):
|
||||
|
||||
* c2_5_rerank reranks c2_vpr candidates → depends on c2_vpr.
|
||||
* c3_5_adhop refines c3_matcher correspondences → depends on c3_matcher.
|
||||
* c4_pose is anchored against c1_vio's pose estimate and consumes c3_matcher
|
||||
correspondences → depends on both.
|
||||
* c5_state fuses c1_vio + c4_pose updates → depends on both.
|
||||
|
||||
The leaf slugs (c1_vio, c2_vpr, c3_matcher) have no inter-component deps
|
||||
inside the registry-driven path; their infrastructure deps (fdr_client,
|
||||
descriptor_index, etc.) come from ``pre_constructed``.
|
||||
"""
|
||||
|
||||
|
||||
_AIRBORNE_REGISTRATIONS: tuple[
|
||||
tuple[str, tuple[str, ...], Any, tuple[str, ...]], ...
|
||||
] = (
|
||||
("c1_vio", _C1_VIO_STRATEGIES, _c1_vio_wrapper, _C1_VIO_DEPENDS_ON),
|
||||
("c2_vpr", _C2_VPR_STRATEGIES, _c2_vpr_wrapper, _C2_VPR_DEPENDS_ON),
|
||||
(
|
||||
"c2_5_rerank",
|
||||
_C2_5_RERANK_STRATEGIES,
|
||||
_c2_5_rerank_wrapper,
|
||||
_C2_5_RERANK_DEPENDS_ON,
|
||||
),
|
||||
(
|
||||
"c3_matcher",
|
||||
_C3_MATCHER_STRATEGIES,
|
||||
_c3_matcher_wrapper,
|
||||
_C3_MATCHER_DEPENDS_ON,
|
||||
),
|
||||
(
|
||||
"c3_5_adhop",
|
||||
_C3_5_ADHOP_STRATEGIES,
|
||||
_c3_5_adhop_wrapper,
|
||||
_C3_5_ADHOP_DEPENDS_ON,
|
||||
),
|
||||
("c4_pose", _C4_POSE_STRATEGIES, _c4_pose_wrapper, _C4_POSE_DEPENDS_ON),
|
||||
(
|
||||
"c5_state",
|
||||
_C5_STATE_STRATEGIES,
|
||||
_c5_state_wrapper,
|
||||
_C5_STATE_DEPENDS_ON,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def register_airborne_strategies() -> None:
|
||||
"""Register every airborne (component, strategy) pair into ``_STRATEGY_REGISTRY``.
|
||||
|
||||
Idempotent: a second call within the same process is a no-op because the
|
||||
underlying :func:`register_strategy` short-circuits identical
|
||||
re-registrations (the dedup check in
|
||||
:mod:`gps_denied_onboard.runtime_root` compares
|
||||
:class:`_Registration` records by value).
|
||||
|
||||
Side effects:
|
||||
|
||||
* Mutates the central :data:`_STRATEGY_REGISTRY` for the 7 strategy-
|
||||
selecting airborne component slots. Each slot gets one entry per
|
||||
buildable strategy (the wrapper is the same callable across all
|
||||
strategies for a slot, because the underlying per-component factory
|
||||
handles the strategy switch internally).
|
||||
* Does NOT touch state_factory's ``_STATE_REGISTRY`` or pose_factory's
|
||||
``_POSE_REGISTRY`` here — those are populated lazily by the c5_state
|
||||
wrapper at compose time, behind ``BUILD_STATE_*`` flag gates, so a
|
||||
binary configured for klt_ransac + ESKF never imports gtsam.
|
||||
|
||||
Call ONCE at process start, before any :func:`compose_root` invocation.
|
||||
Tests call ``clear_strategy_registry()`` first to isolate state across
|
||||
test cases.
|
||||
"""
|
||||
for slug, strategies, wrapper, depends_on in _AIRBORNE_REGISTRATIONS:
|
||||
for strategy in strategies:
|
||||
register_strategy(
|
||||
slug,
|
||||
strategy,
|
||||
wrapper,
|
||||
tier="airborne",
|
||||
depends_on=depends_on,
|
||||
)
|
||||
_LOG.info(
|
||||
"airborne_bootstrap.strategies_registered",
|
||||
extra={
|
||||
"kind": "airborne_bootstrap.strategies_registered",
|
||||
"kv": {
|
||||
"slots": [slug for slug, *_ in _AIRBORNE_REGISTRATIONS],
|
||||
"total_registrations": sum(
|
||||
len(strategies)
|
||||
for _, strategies, *_ in _AIRBORNE_REGISTRATIONS
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""AZ-591 — airborne_bootstrap registry-population AC tests.
|
||||
|
||||
Verifies the contract at
|
||||
``_docs/02_tasks/todo/AZ-591_compose_root_per_binary_bootstrap.md``:
|
||||
|
||||
* AC-1: ``register_airborne_strategies()`` populates the central
|
||||
``_STRATEGY_REGISTRY`` with one entry per (component, strategy) pair the
|
||||
airborne binary supports (14 total across 7 slots).
|
||||
* AC-2: ``compose_root(config, pre_constructed=...)`` reaches completion
|
||||
without ``StrategyNotLinkedError`` when every component selects its default
|
||||
strategy AND ``pre_constructed`` carries the required infrastructure deps.
|
||||
* AC-3: registering twice in the same process is a no-op (idempotent).
|
||||
* AC-4: an unknown strategy in config surfaces ``StrategyNotLinkedError`` with
|
||||
the available-strategies list populated.
|
||||
* AC-5: airborne registrations are tier-isolated from ``compose_operator``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import Config
|
||||
from gps_denied_onboard.runtime_root import (
|
||||
StrategyNotLinkedError,
|
||||
clear_strategy_registry,
|
||||
compose_operator,
|
||||
compose_root,
|
||||
list_registered_strategies,
|
||||
)
|
||||
from gps_denied_onboard.runtime_root.airborne_bootstrap import (
|
||||
AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS,
|
||||
AirborneBootstrapError,
|
||||
register_airborne_strategies,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C1Block:
|
||||
strategy: str = "klt_ransac"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C2Block:
|
||||
strategy: str = "net_vlad"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C25Block:
|
||||
strategy: str = "inlier_count"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C3Block:
|
||||
strategy: str = "disk_lightglue"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C35Block:
|
||||
strategy: str = "adhop"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C4Block:
|
||||
strategy: str = "opencv_gtsam"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _C5Block:
|
||||
strategy: str = "gtsam_isam2"
|
||||
|
||||
|
||||
_EXPECTED_REGISTRATIONS: dict[str, tuple[str, ...]] = {
|
||||
"c1_vio": ("klt_ransac", "okvis2", "vins_mono"),
|
||||
"c2_vpr": (
|
||||
"eigen_places",
|
||||
"mega_loc",
|
||||
"mix_vpr",
|
||||
"net_vlad",
|
||||
"salad",
|
||||
"sela_vpr",
|
||||
"ultra_vpr",
|
||||
),
|
||||
"c2_5_rerank": ("inlier_count",),
|
||||
"c3_matcher": ("aliked_lightglue", "disk_lightglue"),
|
||||
"c3_5_adhop": ("adhop",),
|
||||
"c4_pose": ("opencv_gtsam",),
|
||||
"c5_state": ("eskf", "gtsam_isam2"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_registry() -> Iterator[None]:
|
||||
clear_strategy_registry()
|
||||
yield
|
||||
clear_strategy_registry()
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _operator_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"),
|
||||
("SATELLITE_PROVIDER_URL", "http://localhost:8080"),
|
||||
):
|
||||
monkeypatch.setenv(name, value)
|
||||
|
||||
|
||||
def test_ac1_register_airborne_strategies_populates_all_slots() -> None:
|
||||
# Arrange / Act
|
||||
register_airborne_strategies()
|
||||
|
||||
# Assert
|
||||
for slug, expected_strategies in _EXPECTED_REGISTRATIONS.items():
|
||||
registered = list_registered_strategies(slug)
|
||||
assert sorted(registered) == list(expected_strategies), (
|
||||
f"slot {slug!r}: expected {list(expected_strategies)}, "
|
||||
f"got {registered}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac3_idempotent_double_register_is_a_no_op() -> None:
|
||||
# Arrange
|
||||
register_airborne_strategies()
|
||||
snapshot = {
|
||||
slug: sorted(list_registered_strategies(slug))
|
||||
for slug in _EXPECTED_REGISTRATIONS
|
||||
}
|
||||
|
||||
# Act
|
||||
register_airborne_strategies()
|
||||
|
||||
# Assert
|
||||
for slug, strategies in snapshot.items():
|
||||
assert sorted(list_registered_strategies(slug)) == strategies, (
|
||||
f"slot {slug!r}: second register_airborne_strategies() call "
|
||||
"must not change the registered set"
|
||||
)
|
||||
|
||||
|
||||
def test_ac4_unknown_strategy_in_config_raises_strategy_not_linked(
|
||||
_airborne_env: None,
|
||||
) -> None:
|
||||
# Arrange
|
||||
register_airborne_strategies()
|
||||
config = Config.with_blocks(c2_vpr=_C2Block(strategy="not_a_real_strategy"))
|
||||
|
||||
# Act
|
||||
with pytest.raises(StrategyNotLinkedError) as info:
|
||||
compose_root(config)
|
||||
|
||||
# Assert
|
||||
assert info.value.strategy_name == "not_a_real_strategy"
|
||||
assert info.value.component_slug == "c2_vpr"
|
||||
assert info.value.available_strategies == [
|
||||
"eigen_places",
|
||||
"mega_loc",
|
||||
"mix_vpr",
|
||||
"net_vlad",
|
||||
"salad",
|
||||
"sela_vpr",
|
||||
"ultra_vpr",
|
||||
]
|
||||
|
||||
|
||||
def test_ac5_compose_operator_rejects_airborne_tier(
|
||||
_operator_env: None,
|
||||
) -> None:
|
||||
# Arrange
|
||||
register_airborne_strategies()
|
||||
config = Config.with_blocks(c1_vio=_C1Block())
|
||||
|
||||
# Act
|
||||
with pytest.raises(StrategyNotLinkedError) as info:
|
||||
compose_operator(config)
|
||||
|
||||
# Assert
|
||||
assert info.value.component_slug == "c1_vio"
|
||||
assert "airborne" in info.value.reason or "tier" in info.value.reason
|
||||
|
||||
|
||||
def test_ac2_compose_root_reaches_completion_with_pre_constructed_infra(
|
||||
_airborne_env: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end smoke test of the AC-2 contract.
|
||||
|
||||
The wrappers call into per-component factories that require heavy
|
||||
runtime deps (gtsam, opencv, lightglue, etc.). To keep this a unit test
|
||||
we monkeypatch the wrapper functions themselves to assert the
|
||||
``constructed`` dict is wired correctly and return sentinel objects.
|
||||
The assertion is that ``compose_root`` walks every slot in topological
|
||||
order and stores the wrapper's return value under that slot.
|
||||
"""
|
||||
# Arrange — stub each wrapper to record invocation order + return a
|
||||
# slug-tagged sentinel. We patch the registered wrapper objects directly
|
||||
# on the module so register_airborne_strategies() picks up the stubs.
|
||||
from gps_denied_onboard.runtime_root import airborne_bootstrap as ab
|
||||
|
||||
invocation_order: list[str] = []
|
||||
|
||||
def _make_stub(slug: str) -> Any:
|
||||
def _stub(config: Any, constructed: dict[str, Any]) -> str:
|
||||
invocation_order.append(slug)
|
||||
return f"<{slug}>"
|
||||
|
||||
return _stub
|
||||
|
||||
monkeypatch.setattr(ab, "_c1_vio_wrapper", _make_stub("c1_vio"))
|
||||
monkeypatch.setattr(ab, "_c2_vpr_wrapper", _make_stub("c2_vpr"))
|
||||
monkeypatch.setattr(ab, "_c2_5_rerank_wrapper", _make_stub("c2_5_rerank"))
|
||||
monkeypatch.setattr(ab, "_c3_matcher_wrapper", _make_stub("c3_matcher"))
|
||||
monkeypatch.setattr(ab, "_c3_5_adhop_wrapper", _make_stub("c3_5_adhop"))
|
||||
monkeypatch.setattr(ab, "_c4_pose_wrapper", _make_stub("c4_pose"))
|
||||
monkeypatch.setattr(ab, "_c5_state_wrapper", _make_stub("c5_state"))
|
||||
# Rebuild the registration table so the patched wrappers are picked up.
|
||||
monkeypatch.setattr(
|
||||
ab,
|
||||
"_AIRBORNE_REGISTRATIONS",
|
||||
(
|
||||
("c1_vio", ab._C1_VIO_STRATEGIES, ab._c1_vio_wrapper, ()),
|
||||
("c2_vpr", ab._C2_VPR_STRATEGIES, ab._c2_vpr_wrapper, ()),
|
||||
(
|
||||
"c2_5_rerank",
|
||||
ab._C2_5_RERANK_STRATEGIES,
|
||||
ab._c2_5_rerank_wrapper,
|
||||
("c2_vpr",),
|
||||
),
|
||||
(
|
||||
"c3_matcher",
|
||||
ab._C3_MATCHER_STRATEGIES,
|
||||
ab._c3_matcher_wrapper,
|
||||
(),
|
||||
),
|
||||
(
|
||||
"c3_5_adhop",
|
||||
ab._C3_5_ADHOP_STRATEGIES,
|
||||
ab._c3_5_adhop_wrapper,
|
||||
("c3_matcher",),
|
||||
),
|
||||
(
|
||||
"c4_pose",
|
||||
ab._C4_POSE_STRATEGIES,
|
||||
ab._c4_pose_wrapper,
|
||||
("c1_vio", "c3_matcher"),
|
||||
),
|
||||
(
|
||||
"c5_state",
|
||||
ab._C5_STATE_STRATEGIES,
|
||||
ab._c5_state_wrapper,
|
||||
("c1_vio", "c4_pose"),
|
||||
),
|
||||
),
|
||||
)
|
||||
register_airborne_strategies()
|
||||
config = Config.with_blocks(
|
||||
c1_vio=_C1Block(),
|
||||
c2_vpr=_C2Block(),
|
||||
c2_5_rerank=_C25Block(),
|
||||
c3_matcher=_C3Block(),
|
||||
c3_5_adhop=_C35Block(),
|
||||
c4_pose=_C4Block(),
|
||||
c5_state=_C5Block(),
|
||||
)
|
||||
|
||||
# Act
|
||||
root = compose_root(config, pre_constructed={})
|
||||
|
||||
# Assert
|
||||
assert set(root.components.keys()) == set(_EXPECTED_REGISTRATIONS)
|
||||
for slug in _EXPECTED_REGISTRATIONS:
|
||||
assert root.components[slug] == f"<{slug}>"
|
||||
# Dependencies construct strictly before dependents (declared in the
|
||||
# patched _AIRBORNE_REGISTRATIONS table above).
|
||||
idx = invocation_order.index
|
||||
assert idx("c2_vpr") < idx("c2_5_rerank")
|
||||
assert idx("c3_matcher") < idx("c3_5_adhop")
|
||||
assert idx("c1_vio") < idx("c4_pose")
|
||||
assert idx("c3_matcher") < idx("c4_pose")
|
||||
assert idx("c1_vio") < idx("c5_state")
|
||||
assert idx("c4_pose") < idx("c5_state")
|
||||
|
||||
|
||||
def test_wrapper_raises_clear_error_on_missing_pre_constructed_dep(
|
||||
_airborne_env: None,
|
||||
) -> None:
|
||||
"""Negative path for the production wrappers: deps must be in constructed."""
|
||||
# Arrange
|
||||
register_airborne_strategies()
|
||||
config = Config.with_blocks(c1_vio=_C1Block())
|
||||
|
||||
# Act
|
||||
with pytest.raises(AirborneBootstrapError) as info:
|
||||
compose_root(config, pre_constructed={})
|
||||
|
||||
# Assert
|
||||
msg = str(info.value)
|
||||
assert "c1_vio" in msg
|
||||
assert "c13_fdr" in msg
|
||||
assert "pre_constructed" in msg
|
||||
|
||||
|
||||
def test_required_keys_table_is_consistent_with_expected_registrations() -> None:
|
||||
# Arrange / Assert — every registered slot has documented infrastructure
|
||||
# dep keys; no surprise sources of pre_constructed lookups outside the
|
||||
# documented table.
|
||||
assert set(AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS.keys()) == set(
|
||||
_EXPECTED_REGISTRATIONS
|
||||
)
|
||||
Reference in New Issue
Block a user