| """ |
| 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 |
|
|
|
|
| |
|
|
| 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} |
|
|
|
|
| |
|
|
| 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() |
|
|
|
|
| |
|
|
| @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: |
| |
| 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: |
| |
| 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() |
|
|
|
|
| |
|
|
| 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) |
|
|
| 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: |
| |
| 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} |
|
|