← Files AMDARCHIVED FILE
eval/compare_skill.py
10.6 KB · Sep 30, 2026 · 23:13 UTC
# Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved.
#
# See LICENSE for license information.
"""Compare the same prompt with and without a given skill.
This is a thin wrapper around ``claude_eval.py``: it runs the prompt twice
(or ``--trials N`` times per side), once with the skill exposed and once
without, then prints a side-by-side KPI table and writes a JSON report.
Usage:
python eval/compare_skill.py "your prompt" --skill local-ai-use
python eval/compare_skill.py --prompt-file eval/prompts/foo.txt --skill init
python eval/compare_skill.py "..." --skill review --trials 3
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
# Reuse the single-run primitives from the sibling script.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from claude_eval import ( # noqa: E402
EvalResult,
build_result,
list_available_skills,
read_prompt,
run_claude,
)
DEFAULT_RUNS_DIR = Path(__file__).resolve().parent / "runs"
# (key, human label, value format string). Keys that aren't on EvalResult
# directly (e.g. duration_s) are derived in ``to_metrics``.
METRICS: list[tuple[str, str, str]] = [
("wall_time_s", "Wall time (s)", "{:.3f}"),
("duration_s", "Reported time (s)", "{:.3f}"),
("duration_api_s", "API time (s)", "{:.3f}"),
("num_turns", "Turns", "{:.2f}"),
("input_tokens", "Input tokens", "{:.1f}"),
("cache_creation_input_tokens", "Cache write tokens", "{:.1f}"),
("cache_read_input_tokens", "Cache read tokens", "{:.1f}"),
("total_input_tokens", "Total input tokens", "{:.1f}"),
("output_tokens", "Output tokens", "{:.1f}"),
("total_cost_usd", "Cost (USD)", "${:.6f}"),
]
def to_metrics(r: EvalResult) -> dict[str, float | None]:
"""Project an ``EvalResult`` onto the metric keys we care about."""
d = asdict(r)
d["duration_s"] = (r.duration_ms / 1000.0) if r.duration_ms is not None else None
d["duration_api_s"] = (
(r.duration_api_ms / 1000.0) if r.duration_api_ms is not None else None
)
return d
def aggregate(results: list[EvalResult], keys: list[str]) -> dict[str, dict]:
metric_rows = [to_metrics(r) for r in results]
out: dict[str, dict] = {}
for key in keys:
values = [m.get(key) for m in metric_rows if m.get(key) is not None]
if not values:
out[key] = {"mean": None, "stdev": None, "values": []}
continue
out[key] = {
"mean": statistics.mean(values),
"stdev": statistics.stdev(values) if len(values) > 1 else 0.0,
"values": values,
}
return out
def run_trials(
prompt: str,
model: str,
effort: str,
skill: str | None,
trials: int,
runs_dir: Path | None,
label: str,
yolo: bool = False,
) -> list[EvalResult]:
results: list[EvalResult] = []
for i in range(trials):
sys.stderr.write(f"[{label}] trial {i + 1}/{trials} running ...\n")
sys.stderr.flush()
elapsed, payload = run_claude(prompt, model, effort, skill, [], yolo=yolo)
r = build_result(prompt, model, effort, skill, elapsed, payload)
if r.is_error:
sys.stderr.write(
f"[{label}] trial {i + 1} returned is_error=true; "
"including in results anyway.\n"
)
if runs_dir is not None:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
skill_part = f"-{skill}" if skill else "-noskill"
filename = f"{stamp}-{model}-{effort}{skill_part}-{label}-t{i + 1}.json"
(runs_dir / filename).write_text(
json.dumps(asdict(r), indent=2), encoding="utf-8"
)
results.append(r)
return results
def fmt_value(v: float | int | None, fmt_str: str) -> str:
if v is None:
return "-"
return fmt_str.format(v)
def fmt_delta(v: float | int | None, fmt_str: str) -> str:
if v is None:
return "-"
sign = "+" if v >= 0 else "-"
return sign + fmt_str.format(abs(v))
def fmt_pct(pct: float | None) -> str:
if pct is None:
return "-"
return f"{pct:+.1f}%"
def build_comparison(
without_summary: dict[str, dict],
with_summary: dict[str, dict],
) -> dict[str, dict]:
comp: dict[str, dict] = {}
for key, _label, _fmt in METRICS:
wo = without_summary.get(key, {}).get("mean")
wi = with_summary.get(key, {}).get("mean")
delta = (wi - wo) if (wo is not None and wi is not None) else None
pct = (delta / wo * 100.0) if (delta is not None and wo not in (None, 0)) else None
comp[key] = {
"without_mean": wo,
"with_mean": wi,
"delta": delta,
"pct_change": pct,
}
return comp
def print_table(skill: str, trials: int, comparison: dict[str, dict]) -> None:
headers = ["Metric", "Without skill", f"With '{skill}'", "Δ", "Δ %"]
rows: list[list[str]] = []
for key, label, fmt_str in METRICS:
c = comparison[key]
rows.append(
[
label,
fmt_value(c["without_mean"], fmt_str),
fmt_value(c["with_mean"], fmt_str),
fmt_delta(c["delta"], fmt_str),
fmt_pct(c["pct_change"]),
]
)
widths = [
max(len(row[i]) for row in [headers] + rows) for i in range(len(headers))
]
sep = " "
def render(row: list[str]) -> str:
cells = []
for i, cell in enumerate(row):
cells.append(cell.ljust(widths[i]) if i == 0 else cell.rjust(widths[i]))
return sep.join(cells)
print("=" * (sum(widths) + sep_total(widths, sep)))
print(f"Skill comparison: '{skill}' (trials per side: {trials})")
print("=" * (sum(widths) + sep_total(widths, sep)))
print(render(headers))
print(sep.join("-" * w for w in widths))
for row in rows:
print(render(row))
print("=" * (sum(widths) + sep_total(widths, sep)))
def sep_total(widths: list[int], sep: str) -> int:
return len(sep) * (len(widths) - 1)
def main() -> None:
parser = argparse.ArgumentParser(
description=(
"Compare the same prompt with and without a given skill. "
"Wraps claude_eval.py and produces a side-by-side KPI table."
)
)
parser.add_argument(
"prompt", nargs="?", help="The prompt to send (use '-' to read from stdin)."
)
parser.add_argument("--prompt-file", help="Read the prompt from a file.")
parser.add_argument(
"--skill",
required=True,
help="Name of a skill under skills/ to compare against the no-skill baseline.",
)
parser.add_argument(
"--model",
default="sonnet",
help="Model alias or full name. Default: sonnet.",
)
parser.add_argument(
"--effort",
choices=["low", "medium", "high", "max"],
default="high",
help="Reasoning effort level. Default: high.",
)
parser.add_argument(
"--trials",
type=int,
default=1,
help="Number of trials per side. KPIs are reported as means. Default: 1.",
)
parser.add_argument(
"--output",
help=(
"Path for the comparison JSON report. Default: "
"eval/runs/<timestamp>-compare-<skill>.json. "
"Pass '' to skip writing a report file."
),
)
parser.add_argument(
"--no-save-runs",
action="store_true",
help="Don't save individual per-trial JSON files (only the comparison report).",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit the comparison JSON to stdout instead of the human-readable table.",
)
parser.add_argument(
"--yolo",
"--dangerously-skip-permissions",
dest="yolo",
action="store_true",
help=(
"Pass --dangerously-skip-permissions to claude on every trial. "
"Without this, claude -p stops at the first tool-permission prompt "
"and just answers from memory, which makes any skill that wants to "
"run shell / scripts look like a no-op."
),
)
args = parser.parse_args()
available = list_available_skills()
if args.skill not in available:
hint = f" Available skills: {', '.join(available)}." if available else ""
sys.exit(f"error: skill '{args.skill}' not found under skills/.{hint}")
if args.trials < 1:
sys.exit("error: --trials must be >= 1")
prompt = read_prompt(args)
runs_dir: Path | None
if args.no_save_runs:
runs_dir = None
else:
runs_dir = DEFAULT_RUNS_DIR
runs_dir.mkdir(parents=True, exist_ok=True)
without_results = run_trials(
prompt,
args.model,
args.effort,
None,
args.trials,
runs_dir,
"no-skill",
yolo=args.yolo,
)
with_results = run_trials(
prompt,
args.model,
args.effort,
args.skill,
args.trials,
runs_dir,
args.skill,
yolo=args.yolo,
)
metric_keys = [k for k, _l, _f in METRICS]
without_summary = aggregate(without_results, metric_keys)
with_summary = aggregate(with_results, metric_keys)
comparison = build_comparison(without_summary, with_summary)
report = {
"prompt": prompt,
"model": args.model,
"effort": args.effort,
"skill": args.skill,
"trials": args.trials,
"without_skill": {
"runs": [asdict(r) for r in without_results],
"summary": without_summary,
},
"with_skill": {
"runs": [asdict(r) for r in with_results],
"summary": with_summary,
},
"comparison": comparison,
}
serialized = json.dumps(report, indent=2)
output_path: Path | None
if args.output is None:
DEFAULT_RUNS_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
output_path = DEFAULT_RUNS_DIR / f"{stamp}-compare-{args.skill}.json"
elif args.output == "":
output_path = None
else:
output_path = Path(args.output)
if output_path.parent and not output_path.parent.exists():
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_path is not None:
output_path.write_text(serialized, encoding="utf-8")
if args.json:
print(serialized)
else:
print_table(args.skill, args.trials, comparison)
if output_path is not None:
print(f"Saved comparison report to: {output_path}")
if __name__ == "__main__":
main()
SHA-256: 1ad409a61f921fc765bddabd085a0da2959d0dd76b93fbb784e7e3c8da2d13d4