Files
gps-denied-onboard/tests/unit/c3_matcher/test_protocol_conformance.py
T
Oleksandr Bezdieniezhnykh 89c223882b [AZ-344] C3 CrossDomainMatcher Protocol + factory + RollingHealthWindow
Defines the public `CrossDomainMatcher` Protocol (PEP 544
@runtime_checkable, two methods: `match` + `health_snapshot`),
the three frozen+slotted DTOs (`CandidateMatchSet`, `MatchResult`,
`MatcherHealth`) in the L1 `_types/matcher.py` layer, the
`MatcherError` family (`MatcherBackboneError`,
`InsufficientInliersError`), and the composition-root
`build_matcher_strategy` factory with lazy-import +
`BUILD_MATCHER_<variant>` gating per ADR-002.

`RollingHealthWindow` accumulator (60 s, amortised O(1) update,
strict O(1) snapshot) is constructed by the factory and injected
into every concrete matcher so all backbones share window
semantics; this is what backs C5's spoof-promotion gate.

Legacy placeholder `MatchResult` removed from `_types/matching.py`;
import-only consumers (`c4_pose.interface`, `c3_5_adhop.interface`)
repointed at the new `_types/matcher.py` home — zero behavioural
change to those components.

AC-9 (single-thread binding) and AC-10 (LightGlueRuntime
identity-share with C2.5) deferred to AZ-270 runtime-root
composition, mirroring the AZ-342 Risk-4 escape clause. All other
ACs + NFRs covered by 70 new conformance tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 05:43:33 +03:00

681 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)