Source code for pycsamt.ai.experiments.config

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Immutable source-of-truth configuration for AI inversion experiments.

This module describes *what* an experiment is and which evidence must pass.
It intentionally does not start training jobs, import machine-learning
frameworks, or construct Maxwell solvers.  Runtime orchestration and result
records belong to later modules in :mod:`pycsamt.ai.experiments`.
"""

from __future__ import annotations

import hashlib
import json
import operator
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Any

import numpy as np

from ..data.manifest import canonical_hash

__all__ = [
    "SeedPlan",
    "DatasetReference",
    "AcceptanceCriterion",
    "GateEvaluation",
    "ExperimentConfig",
]

_HEX_64 = re.compile(r"^[0-9a-f]{64}$")
_PORTABLE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
_STAGES = {
    "baseline",
    "data_audit",
    "geology",
    "domain_gap",
    "forward_2d",
    "learning_2d",
    "feasibility_3d",
    "forward_3d",
    "learning_3d",
    "hybrid",
    "field_evaluation",
}
_OPERATORS = {
    "<": operator.lt,
    "<=": operator.le,
    ">": operator.gt,
    ">=": operator.ge,
    "==": operator.eq,
}


def _freeze(value: Any) -> Any:
    if isinstance(value, Mapping):
        return MappingProxyType(
            {str(key): _freeze(item) for key, item in value.items()}
        )
    if isinstance(value, (list, tuple)):
        return tuple(_freeze(item) for item in value)
    return value


def _thaw(value: Any) -> Any:
    if isinstance(value, Mapping):
        return {str(key): _thaw(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [_thaw(item) for item in value]
    return value


def _json_section(value: Mapping[str, Any], name: str) -> Mapping[str, Any]:
    if not isinstance(value, Mapping):
        raise TypeError(f"{name} must be a mapping.")
    canonical_hash(value)
    plain = _thaw(value)
    return _freeze(json.loads(json.dumps(plain, allow_nan=False)))


def _digest(
    value: str | None, name: str, *, required: bool = False
) -> str | None:
    if value is None:
        if required:
            raise ValueError(f"{name} is required.")
        return None
    result = str(value).lower().strip()
    if result.startswith("sha256:"):
        result = result[7:]
    if not _HEX_64.fullmatch(result):
        raise ValueError(f"{name} must be a 64-character SHA-256 digest.")
    return result


def _utc(value: str | None) -> str | None:
    if value is None:
        return None
    text = str(value).strip()
    try:
        parsed = datetime.fromisoformat(
            text[:-1] + "+00:00" if text.endswith("Z") else text
        )
    except ValueError as exc:
        raise ValueError("created_utc must be an ISO-8601 timestamp.") from exc
    if parsed.tzinfo is None or parsed.utcoffset() is None:
        raise ValueError("created_utc must include a timezone.")
    return (
        parsed.astimezone(timezone.utc)
        .isoformat(timespec="seconds")
        .replace("+00:00", "Z")
    )


[docs] @dataclass(frozen=True) class SeedPlan: """Derive stable, labeled child seeds from one experiment root seed. Parameters ---------- root_seed : int Non-negative root seed smaller than ``2**64``. namespace : str, default="pycsamt.ai" Domain separator preventing identical labels in unrelated projects from producing the same child sequence. Examples -------- Child seeds are label-stable and request-order independent: >>> plan = SeedPlan(42, namespace="willy") >>> plan.derive("dataset") == plan.derive("dataset") True >>> plan.derive("dataset") != plan.derive("network") True """ root_seed: int namespace: str = "pycsamt.ai" def __post_init__(self) -> None: if not isinstance(self.root_seed, (int, np.integer)) or isinstance( self.root_seed, bool ): raise TypeError("root_seed must be an integer.") seed = int(self.root_seed) if seed < 0 or seed >= 2**64: raise ValueError("root_seed must be in [0, 2**64).") namespace = str(self.namespace).strip() if not namespace: raise ValueError("namespace cannot be empty.") object.__setattr__(self, "root_seed", seed) object.__setattr__(self, "namespace", namespace)
[docs] def derive(self, label: str) -> int: """Derive one unsigned 32-bit seed for a named subsystem. Parameters ---------- label : str Stable descriptive label such as ``"geology"``, ``"noise"``, or ``"network/seed-0"``. Returns ------- int Deterministic value in ``[0, 2**32)``. Raises ------ ValueError If ``label`` is empty. Examples -------- >>> child = SeedPlan(1).derive("training") >>> 0 <= child < 2**32 True """ name = str(label).strip() if not name: raise ValueError("label cannot be empty.") payload = f"{self.namespace}\0{self.root_seed}\0{name}".encode() return int.from_bytes(hashlib.sha256(payload).digest()[:4], "big")
[docs] def derive_many(self, labels: Sequence[str]) -> dict[str, int]: """Derive seeds for multiple unique labels. Parameters ---------- labels : sequence of str Unique non-empty subsystem labels. Returns ------- dict Labels in caller order mapped to stable child seeds. Examples -------- >>> sorted(SeedPlan(2).derive_many(["data", "model"])) ['data', 'model'] """ names = tuple(str(label).strip() for label in labels) if any(not name for name in names) or len(set(names)) != len(names): raise ValueError("labels must be non-empty and unique.") return {name: self.derive(name) for name in names}
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable seed plan. Returns ------- dict Schema version, root seed, and namespace. Examples -------- >>> SeedPlan(3).to_dict()["root_seed"] 3 """ return { "schema_version": 1, "root_seed": self.root_seed, "namespace": self.namespace, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> SeedPlan: """Restore a validated seed plan. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- SeedPlan Immutable seed plan. Examples -------- >>> SeedPlan.from_dict(SeedPlan(4).to_dict()) == SeedPlan(4) True """ if data.get("schema_version", 1) != 1: raise ValueError("unsupported SeedPlan schema version.") return cls(data["root_seed"], data.get("namespace", "pycsamt.ai"))
[docs] @dataclass(frozen=True) class DatasetReference: """Pin an experiment to exact dataset preparation artifacts. Parameters ---------- dataset_id : str Portable identifier from the dataset manifest. manifest_hash, split_hash : str SHA-256 digests of the complete dataset manifest and realization split. normalization_hash : str or None, optional Digest of fitted normalization state. It may be absent for an audit or forward-only experiment that has not normalized data. manifest_uri : str or None, optional Informational local path or remote URI. Integrity relies on the hash, not on this location. Examples -------- >>> reference = DatasetReference("willy-v1", "a" * 64, "b" * 64) >>> reference.dataset_id 'willy-v1' """ dataset_id: str manifest_hash: str split_hash: str normalization_hash: str | None = None manifest_uri: str | None = None def __post_init__(self) -> None: dataset_id = str(self.dataset_id).strip() if not _PORTABLE_ID.fullmatch(dataset_id): raise ValueError("dataset_id must be a portable identifier.") uri = ( None if self.manifest_uri is None else str(self.manifest_uri).strip() ) if self.manifest_uri is not None and not uri: raise ValueError("manifest_uri cannot be empty.") object.__setattr__(self, "dataset_id", dataset_id) object.__setattr__( self, "manifest_hash", _digest(self.manifest_hash, "manifest_hash", required=True), ) object.__setattr__( self, "split_hash", _digest(self.split_hash, "split_hash", required=True), ) object.__setattr__( self, "normalization_hash", _digest(self.normalization_hash, "normalization_hash"), ) object.__setattr__(self, "manifest_uri", uri)
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable artifact reference. Returns ------- dict Schema version and pinned artifact identifiers. Examples -------- >>> DatasetReference("d", "a" * 64, "b" * 64).to_dict()[ ... "schema_version" ... ] 1 """ return { "schema_version": 1, "dataset_id": self.dataset_id, "manifest_hash": self.manifest_hash, "split_hash": self.split_hash, "normalization_hash": self.normalization_hash, "manifest_uri": self.manifest_uri, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> DatasetReference: """Restore a validated dataset reference. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- DatasetReference Immutable pinned reference. Examples -------- >>> ref = DatasetReference("d", "a" * 64, "b" * 64) >>> DatasetReference.from_dict(ref.to_dict()) == ref True """ if data.get("schema_version", 1) != 1: raise ValueError("unsupported DatasetReference schema version.") return cls( data["dataset_id"], data["manifest_hash"], data["split_hash"], data.get("normalization_hash"), data.get("manifest_uri"), )
[docs] @dataclass(frozen=True) class AcceptanceCriterion: """Predeclare one numerical condition required for an experiment gate. Parameters ---------- metric : str Exact metric key, preferably namespaced, for example ``"test.impedance_nrms"``. operator : {"<", "<=", ">", ">=", "=="} Comparison applied as ``observed operator threshold``. threshold : float Finite value fixed before results are inspected. description : str, optional Human-readable scientific justification. Examples -------- >>> criterion = AcceptanceCriterion("test.impedance_nrms", "<=", 2.0) >>> criterion.evaluate(1.7) True >>> criterion.evaluate(2.5) False """ metric: str operator: str threshold: float description: str = "" def __post_init__(self) -> None: metric = str(self.metric).strip() if not metric: raise ValueError("metric cannot be empty.") if self.operator not in _OPERATORS: raise ValueError(f"operator must be one of {tuple(_OPERATORS)}.") threshold = float(self.threshold) if not np.isfinite(threshold): raise ValueError("threshold must be finite.") object.__setattr__(self, "metric", metric) object.__setattr__(self, "threshold", threshold) object.__setattr__(self, "description", str(self.description).strip())
[docs] def evaluate(self, observed: float) -> bool: """Evaluate an observed metric against the frozen threshold. Parameters ---------- observed : float Finite measured value. Returns ------- bool Result of ``observed operator threshold``. Raises ------ ValueError If ``observed`` is NaN or infinite. Examples -------- >>> AcceptanceCriterion("coverage", ">=", 0.9).evaluate(0.95) True """ value = float(observed) if not np.isfinite(value): raise ValueError("observed metric must be finite.") return bool(_OPERATORS[self.operator](value, self.threshold))
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable criterion. Returns ------- dict Metric, comparison, threshold, and description. Examples -------- >>> AcceptanceCriterion("x", "<", 1).to_dict()["operator"] '<' """ return { "schema_version": 1, "metric": self.metric, "operator": self.operator, "threshold": self.threshold, "description": self.description, }
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> AcceptanceCriterion: """Restore a validated acceptance criterion. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- AcceptanceCriterion Immutable numerical gate condition. Examples -------- >>> c = AcceptanceCriterion("x", ">", 0) >>> AcceptanceCriterion.from_dict(c.to_dict()) == c True """ if data.get("schema_version", 1) != 1: raise ValueError("unsupported AcceptanceCriterion schema version.") return cls( data["metric"], data["operator"], data["threshold"], data.get("description", ""), )
[docs] @dataclass(frozen=True) class GateEvaluation: """Immutable result of evaluating configured acceptance criteria. Parameters ---------- passed : bool Whether every criterion passed and no required metric was missing. criteria : mapping of str to bool Per-metric pass/fail results. observed : mapping of str to float Finite observed values for metrics that were available. missing : sequence of str Required metric keys absent from the supplied result mapping. complete : bool, default=True Whether every configured criterion was evaluated. A partial status report can never pass the final gate. Examples -------- >>> result = GateEvaluation(True, {"nrms": True}, {"nrms": 1.2}, ()) >>> result.failed_metrics () """ passed: bool criteria: Mapping[str, bool] observed: Mapping[str, float] missing: tuple[str, ...] = () complete: bool = True def __post_init__(self) -> None: criteria = { str(key): bool(value) for key, value in self.criteria.items() } observed = { str(key): float(value) for key, value in self.observed.items() } if any(not np.isfinite(value) for value in observed.values()): raise ValueError("observed gate metrics must be finite.") missing = tuple(str(value) for value in self.missing) if len(set(missing)) != len(missing) or any( not value for value in missing ): raise ValueError( "missing metric names must be non-empty and unique." ) complete = bool(self.complete) expected_passed = complete and not missing and all(criteria.values()) if bool(self.passed) != expected_passed: raise ValueError( "passed is inconsistent with criteria and missing metrics." ) object.__setattr__(self, "passed", bool(self.passed)) object.__setattr__(self, "criteria", MappingProxyType(criteria)) object.__setattr__(self, "observed", MappingProxyType(observed)) object.__setattr__(self, "missing", missing) object.__setattr__(self, "complete", complete)
[docs] @property def failed_metrics(self) -> tuple[str, ...]: """Return metrics that failed their configured comparisons. Returns ------- tuple of str Failed available metrics; missing metrics are reported separately. Examples -------- >>> GateEvaluation(False, {"x": False}, {"x": 2.0}).failed_metrics ('x',) """ return tuple( key for key, passed in self.criteria.items() if not passed )
[docs] def to_dict(self) -> dict[str, Any]: """Return a JSON-serializable gate result. Returns ------- dict Overall and per-metric outcomes. Examples -------- >>> GateEvaluation(True, {"x": True}, {"x": 0.5}).to_dict()["passed"] True """ return { "schema_version": 1, "passed": self.passed, "criteria": dict(self.criteria), "observed": dict(self.observed), "missing": list(self.missing), "complete": self.complete, }
[docs] @dataclass(frozen=True) class ExperimentConfig: """Immutable source of truth for one reproducible inversion experiment. Parameters ---------- experiment_id : str Portable unique experiment identifier. stage : str Roadmap stage such as ``"baseline"``, ``"forward_2d"``, or ``"field_evaluation"``. dataset : DatasetReference Exact dataset, split, and optional normalization artifacts. seeds : SeedPlan Root and namespace used for labeled child seeds. model, training, physics : mapping Finite JSON-compatible configuration sections. Their schemas are owned by later model/trainer/solver adapters; this class freezes and hashes them without importing optional dependencies. acceptance : sequence of AcceptanceCriterion, optional Criteria fixed before results are viewed. Metric names must be unique. description : str, optional Human-readable experiment objective. tags : sequence of str, optional Unique searchable labels. created_utc : str or None, optional Timezone-aware ISO-8601 timestamp normalized to UTC. schema_version : int, default=1 Configuration schema version. Examples -------- >>> dataset = DatasetReference("willy-v1", "a" * 64, "b" * 64) >>> config = ExperimentConfig( ... "m0-baseline", ... "baseline", ... dataset, ... SeedPlan(42, "willy"), ... model={"architecture": "unet"}, ... training={"epochs": 100}, ... acceptance=[AcceptanceCriterion("test.nrms", "<=", 2.0)], ... ) >>> len(config.config_hash) 64 >>> config.child_seed("network") == config.seeds.derive("network") True """ experiment_id: str stage: str dataset: DatasetReference seeds: SeedPlan model: Mapping[str, Any] training: Mapping[str, Any] physics: Mapping[str, Any] = field(default_factory=dict) acceptance: tuple[AcceptanceCriterion, ...] = () description: str = "" tags: tuple[str, ...] = () created_utc: str | None = None schema_version: int = 1 def __post_init__(self) -> None: if self.schema_version != 1: raise ValueError("unsupported ExperimentConfig schema version.") experiment_id = str(self.experiment_id).strip() if not _PORTABLE_ID.fullmatch(experiment_id): raise ValueError("experiment_id must be a portable identifier.") stage = str(self.stage).strip() if stage not in _STAGES: raise ValueError(f"stage must be one of {tuple(sorted(_STAGES))}.") if not isinstance(self.dataset, DatasetReference): raise TypeError("dataset must be a DatasetReference.") if not isinstance(self.seeds, SeedPlan): raise TypeError("seeds must be a SeedPlan.") acceptance = tuple(self.acceptance) if any( not isinstance(item, AcceptanceCriterion) for item in acceptance ): raise TypeError( "acceptance entries must be AcceptanceCriterion objects." ) metrics = [item.metric for item in acceptance] if len(set(metrics)) != len(metrics): raise ValueError("acceptance metric names must be unique.") tags = tuple(str(tag).strip() for tag in self.tags) if any(not tag for tag in tags) or len(set(tags)) != len(tags): raise ValueError("tags must be non-empty and unique.") object.__setattr__(self, "experiment_id", experiment_id) object.__setattr__(self, "stage", stage) object.__setattr__(self, "model", _json_section(self.model, "model")) object.__setattr__( self, "training", _json_section(self.training, "training") ) object.__setattr__( self, "physics", _json_section(self.physics, "physics") ) object.__setattr__(self, "acceptance", acceptance) object.__setattr__(self, "description", str(self.description).strip()) object.__setattr__(self, "tags", tags) object.__setattr__(self, "created_utc", _utc(self.created_utc))
[docs] @property def config_hash(self) -> str: """Return the canonical digest of the complete configuration. Returns ------- str SHA-256 digest covering every serialized field. Examples -------- >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> len( ... ExperimentConfig( ... "e", "baseline", d, SeedPlan(0), {}, {} ... ).config_hash ... ) 64 """ return canonical_hash(self.to_dict())
[docs] def child_seed(self, label: str) -> int: """Derive a stable subsystem seed from this experiment. Parameters ---------- label : str Subsystem label passed to :meth:`SeedPlan.derive`. Returns ------- int Deterministic unsigned 32-bit seed. Examples -------- >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> c = ExperimentConfig("e", "baseline", d, SeedPlan(0), {}, {}) >>> c.child_seed("data") == c.child_seed("data") True """ return self.seeds.derive(label)
[docs] def evaluate_gate( self, metrics: Mapping[str, float], *, require_all: bool = True ) -> GateEvaluation: """Evaluate observed metrics against predeclared criteria. Parameters ---------- metrics : mapping of str to float Observed metric values. require_all : bool, default=True Treat absent configured metrics as missing failures. When false, absent metrics are omitted; this is useful only for partial status reports and cannot prove the final gate passed unless all criteria were supplied. Returns ------- GateEvaluation Immutable overall, per-metric, observed, and missing results. Examples -------- >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> c = ExperimentConfig( ... "e", ... "baseline", ... d, ... SeedPlan(0), ... {}, ... {}, ... acceptance=[AcceptanceCriterion("nrms", "<=", 2)], ... ) >>> c.evaluate_gate({"nrms": 1.5}).passed True """ supplied = {str(key): float(value) for key, value in metrics.items()} results = {} observed = {} missing = [] for criterion in self.acceptance: if criterion.metric not in supplied: if require_all: missing.append(criterion.metric) continue value = supplied[criterion.metric] results[criterion.metric] = criterion.evaluate(value) observed[criterion.metric] = value all_evaluated = len(results) == len(self.acceptance) passed = all_evaluated and not missing and all(results.values()) return GateEvaluation( passed, results, observed, tuple(missing), complete=all_evaluated, )
[docs] def to_dict(self) -> dict[str, Any]: """Return the complete JSON-compatible configuration. Returns ------- dict Mutable schema-1 representation. Examples -------- >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> ExperimentConfig( ... "e", "baseline", d, SeedPlan(0), {}, {} ... ).to_dict()["stage"] 'baseline' """ return { "schema_version": self.schema_version, "experiment_id": self.experiment_id, "stage": self.stage, "dataset": self.dataset.to_dict(), "seeds": self.seeds.to_dict(), "model": _thaw(self.model), "training": _thaw(self.training), "physics": _thaw(self.physics), "acceptance": [item.to_dict() for item in self.acceptance], "description": self.description, "tags": list(self.tags), "created_utc": self.created_utc, }
[docs] def write_json(self, path: str | Path, *, overwrite: bool = True) -> Path: """Write a deterministic UTF-8 JSON configuration file. Parameters ---------- path : str or pathlib.Path Destination file. overwrite : bool, default=True Permit replacement of an existing file. Returns ------- pathlib.Path Destination path. Examples -------- >>> from tempfile import TemporaryDirectory >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> c = ExperimentConfig("e", "baseline", d, SeedPlan(0), {}, {}) >>> with TemporaryDirectory() as directory: ... path = c.write_json(Path(directory) / "experiment.json") ... loaded = ExperimentConfig.read_json(path) >>> loaded.config_hash == c.config_hash True """ target = Path(path) if target.exists() and not overwrite: raise FileExistsError( f"experiment configuration already exists: {target}" ) target.write_text( json.dumps(self.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) return target
[docs] @classmethod def from_dict(cls, data: Mapping[str, Any]) -> ExperimentConfig: """Restore and validate a serialized configuration. Parameters ---------- data : mapping State returned by :meth:`to_dict`. Returns ------- ExperimentConfig Immutable source-of-truth configuration. Examples -------- >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> c = ExperimentConfig("e", "baseline", d, SeedPlan(0), {}, {}) >>> ExperimentConfig.from_dict( ... c.to_dict() ... ).config_hash == c.config_hash True """ if data.get("schema_version", 1) != 1: raise ValueError("unsupported ExperimentConfig schema version.") return cls( experiment_id=data["experiment_id"], stage=data["stage"], dataset=DatasetReference.from_dict(data["dataset"]), seeds=SeedPlan.from_dict(data["seeds"]), model=data["model"], training=data["training"], physics=data.get("physics", {}), acceptance=tuple( AcceptanceCriterion.from_dict(item) for item in data.get("acceptance", []) ), description=data.get("description", ""), tags=tuple(data.get("tags", [])), created_utc=data.get("created_utc"), schema_version=data.get("schema_version", 1), )
[docs] @classmethod def read_json(cls, path: str | Path) -> ExperimentConfig: """Read and validate a JSON experiment configuration. Parameters ---------- path : str or pathlib.Path Existing UTF-8 JSON file. Returns ------- ExperimentConfig Validated immutable configuration. Examples -------- >>> from tempfile import TemporaryDirectory >>> d = DatasetReference("d", "a" * 64, "b" * 64) >>> c = ExperimentConfig("e", "baseline", d, SeedPlan(0), {}, {}) >>> with TemporaryDirectory() as directory: ... path = c.write_json(Path(directory) / "config.json") ... loaded = ExperimentConfig.read_json(path) >>> loaded.experiment_id 'e' """ return cls.from_dict( json.loads(Path(path).read_text(encoding="utf-8")) )