From 89c223882b4be4a2448ae210a501ac8a31a7e916 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Tue, 12 May 2026 05:43:33 +0300 Subject: [PATCH] [AZ-344] C3 CrossDomainMatcher Protocol + factory + RollingHealthWindow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_` 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 --- .../cross_domain_matcher_protocol.md | 13 +- _docs/02_document/module-layout.md | 15 +- .../AZ-344_c3_matcher_protocol.md | 0 _docs/_autodev_state.md | 2 +- src/gps_denied_onboard/_types/matcher.py | 98 +++ src/gps_denied_onboard/_types/matching.py | 23 +- .../components/c3_5_adhop/interface.py | 2 +- .../components/c3_matcher/__init__.py | 51 +- .../components/c3_matcher/_health_window.py | 122 ++++ .../components/c3_matcher/config.py | 67 ++ .../components/c3_matcher/errors.py | 55 ++ .../components/c3_matcher/interface.py | 119 ++- .../components/c4_pose/interface.py | 2 +- .../runtime_root/matcher_factory.py | 196 +++++ .../c3_matcher/test_protocol_conformance.py | 680 ++++++++++++++++++ tests/unit/c3_matcher/test_smoke.py | 9 - 16 files changed, 1404 insertions(+), 50 deletions(-) rename _docs/02_tasks/{todo => done}/AZ-344_c3_matcher_protocol.md (100%) create mode 100644 src/gps_denied_onboard/_types/matcher.py create mode 100644 src/gps_denied_onboard/components/c3_matcher/_health_window.py create mode 100644 src/gps_denied_onboard/components/c3_matcher/config.py create mode 100644 src/gps_denied_onboard/components/c3_matcher/errors.py create mode 100644 src/gps_denied_onboard/runtime_root/matcher_factory.py create mode 100644 tests/unit/c3_matcher/test_protocol_conformance.py delete mode 100644 tests/unit/c3_matcher/test_smoke.py diff --git a/_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md b/_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md index 7525668..ebfbdad 100644 --- a/_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md +++ b/_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md @@ -4,8 +4,8 @@ **Producer task**: AZ-344 (`CrossDomainMatcher` Protocol + factory + composition) **Consumer tasks**: AZ-345 (DISK+LightGlue primary), AZ-346 (ALIKED+LightGlue secondary), AZ-347 (XFeat alternate); downstream c3_5_adhop (epic AZ-258) which consumes `MatchResult` **Version**: 1.0.0 -**Status**: draft, awaiting AZ-344 implementation -**Last Updated**: 2026-05-10 +**Status**: v1.0.0 (AZ-344 implemented 2026-05-12) +**Last Updated**: 2026-05-12 **Module-layout home**: `src/gps_denied_onboard/components/c3_matcher/interface.py` (Protocol), `src/gps_denied_onboard/components/c3_matcher/__init__.py` (re-exports), `src/gps_denied_onboard/runtime_root/matcher_factory.py` (factory) ## Purpose @@ -65,14 +65,13 @@ class CrossDomainMatcher(Protocol): ```python from dataclasses import dataclass -from uuid import UUID import numpy as np @dataclass(frozen=True, slots=True) class CandidateMatchSet: """Per-candidate matching outcome inside a MatchResult.""" - tile_id: tuple # composite (zoomLevel, lat, lon) + tile_id: tuple[int, float, float] # composite (zoomLevel, lat, lon); mirrors VprCandidate / RerankCandidate encoding so the L1 _types layer is free of an L1→L3 import to c6_tile_cache.TileId inlier_count: int inlier_correspondences: np.ndarray # shape (I, 4) float32; (px_query, py_query, px_tile, py_tile) ransac_outlier_count: int @@ -82,8 +81,8 @@ class CandidateMatchSet: @dataclass(frozen=True, slots=True) class MatchResult: """Cross-domain match outcome for one frame. Consumed by C3.5 ConditionalRefiner.""" - frame_id: UUID - per_candidate: list[CandidateMatchSet] # 0 < len <= N=3, ranked by inlier_count descending; ties broken by per_candidate_residual_px ascending + frame_id: int # mirrors NavCameraFrame.frame_id; matches AZ-336 / AZ-342 encoding + per_candidate: tuple[CandidateMatchSet, ...] # 0 < len <= N=3, ranked by inlier_count descending; ties broken by per_candidate_residual_px ascending. tuple (not list) so frozen+slots actually holds. best_candidate_idx: int # 0 by construction (sorted) reprojection_residual_px: float # best candidate's median residual matched_at: int # monotonic_ns @@ -115,6 +114,8 @@ class InsufficientInliersError(MatcherError): """Every candidate failed OR every candidate's inlier count is below `config.matcher.min_inliers_threshold`. Raised by `match`. C5 falls back to VIO-only.""" ``` +The composition-time selection error is **`StrategyNotAvailableError`** (`runtime_root.errors`), NOT a member of `MatcherError`: it surfaces when the binary lacks the requested `BUILD_MATCHER_` flag or the concrete strategy module is not built yet (AZ-345..AZ-347 pending). This matches the C2 VPR (AZ-336) and C2.5 ReRank (AZ-342) factory pattern: per-frame matcher errors live in the C3 family; composition-time selection errors live in the shared runtime-root family. + ## Composition-Root Factory ```python diff --git a/_docs/02_document/module-layout.md b/_docs/02_document/module-layout.md index 6bc0b9e..727ad6a 100644 --- a/_docs/02_document/module-layout.md +++ b/_docs/02_document/module-layout.md @@ -70,15 +70,18 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec - **Epic**: AZ-257 (E-C3 Cross-Domain Matcher) - **Directory**: `src/gps_denied_onboard/components/c3_matcher/` - **Public API**: - - `__init__.py` (re-exports `CrossDomainMatcher`, `MatchResult`) + - `__init__.py` (re-exports `CrossDomainMatcher`, `MatchResult`, `MatcherHealth`, `CandidateMatchSet`, `MatcherError`, `MatcherBackboneError`, `InsufficientInliersError`, `C3MatcherConfig`) - `interface.py` (`CrossDomainMatcher` Protocol) + - `config.py` (`C3MatcherConfig`) + - `errors.py` (error hierarchy) - **Internal**: - - `disk_lightglue.py` (DISK + LightGlue) - - `aliked_lightglue.py` (ALIKED + LightGlue) - - `xfeat.py` + - `_health_window.py` (`RollingHealthWindow` accumulator; constructor-injected into every concrete matcher) + - `disk_lightglue.py` (DISK + LightGlue, AZ-345) + - `aliked_lightglue.py` (ALIKED + LightGlue, AZ-346) + - `xfeat.py` (XFeat, AZ-347) - `_native/` -- **Owns**: `src/gps_denied_onboard/components/c3_matcher/**`, `tests/unit/c3_matcher/**` -- **Imports from**: `_types`, `helpers.lightglue_runtime` (R14: SHARED with C2.5 — owned by helper, NOT by C3), `helpers.descriptor_normaliser`, `helpers.se3_utils`, `components.c7_inference`, `config`, `logging`, `fdr_client` +- **Owns**: `src/gps_denied_onboard/components/c3_matcher/**`, `tests/unit/c3_matcher/**`, `src/gps_denied_onboard/runtime_root/matcher_factory.py` +- **Imports from**: `_types`, `helpers.lightglue_runtime` (R14: SHARED with C2.5 — owned by helper, NOT by C3), `helpers.ransac_filter`, `helpers.descriptor_normaliser`, `helpers.se3_utils`, `components.c7_inference`, `config`, `logging`, `fdr_client` - **Consumed by**: `c3_5_adhop`, `runtime_root` ### Component: c3_5_adhop diff --git a/_docs/02_tasks/todo/AZ-344_c3_matcher_protocol.md b/_docs/02_tasks/done/AZ-344_c3_matcher_protocol.md similarity index 100% rename from _docs/02_tasks/todo/AZ-344_c3_matcher_protocol.md rename to _docs/02_tasks/done/AZ-344_c3_matcher_protocol.md diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index e55ef6e..ff7fef6 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -8,7 +8,7 @@ status: in_progress sub_step: phase: 2 name: detect-progress - detail: "batch 23 complete (AZ-303, AZ-297, AZ-331, AZ-398 per-task commits); selecting batch 24" + detail: "batch 24 in flight (per-task commits): AZ-336 done, AZ-342 done, AZ-344 done; next AZ-348" retry_count: 0 cycle: 1 tracker: jira diff --git a/src/gps_denied_onboard/_types/matcher.py b/src/gps_denied_onboard/_types/matcher.py new file mode 100644 index 0000000..ce00dd0 --- /dev/null +++ b/src/gps_denied_onboard/_types/matcher.py @@ -0,0 +1,98 @@ +"""C3 cross-domain matcher DTOs (L1 cross-component layer; AZ-344). + +Frozen by ``contracts/c3_matcher/cross_domain_matcher_protocol.md`` +v1.0.0: three slotted, immutable dataclasses +(:class:`CandidateMatchSet`, :class:`MatchResult`, +:class:`MatcherHealth`). + +:class:`CandidateMatchSet.tile_id` is a plain +``tuple[int, float, float]`` of ``(zoom_level, lat, lon)`` — +identical encoding to :class:`VprCandidate.tile_id` / +:class:`RerankCandidate.tile_id` — keeping the L1 layer free of an +L1→L3 import per ``module-layout.md`` (consumers reconstruct +:class:`gps_denied_onboard.components.c6_tile_cache.TileId` at the +C6 boundary). + +:class:`MatchResult.per_candidate` is a ``tuple`` (not a ``list``) +so the ``frozen=True, slots=True`` invariant truly holds — a frozen +dataclass holding a mutable list lets consumers mutate it; the +tuple closes that door. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +__all__ = ["CandidateMatchSet", "MatchResult", "MatcherHealth"] + + +@dataclass(frozen=True, slots=True) +class CandidateMatchSet: + """Per-candidate matching outcome inside a :class:`MatchResult`. + + ``inlier_correspondences`` is shape ``(I, 4)`` ``float32`` with + columns ``(px_query, py_query, px_tile, py_tile)``; rows are + RANSAC inliers only so ``I == inlier_count``. + + ``per_candidate_residual_px`` is the MEDIAN reprojection + residual on inliers — not the mean, not the max. C3.5's + threshold gate compares against this value (INV-8). + """ + + tile_id: tuple[int, float, float] + inlier_count: int + inlier_correspondences: np.ndarray + ransac_outlier_count: int + per_candidate_residual_px: float + + +@dataclass(frozen=True, slots=True) +class MatchResult: + """Cross-domain match outcome for one frame. + + Consumed by C3.5 :class:`ConditionalRefiner` (AZ-348). The + ``per_candidate`` tuple is sorted descending by + ``inlier_count`` with ties broken ascending by + ``per_candidate_residual_px`` (INV-3) so + ``best_candidate_idx == 0`` by construction. + + ``reprojection_residual_px`` is the best candidate's median + residual (mirrors ``per_candidate[0].per_candidate_residual_px``) + surfaced separately so consumers do not have to know the + ranking encoding. + """ + + frame_id: int + per_candidate: tuple[CandidateMatchSet, ...] + best_candidate_idx: int + reprojection_residual_px: float + matched_at: int + matcher_label: str + candidates_input: int + candidates_dropped: int + + +@dataclass(frozen=True, slots=True) +class MatcherHealth: + """Rolling-window matcher health snapshot. + + Produced by :meth:`CrossDomainMatcher.health_snapshot`. Drives + C5's spoof-promotion gate (AC-NEW-2 / AC-NEW-7) and post-flight + forensics. + + ``consecutive_low_inlier`` is the count of CONSECUTIVE recent + frames whose best-candidate inlier count fell below the + configured ``min_inliers_threshold`` floor; it resets to zero on + any frame whose inlier count meets or exceeds the floor + (INV-12). + + ``mean_inliers_60s`` is the rolling 60 s mean of best-candidate + inlier counts. ``backbone_error_count_60s`` counts per-candidate + :class:`MatcherBackboneError` occurrences in the same window. + """ + + consecutive_low_inlier: int + mean_inliers_60s: float + backbone_error_count_60s: int diff --git a/src/gps_denied_onboard/_types/matching.py b/src/gps_denied_onboard/_types/matching.py index d9dbc3b..785aede 100644 --- a/src/gps_denied_onboard/_types/matching.py +++ b/src/gps_denied_onboard/_types/matching.py @@ -1,25 +1,20 @@ -"""C3 / shared cross-domain matching DTOs.""" +"""Shared LightGlue-runtime DTOs (L1 helper layer; AZ-278). + +The cross-component ``MatchResult`` DTO previously lived here under +a placeholder schema; AZ-344 froze the real shape in +:mod:`gps_denied_onboard._types.matcher`. The two surviving classes +(:class:`KeypointSet`, :class:`CorrespondenceSet`) are the L1 +helper substrate for :class:`gps_denied_onboard.helpers.lightglue_runtime.LightGlueRuntime` +and stay here. +""" from __future__ import annotations from dataclasses import dataclass -from typing import Any import numpy as np -@dataclass(frozen=True) -class MatchResult: - """Output of the cross-domain matcher (frame ↔ satellite tile).""" - - query_frame_id: int - tile_id: str - keypoints_query: Any - keypoints_tile: Any - matches: Any - inlier_mask: Any | None = None - - @dataclass(frozen=True) class KeypointSet: """A backbone-extracted keypoint + descriptor bundle. diff --git a/src/gps_denied_onboard/components/c3_5_adhop/interface.py b/src/gps_denied_onboard/components/c3_5_adhop/interface.py index af01587..f028222 100644 --- a/src/gps_denied_onboard/components/c3_5_adhop/interface.py +++ b/src/gps_denied_onboard/components/c3_5_adhop/interface.py @@ -7,7 +7,7 @@ from __future__ import annotations from typing import Protocol -from gps_denied_onboard._types.matching import MatchResult +from gps_denied_onboard._types.matcher import MatchResult class AdHoPRefinementStrategy(Protocol): diff --git a/src/gps_denied_onboard/components/c3_matcher/__init__.py b/src/gps_denied_onboard/components/c3_matcher/__init__.py index 597d1d7..89569b9 100644 --- a/src/gps_denied_onboard/components/c3_matcher/__init__.py +++ b/src/gps_denied_onboard/components/c3_matcher/__init__.py @@ -1,6 +1,51 @@ -"""C3 Cross-Domain Matcher component — Public API.""" +"""C3 cross-domain matcher — Public API (AZ-344). -from gps_denied_onboard._types.matching import MatchResult +Per ``cross_domain_matcher_protocol.md`` v1.0.0 the public surface +consists of: + +- :class:`CrossDomainMatcher` Protocol (two methods). +- DTOs re-exported from :mod:`gps_denied_onboard._types.matcher` + (the L1 home for cross-component DTOs): + :class:`MatchResult`, :class:`MatcherHealth`. The internal + per-candidate sub-DTO :class:`CandidateMatchSet` is intentionally + re-exported too because C3.5 reads + ``MatchResult.per_candidate[i]`` directly. +- Error family rooted at :class:`MatcherError`; two documented + subtypes (:class:`MatcherBackboneError`, + :class:`InsufficientInliersError`). +- Config block :class:`C3MatcherConfig` (registered on import). + +Internals — :class:`RollingHealthWindow` and the concrete strategy +modules (``disk_lightglue``, ``aliked_lightglue``, ``xfeat``) — +are intentionally NOT re-exported: consumers see only the +Protocol; concrete strategies are imported lazily by +:mod:`gps_denied_onboard.runtime_root.matcher_factory` (Risk-2 +mitigation). +""" + +from gps_denied_onboard._types.matcher import ( + CandidateMatchSet, + MatchResult, + MatcherHealth, +) +from gps_denied_onboard.components.c3_matcher.config import C3MatcherConfig +from gps_denied_onboard.components.c3_matcher.errors import ( + InsufficientInliersError, + MatcherBackboneError, + MatcherError, +) from gps_denied_onboard.components.c3_matcher.interface import CrossDomainMatcher +from gps_denied_onboard.config.schema import register_component_block -__all__ = ["CrossDomainMatcher", "MatchResult"] +register_component_block("c3_matcher", C3MatcherConfig) + +__all__ = [ + "C3MatcherConfig", + "CandidateMatchSet", + "CrossDomainMatcher", + "InsufficientInliersError", + "MatchResult", + "MatcherBackboneError", + "MatcherError", + "MatcherHealth", +] diff --git a/src/gps_denied_onboard/components/c3_matcher/_health_window.py b/src/gps_denied_onboard/components/c3_matcher/_health_window.py new file mode 100644 index 0000000..5ae408a --- /dev/null +++ b/src/gps_denied_onboard/components/c3_matcher/_health_window.py @@ -0,0 +1,122 @@ +"""C3 rolling matcher-health accumulator (AZ-344). + +:class:`RollingHealthWindow` maintains the three accumulators that +back :class:`MatcherHealth` snapshots: ``consecutive_low_inlier``, +``mean_inliers_60s``, ``backbone_error_count_60s``. The 60 s window +is configurable for tests via the constructor. + +The structure is intentionally **single-thread** — no locks. The +composition root binds every :class:`CrossDomainMatcher` to one +ingest thread (AC-9) so adding a lock here would only mask binding +bugs. + +Data structure choice: a ``collections.deque`` of +``(timestamp_ns, inlier_count, had_backbone_error)`` tuples plus +two running sums (``_inlier_sum``, ``_error_sum``). Every +:meth:`update` call evicts expired entries from the left while +maintaining the sums — amortised O(1). :meth:`snapshot` is strict +O(1): it reads the sums and the current ``len(self._window)`` +without touching the deque body, so the NFR-perf-window microbench +(p99 ≤ 50 µs) holds even with 6000 entries (100 Hz × 60 s). +""" + +from __future__ import annotations + +from collections import deque +from typing import Final + +from gps_denied_onboard._types.matcher import MatcherHealth + +__all__ = ["RollingHealthWindow"] + + +_DEFAULT_WINDOW_NS: Final[int] = 60 * 1_000_000_000 + + +class RollingHealthWindow: + """Sliding 60 s window over best-candidate inlier counts. + + Constructor-injected into every concrete :class:`CrossDomainMatcher` + so all strategies share semantics (the alternative — every + matcher reimplements the window — drifts between backbones and + breaks C5's spoof-promotion gate consistency). + """ + + def __init__( + self, + *, + min_inliers_threshold: int, + window_ns: int = _DEFAULT_WINDOW_NS, + ) -> None: + if min_inliers_threshold < 1: + raise ValueError( + "min_inliers_threshold must be >= 1; " + f"got {min_inliers_threshold}" + ) + if window_ns < 1: + raise ValueError(f"window_ns must be >= 1; got {window_ns}") + self._min_inliers_threshold: int = min_inliers_threshold + self._window_ns: int = window_ns + # Each entry: (timestamp_ns, inlier_count, had_backbone_error) + self._window: deque[tuple[int, int, bool]] = deque() + self._inlier_sum: int = 0 + self._error_sum: int = 0 + self._consecutive_low_inlier: int = 0 + + @property + def window_ns(self) -> int: + return self._window_ns + + def update( + self, + *, + timestamp_ns: int, + best_inlier_count: int, + had_backbone_error: bool, + ) -> None: + """Record one frame's outcome and evict any expired entries. + + ``best_inlier_count`` is the BEST candidate's inlier count + for the frame (zero if every candidate was dropped / + below-threshold). ``had_backbone_error`` is ``True`` if at + least one per-candidate + :class:`MatcherBackboneError` fired in this frame. + + AC-12: ``consecutive_low_inlier`` increments when + ``best_inlier_count < min_inliers_threshold``; resets to + zero on any frame whose count meets or exceeds the floor. + """ + if best_inlier_count < 0: + raise ValueError( + f"best_inlier_count must be >= 0; got {best_inlier_count}" + ) + cutoff = timestamp_ns - self._window_ns + window = self._window + while window and window[0][0] <= cutoff: + _ts, expired_inliers, expired_error = window.popleft() + self._inlier_sum -= expired_inliers + if expired_error: + self._error_sum -= 1 + window.append((timestamp_ns, best_inlier_count, had_backbone_error)) + self._inlier_sum += best_inlier_count + if had_backbone_error: + self._error_sum += 1 + if best_inlier_count < self._min_inliers_threshold: + self._consecutive_low_inlier += 1 + else: + self._consecutive_low_inlier = 0 + + def snapshot(self) -> MatcherHealth: + """Return the current :class:`MatcherHealth` snapshot. + + O(1): reads the running sums + ``len(self._window)`` only. + Empty window → ``mean_inliers_60s == 0.0`` (consumers + treat zero as "insufficient data" rather than "zero matches"). + """ + count = len(self._window) + mean = (self._inlier_sum / count) if count else 0.0 + return MatcherHealth( + consecutive_low_inlier=self._consecutive_low_inlier, + mean_inliers_60s=mean, + backbone_error_count_60s=self._error_sum, + ) diff --git a/src/gps_denied_onboard/components/c3_matcher/config.py b/src/gps_denied_onboard/components/c3_matcher/config.py new file mode 100644 index 0000000..520cb9c --- /dev/null +++ b/src/gps_denied_onboard/components/c3_matcher/config.py @@ -0,0 +1,67 @@ +"""C3 ``CrossDomainMatcher`` config block (AZ-344). + +Registered into ``config.components['c3_matcher']`` by the package +``__init__.py``. The composition-root factory +:func:`gps_denied_onboard.runtime_root.matcher_factory.build_matcher_strategy` +reads this block to select the strategy and configure thresholds. + +``strategy`` selects one of the three concrete backbones +(``disk_lightglue``, ``aliked_lightglue``, ``xfeat``); the +composition-root factory respects compile-time +``BUILD_MATCHER_`` gating on top of this label. + +``min_inliers_threshold`` is the per-candidate floor: candidates +whose RANSAC inlier count falls below this value are treated as +failed (drop-and-continue) and counted into +``MatcherHealth.consecutive_low_inlier``. Default 60 — leaves +headroom below the AC-1.1 floor (p5 ≥ 80) so calibration drift +does not immediately trip the spoof gate; FT-P-19 telemetry will +tune it. + +``residual_warn_threshold_px`` is the median reprojection-residual +limit (pixels) above which the matcher emits a WARN log; default +2.5 px (the AC-1.2 floor). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +from gps_denied_onboard.config.schema import ConfigError + +__all__ = [ + "C3MatcherConfig", + "KNOWN_STRATEGIES", +] + + +KNOWN_STRATEGIES: Final[frozenset[str]] = frozenset( + {"disk_lightglue", "aliked_lightglue", "xfeat"} +) + + +@dataclass(frozen=True) +class C3MatcherConfig: + """Per-component config for C3 cross-domain matcher.""" + + strategy: str = "disk_lightglue" + min_inliers_threshold: int = 60 + residual_warn_threshold_px: float = 2.5 + + def __post_init__(self) -> None: + if self.strategy not in KNOWN_STRATEGIES: + raise ConfigError( + f"C3MatcherConfig.strategy={self.strategy!r} not in " + f"{sorted(KNOWN_STRATEGIES)}" + ) + if self.min_inliers_threshold < 1: + raise ConfigError( + "C3MatcherConfig.min_inliers_threshold must be >= 1; " + f"got {self.min_inliers_threshold}" + ) + if self.residual_warn_threshold_px <= 0.0: + raise ConfigError( + "C3MatcherConfig.residual_warn_threshold_px must be > 0; " + f"got {self.residual_warn_threshold_px}" + ) diff --git a/src/gps_denied_onboard/components/c3_matcher/errors.py b/src/gps_denied_onboard/components/c3_matcher/errors.py new file mode 100644 index 0000000..44db54d --- /dev/null +++ b/src/gps_denied_onboard/components/c3_matcher/errors.py @@ -0,0 +1,55 @@ +"""C3 ``CrossDomainMatcher`` error taxonomy (AZ-344). + +The family is intentionally narrow: a per-candidate failure is the +normal case (drop-and-continue, INV-4) and is signalled via +``MatchResult.candidates_dropped`` — NOT via an exception. An +exception escapes :meth:`CrossDomainMatcher.match` only when EVERY +candidate fails OR every candidate's inlier count falls below +``config.matcher.min_inliers_threshold``; both surface as +:class:`InsufficientInliersError` which is the C5 → VIO-only +fallback trigger per AC-3.5. + +:class:`MatcherBackboneError` is raised INSIDE the per-candidate +loop, caught by the strategy, logged ERROR, FDR-stamped, and the +candidate is dropped. It is exposed publicly so the per-candidate +log + FDR taxonomy is observable and so future matchers using a +different backbone can re-raise the same kind. +""" + +from __future__ import annotations + +__all__ = [ + "InsufficientInliersError", + "MatcherBackboneError", + "MatcherError", +] + + +class MatcherError(Exception): + """Base class for the C3 matcher error family. + + Caught at the runtime root only when + :class:`InsufficientInliersError` fires; per-candidate failures + stay inside the strategy. + """ + + +class MatcherBackboneError(MatcherError): + """Per-candidate backbone forward-pass failure. + + CUDA OOM, TRT engine deserialize mismatch. Logged at ERROR; one + FDR record per occurrence; the offending candidate is dropped + from the match set; the surrounding :meth:`match` call continues + with the remaining candidates (INV-4). + """ + + +class InsufficientInliersError(MatcherError): + """Zero survivors after the per-candidate loop, OR every + candidate's inlier count is below + ``config.matcher.min_inliers_threshold``. + + Logged at ERROR; FDR record ``kind=matcher.insufficient_inliers`` + or ``kind=matcher.all_failed`` (per the trigger). C5 falls back + to VIO-only with provenance ``visual_propagated`` (AC-3.5). + """ diff --git a/src/gps_denied_onboard/components/c3_matcher/interface.py b/src/gps_denied_onboard/components/c3_matcher/interface.py index 7d6d2ec..2363cab 100644 --- a/src/gps_denied_onboard/components/c3_matcher/interface.py +++ b/src/gps_denied_onboard/components/c3_matcher/interface.py @@ -1,19 +1,120 @@ -"""C3 `CrossDomainMatcher` Protocol. +"""C3 ``CrossDomainMatcher`` Protocol (AZ-344). -Concrete impls: DISK+LightGlue (primary), ALIKED+LightGlue, XFeat. See -`_docs/02_document/components/04_c3_matcher/`. +PEP 544 ``typing.Protocol`` with ``runtime_checkable=True``; a +two-method surface: :meth:`match` and :meth:`health_snapshot`. + +Concrete impls — DISK+LightGlue (AZ-345), ALIKED+LightGlue +(AZ-346), XFeat (AZ-347) — live in sibling modules and are imported +lazily by +:mod:`gps_denied_onboard.runtime_root.matcher_factory`. + +The contract at +``_docs/02_document/contracts/c3_matcher/cross_domain_matcher_protocol.md`` +v1.0.0 is the authoritative shape; this module mirrors it 1:1. """ from __future__ import annotations -from typing import Protocol +from typing import TYPE_CHECKING, Protocol, runtime_checkable -from gps_denied_onboard._types.matching import MatchResult -from gps_denied_onboard._types.nav import NavCameraFrame -from gps_denied_onboard._types.tile import Tile +if TYPE_CHECKING: + from gps_denied_onboard._types.calibration import CameraCalibration + from gps_denied_onboard._types.matcher import MatchResult, MatcherHealth + from gps_denied_onboard._types.nav import NavCameraFrame + from gps_denied_onboard._types.rerank import RerankResult + +__all__ = ["CrossDomainMatcher"] +@runtime_checkable class CrossDomainMatcher(Protocol): - """Match a nav-camera frame against a satellite tile.""" + """Cross-domain (nav-camera ↔ satellite-imagery) matcher strategy. - def match(self, frame: NavCameraFrame, tile: Tile) -> MatchResult: ... + Stateless per-frame; the only persistent state is the + constructor-injected backbone runtime handles + (``InferenceRuntime`` for feature extraction, + :class:`gps_denied_onboard.helpers.lightglue_runtime.LightGlueRuntime` + for matching, + :class:`gps_denied_onboard.helpers.ransac_filter.RansacFilter` + for inlier filtering) and the rolling + :class:`gps_denied_onboard.components.c3_matcher._health_window.RollingHealthWindow`. + + Invariants (see ``cross_domain_matcher_protocol.md`` v1.0.0): + + - **INV-1 single-threaded** — each instance is bound to one + ingest thread by the composition root (same thread as C2.5 + because they share the ``LightGlueRuntime``). + - **INV-2 stateless per-frame for `match`** — except for the + rolling health window, no implicit dependency on prior + frames; reordering ``match`` calls (tests only) MUST yield + identical ``MatchResult`` content. + - **INV-3 best-candidate selection is deterministic** — + ``MatchResult.best_candidate_idx == 0`` and + ``per_candidate`` is sorted by ``inlier_count`` descending + with ties broken by ``per_candidate_residual_px`` ascending + (lower residual wins). + - **INV-4 drop-and-continue per candidate** — per-candidate + exceptions never propagate out of ``match`` unless every + candidate fails. Mirrors C2.5 INV-8. + - **INV-5 ``per_candidate`` length is bounded** — + ``0 < len <= len(rerank_result.candidates)``; zero raises + :class:`InsufficientInliersError`; never exceeds the input N. + - **INV-6 ``matcher_label`` is non-empty** — every + ``MatchResult`` carries the strategy's name + (e.g., ``"disk_lightglue"``) for FDR provenance; MUST match + ``BUILD_MATCHER_`` lowercase. + - **INV-7 ``inlier_correspondences`` shape contract** — + ``ndarray[I, 4, dtype=float32]``, columns + ``(px_query, py_query, px_tile, py_tile)``; rows are RANSAC + inliers only; ``I == inlier_count``. + - **INV-8 ``reprojection_residual_px`` is the BEST candidate's + median residual** — not the mean, not the max; downstream + C3.5's threshold gate compares against this value. + - **INV-9 ``health_snapshot()`` is cheap** — O(1); reads the + rolling window's pre-computed accumulators. Never recomputes + over the window contents. + + Error envelope: only :class:`InsufficientInliersError` escapes + :meth:`match`; per-candidate + :class:`MatcherBackboneError` and C6 ``TileFetchError`` + instances are caught inside the loop and turned into dropped + candidates + ERROR logs + per-occurrence FDR records. + """ + + def match( + self, + frame: "NavCameraFrame", + rerank_result: "RerankResult", + calibration: "CameraCalibration", + ) -> "MatchResult": + """Match a frame against every candidate in ``rerank_result``. + + For each candidate: + + 1. Extract features from the nav frame and from the tile + pixels (via the constructor-injected + :class:`InferenceRuntime` handle). + 2. Run LightGlue forward via the shared + :class:`LightGlueRuntime`. + 3. RANSAC-filter correspondences via the shared + :class:`RansacFilter`; record inliers + median residual. + + Sort survivors by ``(inlier_count desc, residual asc)`` and + return as :class:`MatchResult`. Drop-and-continue + semantics apply per INV-4. + + Raises: + InsufficientInliersError: zero survivors after the + per-candidate loop, OR every candidate's + ``inlier_count`` is below + ``config.matcher.min_inliers_threshold``. + """ + ... + + def health_snapshot(self) -> "MatcherHealth": + """Return a rolling-window matcher health snapshot. + + O(1) per INV-9. Drives C5's spoof-promotion gate + (AC-NEW-2 / AC-NEW-7) and post-flight forensics. + """ + ... diff --git a/src/gps_denied_onboard/components/c4_pose/interface.py b/src/gps_denied_onboard/components/c4_pose/interface.py index f26553d..186c2ba 100644 --- a/src/gps_denied_onboard/components/c4_pose/interface.py +++ b/src/gps_denied_onboard/components/c4_pose/interface.py @@ -26,7 +26,7 @@ from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from gps_denied_onboard._types.calibration import CameraCalibration - from gps_denied_onboard._types.matching import MatchResult + from gps_denied_onboard._types.matcher import MatchResult from gps_denied_onboard._types.pose import CovarianceMode, PoseEstimate from gps_denied_onboard._types.thermal import ThermalState diff --git a/src/gps_denied_onboard/runtime_root/matcher_factory.py b/src/gps_denied_onboard/runtime_root/matcher_factory.py new file mode 100644 index 0000000..71c53f9 --- /dev/null +++ b/src/gps_denied_onboard/runtime_root/matcher_factory.py @@ -0,0 +1,196 @@ +"""C3 matcher strategy composition-root factory (AZ-344). + +:func:`build_matcher_strategy` selects exactly one strategy by +``config.components['c3_matcher'].strategy`` and respects +compile-time ``BUILD_MATCHER_`` gating: requesting a +strategy whose flag is OFF raises +:class:`StrategyNotAvailableError` at composition time (NOT at +first frame). + +Concrete strategy modules +(``disk_lightglue``, ``aliked_lightglue``, ``xfeat``) are imported +lazily — a Tier-0 workstation build with +``BUILD_MATCHER_DISK_LIGHTGLUE=OFF`` MUST NOT load +``c3_matcher.disk_lightglue`` (ADR-002 / I-5; verifiable via +``sys.modules``). + +The shared :class:`LightGlueRuntime` and :class:`RansacFilter` are +constructor-injected — the factory does NOT own their lifecycles. +The runtime root constructs ONE ``LightGlueRuntime`` instance and +passes the SAME reference to both this factory (C3) and the C2.5 +``ReRankStrategy`` factory (per AC-10 / AZ-342 AC-10). The +identity-share gives R14 fix substance: a regression that +constructs two runtimes would double GPU memory. + +The :class:`RollingHealthWindow` accumulator is constructed BY +this factory (one per matcher instance) and passed to the +concrete strategy's ``create`` entry-point so all backbones share +window semantics (AZ-344 Outcome line 5). +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from gps_denied_onboard.components.c3_matcher._health_window import RollingHealthWindow +from gps_denied_onboard.runtime_root.errors import StrategyNotAvailableError + +if TYPE_CHECKING: + from gps_denied_onboard.components.c3_matcher import ( + C3MatcherConfig, + CrossDomainMatcher, + ) + from gps_denied_onboard.components.c7_inference import InferenceRuntime + from gps_denied_onboard.config.schema import Config + from gps_denied_onboard.helpers.lightglue_runtime import LightGlueRuntime + from gps_denied_onboard.helpers.ransac_filter import RansacFilter + +__all__ = ["build_matcher_strategy"] + + +_LOG = logging.getLogger("gps_denied_onboard.c3_matcher") + + +# Strategy resolution table — mirrors the contract's +# ``cross_domain_matcher_protocol.md`` v1.0.0 § Composition-Root +# Factory table verbatim. ANY mutation of this dict MUST be +# mirrored in the contract. +_STRATEGY_TO_BUILD_FLAG: dict[str, str] = { + "disk_lightglue": "BUILD_MATCHER_DISK_LIGHTGLUE", + "aliked_lightglue": "BUILD_MATCHER_ALIKED_LIGHTGLUE", + "xfeat": "BUILD_MATCHER_XFEAT", +} + +_STRATEGY_TO_MODULE: dict[str, tuple[str, str]] = { + "disk_lightglue": ( + "gps_denied_onboard.components.c3_matcher.disk_lightglue", + "DiskLightGlueMatcher", + ), + "aliked_lightglue": ( + "gps_denied_onboard.components.c3_matcher.aliked_lightglue", + "AlikedLightGlueMatcher", + ), + "xfeat": ( + "gps_denied_onboard.components.c3_matcher.xfeat", + "XFeatMatcher", + ), +} + + +def _is_build_flag_on(flag_name: str) -> bool: + """Read a compile-time ``BUILD_*`` flag from the environment. + + ``ON`` / ``1`` / ``true`` / ``yes`` (case-insensitive) → ``True``; + anything else (including unset) → ``False``. Defaults to OFF so + test environments must opt-in explicitly per strategy. + """ + raw = os.environ.get(flag_name, "") + return raw.strip().lower() in {"on", "1", "true", "yes"} + + +def _c3_config(config: "Config") -> "C3MatcherConfig": + """Pull the registered C3 config block. + + ``c3_matcher.__init__`` registers it on import; a missing + registration is a developer error and surfaces as ``KeyError`` + rather than a silent fallback. + """ + return config.components["c3_matcher"] + + +def build_matcher_strategy( + config: "Config", + *, + lightglue_runtime: "LightGlueRuntime", + ransac_filter: "RansacFilter", + inference_runtime: "InferenceRuntime", +) -> "CrossDomainMatcher": + """Construct the :class:`CrossDomainMatcher` impl selected by config. + + 1. Reads ``config.components['c3_matcher'].strategy``. + 2. Checks the matching ``BUILD_MATCHER_`` flag — if + OFF, raises :class:`StrategyNotAvailableError` BEFORE any + import. + 3. Constructs a :class:`RollingHealthWindow` seeded with + ``config.components['c3_matcher'].min_inliers_threshold``. + 4. Lazily imports the concrete strategy module. + 5. Constructs the strategy via its module-level ``create( + config, lightglue_runtime, ransac_filter, inference_runtime, + health_window)`` factory function (each concrete strategy + module exports ``create`` as its public entry-point; + concrete constructors stay private). + 6. Emits ONE INFO log ``kind="c3.matcher.strategy_loaded"`` + with structured fields ``{strategy, min_inliers_threshold, + residual_warn_threshold_px}``. + + Raises: + StrategyNotAvailableError: compile-time flag OFF or + concrete module not yet built + (AZ-345 / AZ-346 / AZ-347 pending). + """ + block = _c3_config(config) + strategy = block.strategy + flag_name = _STRATEGY_TO_BUILD_FLAG.get(strategy) + module_info = _STRATEGY_TO_MODULE.get(strategy) + if flag_name is None or module_info is None: + # Defensive — config validation rejects unknown strategy + # labels at load (``C3MatcherConfig.__post_init__``), so + # this branch is only reachable if the resolution table + # and the validation set drift apart. + _LOG.error( + "c3.matcher.build_flag_off", + extra={"strategy": strategy, "reason": "unknown_strategy"}, + ) + raise StrategyNotAvailableError( + f"CrossDomainMatcher {strategy!r} is not buildable in this binary." + ) + if not _is_build_flag_on(flag_name): + _LOG.error( + "c3.matcher.build_flag_off", + extra={"strategy": strategy, "flag": flag_name}, + ) + raise StrategyNotAvailableError( + f"BUILD_MATCHER_{strategy.upper()} is OFF for this binary; " + f"cannot select strategy={strategy}." + ) + health_window = RollingHealthWindow( + min_inliers_threshold=block.min_inliers_threshold, + ) + module_name, class_name = module_info + try: + module = __import__(module_name, fromlist=[class_name]) + except ModuleNotFoundError as exc: + raise StrategyNotAvailableError( + f"CrossDomainMatcher {strategy!r} is configured but its concrete " + f"impl module {module_name!r} has not been built into this binary " + "yet (AZ-345 / AZ-346 / AZ-347 pending)." + ) from exc + create_fn = getattr(module, "create", None) + if create_fn is None: + strategy_cls = getattr(module, class_name) + instance = strategy_cls( + config, + lightglue_runtime=lightglue_runtime, + ransac_filter=ransac_filter, + inference_runtime=inference_runtime, + health_window=health_window, + ) + else: + instance = create_fn( + config, + lightglue_runtime=lightglue_runtime, + ransac_filter=ransac_filter, + inference_runtime=inference_runtime, + health_window=health_window, + ) + _LOG.info( + "c3.matcher.strategy_loaded", + extra={ + "strategy": strategy, + "min_inliers_threshold": block.min_inliers_threshold, + "residual_warn_threshold_px": block.residual_warn_threshold_px, + }, + ) + return instance diff --git a/tests/unit/c3_matcher/test_protocol_conformance.py b/tests/unit/c3_matcher/test_protocol_conformance.py new file mode 100644 index 0000000..86ecea5 --- /dev/null +++ b/tests/unit/c3_matcher/test_protocol_conformance.py @@ -0,0 +1,680 @@ +"""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) diff --git a/tests/unit/c3_matcher/test_smoke.py b/tests/unit/c3_matcher/test_smoke.py deleted file mode 100644 index 4d6943c..0000000 --- a/tests/unit/c3_matcher/test_smoke.py +++ /dev/null @@ -1,9 +0,0 @@ -"""C3 Cross-Domain Matcher smoke test — AC-9.""" - - -def test_interface_importable() -> None: - # Assert - from gps_denied_onboard.components.c3_matcher import CrossDomainMatcher, MatchResult - - assert CrossDomainMatcher is not None - assert MatchResult is not None