Spaces:
Sleeping
Sleeping
File size: 8,657 Bytes
feb1b1c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
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/<run_mode>.yaml`` and, when ``profile`` is
given, ``profiles/<profile>.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/<mode>.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()
|