Source code for pymixef.provenance

"""Run provenance, environment capture, and deterministic fingerprints."""

from __future__ import annotations

import importlib.metadata
import os
import platform
import socket
import sys
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any

import numpy as np

from ._contracts import ReproducibilityClass
from ._serialization import stable_hash
from ._version import __version__

_THREAD_VARIABLES = (
    "OMP_NUM_THREADS",
    "OPENBLAS_NUM_THREADS",
    "MKL_NUM_THREADS",
    "VECLIB_MAXIMUM_THREADS",
    "NUMEXPR_NUM_THREADS",
)


def _package_version(name: str) -> str | None:
    try:
        return importlib.metadata.version(name)
    except importlib.metadata.PackageNotFoundError:
        return None


[docs] def environment_snapshot(*, redact_host: bool = True) -> dict[str, Any]: """Capture calculation-relevant environment metadata without telemetry.""" packages = { name: version for name in ( "pymixef", "numpy", "scipy", "pandas", "polars", "pyarrow", "xarray", ) if (version := _package_version(name)) is not None } numpy_config: dict[str, Any] = {} try: config = np.__config__.show(mode="dicts") if isinstance(config, dict): numpy_config = config except (TypeError, AttributeError): numpy_config = {"capture": "unavailable"} return { "python": { "version": platform.python_version(), "implementation": platform.python_implementation(), "executable": "<redacted>" if redact_host else sys.executable, }, "platform": { "system": platform.system(), "release": platform.release(), "machine": platform.machine(), "processor": platform.processor(), "hostname": "<redacted>" if redact_host else socket.gethostname(), }, "packages": packages, "thread_settings": {key: os.environ.get(key, "unset") for key in _THREAD_VARIABLES}, "numpy_build": numpy_config, }
[docs] def fingerprint_data(data: Any) -> str: """Create a deterministic data fingerprint for common columnar inputs.""" if hasattr(data, "to_dict"): try: data = data.to_dict(orient="list") except TypeError: data = data.to_dict() if isinstance(data, Mapping): normalized = { str(name): np.asarray(values) for name, values in sorted(data.items(), key=lambda item: str(item[0])) } lengths = {array.shape[0] for array in normalized.values() if array.ndim} if len(lengths) > 1: raise ValueError("Columns have inconsistent lengths and cannot be fingerprinted.") return stable_hash(normalized) if isinstance(data, np.ndarray): return stable_hash(data) if isinstance(data, Sequence) and not isinstance(data, (str, bytes, bytearray)): return stable_hash(list(data)) raise TypeError(f"Unsupported data container for fingerprinting: {type(data).__name__}.")
[docs] def fingerprint_model_ir(model_ir: Any) -> str: """Hash the exact semantic payload of a model IR object or document. Archived IR documents are hashed as stored rather than migrated first. This keeps legacy documents verifiable against the manifest produced when they were captured while ensuring a :class:`ModelIR` object and its ``to_dict()`` payload have identical fingerprints. """ if isinstance(model_ir, Mapping): payload = model_ir else: to_dict = getattr(model_ir, "to_dict", None) if not callable(to_dict): raise TypeError( "Model IR fingerprinting requires a mapping or an object with to_dict()." ) payload = to_dict() if not isinstance(payload, Mapping): raise TypeError("Model IR to_dict() must return a mapping.") return stable_hash(payload)
[docs] @dataclass(frozen=True, slots=True) class RunManifest: """Complete, serializable description of a PyMixEF execution.""" manifest_schema_version: str package_version: str created_at_utc: str model_ir_hash: str data_hash: str engine: str method: str settings: Mapping[str, Any] seeds: Mapping[str, int] reproducibility_class: str environment: Mapping[str, Any] elapsed_seconds: float | None = None output_hashes: Mapping[str, str] = field(default_factory=dict) source: Mapping[str, Any] = field(default_factory=dict) convergence: Mapping[str, Any] = field(default_factory=dict) warnings: tuple[Mapping[str, Any], ...] = ()
[docs] @classmethod def capture( cls, *, model_ir: Any, data: Any, engine: str, method: str, settings: Mapping[str, Any] | None = None, seeds: Mapping[str, int] | None = None, reproducibility_class: ReproducibilityClass | str = ( ReproducibilityClass.DETERMINISTIC_TOLERANCE ), elapsed_seconds: float | None = None, source: Mapping[str, Any] | None = None, convergence: Mapping[str, Any] | None = None, warnings: Sequence[Mapping[str, Any]] = (), ) -> RunManifest: value = ( reproducibility_class.value if isinstance(reproducibility_class, ReproducibilityClass) else str(reproducibility_class) ) return cls( manifest_schema_version="1.0.0", package_version=__version__, created_at_utc=datetime.now(UTC).isoformat(), model_ir_hash=fingerprint_model_ir(model_ir), data_hash=fingerprint_data(data), engine=engine, method=method, settings=dict(settings or {}), seeds=dict(seeds or {}), reproducibility_class=value, environment=environment_snapshot(), elapsed_seconds=elapsed_seconds, source={ "build_id": os.environ.get("PYMIXEF_BUILD_ID", "unrecorded"), "git_commit": os.environ.get("GITHUB_SHA", "unrecorded"), **dict(source or {}), }, convergence=dict(convergence or {}), warnings=tuple(dict(item) for item in warnings), )
[docs] def with_outputs(self, outputs: Mapping[str, Any]) -> RunManifest: """Return an immutable copy carrying hashes of named result components.""" return RunManifest( manifest_schema_version=self.manifest_schema_version, package_version=self.package_version, created_at_utc=self.created_at_utc, model_ir_hash=self.model_ir_hash, data_hash=self.data_hash, engine=self.engine, method=self.method, settings=self.settings, seeds=self.seeds, reproducibility_class=self.reproducibility_class, environment=self.environment, elapsed_seconds=self.elapsed_seconds, output_hashes={name: stable_hash(value) for name, value in outputs.items()}, source=self.source, convergence=self.convergence, warnings=self.warnings, )
[docs] @classmethod def from_dict(cls, value: Mapping[str, Any]) -> RunManifest: return cls(**dict(value))
[docs] def to_dict(self) -> dict[str, Any]: return { "manifest_schema_version": self.manifest_schema_version, "package_version": self.package_version, "created_at_utc": self.created_at_utc, "model_ir_hash": self.model_ir_hash, "data_hash": self.data_hash, "engine": self.engine, "method": self.method, "settings": dict(self.settings), "seeds": dict(self.seeds), "reproducibility_class": self.reproducibility_class, "environment": dict(self.environment), "elapsed_seconds": self.elapsed_seconds, "output_hashes": dict(self.output_hashes), "source": dict(self.source), "convergence": dict(self.convergence), "warnings": [dict(item) for item in self.warnings], }
[docs] class RunTimer: """Monotonic context timer used when constructing run manifests.""" def __enter__(self) -> RunTimer: self._start = time.perf_counter() self.elapsed_seconds = None return self def __exit__(self, *_: object) -> None: self.elapsed_seconds = time.perf_counter() - self._start