diff --git a/_docs/03_implementation/batch_91_cycle1_report.md b/_docs/03_implementation/batch_91_cycle1_report.md new file mode 100644 index 0000000..310c694 --- /dev/null +++ b/_docs/03_implementation/batch_91_cycle1_report.md @@ -0,0 +1,67 @@ +# Batch 91 Report — AZ-620 Phase B: build_pre_constructed seeds c6_descriptor_index + c6_tile_store (cycle 1) + +**Batch**: 91 +**Date**: 2026-05-19 +**Context**: Product implementation (greenfield Step 7 — Implement; AZ-618 umbrella, Phase B of 6) +**Tasks**: AZ-620 (3 cp) — 1 task +**Cycle**: 1 +**Verdict**: COMPLETE — PASS (self-reviewed inline; 15/15 runtime_root tests green) + +## Summary + +Second of six subtasks decomposing the AZ-618 umbrella. Phase B extends `airborne_bootstrap.build_pre_constructed(config)` additively, adding the two C6 storage entries (`c6_descriptor_index`, `c6_tile_store`) on top of AZ-619's foundational `c13_fdr` + `clock`. Wraps the existing C6 factories so a `BUILD_FAISS_INDEX=OFF` mismatch surfaces a clear operator-facing `AirborneBootstrapError` instead of bubbling up the lower-level `RuntimeNotAvailableError` with no consuming-component context. + +### AZ-620 — build_pre_constructed Phase B (3 cp) + +* **`src/gps_denied_onboard/runtime_root/airborne_bootstrap.py`**: + * Imports `build_descriptor_index` + `build_tile_store` from `storage_factory`, and `RuntimeNotAvailableError` from `errors`. + * New public constant `FAISS_BUILD_FLAG = "BUILD_FAISS_INDEX"` (exported in `__all__`) — single source of truth for the flag name embedded in error messages. + * New module-private helpers: + * `_consumers_of_pre_constructed_key(key)` — reads `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` to find which slugs need a given key. + * `_configured_consumers_of_pre_constructed_key(config, key)` — narrows to consumers present in `config.components`; falls back to the full theoretical set when no components are configured (so error messages stay informative under bare `Config()`). + * `_build_c6_descriptor_index(config)` — calls `build_descriptor_index`; on `RuntimeNotAvailableError`, re-raises as `AirborneBootstrapError` naming `c6_descriptor_index`, `BUILD_FAISS_INDEX`, and the consuming component slug(s). Preserves the original error via `raise ... from exc`. + * `_build_c6_tile_store(config)` — thin pass-through to `build_tile_store` (no `BUILD_*` flag gates this side). + * `build_pre_constructed(config)` now returns four keys: `c13_fdr`, `clock`, `c6_descriptor_index`, `c6_tile_store`. Additivity preserved per AZ-620 Constraint "MUST be additive on top of AZ-619". + * Bonus hygiene (mechanical): `ruff --fix` removed 12 pre-existing UP037 quoted-annotation warnings in the same file (covered by `from __future__ import annotations` at line 49). All 12 lints were in the modified-area scope per `.cursor/rules/quality-gates.mdc`. + +* **`tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py`**: + * New autouse fixture `_stub_c6_builders` monkeypatches `_build_c6_descriptor_index` + `_build_c6_tile_store` to opaque sentinels — keeps AZ-619 tests focused on Phase A keys without entangling them with the new Phase B integration paths (and avoids requiring real Postgres + FAISS for the AZ-619 cases). + * `test_phase_a_only_seeds_two_keys` renamed to `test_phase_a_keys_remain_present_under_az620_additivity`: now asserts AZ-619 keys are a SUBSET of the returned dict (not exact equality), matching the explicit forward-pointer that the original docstring carried ("this test will be relaxed at that point"). + +* **`tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py`** (new): + * `test_ac_620_1_adds_c6_descriptor_index_and_c6_tile_store` — stubs both C6 factories to identifiable mocks; asserts the bootstrap dict contains both Phase B keys referencing the patched returns, and AZ-619 keys are still present. Covers AC-620.1. + * `test_ac_620_2_build_flag_off_with_configured_c2_vpr_raises_named_error` — stubs `build_descriptor_index` to raise `RuntimeNotAvailableError` (mirroring real `BUILD_FAISS_INDEX=OFF` behaviour); builds a config with `c2_vpr.strategy="net_vlad"`; asserts `AirborneBootstrapError` is raised with all three names (`c6_descriptor_index`, `BUILD_FAISS_INDEX`, `c2_vpr`) and the original error preserved as `__cause__`. Covers AC-620.2. + * `test_ac_620_2_no_configured_consumer_still_raises_with_full_set` — defence-in-depth: even with no consumer configured, the bootstrap still fails loudly (rather than silently dropping the key); the message lists the full theoretical consumer set. Strengthens AC-620.2's operator-facing contract. + +## File Ownership + +* OWNED (component `runtime_root`): + * `src/gps_denied_onboard/runtime_root/airborne_bootstrap.py` + * `tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py` + * `tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py` +* READ-ONLY: imports from `runtime_root.storage_factory` (same package), `runtime_root.errors`, `clock`, `fdr_client`, `config`, `c6_tile_cache.interface` (TYPE_CHECKING via storage_factory only). +* No FORBIDDEN-zone writes. + +## Test Results + +* New unit tests: **3** under `tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py` covering AC-620.1 + AC-620.2 (twice, defence-in-depth). +* Updated unit tests: **5** AZ-619 tests still passing after autouse-stub fixture and relaxed-equality assertion. +* AZ-591 regression (`test_az591_airborne_bootstrap.py`): **7/7 passing** — `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` table consistency + airborne registration semantics + tier isolation all unchanged. +* Combined: `pytest tests/unit/runtime_root/ -q` → **15/15 passed in 1.06 s**. +* No new ruff errors on touched files; 12 pre-existing UP037 warnings in the same file fixed. + +## Out of scope (deferred) + +* All other keys in `AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` — AZ-621 (c7 inference), AZ-622 (c3 runtimes), AZ-623 (RANSAC + c5 helpers), AZ-624 (`main()` integration + AC-1..AC-5). +* Real Postgres + FAISS integration testing — covered by the existing AZ-303 / AZ-305 / AZ-306 component-level tests; not duplicated at the bootstrap layer. +* Mandatory-Tier-2 Jetson run — consolidated at AZ-624. + +## State + +* Spec moved: `_docs/02_tasks/todo/AZ-620_pre_constructed_phase_b_c6_storage.md` → `_docs/02_tasks/done/`. +* Tracker: AZ-620 transitioned **To Do → In Progress** at batch start; will transition **In Progress → In Testing** after this commit (per implement skill Step 12). +* `_docs/_autodev_state.md` advanced to `last_completed_batch: 91`. + +## Next Batch + +* **Batch 92**: AZ-621 (3 cp) — Phase C of AZ-618 umbrella. Adds `c7_inference` engine to `build_pre_constructed`. Depends on AZ-619 + AZ-620 (both will be in `done/` after this commit). diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 51878b2..65067bb 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -6,10 +6,10 @@ step: 7 name: Implement status: in_progress sub_step: - phase: 12 - name: archive - detail: "AZ-619 (Phase A) implemented + committed + tests green (12/12 incl. AZ-591 regression). Spec moved to done/. AZ-619 transitioned In Testing. Next batch = AZ-620 (Phase B: c6_descriptor_index + c6_tile_store, 3pt). Recommended session boundary here per 2026-05-18 lesson — fresh session picks up cleanly with AZ-620." + phase: 11 + name: commit + detail: "batch 91 — AZ-620 committed; tracker → In Testing next" retry_count: 0 cycle: 1 tracker: jira -last_completed_batch: 90 +last_completed_batch: 91 diff --git a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py index 494e144..4a7f636 100644 --- a/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py +++ b/src/gps_denied_onboard/runtime_root/airborne_bootstrap.py @@ -56,11 +56,16 @@ from typing import TYPE_CHECKING, Any, Final from gps_denied_onboard.clock.wall_clock import WallClock from gps_denied_onboard.fdr_client.client import make_fdr_client from gps_denied_onboard.runtime_root import register_strategy +from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError 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.storage_factory import ( + build_descriptor_index, + build_tile_store, +) from gps_denied_onboard.runtime_root.vio_factory import build_vio_strategy from gps_denied_onboard.runtime_root.vpr_factory import build_vpr_strategy @@ -70,12 +75,23 @@ if TYPE_CHECKING: __all__ = [ "AIRBORNE_MAIN_PRODUCER_ID", "AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS", + "FAISS_BUILD_FLAG", "AirborneBootstrapError", "build_pre_constructed", "register_airborne_strategies", ] +FAISS_BUILD_FLAG: Final[str] = "BUILD_FAISS_INDEX" +"""Env flag gating the FAISS-backed ``DescriptorIndex`` impl. + +Mirrors :func:`gps_denied_onboard.runtime_root.storage_factory.build_descriptor_index` +which reads the same flag at composition time. Surfaced here so the airborne +bootstrap can name the flag in an :class:`AirborneBootstrapError` when the +flag is OFF but a consuming component still requires the index. +""" + + AIRBORNE_MAIN_PRODUCER_ID: Final[str] = "airborne_main" """Producer ID for the per-binary shared FdrClient placed under ``pre_constructed['c13_fdr']``. @@ -176,12 +192,12 @@ def _require( return constructed[key] -def _c1_vio_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any: +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: +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( @@ -192,7 +208,7 @@ def _c2_vpr_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any: def _c2_5_rerank_wrapper( - config: "Config", constructed: Mapping[str, Any] + config: Config, constructed: Mapping[str, Any] ) -> Any: tile_store = _require(constructed, "c6_tile_store", "c2_5_rerank") lightglue_runtime = _require( @@ -214,7 +230,7 @@ def _c2_5_rerank_wrapper( def _c3_matcher_wrapper( - config: "Config", constructed: Mapping[str, Any] + config: Config, constructed: Mapping[str, Any] ) -> Any: lightglue_runtime = _require( constructed, "c3_lightglue_runtime", "c3_matcher" @@ -234,7 +250,7 @@ def _c3_matcher_wrapper( def _c3_5_adhop_wrapper( - config: "Config", constructed: Mapping[str, Any] + 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") @@ -249,7 +265,7 @@ def _c3_5_adhop_wrapper( ) -def _c4_pose_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any: +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") @@ -269,7 +285,7 @@ def _c4_pose_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any: ) -def _c5_state_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any: +def _c5_state_wrapper(config: Config, constructed: Mapping[str, Any]) -> Any: imu_preintegrator = _require( constructed, "c5_imu_preintegrator", "c5_state" ) @@ -295,7 +311,7 @@ def _c5_state_wrapper(config: "Config", constructed: Mapping[str, Any]) -> Any: return estimator -def _ensure_state_strategy_registered(config: "Config") -> None: +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). @@ -382,27 +398,116 @@ _AIRBORNE_REGISTRATIONS: tuple[ ) -def build_pre_constructed(config: "Config") -> dict[str, Any]: +def _consumers_of_pre_constructed_key(key: str) -> tuple[str, ...]: + """Return component slugs that require ``key`` in ``pre_constructed``. + + Reads :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS` — the single + source of truth for which slot consumes which infrastructure dep. + """ + return tuple( + sorted( + slug + for slug, required in AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS.items() + if key in required + ) + ) + + +def _configured_consumers_of_pre_constructed_key( + config: Config, key: str +) -> tuple[str, ...]: + """Return consumers of ``key`` that are present in ``config.components``. + + Used to narrow an error message from "every theoretical consumer of + ``c6_descriptor_index``" to "the consumer(s) you actually configured". + Falls back to the full theoretical set when config carries no component + blocks (e.g., bare ``Config()`` in early-bootstrap tests). + """ + consumers = _consumers_of_pre_constructed_key(key) + components = getattr(config, "components", None) or {} + if not isinstance(components, Mapping): + return consumers + configured = tuple(slug for slug in consumers if slug in components) + return configured if configured else consumers + + +def _build_c6_descriptor_index(config: Config) -> Any: + """Build ``pre_constructed['c6_descriptor_index']`` via the C6 factory. + + Wraps :func:`storage_factory.build_descriptor_index` so a + :class:`RuntimeNotAvailableError` (typically raised when + ``BUILD_FAISS_INDEX`` is OFF) surfaces as an + :class:`AirborneBootstrapError` naming the missing key, the gating + build flag, and the component(s) that need the index. The original + factory error is preserved via ``raise ... from``. + + AC-620.2: this is the path the test exercises when + ``BUILD_FAISS_INDEX=OFF`` and a C2 strategy needing the index is + configured. + """ + try: + return build_descriptor_index(config) + except RuntimeNotAvailableError as exc: + consumers = _configured_consumers_of_pre_constructed_key( + config, "c6_descriptor_index" + ) + raise AirborneBootstrapError( + f"airborne_bootstrap: cannot construct " + f"pre_constructed['c6_descriptor_index'] because " + f"{FAISS_BUILD_FLAG} is OFF (or the FAISS impl module is " + f"unavailable). Consuming components: {list(consumers)}. " + f"Set {FAISS_BUILD_FLAG}=ON to enable the FAISS DescriptorIndex, " + f"or reconfigure the consuming components to use a strategy " + f"that does not require the index." + ) from exc + + +def _build_c6_tile_store(config: Config) -> Any: + """Build ``pre_constructed['c6_tile_store']`` via the C6 factory. + + Thin pass-through to :func:`storage_factory.build_tile_store`. There + is no ``BUILD_*`` flag for the tile store (the Postgres + filesystem + backend is always built when c6 is configured); failures here surface + as :class:`RuntimeNotAvailableError` with the operator-actionable + message provided by the factory itself. + """ + return build_tile_store(config) + + +def build_pre_constructed(config: Config) -> dict[str, Any]: """Build the airborne ``pre_constructed`` dict for :func:`compose_root`. - AZ-619 (Phase A) lands the foundational keys ``c13_fdr`` and ``clock``. - Phases B..F (AZ-620..AZ-624) extend this function to populate the - remaining 10 keys in :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`. + AZ-619 (Phase A) seeded ``c13_fdr`` and ``clock``. AZ-620 (Phase B) + adds the two C6 storage entries (``c6_descriptor_index`` + + ``c6_tile_store``). Phases C..F (AZ-621..AZ-624) will extend this + function to populate the remaining keys in + :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`. 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 the same process return dicts where ``pre_constructed['c13_fdr']`` is the SAME object — AC-619.2. ``clock`` is a fresh :class:`WallClock` - each call (stateless; the cache would be a no-op). + each call (stateless; the cache would be a no-op). The C6 entries are + constructed via the existing :mod:`storage_factory` builders without + additional caching at this layer. Replay-mode override: :func:`compose_root` merges ``replay_components`` over ``pre_constructed`` so the :class:`WallClock` here is replaced by the replay branch's :class:`TlogDerivedClock`. That's intentional and matches the contract in :func:`compose_root`'s docstring. + + Raises: + AirborneBootstrapError: if ``BUILD_FAISS_INDEX`` is OFF and any + configured consumer (per + :data:`AIRBORNE_REQUIRED_PRE_CONSTRUCTED_KEYS`) requires + ``c6_descriptor_index`` — surfaces with the consuming + component slug(s) and the gating flag. """ return { "c13_fdr": make_fdr_client(AIRBORNE_MAIN_PRODUCER_ID, config), "clock": WallClock(), + "c6_descriptor_index": _build_c6_descriptor_index(config), + "c6_tile_store": _build_c6_tile_store(config), } diff --git a/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py b/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py index caef4c3..8725d0f 100644 --- a/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py +++ b/tests/unit/runtime_root/test_az619_pre_constructed_phase_a.py @@ -1,7 +1,7 @@ """AZ-619 — Phase A of AZ-618: ``build_pre_constructed`` seeds c13_fdr + clock. Verifies the contract at -``_docs/02_tasks/todo/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md``: +``_docs/02_tasks/done/AZ-619_pre_constructed_phase_a_c13_fdr_clock.md``: * AC-619.1: ``build_pre_constructed(default_config)`` returns a dict containing keys ``c13_fdr`` (FdrClient instance) and ``clock`` @@ -12,6 +12,17 @@ Verifies the contract at AC-619.3 (this file exists with the above tests) is satisfied by the existence of this module. + +AZ-620 amendment: ``build_pre_constructed`` now also calls the C6 +storage factories (``build_descriptor_index`` + ``build_tile_store``) +to populate ``c6_descriptor_index`` + ``c6_tile_store``. Those +factories require ``config.components["c6_tile_cache"]`` to be present +and (for the descriptor index) ``BUILD_FAISS_INDEX=ON``. The AZ-619 +tests use bare ``Config()`` — empty ``components`` — so we stub the C6 +builders with sentinel returns. This keeps the AZ-619 contract +assertions focused on Phase A keys without entangling them with the +heavier Phase B integration paths (those are covered by +``test_az620_pre_constructed_phase_b.py``). """ from __future__ import annotations @@ -24,6 +35,7 @@ from gps_denied_onboard.clock.wall_clock import WallClock from gps_denied_onboard.config import Config from gps_denied_onboard.fdr_client import client as fdr_client_module from gps_denied_onboard.fdr_client.client import FdrClient +from gps_denied_onboard.runtime_root import airborne_bootstrap from gps_denied_onboard.runtime_root.airborne_bootstrap import ( AIRBORNE_MAIN_PRODUCER_ID, build_pre_constructed, @@ -40,6 +52,21 @@ def _isolated_fdr_cache() -> Iterator[None]: fdr_client_module._reset_for_tests() +@pytest.fixture(autouse=True) +def _stub_c6_builders(monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange: stub the AZ-620 Phase B C6 storage builders so the AZ-619 + # tests stay focused on the Phase A contract. Without this the bare + # Config() used below would hit KeyError inside storage_factory's + # config.components["c6_tile_cache"] lookup. Sentinel objects are + # opaque on purpose — the AZ-619 assertions never inspect them. + monkeypatch.setattr( + airborne_bootstrap, "_build_c6_descriptor_index", lambda _config: object() + ) + monkeypatch.setattr( + airborne_bootstrap, "_build_c6_tile_store", lambda _config: object() + ) + + def test_ac_619_1_default_config_seeds_c13_fdr_and_clock() -> None: # Arrange config = Config() @@ -92,12 +119,14 @@ def test_ac_619_2_clock_is_independent_instance_per_call() -> None: assert isinstance(second["clock"], WallClock) -def test_phase_a_only_seeds_two_keys() -> None: - """Phase A scope discipline: exactly the two documented keys. +def test_phase_a_keys_remain_present_under_az620_additivity() -> None: + """Phase A's keys must survive AZ-620's additive extension. - Phases B..F (AZ-620..AZ-624) will add more keys; this test will be - relaxed at that point. For now it pins the AZ-619 contract precisely so - a regression that adds keys here without updating the spec is caught. + AZ-620 (Phase B) added ``c6_descriptor_index`` + ``c6_tile_store`` and + the spec mandates additivity ("MUST be additive on top of AZ-619" — + AZ-620 Constraints). The AZ-619 contract is therefore: ``c13_fdr`` and + ``clock`` MUST always be present in the dict, regardless of how many + later phases extend it. """ # Arrange config = Config() @@ -105,5 +134,9 @@ def test_phase_a_only_seeds_two_keys() -> None: # Act pre_constructed = build_pre_constructed(config) - # Assert - assert set(pre_constructed.keys()) == {"c13_fdr", "clock"} + # Assert: AZ-619 keys still there and correctly typed (the additivity + # contract). Other keys are allowed and validated by Phase B/C/D/E/F + # tests. + assert {"c13_fdr", "clock"}.issubset(pre_constructed.keys()) + assert isinstance(pre_constructed["c13_fdr"], FdrClient) + assert isinstance(pre_constructed["clock"], WallClock) diff --git a/tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py b/tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py new file mode 100644 index 0000000..ec36361 --- /dev/null +++ b/tests/unit/runtime_root/test_az620_pre_constructed_phase_b.py @@ -0,0 +1,168 @@ +"""AZ-620 — Phase B of AZ-618: ``build_pre_constructed`` seeds c6_descriptor_index + c6_tile_store. + +Verifies the contract at +``_docs/02_tasks/todo/AZ-620_pre_constructed_phase_b_c6_storage.md``: + +* AC-620.1: with a default airborne ``Config`` (and ``BUILD_FAISS_INDEX=ON``), + ``build_pre_constructed(config)`` additionally contains + ``c6_descriptor_index`` (DescriptorIndex instance) and ``c6_tile_store`` + (TileStore instance) on top of AZ-619's contract. +* AC-620.2: when ``BUILD_FAISS_INDEX=OFF`` AND a config selects + ``c2_vpr.strategy="net_vlad"`` (a strategy that requires + ``c6_descriptor_index``), ``build_pre_constructed`` raises + ``AirborneBootstrapError`` whose message names ``c6_descriptor_index`` + (the missing key), ``BUILD_FAISS_INDEX`` (the gating flag), and + ``c2_vpr`` (the consuming component slug). + +AC-620.3 (this file exists with the above tests) is satisfied by the +existence of this module. + +The tests stub the heavy C6 factories (`build_descriptor_index`, +`build_tile_store`) at the airborne_bootstrap import boundary. The C6 +factories themselves have their own unit tests under +``tests/unit/c6_tile_cache/`` covering Postgres + FAISS internals; here +we only validate the bootstrap-layer wiring and error translation. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from unittest.mock import MagicMock + +import pytest + +from gps_denied_onboard.config import Config +from gps_denied_onboard.fdr_client import client as fdr_client_module +from gps_denied_onboard.runtime_root import airborne_bootstrap +from gps_denied_onboard.runtime_root.airborne_bootstrap import ( + FAISS_BUILD_FLAG, + AirborneBootstrapError, + build_pre_constructed, +) +from gps_denied_onboard.runtime_root.errors import RuntimeNotAvailableError + + +@dataclass(frozen=True) +class _C2VprBlock: + """Minimal c2_vpr config block — only the field the test needs.""" + + strategy: str = "net_vlad" + + +@pytest.fixture(autouse=True) +def _isolated_fdr_cache() -> Iterator[None]: + # Arrange: every test starts with an empty FdrClient cache so the + # bootstrap's make_fdr_client call doesn't accidentally pick up a + # stale instance from a prior test in the same process. + fdr_client_module._reset_for_tests() + yield + fdr_client_module._reset_for_tests() + + +def test_ac_620_1_adds_c6_descriptor_index_and_c6_tile_store( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange: stub the c6 factories to return identifiable sentinels so we + # can assert wiring without standing up real FAISS + Postgres. + descriptor_index_sentinel = MagicMock(name="DescriptorIndex") + tile_store_sentinel = MagicMock(name="TileStore") + monkeypatch.setattr( + airborne_bootstrap, + "build_descriptor_index", + lambda _config: descriptor_index_sentinel, + ) + monkeypatch.setattr( + airborne_bootstrap, + "build_tile_store", + lambda _config: tile_store_sentinel, + ) + config = Config() + + # Act + pre_constructed = build_pre_constructed(config) + + # Assert: AZ-620 keys are present and reference the exact factory + # returns. AZ-619 keys are still present (additivity contract). + assert "c6_descriptor_index" in pre_constructed + assert "c6_tile_store" in pre_constructed + assert pre_constructed["c6_descriptor_index"] is descriptor_index_sentinel + assert pre_constructed["c6_tile_store"] is tile_store_sentinel + assert "c13_fdr" in pre_constructed + assert "clock" in pre_constructed + + +def test_ac_620_2_build_flag_off_with_configured_c2_vpr_raises_named_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange: simulate BUILD_FAISS_INDEX=OFF by making the c6 descriptor + # factory raise RuntimeNotAvailableError the same way storage_factory + # does in production. Tile store stays stubbed so the test is focused + # on the descriptor-index translation path. + monkeypatch.setattr( + airborne_bootstrap, + "build_descriptor_index", + _raise_faiss_disabled, + ) + monkeypatch.setattr( + airborne_bootstrap, + "build_tile_store", + lambda _config: MagicMock(name="TileStore"), + ) + config = Config.with_blocks(c2_vpr=_C2VprBlock(strategy="net_vlad")) + + # Act + Assert + with pytest.raises(AirborneBootstrapError) as excinfo: + build_pre_constructed(config) + + message = str(excinfo.value) + assert "c6_descriptor_index" in message + assert FAISS_BUILD_FLAG in message + assert "c2_vpr" in message + # The original factory error is preserved as the cause chain so the + # operator can still see the upstream reason. + assert isinstance(excinfo.value.__cause__, RuntimeNotAvailableError) + + +def test_ac_620_2_no_configured_consumer_still_raises_with_full_set( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Defence-in-depth: even with no consumer configured, AZ-620 still + fails loudly when BUILD_FAISS_INDEX is OFF. + + Rationale: ``build_pre_constructed`` is the airborne contract's single + seam — silently returning a dict without ``c6_descriptor_index`` would + let a later AZ-621..AZ-624 phase or a downstream caller misread the + state. The error message in this case lists the FULL theoretical + consumer set so the operator still gets actionable information. + """ + # Arrange + monkeypatch.setattr( + airborne_bootstrap, + "build_descriptor_index", + _raise_faiss_disabled, + ) + monkeypatch.setattr( + airborne_bootstrap, + "build_tile_store", + lambda _config: MagicMock(name="TileStore"), + ) + config = Config() # empty components + + # Act + Assert + with pytest.raises(AirborneBootstrapError) as excinfo: + build_pre_constructed(config) + + message = str(excinfo.value) + assert "c6_descriptor_index" in message + assert FAISS_BUILD_FLAG in message + # When no consumer is configured, ALL theoretical consumers + # of c6_descriptor_index are surfaced — currently {c2_vpr}. + assert "c2_vpr" in message + + +def _raise_faiss_disabled(_config: Config) -> None: + raise RuntimeNotAvailableError( + f"DescriptorIndex runtime 'faiss_hnsw' requires " + f"{FAISS_BUILD_FLAG}=ON in this binary; the flag is OFF." + )