mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 13:51:13 +00:00
Decompose Step 6 snapshot: 140 task specs + contract docs
Closes out greenfield Step 6 (Decompose) for all 14 components (C1-C13 + cross-cutting helpers/replay). Covers tasks AZ-266..AZ-446 plus the _dependencies_table.md and component contract documents. State file updated to greenfield Step 7 (Implement), not_started. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
# Contract: `CrossDomainMatcher` Protocol
|
||||
|
||||
**Owner**: c3_matcher (epic AZ-257 / E-C3)
|
||||
**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
|
||||
**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
|
||||
|
||||
Defines the public interface for every C3 cross-domain matcher strategy: `match(frame, rerank_result, calibration)` produces a `MatchResult` containing per-candidate inlier counts + RANSAC-filtered correspondences + median reprojection residual; `health_snapshot()` returns rolling matcher health for AC-NEW-7 cache-poisoning detection. Every concrete matcher (DISK+LightGlue, ALIKED+LightGlue, XFeat) implements this Protocol; the composition root selects exactly one at startup based on `config.matcher.strategy` and refuses to wire a strategy whose `BUILD_MATCHER_<variant>` flag is OFF (ADR-002 + ADR-009).
|
||||
|
||||
The shared `LightGlueRuntime` helper (AZ-278) is constructor-injected — neither C2.5 nor C3 owns its lifecycle (R14 fix); the runtime root constructs ONE instance and passes the same reference to both. The shared `RansacFilter` helper (AZ-282) is also constructor-injected and consumed by C3, C3.5, and C4.
|
||||
|
||||
## Public API
|
||||
|
||||
### Protocol: `CrossDomainMatcher`
|
||||
|
||||
```python
|
||||
from typing import Protocol, runtime_checkable
|
||||
from gps_denied_onboard._types import (
|
||||
NavCameraFrame, CameraCalibration, RerankResult, MatchResult, MatcherHealth,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CrossDomainMatcher(Protocol):
|
||||
"""Cross-domain (nav-camera ↔ satellite-imagery) matcher strategy. Stateless per-frame; the only persistent state is the constructor-injected backbone runtime handles + the rolling health window."""
|
||||
|
||||
def match(
|
||||
self,
|
||||
frame: NavCameraFrame,
|
||||
rerank_result: RerankResult,
|
||||
calibration: CameraCalibration,
|
||||
) -> MatchResult:
|
||||
"""Run feature extraction + matching + RANSAC + reprojection-residual computation against each top-N=3 candidate in `rerank_result`. Pick the best candidate by inlier count (deterministic tie-break: lower median residual ranked higher).
|
||||
|
||||
Drop-and-continue per candidate: per-candidate `MatcherBackboneError` (backbone forward failure) → candidate dropped, ERROR log + FDR record, success path continues. If ALL candidates fail OR every candidate's inlier count falls below `config.matcher.min_inliers_threshold`: raise `InsufficientInliersError`; downstream C5 falls back to VIO-only with provenance `visual_propagated` (AC-3.5).
|
||||
|
||||
Raises:
|
||||
InsufficientInliersError: every candidate failed or every candidate's inlier count is below the configured floor.
|
||||
"""
|
||||
...
|
||||
|
||||
def health_snapshot(self) -> MatcherHealth:
|
||||
"""Return a rolling-window snapshot of matcher health: consecutive low-inlier frames, mean inliers over the last 60 s. Used by C5's spoof-promotion gate (AC-NEW-2 / AC-NEW-7) and by post-flight forensics."""
|
||||
...
|
||||
```
|
||||
|
||||
**Invariants**:
|
||||
|
||||
1. **Single-threaded by contract** — each instance is bound to one ingest thread (composition root enforces; same thread as C2.5 because they share `LightGlueRuntime`).
|
||||
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.
|
||||
3. **Best-candidate selection is deterministic** — `MatchResult.best_candidate_idx == argmax(inlier_count)` over `per_candidate`; ties broken by `per_candidate_residual_px` ascending (lower residual wins).
|
||||
4. **Drop-and-continue per candidate** — per-candidate exceptions never propagate out of `match` unless every candidate fails. Mirrors C2.5 INV-8.
|
||||
5. **`per_candidate` length is bounded** — `0 < len <= len(rerank_result.candidates)` (zero raises `InsufficientInliersError`); never exceeds the input N.
|
||||
6. **`matcher_label` is non-empty** — every `MatchResult` carries the strategy's name (e.g., `"disk_lightglue"`) for FDR provenance. MUST match `BUILD_MATCHER_<variant>` lowercase.
|
||||
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`.
|
||||
8. **`reprojection_residual_px` is the BEST candidate's median residual** — not the mean, not a max; downstream C3.5's threshold gate compares against this value.
|
||||
9. **`health_snapshot()` is cheap** — O(1); reads the rolling window's pre-computed accumulators. Never recomputes over the window contents.
|
||||
|
||||
### DTOs (in `_types/matcher.py`)
|
||||
|
||||
```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)
|
||||
inlier_count: int
|
||||
inlier_correspondences: np.ndarray # shape (I, 4) float32; (px_query, py_query, px_tile, py_tile)
|
||||
ransac_outlier_count: int
|
||||
per_candidate_residual_px: float # median residual on inliers
|
||||
|
||||
|
||||
@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
|
||||
best_candidate_idx: int # 0 by construction (sorted)
|
||||
reprojection_residual_px: float # best candidate's median residual
|
||||
matched_at: int # monotonic_ns
|
||||
matcher_label: str # non-empty; matches BUILD_MATCHER_<variant> lowercase
|
||||
candidates_input: int # len(rerank_result.candidates) at entry
|
||||
candidates_dropped: int # candidates_input - len(per_candidate)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MatcherHealth:
|
||||
"""Rolling-window matcher health snapshot."""
|
||||
consecutive_low_inlier: int # consecutive frames where inlier_count < min_inliers_threshold
|
||||
mean_inliers_60s: float # rolling 60 s mean of best-candidate inlier_count
|
||||
backbone_error_count_60s: int # rolling 60 s count of MatcherBackboneError occurrences
|
||||
```
|
||||
|
||||
### Error Hierarchy (in `c3_matcher/errors.py`)
|
||||
|
||||
```python
|
||||
class MatcherError(Exception):
|
||||
"""Base for all C3 matcher errors. Caught at the runtime root; downstream effect: C5 falls back to VIO-only with provenance `visual_propagated` (AC-3.5)."""
|
||||
|
||||
|
||||
class MatcherBackboneError(MatcherError):
|
||||
"""Per-candidate backbone forward-pass failure (CUDA OOM, TRT engine deserialize mismatch). Drop-and-continue inside `match`."""
|
||||
|
||||
|
||||
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."""
|
||||
```
|
||||
|
||||
## Composition-Root Factory
|
||||
|
||||
```python
|
||||
# src/gps_denied_onboard/runtime_root/matcher_factory.py
|
||||
|
||||
def build_matcher_strategy(
|
||||
config: Config,
|
||||
lightglue_runtime: LightGlueRuntime,
|
||||
ransac_filter: RansacFilter,
|
||||
inference_runtime: InferenceRuntime,
|
||||
) -> CrossDomainMatcher:
|
||||
"""Composition-root factory. Reads `config.matcher.strategy` and lazy-imports the concrete module gated by `BUILD_MATCHER_<variant>`.
|
||||
|
||||
Strategy resolution table:
|
||||
|
||||
| config.matcher.strategy | Implementation | Module | Build flag |
|
||||
|-------------------------|----------------------------|-----------------------------------------------|-----------------------------|
|
||||
| "disk_lightglue" | DiskLightGlueMatcher | components.c3_matcher.disk_lightglue | BUILD_MATCHER_DISK_LIGHTGLUE |
|
||||
| "aliked_lightglue" | AlikedLightGlueMatcher | components.c3_matcher.aliked_lightglue | BUILD_MATCHER_ALIKED_LIGHTGLUE |
|
||||
| "xfeat" | XFeatMatcher | components.c3_matcher.xfeat | BUILD_MATCHER_XFEAT |
|
||||
|
||||
The shared `LightGlueRuntime` and `RansacFilter` are constructor-injected; the factory does NOT own their lifecycles. The runtime root constructs ONE `LightGlueRuntime` and passes the SAME reference to both this factory and the C2.5 ReRank factory (per AZ-342 AC-10).
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
- The `CrossDomainMatcher` Protocol's method signatures are part of the cross-component public API. Any change is a major bump and requires updating every concrete implementation in lockstep.
|
||||
- DTO field additions are minor; field removals are major. The drop-and-continue contract (Invariant 4) is non-negotiable.
|
||||
|
||||
## Test Cases (protocol conformance — runs against every concrete strategy)
|
||||
|
||||
| AC Ref | What to Test | Required Outcome |
|
||||
|--------|--------------|------------------|
|
||||
| INV-1 (single-thread) | Composition root rejects multi-thread binding | `RuntimeError` on second binding attempt |
|
||||
| INV-2 (stateless `match`) | Reorder calls; replay calls | `MatchResult.per_candidate` content is identical (ignoring `matched_at`) |
|
||||
| INV-3 (best-candidate det.) | Mixed inlier counts with one tie | Best candidate is the tied one with lower median residual |
|
||||
| INV-4 (drop-and-continue) | One candidate's backbone raises | Result has remaining survivors; ERROR log + FDR record per failure |
|
||||
| INV-5 (length bound) | N=3 input, 2 candidates fail | `len(per_candidate) == 1` |
|
||||
| INV-6 (matcher_label) | Every MatchResult | `matcher_label` non-empty + matches `BUILD_MATCHER_<variant>` lowercase |
|
||||
| INV-7 (correspondences shape) | Each `CandidateMatchSet` | `inlier_correspondences.shape == (I, 4)`, `dtype == float32`, `I == inlier_count` |
|
||||
| INV-8 (median residual) | Median of inliers' residual list | `per_candidate_residual_px` matches numpy.median computed independently |
|
||||
| INV-9 (`health_snapshot` cheap) | Microbench `health_snapshot` × 1000 | p99 ≤ 50 µs |
|
||||
| AC-1.1 floor | Inlier count p5 across a fixture | ≥ 80 (AC-1.1 partition) |
|
||||
| All-fail | Every candidate's backbone raises | `InsufficientInliersError`; all-failed FDR record |
|
||||
| Below-threshold | Every candidate's inlier_count < `config.matcher.min_inliers_threshold` | `InsufficientInliersError` |
|
||||
|
||||
## Open Questions / Risks
|
||||
|
||||
- **Risk: D-C3-1 IT-12 verdict may shift the production-default backbone** from DISK+LightGlue to ALIKED+LightGlue or another. *Mitigation*: every backbone implements the same Protocol; switching is a config change. The contract holds.
|
||||
- **Risk: `LightGlueRuntime` shared with C2.5** — both must serialise through one ingest thread. *Mitigation*: composition root binds both to the same ingest thread; helper has internal thread-binding assertion (AZ-278).
|
||||
- **Risk: `min_inliers_threshold` is not yet calibrated** — the AC-1.1 floor (p5 ≥ 80) is the production target; the threshold may need to be lower (e.g., 40) to leave headroom. *Mitigation*: `config.matcher.min_inliers_threshold` is config-driven (default 60); FT-P-19 telemetry will tune it.
|
||||
Reference in New Issue
Block a user