[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
@@ -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