sora 370953729b Fix perf stats (wrong import path), per-repeat checkpoints, README
- perf_stats aggregator lives in eval/, not model/: the import failed
  silently and EVERY perf column was empty (not just ttft). Now warns
  on stderr instead of swallowing.
- repeats > 1 get their own checkpoint key (:rep2, :rep3, ...): repeat 2
  previously restored repeat 1's predictions and finished instantly with
  identical scores. rep1 keeps the legacy key (existing checkpoints still
  resume).
- repeats summary: report the MEAN score and aggregate time/tokens over
  ALL runs (was: last run only).
- README: six-benchmark command as the primary example.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-11 13:38:04 +00:00

268 lines
11 KiB
Python

"""Dataset: a lazy handle over a registered dataset.
``get_dataset('gsm8k')`` costs nothing -- no download, no parsing. The first
actual use (iteration / len / indexing) triggers ``materialize()``:
cache hit -> read the cached samples.jsonl
cache miss -> download raw -> record_to_sample -> atomic cache write -> read
Cache directory name: ``{safe_name}-{md5(source+split+subset+version+params)}``
so any config change yields a different cache entry (zero invalidation logic).
Each cache entry is self-contained:
raw/ native source data, exactly as downloaded (never converted)
samples.jsonl the unified Sample stream converted from raw/
meta.json spec + provenance
"""
import fcntl
import hashlib
import json
import os
import re
import string
import time
from pathlib import Path
from typing import Any, Callable, Dict, Iterator, List, Optional, Union
from .loader import get_cache_root, load_raw_records
from .sample import Sample
from .spec import DatasetSpec
# Backward-compatible alias: the canonical root lives in loader.get_cache_root()
# (a function, so runtime overrides via set_cache_root are always respected).
CACHE_ROOT = get_cache_root()
def set_cache_root(path) -> None:
"""Override the cache root at runtime (used by the CLI --cache-dir flag)."""
from . import loader
loader.set_cache_root(path)
def safe_filename(s: str, max_length: int = 255) -> str:
safe_chars = string.ascii_letters + string.digits + '.-_'
s = ''.join(c if c in safe_chars else '_' for c in s)
s = re.sub(r'_+', '_', s).strip('._')
return (s or 'untitled')[:max_length]
def gen_hash(s: str) -> str:
return hashlib.md5(s.encode('utf-8')).hexdigest()
class Dataset:
"""Sequence-like lazy dataset. Nothing is downloaded until first use."""
def __init__(self, spec: DatasetSpec, record_fn: Callable, samples: Optional[List[Sample]] = None):
self.spec = spec
self._record_fn = record_fn
self._samples = samples # None => not materialized yet
self.lineage: Dict[str, Any] = {}
# ---------------- materialization ----------------
@property
def cache_dir(self) -> Path:
key = f'{self.spec.source}{self.spec.split}{self.spec.subset}{self.spec.version}{self.spec.params}'
# Layout: datasets/<benchmark_name>/<subset>_<split>[-<version>]-<hash6>/
# Readable benchmark folder + readable subset/split; the 6-char hash
# suffix disambiguates different sources/params that would otherwise
# collide on the same subset_split name (correctness requirement).
parts = f'{safe_filename(self.spec.subset)}_{safe_filename(self.spec.split)}'
if self.spec.version:
parts += f'_{safe_filename(self.spec.version)}'
subdir = f'{parts}-{gen_hash(key)[:6]}'
return get_cache_root() / 'datasets' / safe_filename(self.spec.name) / subdir
@property
def is_materialized(self) -> bool:
return self._samples is not None
def materialize(self, force: bool = False) -> 'Dataset':
if self._samples is not None and not force:
return self
cache_dir = self.cache_dir
cache_file = cache_dir / 'samples.jsonl'
if cache_file.exists() and not force:
self._samples = self._read_cache(cache_file)
self.lineage = {'from': 'cache', 'cache_dir': str(cache_dir)}
return self
# mkdir+lock with retries: SOMETHING reaps freshly created dataset
# dirs during heavy concurrent runs; retry a few times before giving up
lock_f = None
last_err = None
for _ in range(5):
try:
cache_dir.parent.mkdir(parents=True, exist_ok=True)
lock_path = cache_dir.with_suffix('.lock')
lock_f = open(lock_path, 'w') # noqa: PTH123
break
except (FileExistsError, FileNotFoundError) as e:
last_err = e
import time as _t
_t.sleep(1.0)
if lock_f is None:
raise last_err
with lock_f:
fcntl.flock(lock_f, fcntl.LOCK_EX)
try:
if cache_file.exists() and not force: # double-check under lock
self._samples = self._read_cache(cache_file)
self.lineage = {'from': 'cache', 'cache_dir': str(cache_dir)}
return self
cache_dir.parent.mkdir(parents=True, exist_ok=True)
tmp_dir = cache_dir.with_name(cache_dir.name + f'.tmp-{os.getpid()}')
if tmp_dir.exists():
import shutil
shutil.rmtree(tmp_dir)
# concurrent materialization (parallel stage runners) can race
# on the parent-chain mkdir; retry once -- the dir existing is
# always harmless for a scratchpad
try:
tmp_dir.mkdir(parents=True, exist_ok=True)
except FileExistsError:
tmp_dir.mkdir(parents=True, exist_ok=True)
records = load_raw_records(self.spec, raw_dir=tmp_dir / 'raw')
samples = [self._to_sample(r) for r in records]
self._assign_ids(samples)
self._write_cache_atomic(samples, cache_dir, tmp_dir)
self._samples = samples
self.lineage = {'from': 'source', 'cache_dir': str(cache_dir)}
finally:
fcntl.flock(lock_f, fcntl.LOCK_UN)
return self
def _to_sample(self, record: Dict[str, Any]) -> Sample:
sample = self._record_fn(record)
if not sample.task_type:
sample.task_type = self.spec.task_type
# expose the loaded subset on every sample: per-subtask eval dispatch
# (e.g. bbh MC vs free-form) reads metadata['subset']
if self.spec.subset not in ('default', 'all') and sample.metadata.get('subset') is None:
sample.metadata.setdefault('subset', self.spec.subset)
return sample
@staticmethod
def _assign_ids(samples: List[Sample]) -> None:
"""Assign sequential ids to samples that don't carry one."""
for i, s in enumerate(samples):
if s.id is None:
s.id = i
def _read_cache(self, cache_file: Path) -> List[Sample]:
with open(cache_file, encoding='utf-8') as f:
samples = [Sample.model_validate(json.loads(line)) for line in f if line.strip()]
return samples
def _write_cache_atomic(self, samples: List[Sample], cache_dir: Path, tmp_dir: Path) -> None:
"""Finalize the tmp dir (samples + meta next to raw/) and swap it in.
``raw/`` was already populated by ``load_raw_records`` inside tmp_dir;
the tmp+rename swap makes the whole entry (raw + samples + meta)
appear atomically.
"""
with open(tmp_dir / 'samples.jsonl', 'w', encoding='utf-8') as f:
for s in samples:
f.write(json.dumps(s.model_dump(), ensure_ascii=False) + '\n')
raw_dir = tmp_dir / 'raw'
meta = {
'spec': {k: v for k, v in vars(self.spec).items()},
'num_samples': len(samples),
'raw_files': sorted(p.name for p in raw_dir.iterdir()) if raw_dir.exists() else [],
'note': 'raw/ holds the native source data exactly as downloaded; samples.jsonl is the converted view',
'created_at': time.strftime('%Y-%m-%d %H:%M:%S'),
}
with open(tmp_dir / 'meta.json', 'w', encoding='utf-8') as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
final = cache_dir
if final.exists():
import shutil
# retire the old entry, then swap the new one in
old = cache_dir.with_name(cache_dir.name + f'.old-{os.getpid()}')
os.rename(final, old)
try:
os.rename(tmp_dir, final)
except OSError:
os.rename(old, final)
raise
shutil.rmtree(old, ignore_errors=True)
else:
os.rename(tmp_dir, final)
def unload(self) -> bool:
"""Drop this dataset's cache entry (raw/ + samples.jsonl + meta.json).
Pure cache management: in-memory samples (if any) stay usable; the
next materialize rebuilds from source. Execution-environment
resources (docker images declared by ``Sample.sandbox``) are NOT
touched -- those belong to the sandbox layer's lifecycle.
"""
import shutil
removed = False
if self.cache_dir.exists():
shutil.rmtree(self.cache_dir)
removed = True
lock_path = self.cache_dir.with_suffix('.lock')
if lock_path.exists():
lock_path.unlink()
if self._samples is not None or self.lineage:
self._samples = None
self.lineage = {}
return removed
# ---------------- sequence protocol (triggers materialize) ----------------
def _require(self) -> List[Sample]:
self.materialize()
return self._samples
def __iter__(self) -> Iterator[Sample]:
return iter(self._require())
def __len__(self) -> int:
return len(self._require())
def __getitem__(self, i: Union[int, slice]) -> Union[Sample, List[Sample]]:
return self._require()[i]
# ---------------- views / derived data ----------------
def view(self, samples: List[Sample], lineage: Optional[Dict[str, Any]] = None) -> 'Dataset':
"""An in-memory derived dataset (filter/sample/dedup results).
Same class, same interface; ``lineage`` records how it was produced.
"""
derived = Dataset(self.spec, self._record_fn, samples=samples)
derived.lineage = {'from': 'derived', 'parent': self.spec.name, **(lineage or {})}
return derived
# ---------------- introspection ----------------
def stats(self) -> Dict[str, Any]:
samples = self._require()
lengths = [len(s.input_text) for s in samples]
targets = [s.target if isinstance(s.target, str) else ','.join(s.target) for s in samples]
return {
'name': self.spec.name,
'task_type': self.spec.task_type,
'num_samples': len(samples),
'input_len': {
'min': min(lengths) if lengths else 0,
'max': max(lengths) if lengths else 0,
'mean': round(sum(lengths) / len(lengths), 1) if lengths else 0,
},
'target_top': sorted({t: targets.count(t) for t in set(targets)}.items(), key=lambda kv: -kv[1])[:10],
'cache_dir': str(self.cache_dir),
}
def __repr__(self) -> str:
state = 'materialized' if self.is_materialized else 'lazy'
return f"Dataset(name={self.spec.name!r}, type={self.spec.task_type!r}, {state})"