Skip to content

Commit 3dccac6

Browse files
feat: add immutable evaluation manifests
1 parent d3ec06b commit 3dccac6

2 files changed

Lines changed: 71 additions & 0 deletions

File tree

src/utils/evaluation_manifest.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Immutable episode manifests for trustworthy held-out evaluation."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import json
7+
from pathlib import Path
8+
9+
from src.utils.data_recorder import load_npz_shard, shard_checksum
10+
11+
12+
def create_evaluation_manifest(data_dir: str | Path, destination: str | Path, limit: int | None = None) -> Path:
13+
"""Select complete recorded episodes and bind them to exact source checksums."""
14+
data_dir, destination = Path(data_dir), Path(destination)
15+
metadata_path = data_dir / "metadata.json"
16+
metadata = json.loads(metadata_path.read_text(encoding="utf-8")) if metadata_path.exists() else {}
17+
selected, sources = [], {}
18+
for shard in sorted(data_dir.glob("shard_*.npz")):
19+
checksum = shard_checksum(shard)
20+
episodes = load_npz_shard(shard, metadata.get("shard_checksums", {}).get(shard.name))
21+
sources[shard.name] = checksum
22+
for episode in episodes:
23+
selected.append({
24+
"shard": shard.name,
25+
"episode_id": episode["episode_id"],
26+
"env_id": episode["env_id"],
27+
"length": episode["length"],
28+
"scenario": episode.get("metadata", {}).get("scenario", "unknown"),
29+
})
30+
if limit is not None and len(selected) >= limit:
31+
break
32+
if limit is not None and len(selected) >= limit:
33+
break
34+
if not selected:
35+
raise ValueError("no validated recording episodes are available for evaluation")
36+
payload = {"format_version": 1, "episodes": selected, "source_shards": sources}
37+
payload["manifest_sha256"] = hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()
38+
destination.parent.mkdir(parents=True, exist_ok=True)
39+
destination.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
40+
return destination
41+
42+
43+
def validate_evaluation_manifest(path: str | Path, data_dir: str | Path) -> dict:
44+
"""Reject a manifest when any referenced recording shard has changed."""
45+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
46+
if payload.get("format_version") != 1 or not payload.get("episodes"):
47+
raise ValueError("invalid evaluation manifest")
48+
for name, expected in payload.get("source_shards", {}).items():
49+
if shard_checksum(Path(data_dir) / name) != expected:
50+
raise ValueError(f"evaluation source checksum mismatch: {name}")
51+
return payload

tests/test_evaluation_manifest.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import numpy as np
2+
import pytest
3+
4+
from src.utils.data_recorder import EpisodeRecorder
5+
from src.utils.evaluation_manifest import create_evaluation_manifest, validate_evaluation_manifest
6+
7+
8+
def test_evaluation_manifest_is_episode_based_and_checksum_bound(tmp_path):
9+
data = tmp_path / "recordings"
10+
recorder = EpisodeRecorder(str(data), save_frequency=1)
11+
frame = np.zeros((2, 2, 3), dtype=np.uint8)
12+
recorder.add_transition(frame, 0, 0.0, frame, terminated=True, metadata={"scenario": "level-a"})
13+
recorder.finalize()
14+
manifest = create_evaluation_manifest(data, tmp_path / "evaluation.json")
15+
payload = validate_evaluation_manifest(manifest, data)
16+
assert payload["episodes"][0]["scenario"] == "level-a"
17+
shard = next(data.glob("shard_*.npz"))
18+
shard.write_bytes(shard.read_bytes() + b"changed")
19+
with pytest.raises(ValueError, match="checksum"):
20+
validate_evaluation_manifest(manifest, data)

0 commit comments

Comments
 (0)