# Common Helper — `LightGlueRuntime`

## Purpose

Shared LightGlue inference handle. C2.5 (Re-rank) does single-pair LightGlue matching for inlier counting on K=10 candidates per frame; C3 (CrossDomainMatcher) does the heavier matching pass on the surviving N=3 candidates. Both use the same LightGlue engine; sharing the engine avoids paying the engine-build / GPU-memory cost twice.

## Used By

- C2.5 — Inlier-based Re-rank.
- C3 — Cross-domain Matcher.

## Interface (sketch)

```
class LightGlueRuntime:
    def __init__(engine_handle: EngineHandle): ...
    def match(features_a: KeypointSet, features_b: KeypointSet) -> CorrespondenceSet
    def match_batch(features_a_list, features_b_list) -> list[CorrespondenceSet]
    def descriptor_dim() -> int
```

## Implementation Notes

- Owned by the composition root; the same instance is constructor-injected into both C2.5 and C3.
- Backed by C7's `InferenceRuntime.deserialize_engine(LIGHTGLUE_ENGINE_CACHE_ENTRY)` at takeoff.
- Single CUDA stream; concurrent calls forbidden — composition root binds the runtime to the single F3 hot-path thread.

## Caveats

- The features fed in MUST come from the same backbone as the LightGlue engine was trained for (DISK in production-default; ALIKED / XFeat in alternates). Mixing backbones is a runtime error caught by the matcher's input shape check.

## Cycle-1 operational reality

The shipped surface in `src/gps_denied_onboard/helpers/lightglue_runtime.py` (AZ-278) is the structural fix for R14 (re-rank vs. matcher double-load of the LightGlue engine). Composition root wires ONE `LightGlueRuntime` instance and constructor-injects it into both C2.5 (`InlierBasedReranker`) and C3 (`CrossDomainMatcher`).

- **Constructor** — `LightGlueRuntime(engine_handle: EngineHandle)`. `EngineHandle` is a `Protocol` from `_types/manifests.py` (descriptor_dim, forward(...)) — Layer 1 helper invariant means we do NOT import `gps_denied_onboard.components.*`. C7's `InferenceRuntime.deserialize_engine(LIGHTGLUE_ENGINE_CACHE_ENTRY)` returns the concrete handle at takeoff; the composition root passes it in.
- **Construction guards** — `LightGlueRuntimeError` is raised for: `engine_handle is None`; `engine_handle` missing the `descriptor_dim` Protocol attribute; `descriptor_dim < 1`.
- **Descriptor-dim mismatch** — both `match` and `match_batch` validate every `KeypointSet.descriptors` against the engine's `descriptor_dim` and raise `LightGlueRuntimeError` on mismatch (catches "DISK features fed into an ALIKED-trained LightGlue" regressions).
- **Concurrent-access guard is non-blocking** — the runtime owns a `threading.Lock` but never `.acquire(blocking=True)`. Concurrent entry raises `LightGlueConcurrentAccessError` immediately rather than serialising. This is intentional: if you see this exception, the composition root wired the runtime into more than one thread by mistake — fix the composition, do NOT add blocking serialisation. The lock guards the body of both `match` and `match_batch`; `descriptor_dim()` is lock-free.
- **`match_batch` equal-length precondition** — `LightGlueRuntimeError` if `len(features_a_list) != len(features_b_list)`. Iteration uses `zip(..., strict=True)`. Indexed validation labels (`features_a_list[i]`) so a downstream test failure points to the offending pair.
- **`descriptor_dim()` accessor** — returns the engine's descriptor dim as a plain `int` (cached on construction so per-call overhead is one attribute lookup).
- **Public exceptions** — `LightGlueRuntimeError` (construction / descriptor-dim mismatch / batch-length mismatch) and `LightGlueConcurrentAccessError` (composition-root violation). Both subclass `RuntimeError`.

### Cycle-1 task lineage

- AZ-278 — initial helper, contract producer.
- R14 structural fix: composition-root single-instance injection into C2.5 + C3 lands in `runtime_root/airborne_bootstrap.py` (the `lightglue_runtime` pre-constructed key consumed by both `_C2_5_STRATEGIES` and `_C3_STRATEGIES`).
