Files
gps-denied-onboard/tests/unit/c2_5_rerank/test_protocol_conformance.py
T
Oleksandr Bezdieniezhnykh 48ea1e2fc2 [AZ-343] C2.5 InlierCountReRanker + shared FeatureExtractor helper
Implements the production-default ReRankStrategy: K=10 → N=3 by
single-pair LightGlue inlier count, with strict drop-and-continue
(INV-8) on per-candidate TileFetch / backbone / zero-inlier failures
and RerankAllCandidatesFailedError on zero survivors. Composition
root injects the shared LightGlueRuntime + Clock + the new
FeatureExtractor helper (an L1 placeholder OpenCvOrbExtractor that
unblocks AZ-343 and future C3 strategies — task scope expansion).

Architectural notes:
- Cross-component imports stay banned; tile_store types as `object`
  and the C6 TileCacheError family is duck-typed by class module
  prefix (same workaround AZ-348 adopted for c7_inference; proper
  fix is to relocate TileCacheError to _types/ in a follow-up).
- Clock injection follows the replay contract (AZ-398 Invariant 2);
  reranked_at is sourced from clock.monotonic_ns().
- AZ-342 factory grew `feature_extractor` + `clock` + `fdr_client`
  parameters; existing AZ-342 conformance tests updated.

Tests: 19 new AC-1..AC-12 + mixed-failure scenarios in
test_inlier_count_reranker.py; existing AZ-342 suite (26) still
green. Full repo sweep 1093 passed / 2 skipped (cmake/actionlint
not on PATH).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 06:22:40 +03:00

455 lines
14 KiB
Python

