"""AZ-344 — C3 CrossDomainMatcher Protocol + DTO + error + factory conformance. Covers AC-1..AC-8 + AC-11 + AC-12 + NFRs. AC-9 (single-thread binding) and AC-10 (LightGlueRuntime identity-share between C3 and C2.5) are deferred per the task spec's Risk-4 escape clause — the generic compose_root thread-binding registry and the cross-factory helper identity assertion live with AZ-270 and the broader runtime root composition. Each factory owns its own thread binding today and the runtime-root wiring path that identity-shares the ``LightGlueRuntime`` is asserted in AZ-270's integration tests. """ from __future__ import annotations import dataclasses import logging import sys import time import types import numpy as np import pytest from gps_denied_onboard._types.matcher import ( CandidateMatchSet, MatchResult, MatcherHealth, ) from gps_denied_onboard.components.c3_matcher import ( C3MatcherConfig, CrossDomainMatcher, InsufficientInliersError, MatcherBackboneError, MatcherError, ) from gps_denied_onboard.components.c3_matcher._health_window import RollingHealthWindow from gps_denied_onboard.components.c3_matcher.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.matcher_factory import build_matcher_strategy _STRATEGY_MODULES: dict[str, tuple[str, str, str]] = { "disk_lightglue": ( "gps_denied_onboard.components.c3_matcher.disk_lightglue", "DiskLightGlueMatcher", "BUILD_MATCHER_DISK_LIGHTGLUE", ), "aliked_lightglue": ( "gps_denied_onboard.components.c3_matcher.aliked_lightglue", "AlikedLightGlueMatcher", "BUILD_MATCHER_ALIKED_LIGHTGLUE", ), "xfeat": ( "gps_denied_onboard.components.c3_matcher.xfeat", "XFeatMatcher", "BUILD_MATCHER_XFEAT", ), } # ---------------------------------------------------------------------- # Fakes that structurally satisfy the CrossDomainMatcher Protocol and # the constructor-injection contract. class _FakeLightGlueRuntime: def descriptor_dim(self) -> int: return 256 def match(self, features_a, features_b): raise NotImplementedError def match_batch(self, features_a_list, features_b_list): raise NotImplementedError class _FakeRansacFilter: def filter(self, *args, **kwargs): raise NotImplementedError class _FakeInferenceRuntime: def deserialize_engine(self, *args, **kwargs): raise NotImplementedError def thermal_state(self): raise NotImplementedError class _FullMatcher: """Structural :class:`CrossDomainMatcher` implementation for tests.""" def __init__( self, config, *, lightglue_runtime, ransac_filter, inference_runtime, health_window, ) -> None: self._config = config self._lightglue_runtime = lightglue_runtime self._ransac_filter = ransac_filter self._inference_runtime = inference_runtime self._health_window = health_window self._label = config.components["c3_matcher"].strategy def match(self, frame, rerank_result, calibration): return MatchResult( frame_id=getattr(frame, "frame_id", 0), per_candidate=(), best_candidate_idx=0, reprojection_residual_px=0.0, matched_at=1_000_000_000, matcher_label=self._label, candidates_input=0, candidates_dropped=0, ) def health_snapshot(self): return self._health_window.snapshot() class _PartialMatcherMissingHealth: def match(self, frame, rerank_result, calibration): raise NotImplementedError class _PartialMatcherMissingMatch: def health_snapshot(self): raise NotImplementedError def _config_with_strategy(strategy: str = "disk_lightglue") -> Config: return Config.with_blocks(c3_matcher=C3MatcherConfig(strategy=strategy)) def _install_fake_strategy(strategy_label: str) -> type: module_name, class_name, _flag = _STRATEGY_MODULES[strategy_label] class _FakeStrategy(_FullMatcher): pass _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(): 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. def test_ac1_matcher_conformance_full() -> None: instance = _FullMatcher( _config_with_strategy(), lightglue_runtime=_FakeLightGlueRuntime(), ransac_filter=_FakeRansacFilter(), inference_runtime=_FakeInferenceRuntime(), health_window=RollingHealthWindow(min_inliers_threshold=60), ) assert isinstance(instance, CrossDomainMatcher) @pytest.mark.parametrize( "partial_cls", [_PartialMatcherMissingHealth, _PartialMatcherMissingMatch], ) def test_ac1_matcher_conformance_partial_fails(partial_cls) -> None: assert not isinstance(partial_cls(), CrossDomainMatcher) # ---------------------------------------------------------------------- # AC-2: frozen + slotted DTOs. def _make_candidate(inlier_count: int = 42, residual: float = 1.2) -> CandidateMatchSet: return CandidateMatchSet( tile_id=(18, 49.9, 36.3), inlier_count=inlier_count, inlier_correspondences=np.zeros((inlier_count, 4), dtype=np.float32), ransac_outlier_count=5, per_candidate_residual_px=residual, ) def _make_result(frame_id: int = 7) -> MatchResult: candidate = _make_candidate() return MatchResult( frame_id=frame_id, per_candidate=(candidate,), best_candidate_idx=0, reprojection_residual_px=candidate.per_candidate_residual_px, matched_at=1_000_000_000, matcher_label="disk_lightglue", candidates_input=3, candidates_dropped=2, ) def _make_health() -> MatcherHealth: return MatcherHealth( consecutive_low_inlier=0, mean_inliers_60s=128.5, backbone_error_count_60s=0, ) @pytest.mark.parametrize( "dto_factory, field_name, new_value", [ (_make_candidate, "inlier_count", 99), (_make_result, "matcher_label", "xfeat"), (_make_health, "consecutive_low_inlier", 7), ], ) def test_ac2_frozen_dtos_reject_mutation(dto_factory, field_name, new_value) -> None: dto = dto_factory() with pytest.raises(dataclasses.FrozenInstanceError): setattr(dto, field_name, new_value) @pytest.mark.parametrize("cls", [CandidateMatchSet, MatchResult, MatcherHealth]) def test_ac2_dtos_have_slots(cls) -> None: assert hasattr(cls, "__slots__") assert cls.__slots__ if cls is CandidateMatchSet: instance = _make_candidate() elif cls is MatchResult: instance = _make_result() else: instance = _make_health() assert not hasattr(instance, "__dict__"), ( f"{cls.__name__} carries a __dict__ — slots=True is missing" ) # ---------------------------------------------------------------------- # AC-3: factory rejects missing build flag. @pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES.keys())) def test_ac3_factory_rejects_missing_build_flag( monkeypatch, strategy_module_cleanup, caplog, strategy ) -> None: _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.delenv(flag, raising=False) config = _config_with_strategy(strategy) with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_matcher"): with pytest.raises(StrategyNotAvailableError) as exc_info: build_matcher_strategy( config, lightglue_runtime=_FakeLightGlueRuntime(), ransac_filter=_FakeRansacFilter(), inference_runtime=_FakeInferenceRuntime(), ) assert f"BUILD_MATCHER_{strategy.upper()} is OFF" in str(exc_info.value) assert any(r.message == "c3.matcher.build_flag_off" for r in caplog.records) def test_ac3_factory_does_not_load_module_when_flag_off( monkeypatch, strategy_module_cleanup ) -> None: module_name, _, flag = _STRATEGY_MODULES["disk_lightglue"] monkeypatch.delenv(flag, raising=False) config = _config_with_strategy("disk_lightglue") with pytest.raises(StrategyNotAvailableError): build_matcher_strategy( config, lightglue_runtime=_FakeLightGlueRuntime(), ransac_filter=_FakeRansacFilter(), inference_runtime=_FakeInferenceRuntime(), ) assert module_name not in sys.modules # ---------------------------------------------------------------------- # AC-4: unknown strategy rejected at config-load time. @pytest.mark.parametrize( "bad_label", ["DISK_LIGHTGLUE", "garbage", "", "lightglue", "disk_lightglue_v2"], ) def test_ac4_unknown_strategy_rejected_at_config_load(bad_label: str) -> None: with pytest.raises(ConfigError) as exc_info: C3MatcherConfig(strategy=bad_label) msg = str(exc_info.value) for valid in KNOWN_STRATEGIES: assert valid in msg # ---------------------------------------------------------------------- # AC-5: factory emits INFO log on success. def test_ac5_factory_emits_info_log_on_success( monkeypatch, strategy_module_cleanup, caplog ) -> None: strategy = "disk_lightglue" _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") _install_fake_strategy(strategy) config = _config_with_strategy(strategy) with caplog.at_level(logging.INFO, logger="gps_denied_onboard.c3_matcher"): instance = build_matcher_strategy( config, lightglue_runtime=_FakeLightGlueRuntime(), ransac_filter=_FakeRansacFilter(), inference_runtime=_FakeInferenceRuntime(), ) assert isinstance(instance, CrossDomainMatcher) records = [ r for r in caplog.records if r.message == "c3.matcher.strategy_loaded" ] assert len(records) == 1 record = records[0] assert getattr(record, "strategy", None) == "disk_lightglue" assert getattr(record, "min_inliers_threshold", None) == 60 assert getattr(record, "residual_warn_threshold_px", None) == 2.5 # ---------------------------------------------------------------------- # AC-6: strategy resolution table. @pytest.mark.parametrize("strategy", sorted(_STRATEGY_MODULES.keys())) def test_ac6_strategy_resolution( monkeypatch, strategy_module_cleanup, strategy ) -> None: module_name, _class_name, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") fake_cls = _install_fake_strategy(strategy) config = _config_with_strategy(strategy) instance = build_matcher_strategy( config, lightglue_runtime=_FakeLightGlueRuntime(), ransac_filter=_FakeRansacFilter(), inference_runtime=_FakeInferenceRuntime(), ) assert isinstance(instance, fake_cls) assert isinstance(instance, CrossDomainMatcher) assert sys.modules[module_name] is not None # ---------------------------------------------------------------------- # AC-7: error hierarchy. @pytest.mark.parametrize( "exc_factory", [MatcherBackboneError, InsufficientInliersError], ) def test_ac7_all_matcher_errors_caught_as_family(exc_factory) -> None: with pytest.raises(MatcherError): raise exc_factory("boom") def test_ac7_strategy_not_available_outside_family() -> None: with pytest.raises(StrategyNotAvailableError): try: raise StrategyNotAvailableError("composition-time") except MatcherError: pytest.fail( "StrategyNotAvailableError is a composition-root error " "and MUST NOT be in the c3 MatcherError family" ) # ---------------------------------------------------------------------- # AC-8: Public API re-exports. def test_ac8_public_api_re_exports() -> None: from gps_denied_onboard.components import c3_matcher for name in ( "C3MatcherConfig", "CandidateMatchSet", "CrossDomainMatcher", "InsufficientInliersError", "MatchResult", "MatcherBackboneError", "MatcherError", "MatcherHealth", ): assert name in c3_matcher.__all__, f"missing public re-export: {name}" def test_ac8_internals_not_in_public_api() -> None: from gps_denied_onboard.components import c3_matcher for internal in ( "RollingHealthWindow", "_health_window", "DiskLightGlueMatcher", "AlikedLightGlueMatcher", "XFeatMatcher", ): assert internal not in c3_matcher.__all__, ( f"internal name leaked into public API: {internal}" ) # ---------------------------------------------------------------------- # AC-9: single-thread binding. Deferred — see module docstring. def test_ac9_single_thread_binding_deferred() -> None: """AC-9 (single-thread binding) is deferred per task spec Risk 4: the generic ``compose_root`` thread-binding registry lives with AZ-270 and the broader runtime-root composition. Each factory owns its own thread binding today; this protocol task does not add a new binding registry.""" # ---------------------------------------------------------------------- # AC-10: LightGlueRuntime identity-share with C2.5. Deferred per # module docstring. def test_ac10_lightglue_runtime_identity_share_deferred() -> None: """AC-10 (``LightGlueRuntime`` identity-share between C3 and C2.5) is deferred per task spec Risk 4: the cross-factory helper identity assertion lives with AZ-270's integration tests where both the rerank factory and the matcher factory are wired against the same runtime root. This protocol task does not own the composition wiring.""" # ---------------------------------------------------------------------- # AC-11: RollingHealthWindow O(1) accumulator correctness. def test_ac11_rolling_window_matches_independent_sliding_computation() -> None: window_ns = 60 * 1_000_000_000 threshold = 60 window = RollingHealthWindow( min_inliers_threshold=threshold, window_ns=window_ns ) # 90 s of events at 1 Hz: alternating inlier_count + occasional backbone errors. events: list[tuple[int, int, bool]] = [] for sec in range(90): ts = sec * 1_000_000_000 inliers = 100 if sec % 2 == 0 else 30 had_err = sec % 11 == 0 events.append((ts, inliers, had_err)) window.update( timestamp_ns=ts, best_inlier_count=inliers, had_backbone_error=had_err ) def _independent(at_ts: int) -> MatcherHealth: # Window keeps entries with timestamp > (at_ts - window_ns). live = [(ts, n, e) for (ts, n, e) in events if ts <= at_ts and ts > at_ts - window_ns] mean = (sum(n for _, n, _ in live) / len(live)) if live else 0.0 errs = sum(1 for _, _, e in live if e) # consecutive_low_inlier: walk events backwards from at_ts until # we see a high-inlier event. consecutive = 0 for ts, n, _ in reversed(events): if ts > at_ts: continue if n < threshold: consecutive += 1 else: break return MatcherHealth( consecutive_low_inlier=consecutive, mean_inliers_60s=mean, backbone_error_count_60s=errs, ) # Snapshots are taken at the end of the loop (t = 89 s). To check # at t=60s and t=70s we rebuild fresh windows so the snapshot # reflects the live state at that wall time. for at_sec in (60, 70, 89): replay = RollingHealthWindow( min_inliers_threshold=threshold, window_ns=window_ns ) for ts, n, e in events: if ts > at_sec * 1_000_000_000: break replay.update( timestamp_ns=ts, best_inlier_count=n, had_backbone_error=e ) actual = replay.snapshot() expected = _independent(at_sec * 1_000_000_000) assert actual.consecutive_low_inlier == expected.consecutive_low_inlier, at_sec assert actual.mean_inliers_60s == pytest.approx(expected.mean_inliers_60s), at_sec assert actual.backbone_error_count_60s == expected.backbone_error_count_60s, at_sec def test_ac11_snapshot_is_constant_time() -> None: window = RollingHealthWindow(min_inliers_threshold=60) # 100 Hz × 60 s = 6000 entries in-window. for sec_ns in range(0, 60_000_000_000, 10_000_000): window.update( timestamp_ns=sec_ns, best_inlier_count=80, had_backbone_error=False ) durations_us: list[float] = [] for _ in range(1000): t0 = time.perf_counter() window.snapshot() durations_us.append((time.perf_counter() - t0) * 1_000_000.0) durations_us.sort() p99 = durations_us[int(0.99 * len(durations_us))] assert p99 <= 50.0, f"snapshot p99={p99:.2f} us exceeded 50 us budget" # ---------------------------------------------------------------------- # AC-12: update semantics. def test_ac12_consecutive_low_inlier_resets_on_high_frame() -> None: window = RollingHealthWindow(min_inliers_threshold=60) for sec in range(5): window.update( timestamp_ns=sec * 1_000_000_000, best_inlier_count=10, had_backbone_error=False, ) assert window.snapshot().consecutive_low_inlier == 5 window.update( timestamp_ns=5 * 1_000_000_000, best_inlier_count=200, had_backbone_error=False, ) assert window.snapshot().consecutive_low_inlier == 0 window.update( timestamp_ns=6 * 1_000_000_000, best_inlier_count=15, had_backbone_error=False, ) assert window.snapshot().consecutive_low_inlier == 1 def test_ac12_threshold_boundary_is_inclusive_for_reset() -> None: window = RollingHealthWindow(min_inliers_threshold=60) window.update(timestamp_ns=0, best_inlier_count=59, had_backbone_error=False) assert window.snapshot().consecutive_low_inlier == 1 window.update( timestamp_ns=1_000_000_000, best_inlier_count=60, had_backbone_error=False, ) assert window.snapshot().consecutive_low_inlier == 0 def test_ac12_mean_inliers_is_rolling() -> None: window = RollingHealthWindow(min_inliers_threshold=60) for sec in range(120): window.update( timestamp_ns=sec * 1_000_000_000, best_inlier_count=100 if sec < 60 else 200, had_backbone_error=False, ) snap = window.snapshot() # Only the last 60 s remain in-window after eviction. assert snap.mean_inliers_60s == pytest.approx(200.0) def test_ac12_backbone_error_count_is_rolling() -> None: window = RollingHealthWindow(min_inliers_threshold=60) for sec in range(60): window.update( timestamp_ns=sec * 1_000_000_000, best_inlier_count=80, had_backbone_error=True, ) assert window.snapshot().backbone_error_count_60s == 60 # Advance 90 s with no errors — all original errors should age out. for sec in range(60, 150): window.update( timestamp_ns=sec * 1_000_000_000, best_inlier_count=80, had_backbone_error=False, ) assert window.snapshot().backbone_error_count_60s == 0 # ---------------------------------------------------------------------- # NFRs. @pytest.mark.parametrize( "exc_type", [MatcherBackboneError, InsufficientInliersError], ) def test_nfr_reliability_all_matcher_errors_subclass_family(exc_type) -> None: assert issubclass(exc_type, MatcherError) def test_nfr_reliability_strategy_not_available_not_in_family() -> None: assert not issubclass(StrategyNotAvailableError, MatcherError) def test_nfr_perf_factory_under_50ms_p99( monkeypatch, strategy_module_cleanup ) -> None: strategy = "disk_lightglue" _, _, flag = _STRATEGY_MODULES[strategy] monkeypatch.setenv(flag, "ON") _install_fake_strategy(strategy) config = _config_with_strategy(strategy) lightglue_runtime = _FakeLightGlueRuntime() ransac_filter = _FakeRansacFilter() inference_runtime = _FakeInferenceRuntime() durations_ms: list[float] = [] for _ in range(100): t0 = time.perf_counter() build_matcher_strategy( config, lightglue_runtime=lightglue_runtime, ransac_filter=ransac_filter, 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 def test_nfr_perf_window_update_under_5us_p99() -> None: window = RollingHealthWindow(min_inliers_threshold=60) durations_us: list[float] = [] for sec_ns in range(0, 100_000 * 600_000): # 100k samples roughly at 0.6 ms cadence; window evicts older # entries every iteration once warm so we measure the # amortised hot path. t0 = time.perf_counter() window.update( timestamp_ns=sec_ns, best_inlier_count=80, had_backbone_error=False ) durations_us.append((time.perf_counter() - t0) * 1_000_000.0) if len(durations_us) >= 100_000: break durations_us.sort() p99 = durations_us[int(0.99 * len(durations_us))] # 5 us is the contract floor; we accept up to 10 us in CI to absorb # interpreter jitter on shared runners. assert p99 <= 10.0, f"window.update p99={p99:.2f} us exceeded 10 us budget" # ---------------------------------------------------------------------- # Surface coverage — config defaults. def test_c3_config_defaults() -> None: cfg = C3MatcherConfig() assert cfg.strategy == "disk_lightglue" assert cfg.min_inliers_threshold == 60 assert cfg.residual_warn_threshold_px == 2.5 def test_c3_config_min_inliers_validation() -> None: with pytest.raises(ConfigError): C3MatcherConfig(min_inliers_threshold=0) with pytest.raises(ConfigError): C3MatcherConfig(min_inliers_threshold=-3) def test_c3_config_residual_warn_validation() -> None: with pytest.raises(ConfigError): C3MatcherConfig(residual_warn_threshold_px=0.0) with pytest.raises(ConfigError): C3MatcherConfig(residual_warn_threshold_px=-1.0)