from __future__ import annotations import hashlib import json from collections.abc import Mapping from pathlib import Path from typing import TypeVar import yaml from pydantic import BaseModel, ValidationError from redstack.config.schema import ( EligibilityRulesConfig, HoneypotRulesConfig, JdAnchorsConfig, LexiconSeedConfig, Profile, RedstackConfig, RunMode, ScoringWeightsConfig, ) __all__ = [ "ConfigLoadError", "load_config", "load_scoring_weights", "load_lexicon_seed", "load_jd_anchors", "load_eligibility_rules", "load_honeypot_rules", "config_fingerprint", "deep_merge", ] _ModelT = TypeVar("_ModelT", bound=BaseModel) _BASE_FILENAME = "base.yaml" _RUNTIME_DIR = "runtime" _PROFILES_DIR = "profiles" _SCORING_WEIGHTS = ("weights", "scoring_weights.yaml") _LEXICON_SEED = ("lexicon", "lexicon.seed.yaml") _JD_ANCHORS = ("anchors", "jd_anchors.yaml") _ELIGIBILITY_RULES = ("gates", "eligibility_rules.yaml") _HONEYPOT_RULES = ("integrity", "honeypot_rules.yaml") class ConfigLoadError(RuntimeError): """Raised when a config file is missing, malformed, or fails validation. Carries a human-readable, deterministic message; never leaks a partially constructed config object. """ # --------------------------------------------------------------------------- # # Pure merge. # # --------------------------------------------------------------------------- # def deep_merge( base: Mapping[str, object], override: Mapping[str, object] ) -> dict[str, object]: """Deterministically deep-merge ``override`` over ``base``. Rules (fixed, total, side-effect-free): * Two mappings at the same key are merged recursively. * Any other value in ``override`` replaces the ``base`` value wholesale (including lists — sequences are replaced, never concatenated, so the result is a pure function of the inputs). * Keys absent from ``override`` are carried through unchanged. The output dict preserves ``base`` key order followed by override-only keys, making the merge byte-stable for a fixed input pair. """ merged: dict[str, object] = dict(base) for key, override_value in override.items(): base_value = merged.get(key) if isinstance(base_value, Mapping) and isinstance(override_value, Mapping): merged[key] = deep_merge(base_value, override_value) else: merged[key] = override_value return merged # --------------------------------------------------------------------------- # # IO helpers. # # --------------------------------------------------------------------------- # def _read_yaml_mapping(path: Path) -> dict[str, object]: """Read a YAML file into a string-keyed mapping, or fail loudly. An empty file is treated as an empty mapping (a valid override layer). Any non-mapping top-level document is an authoring error. """ try: raw_text = path.read_text(encoding="utf-8") except OSError as exc: raise ConfigLoadError(f"cannot read config file: {path}") from exc try: document = yaml.safe_load(raw_text) except yaml.YAMLError as exc: raise ConfigLoadError(f"invalid YAML in {path}: {exc}") from exc if document is None: return {} if not isinstance(document, Mapping): raise ConfigLoadError( f"config file {path} must contain a mapping at the top level" ) return {str(key): value for key, value in document.items()} def _validate_into(model: type[_ModelT], data: Mapping[str, object], origin: str) -> _ModelT: """Validate ``data`` into ``model`` or raise :class:`ConfigLoadError`.""" try: return model.model_validate(dict(data)) except ValidationError as exc: raise ConfigLoadError(f"config validation failed for {origin}:\n{exc}") from exc def _read_seed( configs_root: Path, parts: tuple[str, str], model: type[_ModelT] ) -> _ModelT: """Read and validate a single behaviour/authoring seed file.""" path = configs_root.joinpath(*parts) data = _read_yaml_mapping(path) return _validate_into(model, data, origin=str(path)) # --------------------------------------------------------------------------- # # Public composition entrypoint. # # --------------------------------------------------------------------------- # def load_config( configs_root: Path, run_mode: RunMode, profile: Profile | None = None, ) -> RedstackConfig: """Compose and validate the runtime configuration. Reads ``base.yaml``, ``runtime/.yaml`` and, when ``profile`` is given, ``profiles/.yaml``; deep-merges them in that fixed order; injects the authoritative ``run_mode``/``profile`` keys (so YAML never declares them and cannot drift); and validates the result into a frozen :class:`RedstackConfig`. Args: configs_root: Path to the ``configs/`` directory. run_mode: Which ``runtime/.yaml`` layer to compose. profile: Optional final override layer. Returns: The fully-validated, frozen :class:`RedstackConfig`. Raises: ConfigLoadError: If any layer is missing, malformed, or the composed config fails schema validation. """ base = _read_yaml_mapping(configs_root / _BASE_FILENAME) runtime = _read_yaml_mapping( configs_root / _RUNTIME_DIR / f"{run_mode.value}.yaml" ) merged = deep_merge(base, runtime) if profile is not None: profile_layer = _read_yaml_mapping( configs_root / _PROFILES_DIR / f"{profile.value}.yaml" ) merged = deep_merge(merged, profile_layer) # The loader is the single authority for these identity keys. merged["run_mode"] = run_mode.value merged["profile"] = profile.value if profile is not None else None # Profiles are mode-agnostic and may carry overrides for both modes; the # loader keeps only the active run-mode's block so the inactive one cannot # trip the mode-consistency invariant on RedstackConfig. inactive = RunMode.OFFLINE if run_mode is RunMode.ONLINE else RunMode.ONLINE merged.pop(inactive.value, None) return _validate_into( RedstackConfig, merged, origin=f"{configs_root} (mode={run_mode.value}, profile={profile})", ) # --------------------------------------------------------------------------- # # Behaviour / authoring seed loaders (offline pipeline). # # --------------------------------------------------------------------------- # def load_scoring_weights(configs_root: Path) -> ScoringWeightsConfig: """Load the O9 candidate scoring-weight seed.""" return _read_seed(configs_root, _SCORING_WEIGHTS, ScoringWeightsConfig) def load_lexicon_seed(configs_root: Path) -> LexiconSeedConfig: """Load the O4 lexicon seed terms.""" return _read_seed(configs_root, _LEXICON_SEED, LexiconSeedConfig) def load_jd_anchors(configs_root: Path) -> JdAnchorsConfig: """Load the O6 JD anchor intents.""" return _read_seed(configs_root, _JD_ANCHORS, JdAnchorsConfig) def load_eligibility_rules(configs_root: Path) -> EligibilityRulesConfig: """Load the O6 JD eligibility rule seed.""" return _read_seed(configs_root, _ELIGIBILITY_RULES, EligibilityRulesConfig) def load_honeypot_rules(configs_root: Path) -> HoneypotRulesConfig: """Load the O3 honeypot rule-shape seed.""" return _read_seed(configs_root, _HONEYPOT_RULES, HoneypotRulesConfig) # --------------------------------------------------------------------------- # # Reproducibility. # # --------------------------------------------------------------------------- # def config_fingerprint(config: RedstackConfig) -> str: """Return the deterministic sha256 hex digest of a resolved config. Serializes the config to canonical JSON (enums by value, sorted keys, no insignificant whitespace) and hashes the UTF-8 bytes. This is the ``config_hash`` recorded into the run report's ``reproducible`` block; identical inputs ⇒ identical digest, independent of dict iteration order. """ payload = config.model_dump(mode="json") canonical = json.dumps( payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ) return hashlib.sha256(canonical.encode("utf-8")).hexdigest()