"""AZ-342 — C2.5 ReRankStrategy Protocol + DTO + error + factory conformance.
Covers AC-1..AC-8 + AC-11 + NFRs. AC-9 (single-thread binding) and
AC-10 (LightGlueRuntime identity-share between C2.5 and C3) 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 future C3 protocol task
(AZ-344). Each factory owns its own thread binding today.
"""
from __future__ import annotations
import dataclasses
import logging
import sys
import time
import types
import pytest
from gps_denied_onboard._types.rerank import RerankCandidate, RerankResult
from gps_denied_onboard.components.c2_5_rerank import (
C2_5RerankConfig,
ReRankStrategy,
RerankAllCandidatesFailedError,
RerankBackboneError,
RerankError,
)
from gps_denied_onboard.components.c2_5_rerank.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.rerank_factory import build_rerank_strategy
_STRATEGY_MODULES: dict[str, tuple[str, str, str]] = {
"inlier_count": (
"gps_denied_onboard.components.c2_5_rerank.inlier_based_reranker",
"InlierCountReRanker",
"BUILD_RERANK_INLIER_COUNT",
),
}
# ----------------------------------------------------------------------
# Fakes that structurally satisfy the ReRankStrategy Protocol.
class _FakeTileStore:
def read_tile_pixels(self, tile_id):
raise NotImplementedError
def write_tile(self, tile_blob, metadata):
raise NotImplementedError
def tile_exists(self, tile_id):
return False
def delete_tile(self, tile_id):
return False
class _FakeLightGlueRuntime:
def descriptor_dim(self):
return 256
def match(self, features_a, features_b):
raise NotImplementedError
def match_batch(self, features_a_list, features_b_list):
raise NotImplementedError
class _FakeFeatureExtractor:
def descriptor_dim(self):
return 256
def extract(self, image_bgr):
raise NotImplementedError
class _FakeClock:
def __init__(self) -> None:
self._t = 1_000_000_000
def monotonic_ns(self):
self._t += 1
return self._t
def time_ns(self):
return self._t
def sleep_until_ns(self, target_ns):
return None
class _FullReRankStrategy:
def __init__(
self,
config,
*,
tile_store,
lightglue_runtime,
feature_extractor=None,
clock=None,
fdr_client=None,
) -> None:
self._config = config
self._tile_store = tile_store
self._lightglue_runtime = lightglue_runtime
self._feature_extractor = feature_extractor
self._clock = clock
self._fdr_client = fdr_client
self._label = config.components["c2_5_rerank"].strategy
def rerank(self, frame, vpr_result, n, calibration):
return RerankResult(
frame_id=getattr(frame, "frame_id", 0),
candidates=tuple(),
reranked_at=1_000_000_000,
rerank_label=self._label,
candidates_input=0,
candidates_dropped=0,
)
class _PartialReRankStrategy:
pass
def _config_with_strategy(strategy: str = "inlier_count") -> Config:
return Config.with_blocks(c2_5_rerank=C2_5RerankConfig(strategy=strategy))
def _install_fake_strategy(strategy_label: str) -> type:
module_name, class_name, _flag = _STRATEGY_MODULES[strategy_label]
class _FakeStrategy(_FullReRankStrategy):
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_rerank_strategy_conformance_full() -> None:
instance = _FullReRankStrategy(
_config_with_strategy(),
tile_store=_FakeTileStore(),
lightglue_runtime=_FakeLightGlueRuntime(),
clock=_FakeClock(),
)
assert isinstance(instance, ReRankStrategy)
def test_ac1_rerank_strategy_conformance_partial_missing_methods() -> None:
assert not isinstance(_PartialReRankStrategy(), ReRankStrategy)
# ----------------------------------------------------------------------
# AC-2: frozen+slotted DTOs.
def _make_candidate() -> RerankCandidate:
return RerankCandidate(
tile_id=(18, 49.9, 36.3),
inlier_count=42,
descriptor_distance=0.123,
descriptor_dim=512,
tile_pixels_handle=object(),
)
def _make_result(frame_id: int = 7) -> RerankResult:
return RerankResult(
frame_id=frame_id,
candidates=(_make_candidate(),),
reranked_at=1_000_000_000,
rerank_label="inlier_count",
candidates_input=10,
candidates_dropped=9,
)
@pytest.mark.parametrize(
"dto, field_name, new_value",
[
(_make_candidate(), "inlier_count", 99),
(_make_result(), "rerank_label", "learned_reranker"),
],
)
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", [RerankCandidate, RerankResult])
def test_ac2_dtos_have_slots(cls) -> None:
assert hasattr(cls, "__slots__")
assert cls.__slots__
instance = _make_candidate() if cls is RerankCandidate 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 = "inlier_count"
_, _, 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_5_rerank"):
with pytest.raises(StrategyNotAvailableError) as exc_info:
build_rerank_strategy(
config,
tile_store=_FakeTileStore(),
lightglue_runtime=_FakeLightGlueRuntime(),
feature_extractor=_FakeFeatureExtractor(),
clock=_FakeClock(),
)
assert "BUILD_RERANK_INLIER_COUNT is OFF" in str(exc_info.value)
assert any(
r.message == "c2_5.rerank.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["inlier_count"]
monkeypatch.delenv(flag, raising=False)
config = _config_with_strategy("inlier_count")
with pytest.raises(StrategyNotAvailableError):
build_rerank_strategy(
config,
tile_store=_FakeTileStore(),
lightglue_runtime=_FakeLightGlueRuntime(),
feature_extractor=_FakeFeatureExtractor(),
clock=_FakeClock(),
)
assert module_name not in sys.modules
# ----------------------------------------------------------------------
# AC-4: unknown strategy rejected at config-load time.
@pytest.mark.parametrize(
"bad_label",
["INLIER_COUNT", "garbage", "", "learned_reranker"],
)
def test_ac4_unknown_strategy_rejected_at_config_load(bad_label: str) -> None:
with pytest.raises(ConfigError) as exc_info:
C2_5RerankConfig(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 = "inlier_count"
_, _, 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.c2_5_rerank"):
instance = build_rerank_strategy(
config,
tile_store=_FakeTileStore(),
lightglue_runtime=_FakeLightGlueRuntime(),
feature_extractor=_FakeFeatureExtractor(),
clock=_FakeClock(),
)
assert isinstance(instance, ReRankStrategy)
records = [
r for r in caplog.records if r.message == "c2_5.rerank.strategy_loaded"
]
assert len(records) == 1
record = records[0]
assert getattr(record, "strategy", None) == "inlier_count"
assert getattr(record, "top_n", None) == 3
# ----------------------------------------------------------------------
# AC-6: strategy resolution table.
def test_ac6_strategy_resolution(monkeypatch, strategy_module_cleanup) -> None:
strategy = "inlier_count"
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_rerank_strategy(
config,
tile_store=_FakeTileStore(),
lightglue_runtime=_FakeLightGlueRuntime(),
feature_extractor=_FakeFeatureExtractor(),
clock=_FakeClock(),
)
assert isinstance(instance, fake_cls)
assert isinstance(instance, ReRankStrategy)
assert sys.modules[module_name] is not None
# ----------------------------------------------------------------------
# AC-7: error hierarchy.
@pytest.mark.parametrize(
"exc_factory",
[RerankBackboneError, RerankAllCandidatesFailedError],
)
def test_ac7_all_rerank_errors_caught_as_family(exc_factory) -> None:
with pytest.raises(RerankError):
raise exc_factory("boom")
def test_ac7_strategy_not_available_outside_family() -> None:
with pytest.raises(StrategyNotAvailableError):
try:
raise StrategyNotAvailableError("composition-time")
except RerankError:
pytest.fail(
"StrategyNotAvailableError is a composition-root error "
"and MUST NOT be in the c2.5 RerankError family"
)
# ----------------------------------------------------------------------
# AC-8: Public API re-exports.
def test_ac8_public_api_re_exports() -> None:
from gps_denied_onboard.components import c2_5_rerank
assert "ReRankStrategy" in c2_5_rerank.__all__
assert "RerankResult" in c2_5_rerank.__all__
assert "RerankCandidate" in c2_5_rerank.__all__
def test_ac8_internals_not_in_public_api() -> None:
from gps_denied_onboard.components import c2_5_rerank
# Concrete strategy must not leak into the package re-exports;
# consumers see only the Protocol.
assert "InlierCountReRanker" not in c2_5_rerank.__all__
# ----------------------------------------------------------------------
# AC-11: tile_pixels_handle opaqueness.
def test_ac11_tile_pixels_handle_opaque() -> None:
handle = object()
candidate = RerankCandidate(
tile_id=(18, 49.9, 36.3),
inlier_count=10,
descriptor_distance=0.5,
descriptor_dim=256,
tile_pixels_handle=handle,
)
assert candidate.tile_pixels_handle is handle
# ----------------------------------------------------------------------
# NFRs.
@pytest.mark.parametrize(
"exc_type",
[RerankBackboneError, RerankAllCandidatesFailedError],
)
def test_nfr_reliability_all_rerank_errors_subclass_family(exc_type) -> None:
assert issubclass(exc_type, RerankError)
def test_nfr_reliability_strategy_not_available_not_in_family() -> None:
assert not issubclass(StrategyNotAvailableError, RerankError)
def test_nfr_perf_factory_under_50ms_p99(
monkeypatch, strategy_module_cleanup
) -> None:
strategy = "inlier_count"
_, _, flag = _STRATEGY_MODULES[strategy]
monkeypatch.setenv(flag, "ON")
_install_fake_strategy(strategy)
config = _config_with_strategy(strategy)
tile_store = _FakeTileStore()
lightglue_runtime = _FakeLightGlueRuntime()
feature_extractor = _FakeFeatureExtractor()
clock = _FakeClock()
durations_ms: list[float] = []
for _ in range(100):
t0 = time.perf_counter()
build_rerank_strategy(
config,
tile_store=tile_store,
lightglue_runtime=lightglue_runtime,
feature_extractor=feature_extractor,
clock=clock,
)
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
# ----------------------------------------------------------------------
# Surface coverage — config defaults.
def test_c2_5_config_defaults() -> None:
cfg = C2_5RerankConfig()
assert cfg.strategy == "inlier_count"
assert cfg.top_n == 3
def test_c2_5_config_top_n_validation() -> None:
with pytest.raises(ConfigError):
C2_5RerankConfig(top_n=0)
with pytest.raises(ConfigError):
C2_5RerankConfig(top_n=-3)