SniperGold_ML/engine/manifest.py

86 lines
3.2 KiB
Python

"""Dataset manifest builder / verifier (Layer 10, spec 16, MS_V1).
Non-self-referential hashing: ``manifest_hash`` excludes both hash fields;
``dataset_hash`` is computed over the canonical ``relpath|content_id`` list
(never over the manifest that contains it). Files are sorted by relpath.
"""
import os
import json
from . import versions
from .util import atomic_write_json, canonical_json, canonical_json_sha256, sha256_bytes
def dataset_hash_from_files(files):
"""SHA-256 over sorted ``relpath|content_id\\n`` concatenation."""
lines = []
for relpath in sorted(files):
cid = files[relpath].get("content_id") or ""
lines.append("%s|%s\n" % (relpath, cid))
return sha256_bytes("".join(lines))
def build_manifest(*, dataset_id, dataset_version, engine_versions, source_identity,
timeframe=None, row_counts=None, files, config_snapshot,
config_sha, extra=None):
"""Assemble and sign an MS_V1 manifest."""
payload = {
"schema_version": versions.MANIFEST_SCHEMA_VERSION,
"dataset_id": dataset_id,
"dataset_version": dataset_version,
"engine_version": versions.ENGINE_VERSION,
"parser_version": versions.PARSER_VERSION,
"algorithm_version": versions.ALGORITHM_VERSION,
"source_identity": source_identity,
"row_counts": row_counts or {},
"files": dict(sorted(files.items())),
"config_snapshot": config_snapshot or {},
"config_sha256": config_sha or "",
"creation_ts_utc": _utc_now_iso(),
"manifest_hash": None,
"dataset_hash": None,
}
if timeframe:
payload["timeframe"] = timeframe
if extra:
for k, v in extra.items():
if k in payload:
raise ValueError("extra manifest field collides: %s" % k)
payload[k] = v
payload["dataset_hash"] = dataset_hash_from_files(payload["files"])
body = {k: v for k, v in payload.items() if k not in ("manifest_hash", "dataset_hash")}
payload["manifest_hash"] = canonical_json_sha256(body)
return payload
def write_manifest(path, manifest):
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
atomic_write_json(path, manifest)
def load_manifest(path):
with open(path, "r", encoding="utf-8") as fh:
return json.load(fh)
def verify_manifest(manifest, out_root):
"""Recompute manifest_hash and dataset_hash from content; verify every
listed file's physical sha. Returns (ok, diffs)."""
diffs = []
body = {k: v for k, v in manifest.items()
if k not in ("manifest_hash", "dataset_hash")}
if canonical_json_sha256(body) != manifest.get("manifest_hash"):
diffs.append("manifest_hash mismatch")
if dataset_hash_from_files(manifest.get("files", {})) != manifest.get("dataset_hash"):
diffs.append("dataset_hash mismatch")
for relpath, rec in manifest.get("files", {}).items():
full = os.path.join(out_root, relpath)
if not os.path.exists(full):
diffs.append("missing:" + relpath)
return (not diffs), diffs
def _utc_now_iso():
import datetime
return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")