Source code for specmod.config.provenance
"""Provenance stamping: what makes a locally-overridden run reproducible.
Local config files are uncommitted by design, so the repository alone cannot
say how a given result was produced. The output can. Every artifact SpecMod
writes carries the fully resolved configuration, a short hash of it, and the
SpecMod version.
The version matters as much as the config. Defaults move between releases, so
without it "reproducible" fails silently across an upgrade — which is exactly
what made identifying the code behind the published Magna run so difficult (see
``docs/REFACTOR_PLAN.md`` §5.2.5).
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from .. import __version__
from .sections import Config
__all__ = ["Provenance", "config_hash"]
def _canonical(config: Config) -> str:
"""Serialise deterministically so the hash is stable across runs."""
return json.dumps(config.to_dict(), sort_keys=True, separators=(",", ":"))
[docs]
def config_hash(config: Config, *, length: int = 12) -> str:
"""Short, stable digest of a configuration.
Comparing two runs starts here: same hash means same settings, so any
difference is in the data or the code, not the configuration.
"""
return hashlib.sha256(_canonical(config).encode()).hexdigest()[:length]
[docs]
@dataclass(frozen=True, slots=True)
class Provenance:
"""The record attached to every output."""
specmod_version: str
config: dict[str, Any]
config_hash: str
created_at: str
sources: dict[str, str]
@classmethod
def capture(
cls,
config: Config,
*,
sources: dict[str, str] | None = None,
) -> Provenance:
return cls(
specmod_version=__version__,
config=config.to_dict(),
config_hash=config_hash(config),
created_at=datetime.now(UTC).isoformat(timespec="seconds"),
sources=dict(sources or {}),
)
def to_dict(self) -> dict[str, Any]:
return {
"specmod_version": self.specmod_version,
"config_hash": self.config_hash,
"created_at": self.created_at,
"config": self.config,
"config_sources": self.sources,
}
def to_json(self, *, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent, sort_keys=True)