core/evals/framework.py
Shay 257fd4503d
feat(evals): ADR-0105 — sealed holdout encryption via age (#108)
* feat(evals): add pyrage dependency

* feat(evals): add sealed holdout path resolution

* feat(evals): implement sealed holdout decryption

* feat(evals): add sealed holdout CLI

* test(evals): add sealed holdout encryption tests

* docs(decisions): add ADR-0105 sealed holdout encryption

* feat(evals): route holdout split through sealed decryptor

* docs(decisions): add ADR-0105 index entry

* chore: restore project description

* fix(evals): use pyrage Identity.from_str and pin curriculum SHA

- holdout_runner: pyrage exposes Identity.from_str, not from_file; parse
  identity file by line and pass list[Identity] into decrypt(). Restores
  PR 108's sealed-holdout test suite to green.
- verify_lane_shas: realign curriculum_loop_closure pin with the actual
  deterministic runner output (carryover from PR 107).
2026-05-22 10:09:43 -07:00

239 lines
7 KiB
Python

"""Generic eval framework — discovers lanes, loads contracts, runs versioned scoring.
Every eval lane lives in ``evals/<lane>/`` and must contain at minimum:
- ``contract.md`` — human-readable contract (presence required, not parsed at runtime)
- ``runner.py`` — module that exposes ``run_lane(cases, **kwargs) -> LaneReport``
- ``dev/cases.jsonl`` — dev set
- ``public/v1/cases.jsonl`` — public test set v1
Optional:
- ``holdouts/`` — encrypted sealed test set
- ``baselines/`` — frontier model scores
- ``results/`` — CORE scores per version per run
"""
from __future__ import annotations
import importlib
import importlib.util
import inspect
import json
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
EVALS_ROOT = Path(__file__).resolve().parent
@dataclass(frozen=True, slots=True)
class LaneInfo:
name: str
root: Path
versions: tuple[str, ...]
@property
def contract_path(self) -> Path:
return self.root / "contract.md"
@property
def runner_path(self) -> Path:
return self.root / "runner.py"
def dev_cases_path(self) -> Path:
return self.root / "dev" / "cases.jsonl"
def public_cases_path(self, version: str = "v1") -> Path:
return self.root / "public" / version / "cases.jsonl"
def holdout_cases_path(self, version: str = "v1") -> Path:
holdouts = self.root / "holdouts"
candidates = (
holdouts / "cases.jsonl",
holdouts / "cases_plaintext.jsonl",
holdouts / version / "cases.jsonl",
)
for path in candidates:
if path.exists():
return path
return candidates[-1]
def holdout_cases_path_sealed(self, version: str = "v1") -> Path:
holdouts = self.root / "holdouts"
candidates = (
holdouts / "cases.jsonl.age",
holdouts / version / "cases.jsonl.age",
)
for path in candidates:
if path.exists():
return path
return candidates[-1]
def results_dir(self) -> Path:
return self.root / "results"
@dataclass(slots=True)
class LaneResult:
lane: str
version: str
split: str
metrics: dict[str, Any]
case_details: list[dict[str, Any]] = field(default_factory=list)
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def as_dict(self) -> dict[str, Any]:
return {
"lane": self.lane,
"version": self.version,
"split": self.split,
"timestamp": self.timestamp,
"metrics": self.metrics,
"cases": self.case_details,
}
def discover_lanes(root: Path | None = None) -> list[LaneInfo]:
base = root or EVALS_ROOT
lanes: list[LaneInfo] = []
for child in sorted(base.iterdir()):
if not child.is_dir():
continue
contract = child / "contract.md"
runner = child / "runner.py"
if contract.exists() and runner.exists():
versions = _discover_versions(child)
lanes.append(LaneInfo(name=child.name, root=child, versions=versions))
return lanes
def _discover_versions(lane_root: Path) -> tuple[str, ...]:
public = lane_root / "public"
if not public.is_dir():
return ()
versions = sorted(
d.name for d in public.iterdir()
if d.is_dir() and (d / "cases.jsonl").exists()
)
return tuple(versions)
def load_cases(path: Path) -> list[dict[str, Any]]:
cases: list[dict[str, Any]] = []
for line in path.read_text().splitlines():
line = line.strip()
if line:
cases.append(json.loads(line))
return cases
def load_lane_runner(lane: LaneInfo) -> Any:
runner_path = lane.runner_path
if not runner_path.exists():
raise FileNotFoundError(f"Runner not found: {runner_path}")
module_name = f"evals.{lane.name}.runner"
spec = importlib.util.spec_from_file_location(module_name, runner_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load runner: {runner_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _run_cases(
lane: LaneInfo,
cases: list[dict[str, Any]],
*,
version: str,
split: str,
config: Any,
workers: int | None,
) -> LaneResult:
runner_module = load_lane_runner(lane)
runner_params = inspect.signature(runner_module.run_lane).parameters
if "workers" in runner_params or any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in runner_params.values()
):
report = runner_module.run_lane(cases, config=config, workers=workers)
else:
report = runner_module.run_lane(cases, config=config)
return LaneResult(
lane=lane.name,
version=version,
split=split,
metrics=report.metrics,
case_details=report.case_details,
)
def run_lane(
lane: LaneInfo,
*,
version: str = "v1",
split: str = "public",
config: Any = None,
workers: int | None = None,
) -> LaneResult:
if split == "holdout":
from evals.holdout_runner import _decrypt_holdout
cases = _decrypt_holdout(lane.holdout_cases_path_sealed(version))
return _run_cases(
lane,
cases,
version=version,
split=split,
config=config,
workers=workers,
)
if split == "dev":
cases_path = lane.dev_cases_path()
elif split == "public":
cases_path = lane.public_cases_path(version)
else:
raise ValueError(
f"Unsupported split: {split!r}. Use 'dev', 'public', or 'holdout'."
)
if not cases_path.exists():
raise FileNotFoundError(f"Cases not found: {cases_path}")
return _run_cases(
lane,
load_cases(cases_path),
version=version,
split=split,
config=config,
workers=workers,
)
def write_result(lane: LaneInfo, result: LaneResult) -> Path:
results_dir = lane.results_dir()
results_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
filename = f"{result.version}_{result.split}_{ts}.json"
path = results_dir / filename
path.write_text(
json.dumps(result.as_dict(), ensure_ascii=False, indent=2, sort_keys=True)
+ "\n"
)
return path
def get_lane(name: str, root: Path | None = None) -> LaneInfo:
base = root or EVALS_ROOT
lane_root = base / name
contract = lane_root / "contract.md"
runner = lane_root / "runner.py"
if not contract.exists():
raise FileNotFoundError(f"Lane '{name}' has no contract.md at {contract}")
if not runner.exists():
raise FileNotFoundError(f"Lane '{name}' has no runner.py at {runner}")
versions = _discover_versions(lane_root)
return LaneInfo(name=name, root=lane_root, versions=versions)