[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:
Oleksandr Bezdieniezhnykh
2026-05-12 05:52:36 +03:00
parent 89c223882b
commit 9a605c8514
13 changed files with 991 additions and 28 deletions
@@ -4,8 +4,8 @@
**Producer task**: AZ-348 (Protocol + factory + DTOs + composition + `PassthroughRefiner`) **Producer task**: AZ-348 (Protocol + factory + DTOs + composition + `PassthroughRefiner`)
**Consumer tasks**: AZ-349 (`AdHoPRefiner` real refinement); downstream c4_pose (epic AZ-259) which consumes the (possibly refined) `MatchResult` **Consumer tasks**: AZ-349 (`AdHoPRefiner` real refinement); downstream c4_pose (epic AZ-259) which consumes the (possibly refined) `MatchResult`
**Version**: 1.0.0 **Version**: 1.0.0
**Status**: draft, awaiting Producer task implementation **Status**: v1.0.0 (AZ-348 implemented 2026-05-12; PassthroughRefiner shipped — AdHoPRefiner pending AZ-349)
**Last Updated**: 2026-05-10 **Last Updated**: 2026-05-12
**Module-layout home**: `src/gps_denied_onboard/components/c3_5_adhop/interface.py` (Protocol), `src/gps_denied_onboard/components/c3_5_adhop/__init__.py` (re-exports), `src/gps_denied_onboard/runtime_root/refiner_factory.py` (factory) **Module-layout home**: `src/gps_denied_onboard/components/c3_5_adhop/interface.py` (Protocol), `src/gps_denied_onboard/components/c3_5_adhop/__init__.py` (re-exports), `src/gps_denied_onboard/runtime_root/refiner_factory.py` (factory)
> **Public API symbol naming.** The component's public interface symbol is named `ConditionalRefiner` in `description.md` § 2 and `AdHoPRefinementStrategy` in `module-layout.md` § c3_5_adhop. Both refer to the SAME Protocol; the canonical class name in code is `ConditionalRefiner` — it is the role description-first name and matches the method `refine_if_needed`. The producer task ALSO updates `module-layout.md` to align (`AdHoPRefinementStrategy` → `ConditionalRefiner`) so the two documents agree. > **Public API symbol naming.** The component's public interface symbol is named `ConditionalRefiner` in `description.md` § 2 and `AdHoPRefinementStrategy` in `module-layout.md` § c3_5_adhop. Both refer to the SAME Protocol; the canonical class name in code is `ConditionalRefiner` — it is the role description-first name and matches the method `refine_if_needed`. The producer task ALSO updates `module-layout.md` to align (`AdHoPRefinementStrategy` → `ConditionalRefiner`) so the two documents agree.
+9 -5
View File
@@ -89,11 +89,15 @@ Bootstrap reference: `_docs/02_tasks/todo/AZ-263_initial_structure.md`. Architec
- **Epic**: AZ-258 (E-C3.5 AdHoP Refinement) - **Epic**: AZ-258 (E-C3.5 AdHoP Refinement)
- **Directory**: `src/gps_denied_onboard/components/c3_5_adhop/` - **Directory**: `src/gps_denied_onboard/components/c3_5_adhop/`
- **Public API**: - **Public API**:
- `__init__.py` (re-exports `AdHoPRefinementStrategy`) - `__init__.py` (re-exports `ConditionalRefiner`, `C3_5RefinerConfig`)
- `interface.py` (`AdHoPRefinementStrategy` Protocol) - `interface.py` (`ConditionalRefiner` Protocol)
- **Internal**: `default_refiner.py` - `config.py` (`C3_5RefinerConfig`)
- **Owns**: `src/gps_denied_onboard/components/c3_5_adhop/**`, `tests/unit/c3_5_adhop/**` - `errors.py` (`RefinerError`, `RefinerBackboneError`, `RefinerConfigError` — held internal to the component; consumers reach them only via tests)
- **Imports from**: `_types`, `helpers.ransac_filter`, `helpers.se3_utils`, `config`, `logging`, `fdr_client` - **Internal**:
- `passthrough_refiner.py` (reference baseline; AZ-348)
- `adhop_refiner.py` (production-default; AZ-349 pending)
- **Owns**: `src/gps_denied_onboard/components/c3_5_adhop/**`, `tests/unit/c3_5_adhop/**`, `src/gps_denied_onboard/runtime_root/refiner_factory.py`
- **Imports from**: `_types`, `helpers.ransac_filter` (R14: SHARED with C3 and C4 — owned by helper, NOT by C3.5), `helpers.se3_utils`, `components.c7_inference`, `config`, `logging`, `fdr_client`
- **Consumed by**: `c4_pose`, `runtime_root` - **Consumed by**: `c4_pose`, `runtime_root`
### Component: c4_pose ### Component: c4_pose
+1 -1
View File
@@ -8,7 +8,7 @@ status: in_progress
sub_step: sub_step:
phase: 2 phase: 2
name: detect-progress name: detect-progress
detail: "batch 24 in flight (per-task commits): AZ-336 done, AZ-342 done, AZ-344 done; next AZ-348" detail: "batch 24 complete (AZ-336, AZ-342, AZ-344, AZ-348 per-task commits); ready to plan batch 25"
retry_count: 0 retry_count: 0
cycle: 1 cycle: 1
tracker: jira tracker: jira
+17 -2
View File
@@ -52,8 +52,12 @@ class CandidateMatchSet:
class MatchResult: class MatchResult:
"""Cross-domain match outcome for one frame. """Cross-domain match outcome for one frame.
Consumed by C3.5 :class:`ConditionalRefiner` (AZ-348). The Consumed by C3.5 :class:`ConditionalRefiner` (AZ-348) — which
``per_candidate`` tuple is sorted descending by may either pass the result through unchanged or emit a new
instance via :func:`dataclasses.replace` with the refinement
fields set.
The ``per_candidate`` tuple is sorted descending by
``inlier_count`` with ties broken ascending by ``inlier_count`` with ties broken ascending by
``per_candidate_residual_px`` (INV-3) so ``per_candidate_residual_px`` (INV-3) so
``best_candidate_idx == 0`` by construction. ``best_candidate_idx == 0`` by construction.
@@ -62,6 +66,15 @@ class MatchResult:
residual (mirrors ``per_candidate[0].per_candidate_residual_px``) residual (mirrors ``per_candidate[0].per_candidate_residual_px``)
surfaced separately so consumers do not have to know the surfaced separately so consumers do not have to know the
ranking encoding. ranking encoding.
The two refinement fields are populated by C3.5; they default
to the passthrough values so a C3 producer that never goes
through C3.5 still yields a valid downstream-readable
:class:`MatchResult` (AZ-348 AC-2). ``refinement_label`` is
one of ``"adhop"`` or ``"passthrough"``;
``refinement_added_latency_ms`` covers exactly the work done
inside :meth:`ConditionalRefiner.refine_if_needed` and is
``0.0`` on pure passthrough.
""" """
frame_id: int frame_id: int
@@ -72,6 +85,8 @@ class MatchResult:
matcher_label: str matcher_label: str
candidates_input: int candidates_input: int
candidates_dropped: int candidates_dropped: int
refinement_label: str = "passthrough"
refinement_added_latency_ms: float = 0.0
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -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 __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): @runtime_checkable
"""Conditional refinement of a `MatchResult` (geometric verification + outlier purge).""" 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,
)
@@ -0,0 +1,142 @@
"""C3.5 refiner strategy composition-root factory (AZ-348).
:func:`build_refiner_strategy` selects exactly one strategy by
``config.components['c3_5_adhop'].strategy``. Both concrete
strategies are linked into the production binary
**unconditionally** (NO ``BUILD_REFINER_*`` flag — this is NOT
ADR-002 territory). Runtime selection only per ADR-001.
Strategy resolution table — mirrors the contract's
``conditional_refiner_protocol.md`` v1.0.0 § Composition-root
factory table verbatim:
* ``"adhop"`` → ``gps_denied_onboard.components.c3_5_adhop.adhop_refiner.AdHoPRefiner`` (AZ-349; placeholder today).
* ``"passthrough"`` → ``gps_denied_onboard.components.c3_5_adhop.passthrough_refiner.PassthroughRefiner``.
The shared :class:`RansacFilter` and C7 :class:`InferenceRuntime`
handles are constructor-injected — the factory does NOT own their
lifecycles. The runtime root constructs ONE
:class:`RansacFilter` instance and identity-shares it across C3,
C3.5, and C4 (per ``ransac_filter.md`` v1.0.0).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from gps_denied_onboard.components.c3_5_adhop.errors import RefinerConfigError
if TYPE_CHECKING:
from gps_denied_onboard.components.c3_5_adhop import (
C3_5RefinerConfig,
ConditionalRefiner,
)
from gps_denied_onboard.components.c7_inference import InferenceRuntime
from gps_denied_onboard.config.schema import Config
from gps_denied_onboard.helpers.ransac_filter import RansacFilter
__all__ = ["build_refiner_strategy"]
_LOG = logging.getLogger("gps_denied_onboard.c3_5_adhop")
# Strategy resolution table — mirrors the contract verbatim. ANY
# mutation of this dict MUST be mirrored in the contract.
_STRATEGY_TO_MODULE: dict[str, tuple[str, str]] = {
"adhop": (
"gps_denied_onboard.components.c3_5_adhop.adhop_refiner",
"AdHoPRefiner",
),
"passthrough": (
"gps_denied_onboard.components.c3_5_adhop.passthrough_refiner",
"PassthroughRefiner",
),
}
def _c3_5_config(config: "Config") -> "C3_5RefinerConfig":
"""Pull the registered C3.5 config block.
``c3_5_adhop.__init__`` registers it on import; a missing
registration is a developer error and surfaces as ``KeyError``
rather than a silent fallback.
"""
return config.components["c3_5_adhop"]
def build_refiner_strategy(
config: "Config",
*,
ransac_filter: "RansacFilter",
inference_runtime: "InferenceRuntime",
) -> "ConditionalRefiner":
"""Construct the :class:`ConditionalRefiner` impl selected by config.
1. Reads ``config.components['c3_5_adhop'].{strategy, residual_threshold_px}``.
2. Validates ``residual_threshold_px > 0`` — defensive
redundancy on top of the config-load-time check
(:class:`C3_5RefinerConfig.__post_init__`); raises
:class:`RefinerConfigError` on failure.
3. Imports the concrete strategy module via the resolution
table (NOT lazy — both strategies are linked
unconditionally).
4. Constructs the strategy via its module-level
``create(config, ransac_filter, inference_runtime)``
factory function.
5. Emits ONE INFO log ``kind="c3_5.refiner.strategy_loaded"``
with ``{strategy, residual_threshold_px}``.
Raises:
RefinerConfigError: unknown strategy label OR invalid
threshold (``<= 0``).
"""
block = _c3_5_config(config)
strategy = block.strategy
module_info = _STRATEGY_TO_MODULE.get(strategy)
if module_info is None:
_LOG.error(
"c3_5.refiner.strategy_unknown",
extra={"strategy": strategy},
)
raise RefinerConfigError(f"Unknown refiner strategy: {strategy}")
if block.residual_threshold_px <= 0.0:
# Config-load validation should have rejected this already;
# defensive in case a caller constructed Config bypassing
# __post_init__ (e.g., via dataclasses.replace on a partial
# block).
_LOG.error(
"c3_5.refiner.invalid_threshold",
extra={
"strategy": strategy,
"residual_threshold_px": block.residual_threshold_px,
},
)
raise RefinerConfigError(
"residual_threshold_px must be > 0; "
f"got {block.residual_threshold_px}"
)
module_name, class_name = module_info
module = __import__(module_name, fromlist=[class_name])
create_fn = getattr(module, "create", None)
if create_fn is None:
strategy_cls = getattr(module, class_name)
instance = strategy_cls(
ransac_filter=ransac_filter,
inference_runtime=inference_runtime,
)
else:
instance = create_fn(
config,
ransac_filter=ransac_filter,
inference_runtime=inference_runtime,
)
_LOG.info(
"c3_5.refiner.strategy_loaded",
extra={
"strategy": strategy,
"residual_threshold_px": block.residual_threshold_px,
},
)
return instance
@@ -0,0 +1,490 @@
"""AZ-348 — C3.5 ConditionalRefiner Protocol + DTO + error + factory conformance.
Covers AC-1..AC-14 + NFRs. AC-9 (single-thread binding) is
deferred per the task spec's Risk-4 escape clause — the generic
``compose_root`` thread-binding registry lives with AZ-270 and
the broader runtime-root composition. Each factory owns its own
thread binding today; this protocol task does not add a new
binding registry.
AC-7 (strategy resolution table) is asserted up to the lookup
point for ``"adhop"``: the AdHoP module is the AZ-349 placeholder
that does not exist yet, so the assertion on that path stops at
``ModuleNotFoundError`` — explicitly documented as the "import +
class lookup not __init__" rule in the task spec.
"""
from __future__ import annotations
import dataclasses
import logging
import sys
import time
import numpy as np
import pytest
from gps_denied_onboard._types.matcher import (
CandidateMatchSet,
MatchResult,
)
from gps_denied_onboard.components.c3_5_adhop import (
C3_5RefinerConfig,
ConditionalRefiner,
)
from gps_denied_onboard.components.c3_5_adhop.config import KNOWN_STRATEGIES
from gps_denied_onboard.components.c3_5_adhop.errors import (
RefinerBackboneError,
RefinerConfigError,
RefinerError,
)
from gps_denied_onboard.components.c3_5_adhop.passthrough_refiner import (
PassthroughRefiner,
create,
)
from gps_denied_onboard.config.schema import Config, ConfigError
from gps_denied_onboard.runtime_root.refiner_factory import build_refiner_strategy
# ----------------------------------------------------------------------
# Fakes.
class _FakeRansacFilter:
def filter(self, *args, **kwargs):
raise NotImplementedError
class _FakeInferenceRuntime:
def deserialize_engine(self, *args, **kwargs):
raise NotImplementedError
def thermal_state(self):
raise NotImplementedError
class _FakeFrame:
frame_id = 7
class _PartialRefinerMissingWasInvoked:
def refine_if_needed(self, frame, mr, residual_threshold_px):
raise NotImplementedError
class _PartialRefinerMissingRefine:
def was_invoked(self):
raise NotImplementedError
def _config_with_strategy(
strategy: str = "passthrough",
*,
threshold: float = 2.5,
) -> Config:
return Config.with_blocks(
c3_5_adhop=C3_5RefinerConfig(
strategy=strategy,
residual_threshold_px=threshold,
)
)
def _make_candidate(*, inliers: int = 40, residual: float = 1.0) -> CandidateMatchSet:
return CandidateMatchSet(
tile_id=(18, 49.9, 36.3),
inlier_count=inliers,
inlier_correspondences=np.ones((inliers, 4), dtype=np.float32) * 0.5,
ransac_outlier_count=3,
per_candidate_residual_px=residual,
)
def _make_result(
*,
reprojection_residual: float = 1.0,
refinement_label: str = "passthrough",
refinement_latency_ms: float = 0.0,
) -> MatchResult:
candidate = _make_candidate()
return MatchResult(
frame_id=7,
per_candidate=(candidate,),
best_candidate_idx=0,
reprojection_residual_px=reprojection_residual,
matched_at=1_000_000_000,
matcher_label="disk_lightglue",
candidates_input=3,
candidates_dropped=2,
refinement_label=refinement_label,
refinement_added_latency_ms=refinement_latency_ms,
)
# ----------------------------------------------------------------------
# AC-1: Protocol conformance.
def test_ac1_passthrough_refiner_conformance() -> None:
refiner = PassthroughRefiner(
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
assert isinstance(refiner, ConditionalRefiner)
@pytest.mark.parametrize(
"partial_cls",
[_PartialRefinerMissingWasInvoked, _PartialRefinerMissingRefine],
)
def test_ac1_partial_refiners_fail_conformance(partial_cls) -> None:
assert not isinstance(partial_cls(), ConditionalRefiner)
# ----------------------------------------------------------------------
# AC-2: MatchResult backward-compatible defaults.
def test_ac2_match_result_construction_without_new_fields() -> None:
candidate = _make_candidate()
mr = MatchResult(
frame_id=7,
per_candidate=(candidate,),
best_candidate_idx=0,
reprojection_residual_px=candidate.per_candidate_residual_px,
matched_at=1_000_000_000,
matcher_label="disk_lightglue",
candidates_input=3,
candidates_dropped=2,
)
assert mr.refinement_label == "passthrough"
assert mr.refinement_added_latency_ms == 0.0
# ----------------------------------------------------------------------
# AC-3: MatchResult immutability + slots preserved.
def test_ac3_match_result_slots_include_new_fields() -> None:
assert "refinement_label" in MatchResult.__slots__
assert "refinement_added_latency_ms" in MatchResult.__slots__
def test_ac3_match_result_remains_frozen() -> None:
mr = _make_result()
with pytest.raises(dataclasses.FrozenInstanceError):
mr.refinement_label = "adhop"
with pytest.raises(dataclasses.FrozenInstanceError):
mr.refinement_added_latency_ms = 12.5
# ----------------------------------------------------------------------
# AC-4: Factory rejects unknown strategy. (Validated at config-load.)
@pytest.mark.parametrize(
"bad_label",
["ADHOP", "passthrough_v2", "garbage", "", "default"],
)
def test_ac4_unknown_strategy_rejected_at_config_load(bad_label: str) -> None:
with pytest.raises(ConfigError) as exc_info:
C3_5RefinerConfig(strategy=bad_label)
msg = str(exc_info.value)
for valid in KNOWN_STRATEGIES:
assert valid in msg
def test_ac4_factory_emits_error_log_on_unknown_strategy(caplog) -> None:
# Build a Config that bypasses __post_init__ validation by
# constructing the block via dataclasses.replace on an already-valid
# block — this is what catches the defensive factory path.
valid_block = C3_5RefinerConfig(strategy="passthrough")
object.__setattr__(valid_block, "strategy", "garbage")
config = Config.with_blocks(c3_5_adhop=valid_block)
with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_5_adhop"):
with pytest.raises(RefinerConfigError) as exc_info:
build_refiner_strategy(
config,
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
assert "Unknown refiner strategy: garbage" in str(exc_info.value)
assert any(
r.message == "c3_5.refiner.strategy_unknown" for r in caplog.records
)
# ----------------------------------------------------------------------
# AC-5: Factory rejects invalid threshold.
@pytest.mark.parametrize("bad_threshold", [0.0, -0.1, -10.0])
def test_ac5_invalid_threshold_rejected_at_config_load(bad_threshold: float) -> None:
with pytest.raises(ConfigError):
C3_5RefinerConfig(residual_threshold_px=bad_threshold)
def test_ac5_factory_emits_error_log_on_invalid_threshold(caplog) -> None:
valid_block = C3_5RefinerConfig(strategy="passthrough")
object.__setattr__(valid_block, "residual_threshold_px", 0.0)
config = Config.with_blocks(c3_5_adhop=valid_block)
with caplog.at_level(logging.ERROR, logger="gps_denied_onboard.c3_5_adhop"):
with pytest.raises(RefinerConfigError):
build_refiner_strategy(
config,
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
assert any(
r.message == "c3_5.refiner.invalid_threshold" for r in caplog.records
)
# ----------------------------------------------------------------------
# AC-6: Successful factory load emits INFO log.
def test_ac6_factory_emits_info_log_on_success(caplog) -> None:
config = _config_with_strategy("passthrough", threshold=2.5)
with caplog.at_level(logging.INFO, logger="gps_denied_onboard.c3_5_adhop"):
instance = build_refiner_strategy(
config,
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
assert isinstance(instance, ConditionalRefiner)
records = [
r for r in caplog.records if r.message == "c3_5.refiner.strategy_loaded"
]
assert len(records) == 1
record = records[0]
assert getattr(record, "strategy", None) == "passthrough"
assert getattr(record, "residual_threshold_px", None) == 2.5
# ----------------------------------------------------------------------
# AC-7: Strategy resolution table.
def test_ac7_passthrough_resolution() -> None:
config = _config_with_strategy("passthrough")
instance = build_refiner_strategy(
config,
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
assert isinstance(instance, PassthroughRefiner)
assert isinstance(instance, ConditionalRefiner)
assert (
"gps_denied_onboard.components.c3_5_adhop.passthrough_refiner"
in sys.modules
)
def test_ac7_adhop_resolution_stops_at_module_lookup() -> None:
"""Task spec: the full-success path for "adhop" belongs to the
AdHoP task (AZ-349); this assertion verifies the factory reaches
the import step but the module does not exist yet.
"""
config = _config_with_strategy("adhop")
with pytest.raises(ModuleNotFoundError):
build_refiner_strategy(
config,
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
# ----------------------------------------------------------------------
# AC-8: Public API.
def test_ac8_public_api_re_exports() -> None:
from gps_denied_onboard.components import c3_5_adhop
assert "ConditionalRefiner" in c3_5_adhop.__all__
assert "C3_5RefinerConfig" in c3_5_adhop.__all__
def test_ac8_internals_not_in_public_api() -> None:
from gps_denied_onboard.components import c3_5_adhop
for internal in (
"PassthroughRefiner",
"AdHoPRefiner",
"RefinerError",
"RefinerBackboneError",
"RefinerConfigError",
):
assert internal not in c3_5_adhop.__all__, (
f"internal name leaked into public API: {internal}"
)
# ----------------------------------------------------------------------
# AC-9: deferred.
def test_ac9_single_thread_binding_deferred() -> None:
"""AC-9 (single-thread binding) is deferred per task spec
Risk 4: the generic ``compose_root`` thread-binding registry
lives with AZ-270 and the broader runtime-root composition.
Each factory owns its own thread binding today; this protocol
task does not add a new binding registry."""
# ----------------------------------------------------------------------
# AC-10: PassthroughRefiner byte-identical correspondences.
def test_ac10_passthrough_returns_same_match_result_reference() -> None:
refiner = create(
_config_with_strategy(),
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
mr = _make_result()
out = refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
assert out is mr
assert out.refinement_label == "passthrough"
assert out.refinement_added_latency_ms == 0.0
def test_ac10_passthrough_correspondences_bit_identical() -> None:
refiner = PassthroughRefiner(
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
mr = _make_result()
out = refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
for in_cand, out_cand in zip(mr.per_candidate, out.per_candidate, strict=True):
assert np.array_equal(
out_cand.inlier_correspondences, in_cand.inlier_correspondences
)
assert out_cand.inlier_correspondences.dtype == in_cand.inlier_correspondences.dtype
# Same object reference — INV-5 forbids silent copies on the
# passthrough path.
assert out_cand.inlier_correspondences is in_cand.inlier_correspondences
# ----------------------------------------------------------------------
# AC-11: was_invoked always False on PassthroughRefiner.
def test_ac11_was_invoked_always_false_on_passthrough() -> None:
refiner = PassthroughRefiner(
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
assert refiner.was_invoked() is False
mr = _make_result()
for _ in range(10):
refiner.refine_if_needed(_FakeFrame(), mr, residual_threshold_px=2.5)
assert refiner.was_invoked() is False
# ----------------------------------------------------------------------
# AC-12: Threshold validation in refine_if_needed.
@pytest.mark.parametrize("bad_threshold", [0.0, -0.1, -50.0])
def test_ac12_threshold_validation_in_method(bad_threshold: float) -> None:
refiner = PassthroughRefiner(
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
mr = _make_result()
with pytest.raises(ValueError):
refiner.refine_if_needed(
_FakeFrame(), mr, residual_threshold_px=bad_threshold
)
# ----------------------------------------------------------------------
# AC-13: Error hierarchy.
@pytest.mark.parametrize(
"exc_factory",
[RefinerBackboneError, RefinerConfigError],
)
def test_ac13_all_refiner_errors_caught_as_family(exc_factory) -> None:
with pytest.raises(RefinerError):
raise exc_factory("boom")
# ----------------------------------------------------------------------
# AC-14: module-layout.md symbol rename.
def test_ac14_module_layout_references_conditional_refiner() -> None:
from pathlib import Path
doc = Path("_docs/02_document/module-layout.md").read_text()
assert "ConditionalRefiner" in doc, (
"module-layout.md must reference the canonical Public API symbol"
)
# Old symbol name must NOT be present (per AC-14).
assert "AdHoPRefinementStrategy" not in doc, (
"module-layout.md still references the legacy AdHoPRefinementStrategy "
"symbol; rename per AZ-348 AC-14"
)
# ----------------------------------------------------------------------
# NFRs.
def test_nfr_perf_factory_under_20ms_p99(caplog) -> None:
config = _config_with_strategy("passthrough")
ransac_filter = _FakeRansacFilter()
inference_runtime = _FakeInferenceRuntime()
durations_ms: list[float] = []
for _ in range(100):
t0 = time.perf_counter()
build_refiner_strategy(
config,
ransac_filter=ransac_filter,
inference_runtime=inference_runtime,
)
durations_ms.append((time.perf_counter() - t0) * 1000.0)
durations_ms.sort()
p99 = durations_ms[int(0.99 * len(durations_ms))]
assert p99 <= 20.0, f"factory p99={p99:.2f} ms exceeded 20 ms budget"
def test_nfr_perf_passthrough_under_0_5ms_p99() -> None:
refiner = PassthroughRefiner(
ransac_filter=_FakeRansacFilter(),
inference_runtime=_FakeInferenceRuntime(),
)
mr = _make_result()
frame = _FakeFrame()
durations_us: list[float] = []
for _ in range(10_000):
t0 = time.perf_counter()
refiner.refine_if_needed(frame, mr, residual_threshold_px=2.5)
durations_us.append((time.perf_counter() - t0) * 1_000_000.0)
durations_us.sort()
p99_us = durations_us[int(0.99 * len(durations_us))]
assert p99_us <= 500.0, f"refine p99={p99_us:.2f} us exceeded 500 us budget"
# ----------------------------------------------------------------------
# Surface coverage — config defaults.
def test_c3_5_config_defaults() -> None:
cfg = C3_5RefinerConfig()
assert cfg.strategy == "adhop"
assert cfg.residual_threshold_px == 2.5
assert cfg.invocation_rate_warn_threshold == 0.25
def test_c3_5_config_invocation_rate_validation() -> None:
with pytest.raises(ConfigError):
C3_5RefinerConfig(invocation_rate_warn_threshold=0.0)
with pytest.raises(ConfigError):
C3_5RefinerConfig(invocation_rate_warn_threshold=1.0)
with pytest.raises(ConfigError):
C3_5RefinerConfig(invocation_rate_warn_threshold=-0.5)
-8
View File
@@ -1,8 +0,0 @@
"""C3.5 AdHoP smoke test — AC-9."""
def test_interface_importable() -> None:
# Assert
from gps_denied_onboard.components.c3_5_adhop import AdHoPRefinementStrategy
assert AdHoPRefinementStrategy is not None