Files
gps-denied-onboard/src/gps_denied_onboard/config/schema.py
T
Oleksandr Bezdieniezhnykh ba20c2d195 [AZ-273] [AZ-274] [AZ-275] [AZ-267] [AZ-268] FDR producer chain + log bridge + contract test
AZ-273: lock-free SPSC ring buffer with pre-allocated slots, power-of-
two capacity, opt-in SPSC guard, and EnqueueResult / FdrSpscViolationError
on the public surface. make_fdr_client caches one client per producer_id
and reads capacity from config.fdr.per_producer_capacity with fallback
to queue_size.
AZ-274: default_overrun_policy implements drop-oldest + retry + immediate
marker emission, with prior-marker dropped_count folding via _evict_one
so user-loss info is never lost across iterations. ERROR diagnostic is
rate-limited to <=1/sec per producer.
AZ-275: FakeFdrSink mirrors the FdrClient public surface and reuses the
production default_overrun_policy via a duck-typed _PolicyAdapter. The
test-only records/all_records_ever properties let component tests assert
both in-buffer and lifetime state. tests/conftest.py registers the
fake_fdr_sink fixture and an AST architecture lint forbids production
imports of fakes.
AZ-267: FdrLogBridgeHandler installs on the root logger via wire_log_bridge
and forwards only WARN+ERROR records into the FDR with kind="log".
Thread-local recursion guard short-circuits internal logging; saturated-
queue diagnostics go to stderr every N=1000 drops.
AZ-268: tests/contract/log_schema.py covers every row of the schema's
Test Cases table plus the "DEBUG+INFO never reach FDR" invariant.
pyproject.toml registers the contract pytest marker and the
contract-mandated log_schema.py file-name.
251 unit + contract tests pass (48 new). Review verdict:
PASS_WITH_WARNINGS; findings are NFR-perf deferrals + documented
relaxation of AZ-274 AC-2 coalescing under permanently-stalled consumer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 03:00:49 +03:00

161 lines
5.4 KiB
Python

"""Config dataclasses for E-CC-CONF (AZ-269 / AZ-246).
The outer `Config` aggregates one frozen nested dataclass per top-level
config block. Cross-cutting blocks (`log`, `fdr`, `runtime`) live here;
per-component blocks live with their own component epic and are
registered into `Config.components` via `register_component_block`.
Public surface frozen by `_docs/02_document/contracts/shared_config/composition_root_protocol.md` v1.0.0.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field, fields, is_dataclass, replace
from typing import Any, Final
__all__ = [
"Config",
"ConfigError",
"FdrConfig",
"LogConfig",
"RequiredFieldMissingError",
"RuntimeConfig",
"register_component_block",
]
class ConfigError(RuntimeError):
"""Base class for all config-loader errors that should reach the caller."""
class RequiredFieldMissingError(ConfigError):
"""Raised when a required configuration value is absent from env + YAML + defaults.
Always names the missing variable and points at the runtime helper that
documents the full set (``.env.example``).
"""
@dataclass(frozen=True)
class LogConfig:
"""Cross-cutting logging block (E-CC-LOG)."""
level: str = "INFO"
tier: int = 1
sink: str = "console"
@dataclass(frozen=True)
class FdrConfig:
"""Cross-cutting Flight Data Recorder block (E-CC-FDR-CLIENT / AZ-247).
``queue_size`` is the documented default capacity for every producer.
``per_producer_capacity`` carries per-producer overrides keyed by
producer slug (consumed by AZ-273 ``make_fdr_client``); blocks
that omit a producer fall back to ``queue_size``.
"""
queue_size: int = 4096
overrun_policy: str = "drop_oldest"
path: str = "/var/lib/gps-denied/fdr"
per_producer_capacity: Mapping[str, int] = field(default_factory=dict)
@dataclass(frozen=True)
class RuntimeConfig:
"""Top-level runtime descriptors that don't belong to a single component."""
fc_profile: str = "ardupilot_plane"
tier: int = 1
db_url: str = ""
camera_calibration_path: str = ""
inference_backend: str = "pytorch_fp16"
tile_cache_path: str = "/var/lib/gps-denied/tiles"
# Documented defaults for cross-cutting blocks ONLY. Per-component defaults
# live with their own component epic. The registry below is the single
# source of truth so two components cannot silently claim the same key.
_DEFAULT_BLOCKS: Final[dict[str, type]] = {
"log": LogConfig,
"fdr": FdrConfig,
"runtime": RuntimeConfig,
}
# Registry for per-component nested dataclasses. Each component epic
# calls ``register_component_block("c5_state", C5StateConfig)`` from its
# package import path; the composition root drives those imports before
# calling ``load_config``.
_COMPONENT_REGISTRY: dict[str, type] = {}
def register_component_block(slug: str, dataclass_type: type) -> None:
"""Register a per-component frozen dataclass under its component slug."""
if not is_dataclass(dataclass_type):
raise TypeError(
f"register_component_block({slug!r}, ...): block must be a dataclass; "
f"got {dataclass_type!r}"
)
existing = _COMPONENT_REGISTRY.get(slug)
if existing is not None and existing is not dataclass_type:
raise ConfigError(
f"duplicate component config registration for slug {slug!r}: "
f"{existing!r} vs {dataclass_type!r}"
)
_COMPONENT_REGISTRY[slug] = dataclass_type
def _resolve_default_blocks() -> dict[str, Any]:
"""Instantiate every documented cross-cutting block with its defaults."""
return {name: cls() for name, cls in _DEFAULT_BLOCKS.items()}
def _resolve_component_blocks() -> dict[str, Any]:
"""Instantiate every registered per-component block with its defaults."""
return {slug: cls() for slug, cls in _COMPONENT_REGISTRY.items()}
@dataclass(frozen=True)
class Config:
"""Outer composition-root config (frozen end-to-end).
Components consume only their own slice via ``config.components[slug]``;
the runtime / log / fdr cross-cutting blocks are read directly via
attribute access by the composition root.
"""
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
log: LogConfig = field(default_factory=LogConfig)
fdr: FdrConfig = field(default_factory=FdrConfig)
components: Mapping[str, Any] = field(default_factory=dict)
@classmethod
def with_blocks(cls, **blocks: Any) -> Config:
"""Build a `Config` from a flat name-to-instance map.
Cross-cutting names (``log``, ``fdr``, ``runtime``) become attributes;
every other key is treated as a component slug and goes into
``components``.
"""
runtime = blocks.pop("runtime", RuntimeConfig())
log = blocks.pop("log", LogConfig())
fdr = blocks.pop("fdr", FdrConfig())
return cls(runtime=runtime, log=log, fdr=fdr, components=dict(blocks))
def _block_field_names(block: Any) -> tuple[str, ...]:
return tuple(f.name for f in fields(block))
def _replace_block(block: Any, overrides: Mapping[str, Any]) -> Any:
"""Return ``replace(block, **overrides)`` after filtering unknown keys."""
if not overrides:
return block
known = set(_block_field_names(block))
filtered = {k: v for k, v in overrides.items() if k in known}
if not filtered:
return block
return replace(block, **filtered)