"""Provenance and the run manifest — plan §15, spec IN-047, OUT-073, CLI-005. Timestamps and the run ID live only in ``run_manifest.json`` so that data outputs stay byte-identical across runs. """ from __future__ import annotations import datetime as _dt import hashlib import json import os import platform import secrets import sys from dataclasses import dataclass, field from pathlib import Path from typing import Any import numpy as np from simurg import PRODUCT_NAME, __version__ from simurg.core.atomic import atomic_write_text MANIFEST_SCHEMA = "simurg.run_manifest/1" def sha256_file(path: str | Path, chunk: int = 1 << 20) -> str: h = hashlib.sha256() with Path(path).open("rb") as fh: while b := fh.read(chunk): h.update(b) return h.hexdigest() def new_run_id() -> str: """Sortable, unique run ID: UTC timestamp + 8 random hex digits.""" return _dt.datetime.now(_dt.UTC).strftime("%Y%m%dT%H%M%SZ") + "-" + secrets.token_hex(4) def git_revision() -> str | None: here = Path(__file__).resolve() for parent in here.parents: head = parent / ".git" / "HEAD" if head.is_file(): ref = head.read_text().strip() if ref.startswith("ref: "): ref_file = parent / ".git" / ref[5:] return ref_file.read_text().strip()[:12] if ref_file.is_file() else None return ref[:12] return None def environment() -> dict[str, Any]: return { "python": sys.version.split()[0], "numpy": np.__version__, "platform": platform.platform(), "machine": platform.machine(), "cpu_count": os.cpu_count(), } @dataclass class RunManifest: command: str argv: list[str] run_id: str = field(default_factory=new_run_id) started: str = field(default_factory=lambda: _dt.datetime.now(_dt.UTC).isoformat()) effective_options: dict[str, dict[str, Any]] = field(default_factory=dict) inputs: list[dict[str, Any]] = field(default_factory=list) outputs: list[dict[str, Any]] = field(default_factory=list) sheet_header: dict[str, str] = field(default_factory=dict) failed_samples: list[dict[str, str]] = field(default_factory=list) summary: dict[str, Any] = field(default_factory=dict) schema_versions: dict[str, str] = field(default_factory=dict) status: str = "running" seed: int = 42 def set_option(self, name: str, value: Any, source: str) -> None: """Record an effective option with its source ``cli|yaml:|profile:|default`` (CLI-005).""" self.effective_options[name] = {"value": value, "source": source} def add_input(self, role: str, path: str | Path, *, hash_file: bool = True) -> None: p = Path(path) entry: dict[str, Any] = {"role": role, "path": str(p.resolve()), "name": p.name} if p.is_file(): entry["size"] = p.stat().st_size if hash_file: entry["sha256"] = sha256_file(p) self.inputs.append(entry) def add_output(self, path: str | Path, schema: str | None = None) -> None: p = Path(path) entry: dict[str, Any] = {"path": str(p.resolve())} if p.is_file(): entry["size"] = p.stat().st_size entry["sha256"] = sha256_file(p) if schema: entry["schema"] = schema self.schema_versions[p.name] = schema self.outputs.append(entry) def to_dict(self) -> dict[str, Any]: return { "schema": MANIFEST_SCHEMA, "product": PRODUCT_NAME, "version": __version__, "git_revision": git_revision(), "run_id": self.run_id, "command": self.command, "argv": self.argv, "status": self.status, "started": self.started, "finished": _dt.datetime.now(_dt.UTC).isoformat(), "seed": self.seed, "environment": environment(), "effective_options": self.effective_options, "inputs": self.inputs, "outputs": self.outputs, "sheet_header": self.sheet_header, "failed_samples": self.failed_samples, "summary": self.summary, "schema_versions": self.schema_versions, } def write(self, path: str | Path) -> None: atomic_write_text(Path(path), json.dumps(self.to_dict(), indent=2, ensure_ascii=False) + "\n")