[AZ-381] C5 StateEstimator protocol + factory + C8 DTO reshape

- Add StateEstimator Protocol (6 methods, @runtime_checkable) + DTOs
  (EstimatorOutput, EstimatorHealth, IsamState, PoseSourceLabel, Quat)
  in _types/state.py per state_estimator_protocol.md v1.0.0.
- Add C5 error hierarchy (StateEstimatorError + 3 subclasses) and
  C5StateConfig (strategy, keyframe_window, spoof gates,
  no_estimate_fallback_s) with __post_init__ validation.
- Add ISam2GraphHandle Protocol + ISam2GraphHandleImpl skeleton (all
  4 methods raise NotImplementedError naming AZ-382 as owner).
- Add build_state_estimator factory + bind_state_ingest_thread for
  single-writer enforcement; ADR-002 build-flag gating
  (BUILD_STATE_<variant>); INFO log on success.
- Strict reshape of legacy EstimatorOutput / EstimatorHealth across
  all 6 C8 production files (_outbound_provenance,
  _covariance_projector, pymavlink_ardupilot_adapter,
  msp2_inav_adapter, mavlink_gcs_adapter, interface) + 6 C8 test
  files (UUID frame_id, LatLonAlt position_wgs84, Quat orientation,
  PoseSourceLabel enum source_label). Remove ad-hoc DTOs from
  _types/pose.py and from C4's public __init__ (EstimatorOutput is a
  C5 concept, not a C4 one).
- 20 AZ-381 AC tests (10 ACs + 4 config range + NFR + conformance).
- Full suite: 521 passed, 2 skipped (+20 vs Batch 11).
- Contracts: state_estimator_protocol.md v1.0.0 -> active;
  composition_root_protocol.md v1.2.0 -> v1.3.0 (additive state
  block + factory + ingest-thread binding).
