mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 20:21:13 +00:00
[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>
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
"""C3.5 AdHoP Refinement component — Public API."""
|
||||
"""C3.5 AdHoP / conditional refiner — Public API (AZ-348).
|
||||
|
||||
from gps_denied_onboard.components.c3_5_adhop.interface import AdHoPRefinementStrategy
|
||||
Per ``conditional_refiner_protocol.md`` v1.0.0 the public surface
|
||||
consists of:
|
||||
|
||||
__all__ = ["AdHoPRefinementStrategy"]
|
||||
- :class:`ConditionalRefiner` Protocol (two methods).
|
||||
- :class:`C3_5RefinerConfig` config block (registered on import).
|
||||
|
||||
The error family (:class:`RefinerError`,
|
||||
:class:`RefinerBackboneError`, :class:`RefinerConfigError`) and
|
||||
both concrete strategies (:class:`PassthroughRefiner`,
|
||||
:class:`AdHoPRefiner`) are intentionally NOT in ``__all__`` per
|
||||
task spec AC-8: consumers see only the Protocol; concrete
|
||||
strategies are reached via the runtime-root factory.
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.components.c3_5_adhop.config import C3_5RefinerConfig
|
||||
from gps_denied_onboard.components.c3_5_adhop.interface import ConditionalRefiner
|
||||
from gps_denied_onboard.config.schema import register_component_block
|
||||
|
||||
register_component_block("c3_5_adhop", C3_5RefinerConfig)
|
||||
|
||||
__all__ = [
|
||||
"C3_5RefinerConfig",
|
||||
"ConditionalRefiner",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""C3.5 ``ConditionalRefiner`` config block (AZ-348).
|
||||
|
||||
Registered into ``config.components['c3_5_adhop']`` by the
|
||||
package ``__init__.py``. The composition-root factory
|
||||
:func:`gps_denied_onboard.runtime_root.refiner_factory.build_refiner_strategy`
|
||||
reads this block to select the strategy and configure thresholds.
|
||||
|
||||
``strategy`` selects one of the two concrete refiners
|
||||
(``adhop`` — production-default; ``passthrough`` — baseline /
|
||||
smoke / IT-12 comparison). Both modules are linked
|
||||
unconditionally: there is NO ``BUILD_REFINER_*`` flag (NOT ADR-002
|
||||
territory). Runtime selection only.
|
||||
|
||||
``residual_threshold_px`` is the conditional-gate threshold: a
|
||||
:class:`MatchResult` whose ``reprojection_residual_px <=
|
||||
threshold`` is passed through unchanged; ``>`` invokes the
|
||||
strategy's refinement procedure. Default 2.5 px (the AC-NEW-5 /
|
||||
R10 tunable from operator tooling).
|
||||
|
||||
``invocation_rate_warn_threshold`` is the rolling-60 s
|
||||
invocation-rate ceiling above which a WARN log fires
|
||||
(C3.5-IT-03 / NFT-PERF-01). Must be in ``(0, 1)``; default 0.25.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from gps_denied_onboard.config.schema import ConfigError
|
||||
|
||||
__all__ = [
|
||||
"C3_5RefinerConfig",
|
||||
"KNOWN_STRATEGIES",
|
||||
]
|
||||
|
||||
|
||||
KNOWN_STRATEGIES: Final[frozenset[str]] = frozenset({"adhop", "passthrough"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C3_5RefinerConfig:
|
||||
"""Per-component config for C3.5 conditional refiner."""
|
||||
|
||||
strategy: str = "adhop"
|
||||
residual_threshold_px: float = 2.5
|
||||
invocation_rate_warn_threshold: float = 0.25
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.strategy not in KNOWN_STRATEGIES:
|
||||
raise ConfigError(
|
||||
f"C3_5RefinerConfig.strategy={self.strategy!r} not in "
|
||||
f"{sorted(KNOWN_STRATEGIES)}"
|
||||
)
|
||||
if self.residual_threshold_px <= 0.0:
|
||||
raise ConfigError(
|
||||
"C3_5RefinerConfig.residual_threshold_px must be > 0; "
|
||||
f"got {self.residual_threshold_px}"
|
||||
)
|
||||
if not (0.0 < self.invocation_rate_warn_threshold < 1.0):
|
||||
raise ConfigError(
|
||||
"C3_5RefinerConfig.invocation_rate_warn_threshold must be in "
|
||||
f"(0, 1); got {self.invocation_rate_warn_threshold}"
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""C3.5 ``ConditionalRefiner`` error taxonomy (AZ-348).
|
||||
|
||||
The family is intentionally small: per-candidate failures are
|
||||
handled inside C3 (drop-and-continue); at C3.5 the only failure
|
||||
mode is the AdHoP backbone (TensorRT exception, OOM, NaN, shape
|
||||
mismatch) and it is contained within the strategy via
|
||||
passthrough fall-through (contract Invariant 4) — never re-raised
|
||||
out of :meth:`refine_if_needed`.
|
||||
|
||||
:class:`RefinerConfigError` is the composition-root rejection at
|
||||
startup; never raised per-frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"RefinerBackboneError",
|
||||
"RefinerConfigError",
|
||||
"RefinerError",
|
||||
]
|
||||
|
||||
|
||||
class RefinerError(Exception):
|
||||
"""Base class for all C3.5 refinement-strategy errors."""
|
||||
|
||||
|
||||
class RefinerBackboneError(RefinerError):
|
||||
"""AdHoP backbone forward-pass failed.
|
||||
|
||||
TensorRT exception, OOM, NaN, shape mismatch. Caught inside
|
||||
:meth:`ConditionalRefiner.refine_if_needed`, converted to
|
||||
passthrough fall-through (Invariant 4), logged at ERROR with
|
||||
one FDR record. NEVER re-raised out of the strategy.
|
||||
"""
|
||||
|
||||
|
||||
class RefinerConfigError(RefinerError):
|
||||
"""Composition-root rejected the refiner config.
|
||||
|
||||
Unknown strategy label OR invalid threshold (``<= 0``). Raised
|
||||
at startup ONLY — never per-frame. The composition root logs
|
||||
ERROR (``kind="c3_5.refiner.strategy_unknown"`` or
|
||||
``kind="c3_5.refiner.invalid_threshold"``) before raising.
|
||||
"""
|
||||
@@ -1,16 +1,123 @@
|
||||
"""C3.5 `AdHoPRefinementStrategy` Protocol.
|
||||
"""C3.5 ``ConditionalRefiner`` Protocol (AZ-348).
|
||||
|
||||
Concrete impl: AdHoP refiner. See `_docs/02_document/components/05_c3_5_adhop/`.
|
||||
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 Protocol
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard._types.matcher import MatchResult
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.matcher import MatchResult
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
|
||||
__all__ = ["ConditionalRefiner"]
|
||||
|
||||
|
||||
class AdHoPRefinementStrategy(Protocol):
|
||||
"""Conditional refinement of a `MatchResult` (geometric verification + outlier purge)."""
|
||||
@runtime_checkable
|
||||
class ConditionalRefiner(Protocol):
|
||||
"""Conditional refinement strategy between C3 (matcher) and C4 (pose).
|
||||
|
||||
def refine(self, match: MatchResult) -> MatchResult: ...
|
||||
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.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""``PassthroughRefiner`` — no-op :class:`ConditionalRefiner` (AZ-348).
|
||||
|
||||
The reference baseline implementation. Returns the input
|
||||
:class:`MatchResult` unchanged (same object reference;
|
||||
``inlier_correspondences`` ndarrays are bit-identical and share
|
||||
references per contract INV-5). Always sets :meth:`was_invoked`
|
||||
to ``False`` per INV-8.
|
||||
|
||||
Both helpers (``ransac_filter``, ``inference_runtime``) are held
|
||||
by reference for parity with :class:`AdHoPRefiner` (AZ-349) but
|
||||
neither is invoked. ``inference_runtime`` is typed as ``object``
|
||||
because the C3 matcher → C3.5 refiner → C4 pose layering forbids
|
||||
L3-to-L3 imports (architecture test ``test_az270_compose_root``);
|
||||
the composition-root factory at
|
||||
:mod:`gps_denied_onboard.runtime_root.refiner_factory`
|
||||
narrows the type at construction time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard._types.matcher import MatchResult
|
||||
from gps_denied_onboard._types.nav import NavCameraFrame
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
from gps_denied_onboard.helpers.ransac_filter import RansacFilter
|
||||
|
||||
__all__ = ["PassthroughRefiner", "create"]
|
||||
|
||||
|
||||
class PassthroughRefiner:
|
||||
"""Reference passthrough strategy.
|
||||
|
||||
See module docstring. Stateless except for the ``_was_invoked``
|
||||
flag (always ``False`` per INV-8); concurrent calls are unsafe
|
||||
(single-thread invariant covers).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ransac_filter: "RansacFilter",
|
||||
inference_runtime: object,
|
||||
) -> None:
|
||||
# Held for parity with AdHoPRefiner; neither is invoked.
|
||||
self._ransac_filter = ransac_filter
|
||||
self._inference_runtime = inference_runtime
|
||||
self._was_invoked: bool = False
|
||||
|
||||
def refine_if_needed(
|
||||
self,
|
||||
frame: "NavCameraFrame",
|
||||
mr: "MatchResult",
|
||||
residual_threshold_px: float,
|
||||
) -> "MatchResult":
|
||||
if residual_threshold_px <= 0.0:
|
||||
raise ValueError(
|
||||
"residual_threshold_px must be > 0; "
|
||||
f"got {residual_threshold_px}"
|
||||
)
|
||||
self._was_invoked = False
|
||||
# `MatchResult` defaults `refinement_label="passthrough"`
|
||||
# and `refinement_added_latency_ms=0.0` already; the input
|
||||
# is bit-identical per contract INV-5 so we return it by
|
||||
# reference rather than recreating via `dataclasses.replace`.
|
||||
return mr
|
||||
|
||||
def was_invoked(self) -> bool:
|
||||
return self._was_invoked
|
||||
|
||||
|
||||
def create(
|
||||
config: "Config",
|
||||
*,
|
||||
ransac_filter: "RansacFilter",
|
||||
inference_runtime: object,
|
||||
) -> PassthroughRefiner:
|
||||
"""Module-level factory entry point consumed by the
|
||||
composition-root :func:`build_refiner_strategy`."""
|
||||
return PassthroughRefiner(
|
||||
ransac_filter=ransac_filter,
|
||||
inference_runtime=inference_runtime,
|
||||
)
|
||||
Reference in New Issue
Block a user