mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 21:41:12 +00:00
a1185d0a28
Implement the three concrete C3 CrossDomainMatcher strategies plus the C3.5 production-default AdHoPRefiner. C3 (AZ-345/346/347): - DiskLightGlueMatcher + AlikedLightGlueMatcher share a single shared _pipeline.run_lightglue_pipeline orchestrator (decode -> query extract -> per-candidate loop -> RANSAC sort -> health update -> FDR emit) so the only per-backbone delta is the keypoint+descriptor extractor closure. ALIKED adds a create-time engine output-schema probe (AC-special-1). - XFeatMatcher owns its own per-candidate loop (single forward fuses extraction + matching); it re-uses the shared FDR emission helpers to keep telemetry byte-identical across strategies. lightglue_runtime parameter accepted by factory but discarded (AC-special-1). - All three consume the shared LightGlueRuntime / RansacFilter / RollingHealthWindow helpers; no helper forks. InferenceRuntimeCut consumer-side Protocol added per AZ-507. C3.5 (AZ-349): - AdHoPRefiner implements the <= conditional gate, runs the OrthoLoC AdHoP TRT engine over best-candidate correspondences, re-runs RANSAC on the perspective-preconditioned set, and emits an enriched MatchResult with refinement_label="adhop". - Invariant 4 passthrough fall-through: any RefinerBackboneError (TRT failure, OOM, NaN, bad shape) is caught, logged ERROR, FDR-emitted with error: true, and converted to passthrough that still counts against the rolling invocation-rate window. MemoryError and other non-listed exceptions propagate by design (AC-5 closed-set semantics). - Rolling 60-s invocation-rate window + rate-limited WARN log (configurable via ratelimited_warn_window_ns; default 60 s). Shared changes: - C3MatcherConfig + C3_5RefinerConfig extended with the new weights/threshold/window fields. - matcher_factory + refiner_factory optionally forward clock + fdr_client to the strategy's create(); backward-compatible. - fdr_client.records registers five new kinds: matcher.frame_done, matcher.backbone_error, matcher.insufficient_inliers, matcher.all_failed, refiner.frame_done. Tests: 66 new (43 C3 parametrised + 23 AdHoP) covering 47/47 ACs; focused suite green; full project test suite green except for one pre-existing flaky CLI cold-start timing test unrelated to this batch. Co-authored-by: Cursor <cursoragent@cursor.com>
499 lines
16 KiB
Python
499 lines
16 KiB
Python
"""AZ-348 — C3.5 ConditionalRefiner Protocol + DTO + error + factory conformance.
|
|
|
|
Covers AC-1..AC-14 + NFRs. AC-9 (single-thread binding) is
|
|
deferred per the task spec's Risk-4 escape clause — 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-7 (strategy resolution table) is asserted up to the lookup
|
|
point for ``"adhop"``: the AdHoP module is the AZ-349 placeholder
|
|
that does not exist yet, so the assertion on that path stops at
|
|
``ModuleNotFoundError`` — explicitly documented as the "import +
|
|
class lookup not __init__" rule in the task spec.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import logging
|
|
import sys
|
|
import time
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from gps_denied_onboard._types.matcher import (
|
|
CandidateMatchSet,
|
|
MatchResult,
|
|
)
|
|
from gps_denied_onboard.components.c3_5_adhop import (
|
|
C3_5RefinerConfig,
|
|
ConditionalRefiner,
|
|
)
|
|
from gps_denied_onboard.components.c3_5_adhop.config import KNOWN_STRATEGIES
|
|
from gps_denied_onboard.components.c3_5_adhop.errors import (
|
|
RefinerBackboneError,
|
|
RefinerConfigError,
|
|
RefinerError,
|
|
)
|
|
from gps_denied_onboard.components.c3_5_adhop.passthrough_refiner import (
|
|
PassthroughRefiner,
|
|
create,
|
|
)
|
|
from gps_denied_onboard.config.schema import Config, ConfigError
|
|
from gps_denied_onboard.runtime_root.refiner_factory import build_refiner_strategy
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Fakes.
|
|
|
|
|
|
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 _FakeFrame:
|
|
frame_id = 7
|
|
|
|
|
|
class _PartialRefinerMissingWasInvoked:
|
|
def refine_if_needed(self, frame, mr, residual_threshold_px):
|
|
raise NotImplementedError
|
|
|
|
|
|
class _PartialRefinerMissingRefine:
|
|
def was_invoked(self):
|
|
raise NotImplementedError
|
|
|
|
|
|
def _config_with_strategy(
|
|
strategy: str = "passthrough",
|
|
*,
|
|
threshold: float = 2.5,
|
|
) -> Config:
|
|
return Config.with_blocks(
|
|
c3_5_adhop=C3_5RefinerConfig(
|
|
strategy=strategy,
|
|
residual_threshold_px=threshold,
|
|
)
|
|
)
|
|
|
|
|
|
def _make_candidate(*, inliers: int = 40, residual: float = 1.0) -> CandidateMatchSet:
|
|
return CandidateMatchSet(
|
|
tile_id=(18, 49.9, 36.3),
|
|
inlier_count=inliers,
|
|
inlier_correspondences=np.ones((inliers, 4), dtype=np.float32) * 0.5,
|
|
ransac_outlier_count=3,
|
|
per_candidate_residual_px=residual,
|
|
)
|
|
|
|
|
|
def _make_result(
|
|
*,
|
|
reprojection_residual: float = 1.0,
|
|
refinement_label: str = "passthrough",
|
|
refinement_latency_ms: float = 0.0,
|
|
) -> MatchResult:
|
|
candidate = _make_candidate()
|
|
return MatchResult(
|
|
frame_id=7,
|
|
per_candidate=(candidate,),
|
|
best_candidate_idx=0,
|
|
reprojection_residual_px=reprojection_residual,
|
|
matched_at=1_000_000_000,
|
|
matcher_label="disk_lightglue",
|
|
candidates_input=3,
|
|
candidates_dropped=2,
|
|
refinement_label=refinement_label,
|
|
refinement_added_latency_ms=refinement_latency_ms,
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-1: Protocol conformance.
|
|
|
|
|
|
def test_ac1_passthrough_refiner_conformance() -> None:
|
|
refiner = PassthroughRefiner(
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert isinstance(refiner, ConditionalRefiner)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"partial_cls",
|
|
[_PartialRefinerMissingWasInvoked, _PartialRefinerMissingRefine],
|
|
)
|
|
def test_ac1_partial_refiners_fail_conformance(partial_cls) -> None:
|
|
assert not isinstance(partial_cls(), ConditionalRefiner)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-2: MatchResult backward-compatible defaults.
|
|
|
|
|
|
def test_ac2_match_result_construction_without_new_fields() -> None:
|
|
candidate = _make_candidate()
|
|
mr = MatchResult(
|
|
frame_id=7,
|
|
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,
|
|
)
|
|
assert mr.refinement_label == "passthrough"
|
|
assert mr.refinement_added_latency_ms == 0.0
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-3: MatchResult immutability + slots preserved.
|
|
|
|
|
|
def test_ac3_match_result_slots_include_new_fields() -> None:
|
|
assert "refinement_label" in MatchResult.__slots__
|
|
assert "refinement_added_latency_ms" in MatchResult.__slots__
|
|
|
|
|
|
def test_ac3_match_result_remains_frozen() -> None:
|
|
mr = _make_result()
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
mr.refinement_label = "adhop"
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
mr.refinement_added_latency_ms = 12.5
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-4: Factory rejects unknown strategy. (Validated at config-load.)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_label",
|
|
["ADHOP", "passthrough_v2", "garbage", "", "default"],
|
|
)
|
|
def test_ac4_unknown_strategy_rejected_at_config_load(bad_label: str) -> None:
|
|
with pytest.raises(ConfigError) as exc_info:
|
|
C3_5RefinerConfig(strategy=bad_label)
|
|
msg = str(exc_info.value)
|
|
for valid in KNOWN_STRATEGIES:
|
|
assert valid in msg
|
|
|
|
|
|
def test_ac4_factory_emits_error_log_on_unknown_strategy(caplog) -> None:
|
|
# Build a Config that bypasses __post_init__ validation by
|
|
# constructing the block via dataclasses.replace on an already-valid
|
|
# block — this is what catches the defensive factory path.
|
|
valid_block = C3_5RefinerConfig(strategy="passthrough")
|
|
object.__setattr__(valid_block, "strategy", "garbage")
|
|
config = Config.with_blocks(c3_5_adhop=valid_block)
|
|
with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_5_adhop"):
|
|
with pytest.raises(RefinerConfigError) as exc_info:
|
|
build_refiner_strategy(
|
|
config,
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert "Unknown refiner strategy: garbage" in str(exc_info.value)
|
|
assert any(
|
|
r.message == "c3_5.refiner.strategy_unknown" for r in caplog.records
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-5: Factory rejects invalid threshold.
|
|
|
|
|
|
@pytest.mark.parametrize("bad_threshold", [0.0, -0.1, -10.0])
|
|
def test_ac5_invalid_threshold_rejected_at_config_load(bad_threshold: float) -> None:
|
|
with pytest.raises(ConfigError):
|
|
C3_5RefinerConfig(residual_threshold_px=bad_threshold)
|
|
|
|
|
|
def test_ac5_factory_emits_error_log_on_invalid_threshold(caplog) -> None:
|
|
valid_block = C3_5RefinerConfig(strategy="passthrough")
|
|
object.__setattr__(valid_block, "residual_threshold_px", 0.0)
|
|
config = Config.with_blocks(c3_5_adhop=valid_block)
|
|
with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_5_adhop"):
|
|
with pytest.raises(RefinerConfigError):
|
|
build_refiner_strategy(
|
|
config,
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert any(
|
|
r.message == "c3_5.refiner.invalid_threshold" for r in caplog.records
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-6: Successful factory load emits INFO log.
|
|
|
|
|
|
def test_ac6_factory_emits_info_log_on_success(caplog) -> None:
|
|
config = _config_with_strategy("passthrough", threshold=2.5)
|
|
with caplog.at_level(logging.INFO, logger="gps_denied_onboard.c3_5_adhop"):
|
|
instance = build_refiner_strategy(
|
|
config,
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert isinstance(instance, ConditionalRefiner)
|
|
records = [
|
|
r for r in caplog.records if r.message == "c3_5.refiner.strategy_loaded"
|
|
]
|
|
assert len(records) == 1
|
|
record = records[0]
|
|
assert getattr(record, "strategy", None) == "passthrough"
|
|
assert getattr(record, "residual_threshold_px", None) == 2.5
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-7: Strategy resolution table.
|
|
|
|
|
|
def test_ac7_passthrough_resolution() -> None:
|
|
config = _config_with_strategy("passthrough")
|
|
instance = build_refiner_strategy(
|
|
config,
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert isinstance(instance, PassthroughRefiner)
|
|
assert isinstance(instance, ConditionalRefiner)
|
|
assert (
|
|
"gps_denied_onboard.components.c3_5_adhop.passthrough_refiner"
|
|
in sys.modules
|
|
)
|
|
|
|
|
|
def test_ac7_adhop_resolution_loads_module_and_rejects_missing_weights() -> None:
|
|
"""AZ-348 wrote this test as a "module not yet built" stop-gap.
|
|
AZ-349 landed the concrete :class:`AdHoPRefiner`; the resolution
|
|
now reaches the strategy's ``create`` factory. With no weights
|
|
path configured, that factory raises :class:`RefinerConfigError`
|
|
— which is the desired "engine load fails fast at composition"
|
|
behaviour the AdHoP task documents.
|
|
"""
|
|
from gps_denied_onboard.components.c3_5_adhop.errors import RefinerConfigError
|
|
|
|
config = _config_with_strategy("adhop")
|
|
with pytest.raises(RefinerConfigError):
|
|
build_refiner_strategy(
|
|
config,
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert (
|
|
"gps_denied_onboard.components.c3_5_adhop.adhop_refiner" in sys.modules
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-8: Public API.
|
|
|
|
|
|
def test_ac8_public_api_re_exports() -> None:
|
|
from gps_denied_onboard.components import c3_5_adhop
|
|
|
|
assert "ConditionalRefiner" in c3_5_adhop.__all__
|
|
assert "C3_5RefinerConfig" in c3_5_adhop.__all__
|
|
|
|
|
|
def test_ac8_internals_not_in_public_api() -> None:
|
|
from gps_denied_onboard.components import c3_5_adhop
|
|
|
|
for internal in (
|
|
"PassthroughRefiner",
|
|
"AdHoPRefiner",
|
|
"RefinerError",
|
|
"RefinerBackboneError",
|
|
"RefinerConfigError",
|
|
):
|
|
assert internal not in c3_5_adhop.__all__, (
|
|
f"internal name leaked into public API: {internal}"
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-9: deferred.
|
|
|
|
|
|
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: PassthroughRefiner byte-identical correspondences.
|
|
|
|
|
|
def test_ac10_passthrough_returns_same_match_result_reference() -> None:
|
|
refiner = create(
|
|
_config_with_strategy(),
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
mr = _make_result()
|
|
out = refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
|
|
assert out is mr
|
|
assert out.refinement_label == "passthrough"
|
|
assert out.refinement_added_latency_ms == 0.0
|
|
|
|
|
|
def test_ac10_passthrough_correspondences_bit_identical() -> None:
|
|
refiner = PassthroughRefiner(
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
mr = _make_result()
|
|
out = refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
|
|
for in_cand, out_cand in zip(mr.per_candidate, out.per_candidate, strict=True):
|
|
assert np.array_equal(
|
|
out_cand.inlier_correspondences, in_cand.inlier_correspondences
|
|
)
|
|
assert out_cand.inlier_correspondences.dtype == in_cand.inlier_correspondences.dtype
|
|
# Same object reference — INV-5 forbids silent copies on the
|
|
# passthrough path.
|
|
assert out_cand.inlier_correspondences is in_cand.inlier_correspondences
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-11: was_invoked always False on PassthroughRefiner.
|
|
|
|
|
|
def test_ac11_was_invoked_always_false_on_passthrough() -> None:
|
|
refiner = PassthroughRefiner(
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
assert refiner.was_invoked() is False
|
|
mr = _make_result()
|
|
for _ in range(10):
|
|
refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
|
|
assert refiner.was_invoked() is False
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-12: Threshold validation in refine_if_needed.
|
|
|
|
|
|
@pytest.mark.parametrize("bad_threshold", [0.0, -0.1, -50.0])
|
|
def test_ac12_threshold_validation_in_method(bad_threshold: float) -> None:
|
|
refiner = PassthroughRefiner(
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
mr = _make_result()
|
|
with pytest.raises(ValueError):
|
|
refiner.refine_if_needed(
|
|
_FakeFrame(), mr, residual_threshold_px=bad_threshold
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-13: Error hierarchy.
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc_factory",
|
|
[RefinerBackboneError, RefinerConfigError],
|
|
)
|
|
def test_ac13_all_refiner_errors_caught_as_family(exc_factory) -> None:
|
|
with pytest.raises(RefinerError):
|
|
raise exc_factory("boom")
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# AC-14: module-layout.md symbol rename.
|
|
|
|
|
|
def test_ac14_module_layout_references_conditional_refiner() -> None:
|
|
from pathlib import Path
|
|
|
|
doc = Path("_docs/02_document/module-layout.md").read_text()
|
|
assert "ConditionalRefiner" in doc, (
|
|
"module-layout.md must reference the canonical Public API symbol"
|
|
)
|
|
# Old symbol name must NOT be present (per AC-14).
|
|
assert "AdHoPRefinementStrategy" not in doc, (
|
|
"module-layout.md still references the legacy AdHoPRefinementStrategy "
|
|
"symbol; rename per AZ-348 AC-14"
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# NFRs.
|
|
|
|
|
|
def test_nfr_perf_factory_under_20ms_p99(caplog) -> None:
|
|
config = _config_with_strategy("passthrough")
|
|
ransac_filter = _FakeRansacFilter()
|
|
inference_runtime = _FakeInferenceRuntime()
|
|
durations_ms: list[float] = []
|
|
for _ in range(100):
|
|
t0 = time.perf_counter()
|
|
build_refiner_strategy(
|
|
config,
|
|
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 <= 20.0, f"factory p99={p99:.2f} ms exceeded 20 ms budget"
|
|
|
|
|
|
def test_nfr_perf_passthrough_under_0_5ms_p99() -> None:
|
|
refiner = PassthroughRefiner(
|
|
ransac_filter=_FakeRansacFilter(),
|
|
inference_runtime=_FakeInferenceRuntime(),
|
|
)
|
|
mr = _make_result()
|
|
frame = _FakeFrame()
|
|
durations_us: list[float] = []
|
|
for _ in range(10_000):
|
|
t0 = time.perf_counter()
|
|
refiner.refine_if_needed(frame, mr, residual_threshold_px=2.5)
|
|
durations_us.append((time.perf_counter() - t0) * 1_000_000.0)
|
|
durations_us.sort()
|
|
p99_us = durations_us[int(0.99 * len(durations_us))]
|
|
assert p99_us <= 500.0, f"refine p99={p99_us:.2f} us exceeded 500 us budget"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Surface coverage — config defaults.
|
|
|
|
|
|
def test_c3_5_config_defaults() -> None:
|
|
cfg = C3_5RefinerConfig()
|
|
assert cfg.strategy == "adhop"
|
|
assert cfg.residual_threshold_px == 2.5
|
|
assert cfg.invocation_rate_warn_threshold == 0.25
|
|
|
|
|
|
def test_c3_5_config_invocation_rate_validation() -> None:
|
|
with pytest.raises(ConfigError):
|
|
C3_5RefinerConfig(invocation_rate_warn_threshold=0.0)
|
|
with pytest.raises(ConfigError):
|
|
C3_5RefinerConfig(invocation_rate_warn_threshold=1.0)
|
|
with pytest.raises(ConfigError):
|
|
C3_5RefinerConfig(invocation_rate_warn_threshold=-0.5)
|