- Impl report: _docs/03_implementation/batch_12_cycle1_report.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 05:35:20 +03:00
parent 8a9cf88a46
commit beed43724f
32 changed files with 1394 additions and 157 deletions
@@ -40,6 +40,15 @@ from gps_denied_onboard.runtime_root.spoof_recovery_sink import (
SpoofRecoveryPublisher,
SpoofRecoverySink,
)
from gps_denied_onboard.runtime_root.state_factory import (
StateIngestThreadAlreadyBoundError,
bind_state_ingest_thread,
build_state_estimator,
clear_state_ingest_binding,
clear_state_registry,
list_registered_state_strategies,
register_state_estimator,
)
if TYPE_CHECKING:
from gps_denied_onboard.components.c13_fdr.headers import FlightHeader
@@ -55,13 +64,18 @@ __all__ = [
"RuntimeRoot",
"SpoofRecoveryPublisher",
"SpoofRecoverySink",
"StateIngestThreadAlreadyBoundError",
"StrategyNotLinkedError",
"StrategyTier",
"TakeoffResult",
"bind_outbound_emit_thread",
"bind_state_ingest_thread",
"build_fc_adapter",
"build_gcs_adapter",
"build_state_estimator",
"clear_outbound_thread_binding",
"clear_state_ingest_binding",
"clear_state_registry",
"clear_strategy_registries",
"clear_strategy_registry",
"compose_operator",
@@ -69,10 +83,12 @@ __all__ = [
"compose_root",
"list_registered_fc_strategies",
"list_registered_gcs_strategies",
"list_registered_state_strategies",
"list_registered_strategies",
"main",
"register_fc_adapter",
"register_gcs_adapter",
"register_state_estimator",
"register_strategy",
"take_off",
]
@@ -0,0 +1,214 @@
"""Composition-root factory for C5 (AZ-381 / E-C5).
Mirrors the C8 factory shape (``runtime_root.fc_factory``): per-binary
bootstrap modules register concrete strategy factories under their
``BUILD_STATE_<variant>`` flag; :func:`build_state_estimator` resolves
the configured strategy, gates by build flag, constructs the
estimator, and returns the tuple ``(StateEstimator,
ISam2GraphHandle)`` so the runtime root can inject the handle into
C4.
Single-writer-thread binding for C5 + C4 is enforced via
:func:`bind_state_ingest_thread`; the second binding from a different
thread raises :class:`StateIngestThreadAlreadyBoundError`. The runtime
root binds C4 + C5 to the SAME thread.
ADR-002 build-flag gating: ``config.components["c5_state"].strategy ==
"gtsam_isam2"`` requires ``BUILD_STATE_GTSAM_ISAM2=ON``. Default is
``ON`` (most binaries link the production-default); the operator-side
binary may set ``OFF`` and only link the ESKF baseline.
"""
from __future__ import annotations
import os
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final
from gps_denied_onboard.components.c5_state._isam2_handle import ISam2GraphHandle
from gps_denied_onboard.components.c5_state.config import (
KNOWN_STATE_STRATEGIES,
C5StateConfig,
)
from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError
from gps_denied_onboard.components.c5_state.interface import StateEstimator
from gps_denied_onboard.config import Config
from gps_denied_onboard.logging import get_logger
if TYPE_CHECKING:
pass
__all__ = [
"StateEstimatorFactory",
"StateIngestThreadAlreadyBoundError",
"bind_state_ingest_thread",
"build_state_estimator",
"clear_state_ingest_binding",
"clear_state_registry",
"list_registered_state_strategies",
"register_state_estimator",
]
StateEstimatorFactory = Callable[..., tuple[StateEstimator, ISam2GraphHandle]]
_STATE_REGISTRY: dict[str, StateEstimatorFactory] = {}
_STATE_BUILD_FLAGS: Final[dict[str, str]] = {
"gtsam_isam2": "BUILD_STATE_GTSAM_ISAM2",
"eskf": "BUILD_STATE_ESKF",
}
def register_state_estimator(strategy: str, factory: StateEstimatorFactory) -> None:
"""Register a concrete `StateEstimator` strategy.
Called by per-binary bootstrap modules under the matching
``BUILD_STATE_<variant>`` flag. Duplicate registration with a
different factory raises :class:`StateEstimatorConfigError`.
"""
existing = _STATE_REGISTRY.get(strategy)
if existing is not None and existing is not factory:
raise StateEstimatorConfigError(
f"duplicate StateEstimator registration for strategy {strategy!r}"
)
_STATE_REGISTRY[strategy] = factory
def clear_state_registry() -> None:
"""Reset the state registry; unit-test isolation only."""
_STATE_REGISTRY.clear()
def list_registered_state_strategies() -> list[str]:
return sorted(_STATE_REGISTRY)
# ----------------------------------------------------------------------
# Single-writer state-ingest thread (Invariant 1; C4 + C5 share it).
class StateIngestThreadAlreadyBoundError(RuntimeError):
"""Raised on a second :func:`bind_state_ingest_thread` call from a different thread."""
_ingest_lock = threading.Lock()
_ingest_bound_thread: int | None = None
def bind_state_ingest_thread(thread_ident: int | None = None) -> int:
"""Bind ``thread_ident`` (defaults to the caller) as the sole state-ingest thread.
A second call from a different thread raises
:class:`StateIngestThreadAlreadyBoundError`. C4 + C5 + the shared
GTSAM substrate live on this thread per ADR-003. Repeated binding
from the SAME thread is permitted (idempotent for re-entrant
composition under tests).
"""
global _ingest_bound_thread
ident = thread_ident if thread_ident is not None else threading.get_ident()
with _ingest_lock:
if _ingest_bound_thread is not None and _ingest_bound_thread != ident:
raise StateIngestThreadAlreadyBoundError(
f"state ingest thread already bound to {_ingest_bound_thread}; "
f"refused to re-bind to {ident}"
)
_ingest_bound_thread = ident
return ident
def clear_state_ingest_binding() -> None:
"""Reset the state-ingest-thread binding; unit-test isolation only."""
global _ingest_bound_thread
with _ingest_lock:
_ingest_bound_thread = None
# ----------------------------------------------------------------------
# Public composition-root factory.
def build_state_estimator(
config: Config,
*,
imu_preintegrator: Any,
se3_utils: Any,
wgs_converter: Any,
fdr_client: Any,
) -> tuple[StateEstimator, ISam2GraphHandle]:
"""Resolve + build the configured state estimator.
Returns the ``(StateEstimator, ISam2GraphHandle)`` tuple so the
runtime root can inject the handle into C4 via
``build_pose_estimator``.
Validation order: config block lookup → build-flag gate → factory
lookup. The first failure surfaces a :class:`StateEstimatorConfigError`
with the offending strategy + flag name so the operator gets a
clear next step.
"""
block = _read_state_block(config)
strategy = block.strategy
if strategy not in KNOWN_STATE_STRATEGIES:
raise StateEstimatorConfigError(
f"C5StateConfig.strategy={strategy!r} not in {sorted(KNOWN_STATE_STRATEGIES)}"
)
flag_name = _STATE_BUILD_FLAGS.get(strategy)
if flag_name is None:
raise StateEstimatorConfigError(
f"state strategy {strategy!r} has no BUILD_STATE_* flag mapping"
)
if os.environ.get(flag_name, "ON").upper() == "OFF":
raise StateEstimatorConfigError(
f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
)
factory = _STATE_REGISTRY.get(strategy)
if factory is None:
raise StateEstimatorConfigError(
f"state strategy {strategy!r} selected by config but not registered; "
f"registered strategies: {list_registered_state_strategies()}"
)
estimator, handle = factory(
config=config,
imu_preintegrator=imu_preintegrator,
se3_utils=se3_utils,
wgs_converter=wgs_converter,
fdr_client=fdr_client,
)
_log_strategy_loaded(
strategy=strategy,
keyframe_window_size=block.keyframe_window_size,
)
return estimator, handle
def _read_state_block(config: Config) -> C5StateConfig:
"""Pull the c5_state block out of ``config.components`` (or fall back to defaults)."""
components = getattr(config, "components", None) or {}
block = components.get("c5_state") if isinstance(components, dict) else None
if block is None:
# Allow missing block to mean "documented defaults" — same shape
# as the cross-cutting blocks. Tests that exercise the factory
# without YAML/env see defaults.
return C5StateConfig()
if isinstance(block, C5StateConfig):
return block
raise StateEstimatorConfigError(
f"config.components['c5_state'] must be a C5StateConfig; got {type(block).__name__}"
)
def _log_strategy_loaded(*, strategy: str, keyframe_window_size: int) -> None:
log = get_logger("runtime_root.state_factory")
log.info(
f"c5.state.strategy_loaded: strategy={strategy} "
f"keyframe_window_size={keyframe_window_size}",
extra={
"kind": "c5.state.strategy_loaded",
"kv": {
"strategy": strategy,
"keyframe_window_size": keyframe_window_size,
},
},
)