"""AZ-336 — C2 VprStrategy Protocol + DTO + error + factory conformance. Covers all 9 ACs of AZ-336 + the NFRs. The factory ACs (AC-3..AC-6) substitute fake strategy modules at the ``sys.modules`` boundary so the test never touches UltraVPR / NetVLAD / FAISS / TensorRT native libraries. """ from __future__ import annotations import dataclasses import logging import sys import time import types from pathlib import Path import numpy as np import pytest from gps_denied_onboard._types.vpr import VprCandidate, VprQuery, VprResult from gps_denied_onboard.components.c2_vpr import ( C2VprConfig, IndexUnavailableError, VprBackboneError, VprError, VprPreprocessError, VprStrategy, ) from gps_denied_onboard.components.c2_vpr._preprocessor import BackbonePreprocessor from gps_denied_onboard.components.c2_vpr.config import KNOWN_STRATEGIES from gps_denied_onboard.config.schema import Config, ConfigError from gps_denied_onboard.runtime_root.errors import StrategyNotAvailableError from gps_denied_onboard.runtime_root.vpr_factory import build_vpr_strategy _STRATEGY_MODULES: dict[str, tuple[str, str, str]] = { "ultra_vpr": ( "gps_denied_onboard.components.c2_vpr.ultra_vpr", "UltraVprStrategy", "BUILD_VPR_ULTRA_VPR", ), "net_vlad": ( "gps_denied_onboard.components.c2_vpr.net_vlad", "NetVladStrategy", "BUILD_VPR_NETVLAD", ), "mega_loc": ( "gps_denied_onboard.components.c2_vpr.mega_loc", "MegaLocStrategy", "BUILD_VPR_MEGALOC", ), "mix_vpr": ( "gps_denied_onboard.components.c2_vpr.mix_vpr", "MixVprStrategy", "BUILD_VPR_MIXVPR", ), "sela_vpr": ( "gps_denied_onboard.components.c2_vpr.sela_vpr", "SelaVprStrategy", "BUILD_VPR_SELAVPR", ), "eigen_places": ( "gps_denied_onboard.components.c2_vpr.eigen_places", "EigenPlacesStrategy", "BUILD_VPR_EIGENPLACES", ), "salad": ( "gps_denied_onboard.components.c2_vpr.salad", "SaladStrategy", "BUILD_VPR_SALAD", ), } # ---------------------------------------------------------------------- # Fakes that structurally satisfy the VprStrategy + DescriptorIndex # Protocols. Tests substitute these at the sys.modules boundary so no # native library is loaded. class _FakeDescriptorIndex: def __init__(self, dim: int = 512) -> None: self._dim = dim def search_topk(self, query, k): return [] def descriptor_dim(self): return self._dim def mmap_handle(self): return Path("/tmp/fake.faiss") def rebuild_from_descriptors(self, descriptors, tile_ids, hnsw_params): return None def index_metadata(self): raise NotImplementedError class _FakeInferenceRuntime: def load_engine(self, *args, **kwargs): raise NotImplementedError def infer(self, *args, **kwargs): raise NotImplementedError def warm_up(self): return None def thermal_state(self): raise NotImplementedError class _FullVprStrategy: def __init__(self, config, *, descriptor_index, inference_runtime, dim=512) -> None: self._config = config self._descriptor_index = descriptor_index self._inference_runtime = inference_runtime self._dim = dim self._label = config.components["c2_vpr"].strategy def embed_query(self, frame, calibration): return VprQuery( frame_id=getattr(frame, "frame_id", 0), embedding=np.zeros((self._dim,), dtype=np.float32), produced_at=1_000_000_000, ) def retrieve_topk(self, query, k): return VprResult( frame_id=query.frame_id, candidates=tuple(), retrieved_at=1_000_000_000, backbone_label=self._label, ) def descriptor_dim(self): return self._dim class _PartialVprStrategy: def embed_query(self, frame, calibration): raise NotImplementedError def retrieve_topk(self, query, k): raise NotImplementedError def _config_with_strategy(strategy: str) -> Config: return Config.with_blocks(c2_vpr=C2VprConfig(strategy=strategy)) def _install_fake_strategy(strategy_label: str, *, dim: int = 512) -> type: module_name, class_name, _flag = _STRATEGY_MODULES[strategy_label] class _FakeStrategy(_FullVprStrategy): def __init__(self, config, **kwargs) -> None: super().__init__(config, dim=dim, **kwargs) _FakeStrategy.__name__ = class_name module = types.ModuleType(module_name) setattr(module, class_name, _FakeStrategy) sys.modules[module_name] = module return _FakeStrategy @pytest.fixture def strategy_module_cleanup(): """Pop every fake strategy module before/after each factory test.""" for module_name, _, _ in _STRATEGY_MODULES.values(): sys.modules.pop(module_name, None) yield for module_name, _, _ in _STRATEGY_MODULES.values(): sys.modules.pop(module_name, None) # ---------------------------------------------------------------------- # AC-1: Protocol conformance — full satisfies, partial does not. def test_ac1_vpr_strategy_conformance_full() -> None: instance = _FullVprStrategy( _config_with_strategy("net_vlad"), descriptor_index=_FakeDescriptorIndex(), inference_runtime=_FakeInferenceRuntime(), ) assert isinstance(instance, VprStrategy) def test_ac1_vpr_strategy_conformance_partial_missing_methods() -> None: assert not isinstance(_PartialVprStrategy(), VprStrategy) # ---------------------------------------------------------------------- # AC-2: frozen+slotted DTOs reject mutation and forbid __dict__. def _make_query(frame_id: int = 7, dim: int = 512) -> VprQuery: return VprQuery( frame_id=frame_id, embedding=np.zeros((dim,), dtype=np.float32), produced_at=1_000_000_000, ) def _make_candidate() -> VprCandidate: return VprCandidate( tile_id=(18, 49.9, 36.3), descriptor_distance=0.123, descriptor_dim=512, ) def _make_result(frame_id: int = 7) -> VprResult: return VprResult( frame_id=frame_id, candidates=(_make_candidate(),), retrieved_at=1_000_000_000, backbone_label="net_vlad", ) @pytest.mark.parametrize( "dto, field_name, new_value", [ (_make_query(), "frame_id", 99), (_make_candidate(), "descriptor_distance", 0.9), (_make_result(), "backbone_label", "ultra_vpr"), ], ) def test_ac2_frozen_dtos_reject_mutation(dto, field_name: str, new_value) -> None: original_value = getattr(dto, field_name) with pytest.raises(dataclasses.FrozenInstanceError): setattr(dto, field_name, new_value) assert getattr(dto, field_name) == original_value @pytest.mark.parametrize("cls", [VprQuery, VprCandidate, VprResult]) def test_ac2_dtos_have_slots(cls) -> None: assert hasattr(cls, "__slots__"), f"{cls.__name__} must use slots=True" assert cls.__slots__, f"{cls.__name__}.__slots__ must be non-empty" instance = ( _make_query() if cls is VprQuery else _make_candidate() if cls is VprCandidate else _make_result() ) assert not hasattr(instance, "__dict__"), ( f"{cls.__name__} carries a __dict__ — slots=True is missing" ) # ---------------------------------------------------------------------- # AC-3: factory rejects missing build flag. def test_ac3_factory_rejects_missing_build_flag( monkeypatch, strategy_module_cleanup, caplog ) -> None: strategy = "ultra_vpr" _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.delenv(flag, raising=False) config = _config_with_strategy(strategy) with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c2_vpr"): with pytest.raises(StrategyNotAvailableError) as exc_info: build_vpr_strategy( config, descriptor_index=_FakeDescriptorIndex(), inference_runtime=_FakeInferenceRuntime(), ) assert "BUILD_VPR_ULTRA_VPR is OFF" in str(exc_info.value) assert any( r.message == "c2.vpr.build_flag_off" for r in caplog.records ), "ERROR log kind=c2.vpr.build_flag_off must be emitted" @pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES)) def test_ac3_factory_does_not_load_module_when_flag_off( monkeypatch, strategy_module_cleanup, strategy ) -> None: module_name, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.delenv(flag, raising=False) config = _config_with_strategy(strategy) with pytest.raises(StrategyNotAvailableError): build_vpr_strategy( config, descriptor_index=_FakeDescriptorIndex(), inference_runtime=_FakeInferenceRuntime(), ) assert module_name not in sys.modules, ( f"{module_name} must NOT be in sys.modules when its BUILD flag is OFF" ) # ---------------------------------------------------------------------- # AC-4: factory rejects descriptor_dim mismatch. def test_ac4_factory_rejects_dim_mismatch( monkeypatch, strategy_module_cleanup, caplog ) -> None: strategy = "ultra_vpr" _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") _install_fake_strategy(strategy, dim=512) config = _config_with_strategy(strategy) with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c2_vpr"): with pytest.raises(ConfigError) as exc_info: build_vpr_strategy( config, descriptor_index=_FakeDescriptorIndex(dim=4096), inference_runtime=_FakeInferenceRuntime(), ) assert "descriptor_dim mismatch: strategy=512, corpus=4096" in str( exc_info.value ) assert any( r.message == "c2.vpr.dim_mismatch" for r in caplog.records ), "ERROR log kind=c2.vpr.dim_mismatch must be emitted" # ---------------------------------------------------------------------- # AC-5: successful factory load emits INFO log with structured fields. def test_ac5_factory_emits_info_log_on_success( monkeypatch, strategy_module_cleanup, caplog ) -> None: strategy = "ultra_vpr" _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") _install_fake_strategy(strategy, dim=512) config = _config_with_strategy(strategy) with caplog.at_level(logging.INFO, logger="gps_denied_onboard.c2_vpr"): instance = build_vpr_strategy( config, descriptor_index=_FakeDescriptorIndex(dim=512), inference_runtime=_FakeInferenceRuntime(), ) assert isinstance(instance, VprStrategy) records = [ r for r in caplog.records if r.message == "c2.vpr.strategy_loaded" ] assert len(records) == 1, "Exactly one strategy_loaded INFO log expected" record = records[0] assert getattr(record, "strategy", None) == "ultra_vpr" assert getattr(record, "descriptor_dim", None) == 512 # ---------------------------------------------------------------------- # AC-6: every entry in the resolution table resolves to its module path. @pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES)) def test_ac6_strategy_resolution_table( monkeypatch, strategy_module_cleanup, strategy ) -> None: module_name, class_name, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") fake_cls = _install_fake_strategy(strategy, dim=512) config = _config_with_strategy(strategy) instance = build_vpr_strategy( config, descriptor_index=_FakeDescriptorIndex(dim=512), inference_runtime=_FakeInferenceRuntime(), ) assert isinstance(instance, fake_cls) assert isinstance(instance, VprStrategy) assert sys.modules[module_name] is not None # ---------------------------------------------------------------------- # AC-7: error hierarchy — every concrete error is catchable as VprError. @pytest.mark.parametrize( "exc_factory", [VprBackboneError, VprPreprocessError, IndexUnavailableError], ) def test_ac7_all_vpr_errors_caught_as_family(exc_factory) -> None: with pytest.raises(VprError): raise exc_factory("boom") def test_ac7_unrelated_exception_not_caught_as_family() -> None: with pytest.raises(ValueError): try: raise ValueError("not us") except VprError: pytest.fail("ValueError must not be caught as VprError") def test_ac7_strategy_not_available_outside_family() -> None: with pytest.raises(StrategyNotAvailableError): try: raise StrategyNotAvailableError("composition-time") except VprError: pytest.fail( "StrategyNotAvailableError is a composition-root error " "and MUST NOT be in the c2 VprError family" ) # ---------------------------------------------------------------------- # AC-8: Public API surface — re-exports + BackbonePreprocessor exclusion. def test_ac8_public_api_re_exports() -> None: from gps_denied_onboard.components import c2_vpr assert "VprStrategy" in c2_vpr.__all__ assert "VprQuery" in c2_vpr.__all__ assert "VprCandidate" in c2_vpr.__all__ assert "VprResult" in c2_vpr.__all__ def test_ac8_backbone_preprocessor_not_in_public_api() -> None: from gps_denied_onboard.components import c2_vpr assert "BackbonePreprocessor" not in c2_vpr.__all__ assert not hasattr(c2_vpr, "BackbonePreprocessor"), ( "BackbonePreprocessor is C2-internal per description.md § 6 and " "MUST NOT be re-exported from c2_vpr/__init__.py" ) def test_ac8_backbone_preprocessor_protocol_is_runtime_checkable() -> None: # BackbonePreprocessor is internal but still a Protocol; tests in # AZ-337..AZ-340 will use isinstance against it. class _OkPreprocessor: def preprocess(self, frame, calibration): raise NotImplementedError def input_shape(self): return (224, 224) assert isinstance(_OkPreprocessor(), BackbonePreprocessor) # ---------------------------------------------------------------------- # Config validation — unknown strategy label is rejected at load. # (AC-9 single-thread binding is deferred per AZ-336 task spec Risk 4; # the generic compose_root thread-binding registry referenced by AC-9 # has not materialised — each factory owns its own thread binding # today, e.g. ``runtime_root.fc_factory.clear_outbound_thread_binding``. # AC-9 ships with AZ-270's registry or its replacement; this task # delivers AC-1..AC-8 + NFRs in line with the spec's escape clause.) @pytest.mark.parametrize( "bad_label", ["ULTRA_VPR", "ultraVpr", "openVLAD", "", "vins_mono"], ) def test_unknown_strategy_rejected_at_config_load(bad_label: str) -> None: with pytest.raises(ConfigError) as exc_info: C2VprConfig(strategy=bad_label) msg = str(exc_info.value) for valid in KNOWN_STRATEGIES: assert valid in msg # ---------------------------------------------------------------------- # NFRs. @pytest.mark.parametrize( "exc_type", [VprBackboneError, VprPreprocessError, IndexUnavailableError], ) def test_nfr_reliability_all_vpr_errors_subclass_family(exc_type) -> None: assert issubclass(exc_type, VprError) def test_nfr_reliability_strategy_not_available_not_in_family() -> None: assert not issubclass(StrategyNotAvailableError, VprError) def test_nfr_perf_factory_under_50ms_p99( monkeypatch, strategy_module_cleanup ) -> None: """Factory p99 ≤ 50 ms across 100 calls (NFR-perf-factory).""" strategy = "net_vlad" _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") _install_fake_strategy(strategy, dim=512) config = _config_with_strategy(strategy) descriptor_index = _FakeDescriptorIndex(dim=512) inference_runtime = _FakeInferenceRuntime() durations_ms: list[float] = [] for _ in range(100): t0 = time.perf_counter() build_vpr_strategy( config, descriptor_index=descriptor_index, inference_runtime=inference_runtime, ) durations_ms.append((time.perf_counter() - t0) * 1000.0) durations_ms.sort() p99 = durations_ms[int(0.99 * len(durations_ms))] assert p99 <= 50.0, ( f"build_vpr_strategy() p99={p99:.3f} ms exceeds 50 ms NFR" ) # ---------------------------------------------------------------------- # Surface coverage — config defaults round-trip. def test_c2_config_default_strategy_is_net_vlad() -> None: cfg = C2VprConfig() assert cfg.strategy == "net_vlad" def test_c2_config_paths_coerce_to_path() -> None: cfg = C2VprConfig( backbone_weights_path="/tmp/weights", # type: ignore[arg-type] faiss_index_path="/tmp/index.faiss", # type: ignore[arg-type] ) assert isinstance(cfg.backbone_weights_path, Path) assert isinstance(cfg.faiss_index_path, Path)