# 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**: 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

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
import numpy as np


@dataclass(frozen=True, slots=True)
class CandidateMatchSet:
    """Per-candidate matching outcome inside a MatchResult."""
    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
    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: 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
    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."""
```

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_<variant>` 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
# 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.
