Files
gps-denied-onboard/src/gps_denied_onboard/components/c3_5_adhop/interface.py
T
Oleksandr Bezdieniezhnykh 9a605c8514 [AZ-348] C3.5 ConditionalRefiner Protocol + factory + PassthroughRefiner
Defines the public `ConditionalRefiner` Protocol (PEP 544
@runtime_checkable, two methods: `refine_if_needed` +
`was_invoked`), extends `MatchResult` in-place with two
default-valued refinement fields (`refinement_label`,
`refinement_added_latency_ms`), defines the `RefinerError` family
(`RefinerBackboneError`, `RefinerConfigError`), and ships the
trivial `PassthroughRefiner` reference impl.

Both refiner strategies are linked unconditionally — no
`BUILD_REFINER_*` flag (NOT ADR-002 territory). Runtime selection
only per ADR-001. `PassthroughRefiner` returns the input
`MatchResult` by reference (bit-identical correspondences per
contract INV-5) and always reports `was_invoked() is False`.

Documentation: renames `module-layout.md` `c3_5_adhop` Public API
symbol from `AdHoPRefinementStrategy` to `ConditionalRefiner`
(AC-14) so the doc agrees with `description.md` and the contract.

AC-9 (single-thread binding) deferred to AZ-270 runtime-root
composition, mirroring AZ-336 / AZ-342 / AZ-344 Risk-4 precedent.
AC-7 for the `"adhop"` strategy stops at `ModuleNotFoundError`
because the AdHoP backbone is owned by AZ-349. All other ACs +
NFRs covered by 36 new conformance tests.

Architectural note: `PassthroughRefiner.inference_runtime` is
typed as `object` because the L3→L3 import ban
(`test_az270_compose_root`) forbids c3_5_adhop from importing
c7_inference; the runtime-root factory narrows the type at
construction time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 05:52:36 +03:00

124 lines
5.3 KiB
Python

"""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.
"""
...