"""Load and merge ``configs/*.yaml`` into a validated DiagnosticConfig. ``base.yaml`` holds the industry-independent competency model; every other YAML in the directory is an industry overlay declaring ``extends: base`` plus its value drivers and unlock costs. Merging is shallow and explicit: the overlay contributes industry identity, drivers, and costs; the base contributes everything else. Overlays may not redefine the competency model — one instrument, many industries. """ from __future__ import annotations from pathlib import Path from typing import Any import yaml from .models import DiagnosticConfig #: Overlay keys an industry file may set. Anything else (competencies, #: capping_heuristic, …) belongs in base.yaml and is rejected loudly. _OVERLAY_KEYS = {"extends", "industry", "display_name", "value_drivers", "unlock_costs"} def configs_dir(start: Path | None = None) -> Path: """The study's ``configs/`` directory, found from ``start`` (or CWD). Walks up so it works from the study root, ``notebooks/``, or ``tests/``. """ here = (start or Path.cwd()).resolve() for candidate in (here, *here.parents): d = candidate / "configs" if (d / "base.yaml").exists(): return d raise FileNotFoundError("configs/base.yaml not found above " + str(here)) def list_industries(directory: Path | None = None) -> list[str]: """Industry config names (file stems), base excluded, sorted.""" d = directory or configs_dir() return sorted(p.stem for p in d.glob("*.yaml") if p.stem != "base") def _read_yaml(path: Path) -> dict[str, Any]: with open(path, encoding="utf-8") as fh: data = yaml.safe_load(fh) if not isinstance(data, dict): raise ValueError(f"{path.name}: expected a mapping at top level") return data def load_config(industry: str, directory: Path | None = None) -> DiagnosticConfig: """Load ``base.yaml`` + the named industry overlay, validated.""" d = directory or configs_dir() base = _read_yaml(d / "base.yaml") overlay = _read_yaml(d / f"{industry}.yaml") if overlay.get("extends") != "base": raise ValueError(f"{industry}.yaml must declare 'extends: base'") stray = set(overlay) - _OVERLAY_KEYS if stray: raise ValueError( f"{industry}.yaml sets base-only keys {sorted(stray)} — " "the competency model lives in base.yaml") merged: dict[str, Any] = { "version": base["version"], "dimensions": base["dimensions"], "competencies": base["competencies"], "capping_heuristic": base["capping_heuristic"], "foundational_competencies": base["foundational_competencies"], "industry": overlay["industry"], "display_name": overlay.get("display_name", overlay["industry"]), "value_drivers": overlay.get("value_drivers") or [], "unlock_costs": overlay.get("unlock_costs") or {}, } return DiagnosticConfig.model_validate(merged)