mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:11:12 +00:00
48ea1e2fc2
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>
455 lines
14 KiB
Python
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)
|