"""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__ = [ "DEFAULT_FORBIDDEN_RECORD_KINDS", "Config", "ConfigError", "FdrConfig", "FdrWriterConfig", "LogConfig", "RecordKindPolicyConfig", "RequiredFieldMissingError", "RuntimeConfig", "TileSnapshotConfig", "register_component_block", ] # Default raw-frame kinds that AZ-295's RecordKindPolicy must reject # synchronously at the producer call site. Removing any of these from # a Config requires an explicit `unsafe_remove_default_forbidden=True` # flag (which is intentionally not present in any standard preset). DEFAULT_FORBIDDEN_RECORD_KINDS: Final[frozenset[str]] = frozenset( {"raw_nav_frame", "raw_ai_cam_frame"} ) 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 FdrWriterConfig: """C13 writer-thread block (E-C13 / AZ-291..AZ-296). ``segment_size_bytes`` controls per-segment rotation; the writer closes the current segment and opens the next once a record's serialised size would push the segment past this cap. ``batch_size`` bounds the per-producer ``drain(max_records=N)`` call so one slow producer cannot starve others. ``flight_cap_bytes`` is the AC-NEW-3 per-flight cap (default 64 GiB exactly). Lowered in tests to exercise the cap policy on small fixtures. There is no flag that disables cap enforcement (verified by C13-ST-01). ``debug_log_per_record`` enables a per-record DEBUG log line — OFF by default because a 100 Hz aggregate would flood logs. """ segment_size_bytes: int = 64 * 1024 * 1024 batch_size: int = 64 flight_cap_bytes: int = 64 * 1024**3 debug_log_per_record: bool = False @dataclass(frozen=True) class TileSnapshotConfig: """C13 mid-flight tile snapshot sidecar block (AZ-294). ``tile_snapshot_cap_bytes`` is the per-flight ceiling on the cumulative size of the ``tiles/`` subdirectory under the flight root (default 64 MiB to comfortably hold the worst-case ~50 MB from per-component description.md). ``jpeg_max_bytes`` rejects single tile JPEGs larger than this bound (default 256 KiB; description.md gives 50-200 KiB). """ tile_snapshot_cap_bytes: int = 64 * 1024 * 1024 jpeg_max_bytes: int = 256 * 1024 @dataclass(frozen=True) class RecordKindPolicyConfig: """C13 record-kind policy block (AZ-295). ``forbidden_record_kinds`` lists FdrRecord ``kind`` values that the producer-side ``enforce_or_raise`` gate rejects with ``RawFrameWriteForbiddenError``. The default set (``DEFAULT_FORBIDDEN_RECORD_KINDS``) MUST be a subset of the configured set — removing defaults is a security-review-required path guarded by ``unsafe_remove_default_forbidden``. ``failed_tile_thumbnail_max_hz`` caps the writer-side rate of ``kind="failed_tile_thumbnail"`` records (default 0.1 Hz per AC-8.5 + description.md § 7). Setting this to 0 is rejected at config validation (would silence the kind entirely; that path is intentionally not exposed). """ forbidden_record_kinds: frozenset[str] = field( default_factory=lambda: DEFAULT_FORBIDDEN_RECORD_KINDS ) failed_tile_thumbnail_max_hz: float = 0.1 unsafe_remove_default_forbidden: bool = False def __post_init__(self) -> None: if not isinstance(self.forbidden_record_kinds, frozenset): raise ConfigError( "RecordKindPolicyConfig.forbidden_record_kinds must be a frozenset; " f"got {type(self.forbidden_record_kinds).__name__}" ) if not self.unsafe_remove_default_forbidden: missing_defaults = DEFAULT_FORBIDDEN_RECORD_KINDS - self.forbidden_record_kinds if missing_defaults: raise ConfigError( "RecordKindPolicyConfig.forbidden_record_kinds removes default raw-frame " f"kinds without unsafe_remove_default_forbidden=True: missing {sorted(missing_defaults)}" ) if not ( isinstance(self.failed_tile_thumbnail_max_hz, (int, float)) and not isinstance(self.failed_tile_thumbnail_max_hz, bool) ): raise ConfigError( "RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be a number; " f"got {self.failed_tile_thumbnail_max_hz!r}" ) if self.failed_tile_thumbnail_max_hz <= 0: raise ConfigError( "RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be > 0; " f"got {self.failed_tile_thumbnail_max_hz}" ) if self.failed_tile_thumbnail_max_hz > 10.0: raise ConfigError( "RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be <= 10.0; " f"got {self.failed_tile_thumbnail_max_hz}" ) @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``. Sub-blocks (AZ-291..AZ-296): ``writer``, ``tile_snapshot``, ``record_policy``. """ 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) writer: FdrWriterConfig = field(default_factory=FdrWriterConfig) tile_snapshot: TileSnapshotConfig = field(default_factory=TileSnapshotConfig) record_policy: RecordKindPolicyConfig = field(default_factory=RecordKindPolicyConfig) @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)