skill-scoreboard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- skill_scoreboard/__init__.py +3 -0
- skill_scoreboard/ab.py +93 -0
- skill_scoreboard/cli.py +477 -0
- skill_scoreboard/config.py +55 -0
- skill_scoreboard/evaluators/__init__.py +31 -0
- skill_scoreboard/evaluators/ast_assertions.py +142 -0
- skill_scoreboard/evaluators/base.py +48 -0
- skill_scoreboard/evaluators/llm_judge.py +80 -0
- skill_scoreboard/evaluators/pydev_static.py +98 -0
- skill_scoreboard/evaluators/pydev_tests.py +62 -0
- skill_scoreboard/evaluators/skill_activation.py +20 -0
- skill_scoreboard/evaluators/trajectory_efficiency.py +54 -0
- skill_scoreboard/llm.py +110 -0
- skill_scoreboard/metrics/__init__.py +1 -0
- skill_scoreboard/metrics/loops.py +153 -0
- skill_scoreboard/metrics/stats.py +29 -0
- skill_scoreboard/metrics/tokens.py +59 -0
- skill_scoreboard/models/__init__.py +91 -0
- skill_scoreboard/models/portfolio.py +143 -0
- skill_scoreboard/models/results.py +98 -0
- skill_scoreboard/models/task.py +187 -0
- skill_scoreboard/models/trajectory.py +105 -0
- skill_scoreboard/portfolio/__init__.py +31 -0
- skill_scoreboard/portfolio/critic.py +76 -0
- skill_scoreboard/portfolio/lint.py +138 -0
- skill_scoreboard/portfolio/mine.py +84 -0
- skill_scoreboard/portfolio/scorecard.py +264 -0
- skill_scoreboard/portfolio/skills.py +80 -0
- skill_scoreboard/portfolio/tfidf.py +114 -0
- skill_scoreboard/portfolio/triggers.py +251 -0
- skill_scoreboard/report.py +177 -0
- skill_scoreboard/runner/__init__.py +8 -0
- skill_scoreboard/runner/base.py +28 -0
- skill_scoreboard/runner/claude_cli.py +117 -0
- skill_scoreboard/runner/ndjson.py +210 -0
- skill_scoreboard/runner/replay.py +50 -0
- skill_scoreboard/runner/workspace.py +132 -0
- skill_scoreboard/runs.py +69 -0
- skill_scoreboard/trial.py +112 -0
- skill_scoreboard-0.1.0.dist-info/METADATA +361 -0
- skill_scoreboard-0.1.0.dist-info/RECORD +44 -0
- skill_scoreboard-0.1.0.dist-info/WHEEL +4 -0
- skill_scoreboard-0.1.0.dist-info/entry_points.txt +2 -0
- skill_scoreboard-0.1.0.dist-info/licenses/LICENSE +21 -0
skill_scoreboard/ab.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""A/B differential engine: Control (--bare, no skill) vs Treatment (skill loaded)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from skill_scoreboard.llm import JsonCaller
|
|
9
|
+
from skill_scoreboard.metrics.stats import mean, median, pct_delta, rate
|
|
10
|
+
from skill_scoreboard.models.results import ABReport, ArmAggregate, TrialResult
|
|
11
|
+
from skill_scoreboard.models.task import LoadedTask
|
|
12
|
+
from skill_scoreboard.runner.base import AgentRunner
|
|
13
|
+
from skill_scoreboard.trial import Arm, run_trial
|
|
14
|
+
|
|
15
|
+
DEFAULT_TRIALS = 3
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def utc_now() -> str:
|
|
19
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def aggregate_arm(arm: Arm, trials: list[TrialResult]) -> ArmAggregate:
|
|
23
|
+
totals = [float(t.metrics.total_tokens) for t in trials]
|
|
24
|
+
return ArmAggregate(
|
|
25
|
+
arm=arm,
|
|
26
|
+
n=len(trials),
|
|
27
|
+
pass_rate=rate(sum(t.passed for t in trials), len(trials)),
|
|
28
|
+
mean_total_tokens=mean(totals),
|
|
29
|
+
median_total_tokens=median(totals),
|
|
30
|
+
mean_cost_usd=mean([t.metrics.cost_usd for t in trials]),
|
|
31
|
+
mean_duration_seconds=mean([t.metrics.duration_seconds for t in trials]),
|
|
32
|
+
mean_duplicate_calls=mean([float(t.metrics.duplicate_tool_calls) for t in trials]),
|
|
33
|
+
mean_re_reads=mean([float(t.metrics.file_re_reads) for t in trials]),
|
|
34
|
+
skill_activation_rate=rate(sum(t.skill_activated for t in trials), len(trials)),
|
|
35
|
+
any_cost_estimated=any(t.metrics.cost_estimated for t in trials),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def compute_deltas(control: ArmAggregate, treatment: ArmAggregate) -> dict[str, float]:
|
|
40
|
+
"""treatment - control; negative token/cost/duration deltas mean savings."""
|
|
41
|
+
return {
|
|
42
|
+
"pass_rate_delta": treatment.pass_rate - control.pass_rate,
|
|
43
|
+
"token_delta_pct": pct_delta(treatment.mean_total_tokens, control.mean_total_tokens),
|
|
44
|
+
"cost_delta_usd": treatment.mean_cost_usd - control.mean_cost_usd,
|
|
45
|
+
"duration_delta_pct": pct_delta(
|
|
46
|
+
treatment.mean_duration_seconds, control.mean_duration_seconds
|
|
47
|
+
),
|
|
48
|
+
"duplicate_calls_delta": treatment.mean_duplicate_calls - control.mean_duplicate_calls,
|
|
49
|
+
"re_reads_delta": treatment.mean_re_reads - control.mean_re_reads,
|
|
50
|
+
"skill_activation_delta": (
|
|
51
|
+
treatment.skill_activation_rate - control.skill_activation_rate
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_ab(
|
|
57
|
+
loaded: LoadedTask,
|
|
58
|
+
runners: dict[Arm, AgentRunner],
|
|
59
|
+
*,
|
|
60
|
+
trials: int | None = None,
|
|
61
|
+
llm: JsonCaller | None = None,
|
|
62
|
+
out_dir: Path | None = None,
|
|
63
|
+
keep_workspace: bool = False,
|
|
64
|
+
) -> ABReport:
|
|
65
|
+
n_trials = trials or loaded.spec.trials or DEFAULT_TRIALS
|
|
66
|
+
all_trials: list[TrialResult] = []
|
|
67
|
+
per_arm: dict[Arm, list[TrialResult]] = {"control": [], "treatment": []}
|
|
68
|
+
arms: tuple[Arm, ...] = ("control", "treatment")
|
|
69
|
+
for arm in arms:
|
|
70
|
+
for index in range(n_trials):
|
|
71
|
+
result = run_trial(
|
|
72
|
+
loaded,
|
|
73
|
+
runners[arm],
|
|
74
|
+
arm,
|
|
75
|
+
index,
|
|
76
|
+
llm=llm,
|
|
77
|
+
out_dir=out_dir,
|
|
78
|
+
keep_workspace=keep_workspace,
|
|
79
|
+
)
|
|
80
|
+
per_arm[arm].append(result)
|
|
81
|
+
all_trials.append(result)
|
|
82
|
+
|
|
83
|
+
control = aggregate_arm("control", per_arm["control"])
|
|
84
|
+
treatment = aggregate_arm("treatment", per_arm["treatment"])
|
|
85
|
+
return ABReport(
|
|
86
|
+
task_id=loaded.spec.id,
|
|
87
|
+
skill_name=loaded.skill_name,
|
|
88
|
+
control=control,
|
|
89
|
+
treatment=treatment,
|
|
90
|
+
deltas=compute_deltas(control, treatment),
|
|
91
|
+
trials=all_trials,
|
|
92
|
+
generated_at=utc_now(),
|
|
93
|
+
)
|
skill_scoreboard/cli.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""`skill-scoreboard` — command-line entry point for skill-scoreboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from skill_scoreboard import __version__, config
|
|
12
|
+
from skill_scoreboard.ab import DEFAULT_TRIALS, run_ab, utc_now
|
|
13
|
+
from skill_scoreboard.llm import JsonCaller, default_caller
|
|
14
|
+
from skill_scoreboard.models.portfolio import (
|
|
15
|
+
AuditReport,
|
|
16
|
+
CriticReport,
|
|
17
|
+
MiningReport,
|
|
18
|
+
TriggerReport,
|
|
19
|
+
)
|
|
20
|
+
from skill_scoreboard.models.results import ABReport, TaskReport
|
|
21
|
+
from skill_scoreboard.models.task import RunLimits, load_task
|
|
22
|
+
from skill_scoreboard.portfolio import (
|
|
23
|
+
build_scorecard,
|
|
24
|
+
cluster_overlaps,
|
|
25
|
+
critique_all,
|
|
26
|
+
discover_skills,
|
|
27
|
+
lint_all,
|
|
28
|
+
load_probes,
|
|
29
|
+
mine_sessions,
|
|
30
|
+
run_triggers,
|
|
31
|
+
)
|
|
32
|
+
from skill_scoreboard.portfolio.skills import parse_skill
|
|
33
|
+
from skill_scoreboard.portfolio.triggers import cross_activation_pairs
|
|
34
|
+
from skill_scoreboard.report import render_ab_report, render_leaderboard, render_task_report
|
|
35
|
+
from skill_scoreboard.runner.base import AgentRunner
|
|
36
|
+
from skill_scoreboard.runner.claude_cli import ClaudeCliRunner
|
|
37
|
+
from skill_scoreboard.runner.replay import ReplayRunner
|
|
38
|
+
from skill_scoreboard.runs import new_run_dir, save_json, save_meta
|
|
39
|
+
from skill_scoreboard.trial import run_trial
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_FAILED = 1
|
|
43
|
+
EXIT_USAGE = 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _make_runner(
|
|
47
|
+
kind: str, fixtures: list[str] | None, claude_bin: str
|
|
48
|
+
) -> AgentRunner:
|
|
49
|
+
if kind == "replay":
|
|
50
|
+
if not fixtures:
|
|
51
|
+
raise SystemExit("--runner replay needs at least one --fixture")
|
|
52
|
+
return ReplayRunner([Path(f) for f in fixtures])
|
|
53
|
+
return ClaudeCliRunner(claude_bin=claude_bin)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_llm(no_llm: bool) -> JsonCaller | None:
|
|
57
|
+
return None if no_llm else default_caller()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _task_paths(target: str) -> list[Path]:
|
|
61
|
+
path = Path(target)
|
|
62
|
+
if path.is_dir():
|
|
63
|
+
return sorted(p for p in path.glob("*.y*ml"))
|
|
64
|
+
return [path]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --------------------------------------------------------------------------- validate
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_validate(args: argparse.Namespace) -> int:
|
|
71
|
+
errors = 0
|
|
72
|
+
for raw in args.paths:
|
|
73
|
+
path = Path(raw)
|
|
74
|
+
if not path.exists():
|
|
75
|
+
print(f"MISSING {path}")
|
|
76
|
+
errors += 1
|
|
77
|
+
continue
|
|
78
|
+
kind = args.kind
|
|
79
|
+
if kind == "auto":
|
|
80
|
+
name = path.name.lower()
|
|
81
|
+
if "probe" in name:
|
|
82
|
+
kind = "probes"
|
|
83
|
+
elif path.suffix.lower() == ".md":
|
|
84
|
+
kind = "skill"
|
|
85
|
+
else:
|
|
86
|
+
kind = "task"
|
|
87
|
+
try:
|
|
88
|
+
if kind == "task":
|
|
89
|
+
loaded = load_task(path)
|
|
90
|
+
evals = ", ".join(e.type for e in loaded.spec.evaluations)
|
|
91
|
+
print(f"OK {path} [task {loaded.spec.id}] evaluations: {evals}")
|
|
92
|
+
elif kind == "probes":
|
|
93
|
+
probes = load_probes(path)
|
|
94
|
+
total = sum(len(p.should_trigger) for p in probes.probes)
|
|
95
|
+
print(
|
|
96
|
+
f"OK {path} [probes] {len(probes.probes)} skills, "
|
|
97
|
+
f"{total} positive probes, {len(probes.shared_negatives)} shared negatives"
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
meta = parse_skill(path)
|
|
101
|
+
print(f"OK {path} [skill {meta.name}] ~{meta.token_estimate} tokens")
|
|
102
|
+
except Exception as exc: # noqa: BLE001 - report, don't traceback at the CLI
|
|
103
|
+
print(f"INVALID {path}\n {exc}")
|
|
104
|
+
errors += 1
|
|
105
|
+
return EXIT_FAILED if errors else EXIT_OK
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------- run / ab
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _apply_overrides(limits: RunLimits, args: argparse.Namespace) -> RunLimits:
|
|
112
|
+
updates: dict[str, Any] = {}
|
|
113
|
+
if getattr(args, "model", None):
|
|
114
|
+
updates["model"] = args.model
|
|
115
|
+
if getattr(args, "max_turns", None):
|
|
116
|
+
updates["max_turns"] = args.max_turns
|
|
117
|
+
if getattr(args, "max_budget_usd", None):
|
|
118
|
+
updates["max_budget_usd"] = args.max_budget_usd
|
|
119
|
+
return limits.model_copy(update=updates) if updates else limits
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
123
|
+
loaded = load_task(args.task)
|
|
124
|
+
loaded.spec.run_limits = _apply_overrides(loaded.spec.run_limits, args)
|
|
125
|
+
runner = _make_runner(args.runner, args.fixture, args.claude_bin)
|
|
126
|
+
out_dir = new_run_dir(Path(args.out_dir), loaded.spec.id)
|
|
127
|
+
save_meta(out_dir, {"command": "run", "arm": args.arm, "runner": args.runner})
|
|
128
|
+
|
|
129
|
+
trials = [
|
|
130
|
+
run_trial(
|
|
131
|
+
loaded,
|
|
132
|
+
runner,
|
|
133
|
+
args.arm,
|
|
134
|
+
index,
|
|
135
|
+
llm=_resolve_llm(args.no_llm),
|
|
136
|
+
out_dir=out_dir,
|
|
137
|
+
keep_workspace=args.keep_workspace,
|
|
138
|
+
)
|
|
139
|
+
for index in range(args.trials)
|
|
140
|
+
]
|
|
141
|
+
report = TaskReport(
|
|
142
|
+
task_id=loaded.spec.id,
|
|
143
|
+
skill_name=loaded.skill_name,
|
|
144
|
+
arm=args.arm,
|
|
145
|
+
trials=trials,
|
|
146
|
+
generated_at=utc_now(),
|
|
147
|
+
)
|
|
148
|
+
save_json(report, out_dir / "run_report.json")
|
|
149
|
+
markdown = render_task_report(report)
|
|
150
|
+
(out_dir / "report.md").write_text(markdown, encoding="utf-8")
|
|
151
|
+
print(markdown)
|
|
152
|
+
print(f"\nArtifacts: {out_dir}")
|
|
153
|
+
return EXIT_OK if all(t.passed for t in trials) else EXIT_FAILED
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_ab(args: argparse.Namespace) -> int:
|
|
157
|
+
llm = _resolve_llm(args.no_llm)
|
|
158
|
+
overall_ok = True
|
|
159
|
+
for task_path in _task_paths(args.task):
|
|
160
|
+
loaded = load_task(task_path)
|
|
161
|
+
loaded.spec.run_limits = _apply_overrides(loaded.spec.run_limits, args)
|
|
162
|
+
runners: dict[str, AgentRunner] = {
|
|
163
|
+
"control": _make_runner(args.runner, args.control_fixture, args.claude_bin),
|
|
164
|
+
"treatment": _make_runner(args.runner, args.treatment_fixture, args.claude_bin),
|
|
165
|
+
}
|
|
166
|
+
out_dir = new_run_dir(Path(args.out_dir), loaded.spec.id)
|
|
167
|
+
save_meta(out_dir, {"command": "ab", "runner": args.runner, "trials": args.trials})
|
|
168
|
+
report = run_ab(
|
|
169
|
+
loaded,
|
|
170
|
+
runners, # type: ignore[arg-type]
|
|
171
|
+
trials=args.trials,
|
|
172
|
+
llm=llm,
|
|
173
|
+
out_dir=out_dir,
|
|
174
|
+
keep_workspace=args.keep_workspace,
|
|
175
|
+
)
|
|
176
|
+
save_json(report, out_dir / "ab_report.json")
|
|
177
|
+
markdown = render_ab_report(report)
|
|
178
|
+
(out_dir / "report.md").write_text(markdown, encoding="utf-8")
|
|
179
|
+
if args.json:
|
|
180
|
+
Path(args.json).write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
181
|
+
if args.md:
|
|
182
|
+
Path(args.md).write_text(markdown, encoding="utf-8")
|
|
183
|
+
print(markdown)
|
|
184
|
+
print(f"\nArtifacts: {out_dir}")
|
|
185
|
+
overall_ok &= report.deltas.get("pass_rate_delta", 0.0) >= 0
|
|
186
|
+
return EXIT_OK if overall_ok else EXIT_FAILED
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
190
|
+
run_dir = Path(args.run_dir)
|
|
191
|
+
ab_path, run_path = run_dir / "ab_report.json", run_dir / "run_report.json"
|
|
192
|
+
if ab_path.is_file():
|
|
193
|
+
report: ABReport | TaskReport = ABReport.model_validate_json(
|
|
194
|
+
ab_path.read_text(encoding="utf-8")
|
|
195
|
+
)
|
|
196
|
+
markdown = render_ab_report(report) # type: ignore[arg-type]
|
|
197
|
+
elif run_path.is_file():
|
|
198
|
+
report = TaskReport.model_validate_json(run_path.read_text(encoding="utf-8"))
|
|
199
|
+
markdown = render_task_report(report)
|
|
200
|
+
else:
|
|
201
|
+
print(f"no ab_report.json or run_report.json under {run_dir}", file=sys.stderr)
|
|
202
|
+
return EXIT_USAGE
|
|
203
|
+
print(report.model_dump_json(indent=2) if args.format == "json" else markdown)
|
|
204
|
+
return EXIT_OK
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# --------------------------------------------------------------------------- portfolio
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
211
|
+
skills = discover_skills(args.skills_dir)
|
|
212
|
+
if not skills:
|
|
213
|
+
print(f"no skills found under {args.skills_dir}", file=sys.stderr)
|
|
214
|
+
return EXIT_USAGE
|
|
215
|
+
lint_reports = lint_all(skills)
|
|
216
|
+
clusters = cluster_overlaps(skills, threshold=args.similarity_threshold)
|
|
217
|
+
|
|
218
|
+
trigger_reports: list[TriggerReport] = []
|
|
219
|
+
confusion: dict[str, dict[str, int]] | None = None
|
|
220
|
+
if args.probes:
|
|
221
|
+
probes = load_probes(args.probes)
|
|
222
|
+
mode = args.trigger_mode or probes.defaults.mode
|
|
223
|
+
runner = (
|
|
224
|
+
ClaudeCliRunner(claude_bin=args.claude_bin) if mode == "headless" else None
|
|
225
|
+
)
|
|
226
|
+
trigger_reports, confusion = run_triggers(
|
|
227
|
+
probes,
|
|
228
|
+
skills,
|
|
229
|
+
mode=mode,
|
|
230
|
+
llm=_resolve_llm(args.no_llm),
|
|
231
|
+
runner=runner,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
critics: list[CriticReport] = []
|
|
235
|
+
if args.critic:
|
|
236
|
+
llm = _resolve_llm(args.no_llm)
|
|
237
|
+
if llm is None:
|
|
238
|
+
print("--critic needs API credentials (or drop --critic)", file=sys.stderr)
|
|
239
|
+
return EXIT_USAGE
|
|
240
|
+
critics = critique_all(
|
|
241
|
+
skills, llm, model=args.critic_model, lint_by_skill={r.skill: r for r in lint_reports}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
audit = AuditReport(
|
|
245
|
+
skills_dir=str(args.skills_dir),
|
|
246
|
+
lint=lint_reports,
|
|
247
|
+
clusters=clusters,
|
|
248
|
+
triggers=trigger_reports,
|
|
249
|
+
confusion=confusion,
|
|
250
|
+
critics=critics,
|
|
251
|
+
generated_at=utc_now(),
|
|
252
|
+
)
|
|
253
|
+
if args.json:
|
|
254
|
+
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
Path(args.json).write_text(audit.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
256
|
+
|
|
257
|
+
print(f"# Audit: {args.skills_dir} ({len(skills)} skills)\n")
|
|
258
|
+
for lint_report in sorted(lint_reports, key=lambda r: r.score):
|
|
259
|
+
print(f"{lint_report.score:.2f} {lint_report.skill}")
|
|
260
|
+
for finding in lint_report.findings:
|
|
261
|
+
print(f" [{finding.severity}] {finding.check}: {finding.message}")
|
|
262
|
+
if clusters:
|
|
263
|
+
print("\n## Overlap clusters")
|
|
264
|
+
for cluster in clusters:
|
|
265
|
+
print(f" {', '.join(cluster.skills)} (similarity {cluster.max_similarity:.2f})")
|
|
266
|
+
if trigger_reports:
|
|
267
|
+
print("\n## Trigger accuracy")
|
|
268
|
+
for trigger_report in trigger_reports:
|
|
269
|
+
print(
|
|
270
|
+
f" {trigger_report.skill}: F1 {trigger_report.f1:.2f} "
|
|
271
|
+
f"(precision {trigger_report.precision:.2f}, "
|
|
272
|
+
f"recall {trigger_report.recall:.2f}, tp={trigger_report.tp} "
|
|
273
|
+
f"fp={trigger_report.fp} fn={trigger_report.fn})"
|
|
274
|
+
)
|
|
275
|
+
if critics:
|
|
276
|
+
print("\n## Critic")
|
|
277
|
+
for critic in critics:
|
|
278
|
+
print(f" {critic.score:.2f} {critic.skill}")
|
|
279
|
+
for issue in critic.issues[:5]:
|
|
280
|
+
print(f" - {issue}")
|
|
281
|
+
if args.json:
|
|
282
|
+
print(f"\nWrote {args.json}")
|
|
283
|
+
return EXIT_OK
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def cmd_mine(args: argparse.Namespace) -> int:
|
|
287
|
+
known = None
|
|
288
|
+
if args.skills_dir:
|
|
289
|
+
known = [s.name for s in discover_skills(args.skills_dir)]
|
|
290
|
+
report = mine_sessions(list(args.paths), known_skills=known)
|
|
291
|
+
if args.json:
|
|
292
|
+
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
|
|
293
|
+
Path(args.json).write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
294
|
+
print(f"# Mined {report.sessions_scanned} session transcript(s)\n")
|
|
295
|
+
print(f"{'skill':<32} {'uses':>6} {'sessions':>9} {'est tokens':>12} {'est cost':>10}")
|
|
296
|
+
for usage in report.skills:
|
|
297
|
+
print(
|
|
298
|
+
f"{usage.skill:<32} {usage.activations:>6} {usage.sessions:>9} "
|
|
299
|
+
f"{usage.est_tokens:>12,} {usage.est_cost_usd:>10.4f}"
|
|
300
|
+
)
|
|
301
|
+
if args.json:
|
|
302
|
+
print(f"\nWrote {args.json}")
|
|
303
|
+
return EXIT_OK
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _parse_weights(raw: str | None) -> dict[str, float] | None:
|
|
307
|
+
if not raw:
|
|
308
|
+
return None
|
|
309
|
+
weights = dict(config.DEFAULT_WEIGHTS)
|
|
310
|
+
for pair in raw.split(","):
|
|
311
|
+
key, _, value = pair.partition("=")
|
|
312
|
+
key = key.strip()
|
|
313
|
+
if key not in weights:
|
|
314
|
+
raise SystemExit(f"unknown weight {key!r}; expected one of {sorted(weights)}")
|
|
315
|
+
weights[key] = float(value)
|
|
316
|
+
return weights
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def cmd_rank(args: argparse.Namespace) -> int:
|
|
320
|
+
skills = discover_skills(args.skills_dir)
|
|
321
|
+
lint_reports = lint_all(skills)
|
|
322
|
+
clusters = cluster_overlaps(skills, threshold=args.similarity_threshold)
|
|
323
|
+
trigger_reports: list[TriggerReport] = []
|
|
324
|
+
critics: list[CriticReport] = []
|
|
325
|
+
cross: list[tuple[str, str, float]] = []
|
|
326
|
+
|
|
327
|
+
if args.audit:
|
|
328
|
+
audit = AuditReport.model_validate_json(Path(args.audit).read_text(encoding="utf-8"))
|
|
329
|
+
lint_reports = audit.lint or lint_reports
|
|
330
|
+
clusters = audit.clusters or clusters
|
|
331
|
+
trigger_reports = audit.triggers
|
|
332
|
+
critics = audit.critics
|
|
333
|
+
if audit.confusion:
|
|
334
|
+
cross = cross_activation_pairs(audit.confusion)
|
|
335
|
+
|
|
336
|
+
ab_reports: list[ABReport] = []
|
|
337
|
+
if args.runs:
|
|
338
|
+
from skill_scoreboard.runs import load_ab_reports
|
|
339
|
+
|
|
340
|
+
ab_reports = load_ab_reports(Path(args.runs))
|
|
341
|
+
|
|
342
|
+
mining = None
|
|
343
|
+
if args.mine:
|
|
344
|
+
mining = MiningReport.model_validate_json(Path(args.mine).read_text(encoding="utf-8"))
|
|
345
|
+
|
|
346
|
+
report = build_scorecard(
|
|
347
|
+
skills,
|
|
348
|
+
lint_reports=lint_reports,
|
|
349
|
+
trigger_reports=trigger_reports,
|
|
350
|
+
ab_reports=ab_reports,
|
|
351
|
+
critic_reports=critics,
|
|
352
|
+
mining=mining,
|
|
353
|
+
clusters=clusters,
|
|
354
|
+
cross_activation=cross,
|
|
355
|
+
weights=_parse_weights(args.weights),
|
|
356
|
+
)
|
|
357
|
+
if args.audit and trigger_reports:
|
|
358
|
+
audit_confusion = AuditReport.model_validate_json(
|
|
359
|
+
Path(args.audit).read_text(encoding="utf-8")
|
|
360
|
+
).confusion
|
|
361
|
+
report.confusion = audit_confusion
|
|
362
|
+
|
|
363
|
+
markdown = render_leaderboard(report)
|
|
364
|
+
if args.md:
|
|
365
|
+
Path(args.md).parent.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
Path(args.md).write_text(markdown, encoding="utf-8")
|
|
367
|
+
if args.json:
|
|
368
|
+
Path(args.json).parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
Path(args.json).write_text(report.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
370
|
+
print(markdown)
|
|
371
|
+
if args.md:
|
|
372
|
+
print(f"Wrote {args.md}")
|
|
373
|
+
return EXIT_OK
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# --------------------------------------------------------------------------- parser
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
380
|
+
parser = argparse.ArgumentParser(
|
|
381
|
+
prog="skill-scoreboard",
|
|
382
|
+
description="Evaluate Claude Code skills: A/B differentials, token waste, "
|
|
383
|
+
"skill adherence, and whole-library ranking.",
|
|
384
|
+
)
|
|
385
|
+
parser.add_argument("--version", action="version", version=f"skill-scoreboard {__version__}")
|
|
386
|
+
parser.add_argument(
|
|
387
|
+
"--out-dir", default="runs", help="where run artifacts land (default: runs)"
|
|
388
|
+
)
|
|
389
|
+
parser.add_argument(
|
|
390
|
+
"--no-llm",
|
|
391
|
+
action="store_true",
|
|
392
|
+
help="never call the API: skips llm_judge, critic, and classifier triggers",
|
|
393
|
+
)
|
|
394
|
+
parser.add_argument("--claude-bin", default="claude", help="claude CLI binary name/path")
|
|
395
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
396
|
+
|
|
397
|
+
p_validate = sub.add_parser("validate", help="schema-check task/probes/skill files")
|
|
398
|
+
p_validate.add_argument("paths", nargs="+")
|
|
399
|
+
p_validate.add_argument("--kind", choices=["auto", "task", "probes", "skill"], default="auto")
|
|
400
|
+
p_validate.set_defaults(func=cmd_validate)
|
|
401
|
+
|
|
402
|
+
def add_run_limits(p: argparse.ArgumentParser) -> None:
|
|
403
|
+
p.add_argument("--model", help="override run_limits.model")
|
|
404
|
+
p.add_argument("--max-turns", type=int, help="override run_limits.max_turns")
|
|
405
|
+
p.add_argument("--max-budget-usd", type=float, help="override run_limits.max_budget_usd")
|
|
406
|
+
p.add_argument("--runner", choices=["claude", "replay"], default="claude")
|
|
407
|
+
p.add_argument("--keep-workspace", action="store_true")
|
|
408
|
+
|
|
409
|
+
p_run = sub.add_parser("run", help="run one arm of a task (debugging)")
|
|
410
|
+
p_run.add_argument("task")
|
|
411
|
+
p_run.add_argument("--arm", choices=["control", "treatment"], default="treatment")
|
|
412
|
+
p_run.add_argument("--trials", type=int, default=1)
|
|
413
|
+
p_run.add_argument("--fixture", action="append", help="replay NDJSON (repeatable)")
|
|
414
|
+
add_run_limits(p_run)
|
|
415
|
+
p_run.set_defaults(func=cmd_run)
|
|
416
|
+
|
|
417
|
+
p_ab = sub.add_parser("ab", help="A/B a task: control (no skill) vs treatment")
|
|
418
|
+
p_ab.add_argument("task", help="task.yaml or a directory of them")
|
|
419
|
+
p_ab.add_argument("--trials", type=int, default=DEFAULT_TRIALS)
|
|
420
|
+
p_ab.add_argument("--control-fixture", action="append")
|
|
421
|
+
p_ab.add_argument("--treatment-fixture", action="append")
|
|
422
|
+
p_ab.add_argument("--json", help="also write the ABReport JSON here")
|
|
423
|
+
p_ab.add_argument("--md", help="also write the Markdown report here")
|
|
424
|
+
add_run_limits(p_ab)
|
|
425
|
+
p_ab.set_defaults(func=cmd_ab)
|
|
426
|
+
|
|
427
|
+
p_report = sub.add_parser("report", help="re-render a stored run directory")
|
|
428
|
+
p_report.add_argument("run_dir")
|
|
429
|
+
p_report.add_argument("--format", choices=["md", "json"], default="md")
|
|
430
|
+
p_report.set_defaults(func=cmd_report)
|
|
431
|
+
|
|
432
|
+
p_audit = sub.add_parser("audit", help="Tier 0/1 audit of a skill library")
|
|
433
|
+
p_audit.add_argument("skills_dir")
|
|
434
|
+
p_audit.add_argument("--probes", help="probes.yaml enables trigger accuracy (Tier 1)")
|
|
435
|
+
p_audit.add_argument("--trigger-mode", choices=["classifier", "headless"])
|
|
436
|
+
p_audit.add_argument("--critic", action="store_true", help="add LLM critique (costs tokens)")
|
|
437
|
+
p_audit.add_argument("--critic-model", default=config.DEFAULT_CRITIC_MODEL)
|
|
438
|
+
p_audit.add_argument(
|
|
439
|
+
"--similarity-threshold", type=float, default=config.DEFAULT_SIMILARITY_THRESHOLD
|
|
440
|
+
)
|
|
441
|
+
p_audit.add_argument("--json", help="write the AuditReport JSON here")
|
|
442
|
+
p_audit.set_defaults(func=cmd_audit)
|
|
443
|
+
|
|
444
|
+
p_mine = sub.add_parser("mine", help="mine real session transcripts for skill usage")
|
|
445
|
+
p_mine.add_argument("paths", nargs="+")
|
|
446
|
+
p_mine.add_argument("--skills-dir", help="restrict to skills found in this library")
|
|
447
|
+
p_mine.add_argument("--json")
|
|
448
|
+
p_mine.set_defaults(func=cmd_mine)
|
|
449
|
+
|
|
450
|
+
p_rank = sub.add_parser("rank", help="compose stored artifacts into a leaderboard")
|
|
451
|
+
p_rank.add_argument("skills_dir")
|
|
452
|
+
p_rank.add_argument("--audit", help="audit.json from `skill-scoreboard audit`")
|
|
453
|
+
p_rank.add_argument("--runs", help="runs/ directory holding ab_report.json files")
|
|
454
|
+
p_rank.add_argument("--mine", help="mine.json from `skill-scoreboard mine`")
|
|
455
|
+
p_rank.add_argument("--weights", help="e.g. lint=0.1,trigger=0.2,ab=0.5,critic=0.2")
|
|
456
|
+
p_rank.add_argument(
|
|
457
|
+
"--similarity-threshold", type=float, default=config.DEFAULT_SIMILARITY_THRESHOLD
|
|
458
|
+
)
|
|
459
|
+
p_rank.add_argument("--md")
|
|
460
|
+
p_rank.add_argument("--json")
|
|
461
|
+
p_rank.set_defaults(func=cmd_rank)
|
|
462
|
+
return parser
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
466
|
+
parser = build_parser()
|
|
467
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
468
|
+
try:
|
|
469
|
+
result: int = args.func(args)
|
|
470
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
471
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
472
|
+
return EXIT_USAGE
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Central configuration: pricing, default models, scorecard weights, thresholds.
|
|
2
|
+
|
|
3
|
+
Prices are USD per million tokens (input, output), Anthropic first-party API
|
|
4
|
+
rates. Used only to *estimate* cost when the runner does not report
|
|
5
|
+
``total_cost_usd``; estimated costs are always flagged as such in results.
|
|
6
|
+
Cache reads are billed at 0.1x the input rate, cache creation at 1.25x.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# model id -> (input $/MTok, output $/MTok)
|
|
12
|
+
PRICING: dict[str, tuple[float, float]] = {
|
|
13
|
+
"claude-fable-5": (10.0, 50.0),
|
|
14
|
+
"claude-opus-5": (5.0, 25.0),
|
|
15
|
+
"claude-sonnet-5": (2.0, 10.0),
|
|
16
|
+
"claude-haiku-4-5": (1.0, 5.0),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
CACHE_READ_MULTIPLIER = 0.1
|
|
20
|
+
CACHE_CREATION_MULTIPLIER = 1.25
|
|
21
|
+
|
|
22
|
+
#: Fallback pricing for models absent from PRICING (opus-tier, conservative).
|
|
23
|
+
DEFAULT_PRICING: tuple[float, float] = (5.0, 25.0)
|
|
24
|
+
|
|
25
|
+
#: Rough token estimate used when per-message usage is absent from the stream.
|
|
26
|
+
CHARS_PER_TOKEN = 4
|
|
27
|
+
|
|
28
|
+
DEFAULT_JUDGE_MODEL = "claude-opus-5"
|
|
29
|
+
DEFAULT_CLASSIFIER_MODEL = "claude-haiku-4-5"
|
|
30
|
+
DEFAULT_CRITIC_MODEL = "claude-opus-5"
|
|
31
|
+
|
|
32
|
+
#: Portfolio scorecard component weights (renormalized over available tiers).
|
|
33
|
+
DEFAULT_WEIGHTS: dict[str, float] = {
|
|
34
|
+
"lint": 0.15,
|
|
35
|
+
"trigger": 0.25,
|
|
36
|
+
"ab": 0.40,
|
|
37
|
+
"critic": 0.20,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#: TF-IDF cosine similarity at or above which two skills join an overlap cluster.
|
|
41
|
+
#: Cosine on prose rarely exceeds ~0.6 even for genuine duplicates, while
|
|
42
|
+
#: unrelated skills sit near 0 — so 0.45 separates them with room to spare.
|
|
43
|
+
#: Tune per library size with `skill-scoreboard audit --similarity-threshold`.
|
|
44
|
+
DEFAULT_SIMILARITY_THRESHOLD = 0.45
|
|
45
|
+
|
|
46
|
+
#: Cross-activation fraction above which the confusion matrix signals a merge.
|
|
47
|
+
CROSS_ACTIVATION_MERGE_THRESHOLD = 0.30
|
|
48
|
+
|
|
49
|
+
#: Skill body token estimates above these thresholds draw lint findings
|
|
50
|
+
#: (the skill's own footprint is paid on every activation).
|
|
51
|
+
SKILL_TOKENS_WARNING = 2000
|
|
52
|
+
SKILL_TOKENS_ERROR = 4000
|
|
53
|
+
|
|
54
|
+
#: Files larger than this (in lines) count toward the re-read waste metric.
|
|
55
|
+
LARGE_FILE_LINES = 100
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Evaluator registry: importing this package registers all built-in evaluators."""
|
|
2
|
+
|
|
3
|
+
from skill_scoreboard.evaluators import ( # noqa: F401 (registration side effects)
|
|
4
|
+
ast_assertions,
|
|
5
|
+
llm_judge,
|
|
6
|
+
pydev_static,
|
|
7
|
+
pydev_tests,
|
|
8
|
+
skill_activation,
|
|
9
|
+
trajectory_efficiency,
|
|
10
|
+
)
|
|
11
|
+
from skill_scoreboard.evaluators.base import (
|
|
12
|
+
EvalContext,
|
|
13
|
+
get_evaluator,
|
|
14
|
+
register,
|
|
15
|
+
registered_types,
|
|
16
|
+
)
|
|
17
|
+
from skill_scoreboard.models.results import EvalResult
|
|
18
|
+
from skill_scoreboard.models.task import TaskSpec
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run_evaluations(task: TaskSpec, ctx: EvalContext) -> list[EvalResult]:
|
|
22
|
+
return [get_evaluator(spec.type)(spec, ctx) for spec in task.evaluations]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EvalContext",
|
|
27
|
+
"get_evaluator",
|
|
28
|
+
"register",
|
|
29
|
+
"registered_types",
|
|
30
|
+
"run_evaluations",
|
|
31
|
+
]
|