core/evals/framework.py
Shay 1e01f7794e feat(evals): Phase 0 — benchmark methodology lock-in and eval framework
Implement the eval infrastructure defined in ADR-0016 before building new
eval lanes. This establishes the discipline that governs the entire
capability roadmap.

- Generic eval framework (evals/framework.py): lane discovery, versioned
  scoring, result persistence
- Cognition lane retrofitted into new convention: 45 cases split into
  stratified dev (13) / public v1 (13) / holdout (19) sets with contract,
  runner, and recorded results
- Generalized `core eval <lane>` CLI: dynamic lane discovery, --list,
  --version, --split, --save, --json flags
- Holdout runner scaffold: plaintext fallback, encryption interface ready
- Baseline runner scaffold: pluggable frontier model interface
- Fix: CognitiveTurnPipeline.run() crashed on turn_log[-1] when the
  unknown-domain gate returned a stub without appending to turn_log
- ADR-0016, eval_methodology.md, PROGRESS.md, capability gates session log

Phase 0 exit audit found two methodology issues:
1. Pipeline turn_log crash (fixed here)
2. Versor drift in multi-turn sessions (pre-existing, under investigation)
2026-05-15 22:36:53 -07:00

182 lines
5.6 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 (scored by holdout_runner)
- ``baselines/`` — frontier model scores
- ``results/`` — CORE scores per version per run
"""
from __future__ import annotations
import importlib
import importlib.util
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 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]:
"""Find all valid eval lanes under *root*."""
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]]:
"""Load cases from a JSONL file."""
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:
"""Dynamically import a lane's runner module."""
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_lane(
lane: LaneInfo,
*,
version: str = "v1",
split: str = "public",
config: Any = None,
) -> LaneResult:
"""Run a single lane on a given version and split."""
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' or 'public'.")
if not cases_path.exists():
raise FileNotFoundError(f"Cases not found: {cases_path}")
cases = load_cases(cases_path)
runner_module = load_lane_runner(lane)
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 write_result(lane: LaneInfo, result: LaneResult) -> Path:
"""Write a result to the lane's results directory, returning the 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:
"""Look up a single lane by name."""
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)