core/evals/framework.py
Shay 21b10028b5 fix(evals): introspect run_lane signature before passing workers kwarg
PR #46 added the `workers` kwarg to framework dispatch (evals/framework.py:176)
but only the cognition runner was updated to accept it. The three serial
lanes (cold_start_grounding, deterministic_fluency, warmed_session_consistency)
— and ~30 other runners — raised TypeError on every framework invocation,
producing 18 test failures across the full suite.

Fix at the dispatch site rather than per-runner: inspect the target
run_lane signature and pass `workers=` only when it accepts the kwarg
(or has **kwargs). This keeps the framework contract backward-compatible
with the legacy two-arg shape and forward-compatible with future
parallelized runners — no runner needs updating.

Full lane: 2859 passed, 3 skipped, 0 failed (was 2841/18 failed).
Cognition eval byte-identical: 100/100/91.7/100.
2026-05-20 05:59:51 -07:00

226 lines
7.5 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 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:
"""Return the resolved holdout cases path for this lane.
Resolution order (first existing wins):
1. ``holdouts/cases.jsonl`` — flat plaintext
2. ``holdouts/cases_plaintext.jsonl`` — cognition convention
3. ``holdouts/<version>/cases.jsonl`` — versioned plaintext
If none exist, returns the versioned path so callers receive a
coherent ``FileNotFoundError``.
This intentionally does NOT decrypt sealed (``*.age``) holdouts —
sealed runs must go through ``evals.holdout_runner.run_holdout``,
which enforces aggregate-only output per its trust boundary.
"""
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 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,
workers: int | None = 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)
elif split == "holdout":
cases_path = lane.holdout_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}")
cases = load_cases(cases_path)
runner_module = load_lane_runner(lane)
# PR #46 added the `workers` kwarg to enable parallel execution on
# lanes that opt in (currently: cognition). Most lane runners are
# serial and predate this contract. Pass `workers` only when the
# target runner's signature accepts it — otherwise call the legacy
# two-arg form. This keeps the framework dispatch backward-compatible
# without forcing every runner to accept a no-op kwarg.
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 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)