"""Capability-evaluation runner: discover scenarios, run them, write a report and a scoreboard.
On demand only — nothing here schedules itself. See
`docs/superpowers/specs/2026-10-29-capability-evaluation-design.md` for the axis definitions.
Examples:
python3 scripts/eval/capability/run.py --list
python3 scripts/eval/capability/run.py --axis S --profile smoke
python3 scripts/eval/capability/run.py --scenarios S0-smoke-pass,S0-smoke-fail --record
python3 scripts/eval/capability/run.py --axis A --from-run jobs/full-88-v0514/2026-10-28__09-12-44
python3 scripts/eval/capability/run.py --axis A --from-run
--execute --multipliers 2,4
python3 scripts/eval/capability/run.py --failures last
python3 scripts/eval/capability/run.py --against last --record
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
sys.path.insert(0, str(HERE))
from lib import backend, metrics, report, scenario # noqa: E402
from axes import axis_a # noqa: E402
SCENARIO_ROOT = HERE / "scenarios"
DEFAULT_OUT = REPO / "jobs" / "capability"
SCOREBOARD = REPO / "docs" / "reports" / "capability-scoreboard.json"
PROFILES = {"smoke": 1, "standard": 1, "deep": 3}
def binary_sha(path: Path) -> str | None:
try:
return hashlib.sha256(path.read_bytes()).hexdigest()
except OSError:
return None
def protocol(args, binary: Path) -> dict:
return {
"source_commit": subprocess_output(["git", "rev-parse", "--short", "HEAD"]) or "unknown",
"binary": str(binary),
"binary_sha256": (binary_sha(binary) or "unknown")[:16],
"model": "deepseek-flash (effort max)" if (args.provider or "") != "mock" else "mock",
"host": sys.platform,
}
def subprocess_output(command: list[str]) -> str | None:
import subprocess
try:
return subprocess.run(command, cwd=REPO, capture_output=True, text=True, timeout=10).stdout.strip() or None
except (OSError, subprocess.SubprocessError):
return None
def parse_rungs(text: str) -> tuple[int, int]:
low, _, high = text.partition("-")
return int(low), int(high or low)
def run_axis_a(args) -> int:
out = (Path(args.out) if Path(args.out).is_absolute() else REPO / args.out) / f"A-{time.strftime('%Y%m%d-%H%M%S')}"
out.mkdir(parents=True, exist_ok=True)
run_dirs = [Path(item) for item in (args.from_run or [])]
if not run_dirs:
print("axis A needs --from-run (repeatable)", file=sys.stderr)
return 2
tasks = axis_a.failing_tasks(run_dirs)
if args.tasks:
wanted = {name.strip() for name in args.tasks.split(",") if name.strip()}
tasks = [task for task in tasks if task in wanted]
missing = wanted - set(tasks)
if missing:
print(f"note: not in the failing set: {', '.join(sorted(missing))}", file=sys.stderr)
multipliers = [float(value) for value in (args.multipliers or "2,4").split(",")]
planned = axis_a.plan(tasks, multipliers)
(out / "plan.json").write_text(json.dumps(planned, indent=2) + "\n", encoding="utf-8")
print(f"axis A plan: {len(tasks)} failing tasks x {len(multipliers)} multipliers = {len(planned)} runs")
for item in planned[:12]:
print(f" {item['task']} x{item['multiplier']:g}")
if len(planned) > 12:
print(f" ... {len(planned) - 12} more")
if not args.execute:
print(f"\nplan only (pass --execute to run Harbor). written: {out/'plan.json'}")
return 0
print("\nexecuting (this spends API budget)...")
rows = axis_a.execute(tasks, multipliers, out)
transitions = metrics.budget_transitions(rows)
(out / "transitions.json").write_text(json.dumps(transitions, indent=2) + "\n", encoding="utf-8")
print(f"time-bound: {transitions['passed_at_some_budget']}/{transitions['tasks']} "
f"({transitions['time_bound_share']}) · capability gaps: {len(transitions['capability_gap_tasks'])}")
print(f"written: {out/'transitions.json'}")
return 0
def run_scenarios(args, scenarios: list[scenario.Scenario], binary: Path) -> dict:
started = time.strftime("%Y-%m-%dT%H:%M:%S")
run_id = time.strftime("%Y%m%d-%H%M%S")
out = (Path(args.out) if Path(args.out).is_absolute() else REPO / args.out) / run_id
out.mkdir(parents=True, exist_ok=True)
pairs = []
product_findings: list[tuple[str, str]] = []
spent_usd = 0.0
spent_limit = args.max_usd if args.max_usd is not None else (20.0 if args.profile == "deep" else 5.0)
for item in scenarios:
if spent_usd >= spent_limit:
print(f"stop: estimated spend ${spent_usd:.2f} reached the ${spent_limit:.2f} cap", flush=True)
break
attempts = []
for attempt_index in range(args.k):
attempt_dir = out / "per-scenario" / item.id / f"attempt-{attempt_index + 1}"
if item.backend != "local":
print(f"skip {item.id}: backend `{item.backend}` not implemented yet", flush=True)
continue
attempt = backend.run_local(item, attempt_dir, binary=binary, provider=args.provider)
spent_usd += (
attempt.cost_usd
if attempt.cost_usd is not None
else metrics.estimate_usd(attempt.tokens_in, attempt.tokens_out)
)
attempts.append(attempt.as_dict())
if attempt.failure_class == "product_bug":
product_findings.extend(triage_signals(item.id, attempt))
print(
f"{item.id} [{item.axis} L{item.rung}] attempt {attempt_index + 1}: {attempt.outcome}"
f" ({attempt.failure_class or 'pass'}, {attempt.wall_seconds:.1f}s)",
flush=True,
)
if attempts:
pairs.append(
(
{
"id": item.id,
"axis": item.axis,
"rung": item.rung,
"difficulty": item.difficulty,
"difficulty_value": item.difficulty_value,
},
attempts,
)
)
providers = {item.provider for item in scenarios}
protocol_block = protocol(args, binary)
protocol_block["model"] = "mock" if providers == {"mock"} else "deepseek-flash (effort max)"
run = {
"schema_version": 1,
"run_id": run_id,
"started_at": started,
"finished_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"profile": args.profile,
"k": args.k,
"out": str(out),
"protocol": protocol_block,
"spend_usd": round(spent_usd, 3),
"summary": report.summarize(pairs),
"product_findings": product_findings,
"scenarios": [
{
"id": meta["id"],
"axis": meta["axis"],
"rung": meta["rung"],
"attempts": attempts,
}
for meta, attempts in pairs
],
}
baseline = None
if args.against:
root = out.parent
baseline = report.latest_run(root, exclude=run["run_id"]) if args.against == "last" else _find_run(root, args.against)
if baseline:
run["diff"] = report.diff(run, baseline)
report.write(out, run)
print(f"\nreport: {out/'report.md'}")
if args.record:
report.scoreboard_append(SCOREBOARD, run)
daily = REPO / "docs" / "reports" / f"{time.strftime('%Y-%m-%d')}-capability-{run_id}.md"
shutil.copyfile(out / "report.md", daily)
print(f"recorded: {SCOREBOARD.relative_to(REPO)} and {daily.relative_to(REPO)}")
return run
def triage_signals(scenario_id: str, attempt: backend.Attempt) -> list[tuple[str, str]]:
from lib import triage
return [(scenario_id, signal) for signal in attempt.product_signals if signal in triage.PRODUCT_SIGNALS]
def _find_run(root: Path, run_id: str) -> dict | None:
for run in report.load_runs(root):
if run.get("run_id") == run_id:
return run
return None
def show_issue(args) -> int:
root = (Path(args.out) if Path(args.out).is_absolute() else REPO / args.out)
run = report.latest_run(root)
if not run:
print("no runs yet", file=sys.stderr)
return 2
entry = next((s for s in run["scenarios"] if s["id"] == args.issue), None)
if not entry:
print(f"scenario {args.issue} not in the latest run", file=sys.stderr)
return 2
attempts = entry["attempts"]
first = attempts[0]
print(f"### {args.issue}\n")
print(f"- axis: {entry['axis']} rung {entry['rung']}")
print(f"- outcome: {first.get('outcome')} · class: {first.get('failure_class')}")
print(f"- wall: {round(first.get('wall_seconds') or 0, 2)}s · verify: {round(first.get('verify_seconds') or 0, 2)}s · "
f"agent exit: {first.get('agent_exit_code')}")
for name in ("harness_error", "environment_error", "product_signals"):
if first.get(name):
print(f"- {name}: {first[name]}")
print("\n- reproduce: `python3 scripts/eval/capability/run.py "
f"--scenarios {args.issue} --profile smoke`")
print(f"- artifacts: `{run['out']}/per-scenario/{args.issue}/attempt-1/`")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--list", action="store_true", help="list discovered scenarios and exit")
parser.add_argument("--axis", help="comma-separated axis letters (A..H, S for the sanity fixtures)")
parser.add_argument("--rungs", help="rung range, e.g. 1-3")
parser.add_argument("--scenarios", help="comma-separated scenario ids")
parser.add_argument("--failures", help="`last`: re-run the failing scenarios of the latest run")
parser.add_argument("--profile", choices=sorted(PROFILES), default="standard")
parser.add_argument("--k", type=int, default=None, help="attempts per scenario (default from profile)")
parser.add_argument("--binary", default=str(backend.DEFAULT_BINARY))
parser.add_argument("--provider", default=None, help="mock | deepseek (default: scenario value)")
parser.add_argument("--out", default=str(DEFAULT_OUT))
parser.add_argument("--record", action="store_true", help="append the scoreboard and a dated report")
parser.add_argument("--max-usd", type=float, default=None,
help="stop before the next scenario when the estimated spend exceeds this")
parser.add_argument("--against", help="`last` or a run id: write a diff against that run")
parser.add_argument("--from-run", action="append", help="axis A: Harbor run directory to take failures from")
parser.add_argument("--multipliers", help="axis A: timeout multipliers, e.g. 2,4")
parser.add_argument("--tasks", help="axis A: restrict to these task names (comma-separated)")
parser.add_argument("--execute", action="store_true", help="axis A: actually run Harbor (costs API budget)")
parser.add_argument("--issue", help="print an issue body for a scenario from the latest run")
args = parser.parse_args()
discovered = scenario.discover(SCENARIO_ROOT)
if args.list:
for item in discovered:
print(f"{item.axis:1} L{item.rung} {item.id:28} backend={item.backend:5} provider={item.provider}")
return 0
if args.issue:
return show_issue(args)
axes = args.axis.split(",") if args.axis else None
if axes and "A" in axes:
return run_axis_a(args)
binary = Path(args.binary)
if not binary.exists():
print(f"binary not found: {binary}", file=sys.stderr)
return 2
args.k = args.k or PROFILES[args.profile]
if args.failures == "last":
root = args.out if Path(args.out).is_absolute() else REPO / args.out
latest = report.latest_run(root)
if not latest:
print("no previous run", file=sys.stderr)
return 2
ids = report.failed_ids(latest)
print(f"re-running {len(ids)} failing scenario(s) from {latest['run_id']}")
selected = scenario.select(discovered, ids=ids)
else:
selected = scenario.select(
discovered,
axes=axes,
rungs=parse_rungs(args.rungs) if args.rungs else None,
ids=args.scenarios.split(",") if args.scenarios else None,
)
if not selected:
print("no scenarios selected", file=sys.stderr)
return 2
if args.profile == "smoke" and len(selected) > 5:
print(f"smoke profile covers at most 5 scenarios (selected {len(selected)})", file=sys.stderr)
return 2
run_scenarios(args, selected, binary)
return 0
if __name__ == "__main__":
raise SystemExit(main())