From f7a99282fbc9a1b35197f588f8221e106b42524f Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Sat, 16 May 2026 12:58:38 +0300 Subject: [PATCH] [AZ-591] Add airborne_bootstrap to populate _STRATEGY_REGISTRY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...Z-591_compose_root_per_binary_bootstrap.md | 23 + _docs/_autodev_state.md | 6 +- .../runtime_root/__init__.py | 21 +- .../runtime_root/airborne_bootstrap.py | 414 ++++++++++++++++++ tests/unit/runtime_root/__init__.py | 0 .../test_az591_airborne_bootstrap.py | 336 ++++++++++++++ 6 files changed, 796 insertions(+), 4 deletions(-) rename _docs/02_tasks/{todo => done}/AZ-591_compose_root_per_binary_bootstrap.md (77%) create mode 100644 src/gps_denied_onboard/runtime_root/airborne_bootstrap.py create mode 100644 tests/unit/runtime_root/__init__.py create mode 100644 tests/unit/runtime_root/test_az591_airborne_bootstrap.py diff --git a/_docs/02_tasks/todo/AZ-591_compose_root_per_binary_bootstrap.md b/_docs/02_tasks/done/AZ-591_compose_root_per_binary_bootstrap.md similarity index 77% rename from _docs/02_tasks/todo/AZ-591_compose_root_per_binary_bootstrap.md rename to _docs/02_tasks/done/AZ-591_compose_root_per_binary_bootstrap.md index 6cf3168..2ab21f2 100644 --- a/_docs/02_tasks/todo/AZ-591_compose_root_per_binary_bootstrap.md +++ b/_docs/02_tasks/done/AZ-591_compose_root_per_binary_bootstrap.md @@ -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__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. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 8ed4344..5d21e55 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -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: "" diff --git a/src/gps_denied_onboard/runtime_root/__init__.py b/src/gps_denied_onboard/runtime_root/__init__.py index a396511..ef3a6bd 100644 --- a/src/gps_denied_onboard/runtime_root/__init__.py +++ b/src/gps_denied_onboard/runtime_root/__init__.py @@ -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) diff --git a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py new file mode 100644 index 0000000..a40837a --- /dev/null +++ b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py @@ -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 + ), + }, + }, + ) diff --git a/tests/unit/runtime_root/__init__.py b/tests/unit/runtime_root/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/runtime_root/test_az591_airborne_bootstrap.py b/tests/unit/runtime_root/test_az591_airborne_bootstrap.py new file mode 100644 index 0000000..d272101 --- /dev/null +++ b/tests/unit/runtime_root/test_az591_airborne_bootstrap.py @@ -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 + )