poincare-hyper / src /provenance.py
DHDRL's picture
Rename provenance.py to src/provenance.py
d60b49f verified
Raw
History Blame Contribute Delete
12 kB
"""
Provenance, content-hashing, and dataset-reuse prevention.
Design goals (per project rules):
- Single source of truth for "has this dataset already been trained on".
- No silent fallback behavior anywhere in this module.
- Dataset claims are atomic (safe under concurrent/multi-contributor use).
- Checkpoints are content-addressed (Option A): filename = hash(config+code+dataset).
This module intentionally has NO knowledge of model internals. It only
handles identity, claiming, and structured errors.
"""
from __future__ import annotations
import hashlib
import json
import os
import time
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any, Dict, Optional
import torch
# ----------------------------- Errors ----------------------------------- #
class ProvenanceError(Exception):
outcome_code: str = "PROVENANCE_ERROR"
def __init__(self, detail: str, outcome_code: Optional[str] = None):
self.detail = detail
if outcome_code:
self.outcome_code = outcome_code
super().__init__(f"[{self.outcome_code}] {detail}")
class DataLoadError(ProvenanceError):
outcome_code = "DATA_LOAD_FAILED"
class SchemaValidationError(ProvenanceError):
outcome_code = "SCHEMA_VALIDATION_FAILED"
class DatasetAlreadyUsedError(ProvenanceError):
outcome_code = "ALREADY_CONSUMED"
class DatasetInProgressError(ProvenanceError):
outcome_code = "ALREADY_IN_PROGRESS"
class CheckpointIntegrityError(ProvenanceError):
outcome_code = "CHECKPOINT_INTEGRITY_FAILED"
class TrajectoryTooShortError(ProvenanceError):
outcome_code = "TRAJECTORY_TOO_SHORT"
class EmptyDatasetError(ProvenanceError):
outcome_code = "EMPTY_DATASET"
def validate_trajectory_lengths(
dataset,
required_length: int,
sample_cap: int = 64,
):
n = len(dataset)
if n == 0:
raise EmptyDatasetError(
"dataset has zero trajectories; nothing to train on",
)
check_n = min(n, sample_cap)
too_short = []
for i in range(check_n):
item = dataset[i]
fields = item["fields"] if isinstance(item, dict) else item
T = fields.shape[0]
if T < required_length:
too_short.append((i, T))
if too_short:
raise TrajectoryTooShortError(
f"{len(too_short)}/{check_n} sampled trajectories are shorter "
f"than required_length={required_length} "
f"(examples: {too_short[:5]}). Every trajectory must satisfy "
f"T >= window + pred_steps; silently skipping short "
f"trajectories mid-training is not permitted.",
)
return {"success": True, "outcome_code": "OK", "checked": check_n, "total": n}
# ----------------------------- Hashing ----------------------------------- #
def hash_config(config: Dict[str, Any]) -> str:
def _norm(v):
if isinstance(v, float):
return round(v, 10)
if isinstance(v, dict):
return {k: _norm(vv) for k, vv in sorted(v.items())}
if isinstance(v, (list, tuple)):
return [_norm(vv) for vv in v]
return v
normalized = _norm(config)
blob = json.dumps(normalized, sort_keys=True).encode("utf-8")
return hashlib.sha256(blob).hexdigest()
def hash_code(src_dir: str) -> str:
src_path = Path(src_dir)
py_files = sorted(src_path.rglob("*.py"))
hasher = hashlib.sha256()
for f in py_files:
hasher.update(f.name.encode("utf-8"))
hasher.update(f.read_bytes())
return hasher.hexdigest()
def hash_dataset(dataset, sample_cap: Optional[int] = None) -> str:
n = len(dataset)
if sample_cap is not None:
n = min(n, sample_cap)
hasher = hashlib.sha256()
hasher.update(str(n).encode("utf-8"))
for i in range(n):
item = dataset[i]
fields = item["fields"] if isinstance(item, dict) else item
if not torch.is_tensor(fields):
raise SchemaValidationError(
f"dataset[{i}] did not return a tensor under key 'fields'; "
f"got {type(fields)}"
)
arr = fields.detach().cpu().contiguous().numpy()
hasher.update(arr.tobytes())
hasher.update(str(arr.shape).encode("utf-8"))
return hasher.hexdigest()
def combined_identity_hash(config_hash: str, code_hash: str, dataset_hash: str) -> str:
blob = f"{config_hash}:{code_hash}:{dataset_hash}".encode("utf-8")
return hashlib.sha256(blob).hexdigest()
# ------------------------- Dataset reuse registry ------------------------ #
@dataclass
class ClaimResult:
success: bool
outcome_code: str
dataset_hash: str
detail: str = ""
class DatasetRegistry:
def __init__(self, registry_dir: str = "registry/datasets"):
self.dir = Path(registry_dir)
self.dir.mkdir(parents=True, exist_ok=True)
def _path(self, dataset_hash: str) -> Path:
return self.dir / f"{dataset_hash}.json"
def status(self, dataset_hash: str) -> Optional[Dict[str, Any]]:
p = self._path(dataset_hash)
if not p.exists():
return None
return json.loads(p.read_text())
def claim(self, dataset_hash: str, experiment_id: str) -> ClaimResult:
existing = self.status(dataset_hash)
if existing is not None:
if existing["status"] == "CONSUMED":
raise DatasetAlreadyUsedError(
f"dataset {dataset_hash[:12]} was already consumed by "
f"experiment {existing.get('experiment_id')} at "
f"{existing.get('consumed_at')}. Retraining on it is blocked."
)
if existing["status"] == "IN_PROGRESS":
raise DatasetInProgressError(
f"dataset {dataset_hash[:12]} is currently IN_PROGRESS "
f"(experiment {existing.get('experiment_id')}, claimed "
f"{existing.get('claimed_at')}). If that run crashed, "
f"resolve manually with DatasetRegistry.mark_failed() "
f"before retrying — this is not automatic by design."
)
if existing["status"] == "FAILED":
raise DatasetInProgressError(
f"dataset {dataset_hash[:12]} previously FAILED "
f"(experiment {existing.get('experiment_id')}). "
f"Explicit human confirmation required to retry: "
f"call DatasetRegistry.allow_retry(dataset_hash) first."
)
record = {
"status": "IN_PROGRESS",
"experiment_id": experiment_id,
"claimed_at": time.time(),
"consumed_at": None,
}
p = self._path(dataset_hash)
try:
# O_EXCL makes this atomic: fails if another process just created it.
fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
with os.fdopen(fd, "w") as f:
json.dump(record, f, indent=2)
except FileExistsError:
# lost the race — re-check what the winner wrote
return self.claim(dataset_hash, experiment_id)
return ClaimResult(True, "CLAIMED", dataset_hash)
def mark_consumed(self, dataset_hash: str):
record = self.status(dataset_hash)
if record is None:
raise ProvenanceError(
f"cannot mark {dataset_hash[:12]} consumed: no claim exists",
outcome_code="NO_CLAIM_FOUND",
)
record["status"] = "CONSUMED"
record["consumed_at"] = time.time()
self._path(dataset_hash).write_text(json.dumps(record, indent=2))
def mark_failed(self, dataset_hash: str, error_detail: str = ""):
record = self.status(dataset_hash)
if record is None:
return
record["status"] = "FAILED"
record["error_detail"] = error_detail
self._path(dataset_hash).write_text(json.dumps(record, indent=2))
def allow_retry(self, dataset_hash: str):
"""Explicit human action required to clear a FAILED claim. Not automatic."""
p = self._path(dataset_hash)
if p.exists():
p.unlink()
# ------------------------------ Checkpoints ------------------------------- #
def _json_safe(obj: Any) -> Any:
if isinstance(obj, torch.Tensor):
return obj.detach().cpu().tolist()
if isinstance(obj, dict):
return {k: _json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_json_safe(v) for v in obj]
if isinstance(obj, (str, int, float, bool)) or obj is None:
return obj
try:
json.dumps(obj)
return obj
except TypeError:
return str(obj)
class CheckpointStore:
def __init__(self, checkpoints_dir: str = "checkpoints"):
self.dir = Path(checkpoints_dir)
self.dir.mkdir(parents=True, exist_ok=True)
def save(
self,
model_state: Dict[str, Any],
config: Dict[str, Any],
dataset_hash: str,
code_hash: str,
data_provenance: str,
extra: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
if not dataset_hash:
raise ProvenanceError(
"refusing to save checkpoint without dataset_hash",
outcome_code="MISSING_DATASET_HASH",
)
if data_provenance not in ("REAL_LOCAL", "REAL_STREAMED", "REAL_PBDB",
"REAL_PBDB_TAXONOMY", "SYNTHETIC_TREE", "SYNTHETIC"):
raise ProvenanceError(
f"invalid data_provenance '{data_provenance}'",
outcome_code="INVALID_PROVENANCE",
)
config_hash = hash_config(config)
identity = combined_identity_hash(config_hash, code_hash, dataset_hash)
final_path = self.dir / f"{identity}.pt"
meta_path = self.dir / f"{identity}.meta.json"
if final_path.exists() and meta_path.exists():
return {
"success": True,
"outcome_code": "DUPLICATE_EXISTS",
"path": str(final_path),
"identity_hash": identity,
}
meta = {
"identity_hash": identity,
"config_hash": config_hash,
"code_hash": code_hash,
"dataset_hash": dataset_hash,
"data_provenance": data_provenance,
"config": _json_safe(config),
"created_at": time.time(),
"extra": _json_safe(extra or {}),
}
meta_json_str = json.dumps(meta, indent=2) # _json_safe guarantees this succeeds
pid_tag = f"{os.getpid()}_{int(time.time()*1000)}"
tmp_path = self.dir / f".tmp_{identity}_{pid_tag}.pt"
meta_tmp = self.dir / f".tmp_{identity}_meta_{pid_tag}.json"
try:
torch.save(model_state, tmp_path)
meta_tmp.write_text(meta_json_str)
os.replace(tmp_path, final_path)
os.replace(meta_tmp, meta_path)
finally:
# Clean up any tmp file left behind by a failed/partial attempt.
for p in (tmp_path, meta_tmp):
if p.exists():
p.unlink()
return {
"success": True,
"outcome_code": "SAVED",
"path": str(final_path),
"identity_hash": identity,
}
def load(self, identity_hash: str) -> Dict[str, Any]:
final_path = self.dir / f"{identity_hash}.pt"
meta_path = self.dir / f"{identity_hash}.meta.json"
if not final_path.exists() or not meta_path.exists():
raise CheckpointIntegrityError(
f"checkpoint {identity_hash[:12]} incomplete or missing "
f"(model or meta file absent)"
)
model_state = torch.load(final_path, map_location="cpu")
meta = json.loads(meta_path.read_text())
return {"model_state": model_state, "meta": meta}