"""C3.5 ``ConditionalRefiner`` Protocol (AZ-348). PEP 544 ``typing.Protocol`` with ``runtime_checkable=True``; a two-method surface: :meth:`refine_if_needed` and :meth:`was_invoked`. Concrete impls — :class:`PassthroughRefiner` (this task) and :class:`AdHoPRefiner` (AZ-349) — live in sibling modules. Both are linked into the production binary unconditionally per ADR-001; runtime selection is via ``config.refiner.strategy`` (NO ``BUILD_REFINER_*`` flag — NOT ADR-002 territory because both strategies are tiny and the AdHoP TRT engine is shared C7 infrastructure). The contract at ``_docs/02_document/contracts/c3_5_adhop/conditional_refiner_protocol.md`` v1.0.0 is the authoritative shape; this module mirrors it 1:1. """ from __future__ import annotations from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from gps_denied_onboard._types.matcher import MatchResult from gps_denied_onboard._types.nav import NavCameraFrame __all__ = ["ConditionalRefiner"] @runtime_checkable class ConditionalRefiner(Protocol): """Conditional refinement strategy between C3 (matcher) and C4 (pose). Stateless per-frame; the only persistent state is the constructor-injected backbone runtime handle (when the strategy uses one) and the last-invocation flag. Invariants (see ``conditional_refiner_protocol.md`` v1.0.0): - **INV-1 single-threaded** — each instance is bound to one ingest thread; same thread as C3 (shared C-frame ingest path). - **INV-2 stateless per-frame** — except for the ``was_invoked`` flag, no implicit dependency on prior frames; reordering :meth:`refine_if_needed` calls (tests only) MUST yield identical output ``MatchResult`` content. - **INV-3 conditional gate is a pure comparison** — ``mr.reprojection_residual_px <= threshold`` → passthrough; ``>`` → invoke. No tolerance, no smoothing, no hysteresis. - **INV-4 passthrough fall-through on backbone error** — :class:`RefinerBackboneError` raised inside the invoked path is caught by the strategy and converted to passthrough output with ``refinement_label = "passthrough"``; the error is logged at ERROR + emitted to FDR; NEVER re-raised out of :meth:`refine_if_needed`. - **INV-5 bit-identical correspondences on passthrough** — when ``refinement_label == "passthrough"``, every ``inlier_correspondences`` ndarray in the output equals the input ndarray bit-for-bit (``np.array_equal`` AND same ``dtype``). - **INV-6 ``refinement_label`` is `"adhop"` OR `"passthrough"`** — exactly one of those two values; matches the strategy's selected variant. Readers check :meth:`was_invoked` to discriminate "AdHoP ran" from "AdHoP-fell-through-to-passthrough". - **INV-7 ``refinement_added_latency_ms`` is strategy-internal added latency** — always ``>= 0``; near-zero on passthrough; up to ~90 ms on AdHoP invoke per C3.5-PT-01. - **INV-8 ``was_invoked()`` semantics** — set to ``True`` iff the strategy entered the refinement procedure (post-gate, regardless of whether AdHoP succeeded or fell through). On pure passthrough strategy + every gate-decided-passthrough call: ``False``. - **INV-9 threshold validation** — the strategy MUST reject ``residual_threshold_px <= 0`` (raise :class:`ValueError`); the composition root validates the config-loaded threshold at startup so this in-method check is defensive. """ def refine_if_needed( self, frame: "NavCameraFrame", mr: "MatchResult", residual_threshold_px: float, ) -> "MatchResult": """Either pass ``mr`` through unchanged or run refinement. If ``mr.reprojection_residual_px <= residual_threshold_px`` (the steady-state path), return ``mr`` unchanged AND set :meth:`was_invoked` to ``False``. Otherwise run the strategy's refinement procedure and return an enriched :class:`MatchResult` (typically via :func:`dataclasses.replace`) with ``refinement_label`` set, AND set :meth:`was_invoked` to ``True``. On :class:`RefinerBackboneError` (AdHoP backbone failure during the invoked path), the refiner MUST fall through to passthrough — return ``mr`` unchanged with ``refinement_label = "passthrough"`` AND :meth:`was_invoked` ``True`` (the attempt counts towards the invocation rate even on failure). The error is logged at ERROR + emitted to FDR; downstream pose estimation gets a usable :class:`MatchResult` and decides whether to trigger F6. Determinism: same inputs MUST produce the same output. No probabilistic gating; no time-based gating. Raises: ValueError: ``residual_threshold_px <= 0`` (defensive; composition root should have caught this already). """ ... def was_invoked(self) -> bool: """Return ``True`` iff the last :meth:`refine_if_needed` call entered the refinement procedure. Set at the start of every :meth:`refine_if_needed` call. Used by FDR per-frame provenance and by NFT-PERF-01 / C3.5-IT-03 invocation-rate accounting. """ ...