skillproof 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.
- skillproof/__init__.py +3 -0
- skillproof/__main__.py +3 -0
- skillproof/assertions/__init__.py +62 -0
- skillproof/assertions/base.py +44 -0
- skillproof/assertions/builtin.py +176 -0
- skillproof/assertions/llm_judge.py +24 -0
- skillproof/cli.py +313 -0
- skillproof/config.py +188 -0
- skillproof/engine.py +268 -0
- skillproof/errors.py +72 -0
- skillproof/estimate.py +83 -0
- skillproof/judges/__init__.py +43 -0
- skillproof/judges/anthropic_judge.py +59 -0
- skillproof/judges/base.py +69 -0
- skillproof/judges/mock.py +21 -0
- skillproof/judges/openai_judge.py +58 -0
- skillproof/parsers/__init__.py +6 -0
- skillproof/parsers/claude_code.py +144 -0
- skillproof/parsers/codex.py +112 -0
- skillproof/report/__init__.py +34 -0
- skillproof/report/badge.py +60 -0
- skillproof/report/format.py +75 -0
- skillproof/report/html_report.py +296 -0
- skillproof/report/json_report.py +9 -0
- skillproof/report/markdown.py +78 -0
- skillproof/report/summary.py +216 -0
- skillproof/report/terminal.py +89 -0
- skillproof/results.py +88 -0
- skillproof/runners/__init__.py +33 -0
- skillproof/runners/base.py +102 -0
- skillproof/runners/claude_code.py +117 -0
- skillproof/runners/codex.py +133 -0
- skillproof/runners/mock.py +91 -0
- skillproof/runners/openai_compat.py +238 -0
- skillproof/sandbox.py +85 -0
- skillproof/scaffold.py +153 -0
- skillproof/skill.py +97 -0
- skillproof/stats.py +228 -0
- skillproof-0.1.0.dist-info/METADATA +262 -0
- skillproof-0.1.0.dist-info/RECORD +43 -0
- skillproof-0.1.0.dist-info/WHEEL +4 -0
- skillproof-0.1.0.dist-info/entry_points.txt +2 -0
- skillproof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""HTML report: one self-contained file (inline CSS, no scripts, no network).
|
|
2
|
+
|
|
3
|
+
Layout: headline lift + verdict, stat tiles, a with/without pass-rate chart per
|
|
4
|
+
case, the case table, then every trial behind a <details> toggle.
|
|
5
|
+
Colors: "with skill" = blue, "without" = orange (validated categorical pair,
|
|
6
|
+
light and dark); verdicts use status colors and always carry an icon + label.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from html import escape
|
|
12
|
+
|
|
13
|
+
from skillproof.report.format import (
|
|
14
|
+
VERDICT_TEXT,
|
|
15
|
+
fraction,
|
|
16
|
+
interval,
|
|
17
|
+
pct,
|
|
18
|
+
seconds,
|
|
19
|
+
signed_pct,
|
|
20
|
+
tokens,
|
|
21
|
+
usd,
|
|
22
|
+
)
|
|
23
|
+
from skillproof.report.summary import CaseSummary, Summary, VariantSummary
|
|
24
|
+
from skillproof.results import CaseResult, SuiteResult, TrialResult
|
|
25
|
+
|
|
26
|
+
MAX_ANSWER_CHARS = 4000
|
|
27
|
+
|
|
28
|
+
CSS = """
|
|
29
|
+
:root {
|
|
30
|
+
--page: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
|
|
31
|
+
--grid: #e1e0d9; --border: rgba(11,11,11,0.10);
|
|
32
|
+
--with: #2a78d6; --without: #eb6834;
|
|
33
|
+
--good: #006300; --bad: #d03b3b; --neutral: #52514e;
|
|
34
|
+
}
|
|
35
|
+
@media (prefers-color-scheme: dark) {
|
|
36
|
+
:root {
|
|
37
|
+
--page: #0d0d0d; --surface: #1a1a19; --ink: #f0efec; --ink-2: #c3c2b7; --muted: #898781;
|
|
38
|
+
--grid: #2c2c2a; --border: rgba(255,255,255,0.10);
|
|
39
|
+
--with: #3987e5; --without: #d95926;
|
|
40
|
+
--good: #0ca30c; --bad: #e66767; --neutral: #c3c2b7;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
* { box-sizing: border-box; }
|
|
44
|
+
body { margin: 0; background: var(--page); color: var(--ink);
|
|
45
|
+
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
46
|
+
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
|
|
47
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
48
|
+
h2 { font-size: 17px; margin: 40px 0 12px; }
|
|
49
|
+
.meta { color: var(--ink-2); font-size: 13px; }
|
|
50
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; }
|
|
51
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; }
|
|
52
|
+
.hero { display: flex; flex-wrap: wrap; gap: 24px; align-items: center; padding: 24px;
|
|
53
|
+
margin-top: 20px; }
|
|
54
|
+
.hero .value { font-size: 56px; font-weight: 600; line-height: 1; }
|
|
55
|
+
.hero .label { color: var(--ink-2); font-size: 13px; margin-bottom: 6px; }
|
|
56
|
+
.verdict { font-weight: 600; font-size: 18px; }
|
|
57
|
+
.verdict.helps { color: var(--good); } .verdict.hurts { color: var(--bad); }
|
|
58
|
+
.verdict.neutral { color: var(--neutral); }
|
|
59
|
+
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px;
|
|
60
|
+
margin-top: 12px; }
|
|
61
|
+
.tile { padding: 14px 16px; }
|
|
62
|
+
.tile .label { color: var(--ink-2); font-size: 13px; }
|
|
63
|
+
.tile .value { font-size: 22px; font-weight: 600; }
|
|
64
|
+
.tile .sub { color: var(--muted); font-size: 12px; }
|
|
65
|
+
.notes { margin: 16px 0 0; padding: 12px 16px 12px 32px; }
|
|
66
|
+
.notes li { margin: 4px 0; }
|
|
67
|
+
.legend { display: flex; gap: 16px; font-size: 13px; color: var(--ink-2); margin-bottom: 8px; }
|
|
68
|
+
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px;
|
|
69
|
+
margin-right: 6px; vertical-align: -1px; }
|
|
70
|
+
.chart { padding: 16px; }
|
|
71
|
+
.chart-row { display: grid; grid-template-columns: minmax(120px, 220px) 1fr; gap: 12px;
|
|
72
|
+
align-items: center; padding: 8px 0; border-top: 1px solid var(--grid); }
|
|
73
|
+
.chart-row:first-child { border-top: 0; }
|
|
74
|
+
.chart-name { font-size: 13px; overflow-wrap: anywhere; }
|
|
75
|
+
@media (max-width: 600px) { .chart-row { grid-template-columns: 1fr; gap: 4px; } }
|
|
76
|
+
.bars { display: grid; gap: 2px; }
|
|
77
|
+
.bar-line { display: grid; grid-template-columns: 1fr 56px; gap: 8px; align-items: center; }
|
|
78
|
+
.track { height: 12px; position: relative; }
|
|
79
|
+
.bar { height: 12px; border-radius: 0 4px 4px 0; min-width: 2px; }
|
|
80
|
+
.bar.with { background: var(--with); } .bar.without { background: var(--without); }
|
|
81
|
+
.bar-value { font-size: 12px; color: var(--ink-2); font-variant-numeric: tabular-nums; }
|
|
82
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
83
|
+
th, td { text-align: left; padding: 8px 10px; border-top: 1px solid var(--grid);
|
|
84
|
+
vertical-align: top; }
|
|
85
|
+
th { color: var(--ink-2); font-weight: 600; border-top: 0; }
|
|
86
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
87
|
+
.table-wrap { overflow-x: auto; padding: 4px 6px; }
|
|
88
|
+
.pass { color: var(--good); font-weight: 600; } .fail { color: var(--bad); font-weight: 600; }
|
|
89
|
+
.err { color: var(--muted); font-weight: 600; }
|
|
90
|
+
details { margin-top: 10px; padding: 10px 14px; }
|
|
91
|
+
summary { cursor: pointer; font-weight: 600; }
|
|
92
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: var(--page); padding: 10px;
|
|
93
|
+
border-radius: 6px; font-size: 12px; max-height: 320px; overflow-y: auto; margin: 6px 0 0; }
|
|
94
|
+
ul.asserts { margin: 4px 0 0; padding-left: 18px; }
|
|
95
|
+
footer { margin-top: 48px; color: var(--muted); font-size: 12px; }
|
|
96
|
+
a { color: var(--with); }
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
VERDICT_ICON = {
|
|
100
|
+
"helps": "✓",
|
|
101
|
+
"hurts": "✗",
|
|
102
|
+
"no clear effect": "–",
|
|
103
|
+
"too few runs": "–",
|
|
104
|
+
"not measured": "–",
|
|
105
|
+
}
|
|
106
|
+
VERDICT_CLASS = {"helps": "helps", "hurts": "hurts"}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _tile(label: str, value: str, sub: str = "") -> str:
|
|
110
|
+
sub_html = f'<div class="sub">{escape(sub)}</div>' if sub else ""
|
|
111
|
+
return (
|
|
112
|
+
f'<div class="card tile"><div class="label">{escape(label)}</div>'
|
|
113
|
+
f'<div class="value">{escape(value)}</div>{sub_html}</div>'
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _bar(kind: str, label: str, v: VariantSummary) -> str:
|
|
118
|
+
width = 0.0 if v.pass_rate is None else v.pass_rate * 100
|
|
119
|
+
ci = f", 95% CI {pct(v.pass_ci[0])}–{pct(v.pass_ci[1])}" if v.pass_ci else ""
|
|
120
|
+
tip = f"{label}: {fraction(v.passed, v.runs)} passed{ci}"
|
|
121
|
+
return (
|
|
122
|
+
f'<div class="bar-line" title="{escape(tip)}">'
|
|
123
|
+
f'<div class="track"><div class="bar {kind}" style="width:{width:.1f}%"></div></div>'
|
|
124
|
+
f'<div class="bar-value">{pct(v.pass_rate)}</div></div>'
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def chart(summary: Summary) -> str:
|
|
129
|
+
rows = []
|
|
130
|
+
for case in summary.cases:
|
|
131
|
+
if not case.has_assertions:
|
|
132
|
+
continue
|
|
133
|
+
rows.append(
|
|
134
|
+
f'<div class="chart-row"><div class="chart-name">{escape(case.name)}</div>'
|
|
135
|
+
f'<div class="bars">{_bar("with", "with skill", case.with_skill)}'
|
|
136
|
+
f"{_bar('without', 'without skill', case.without_skill)}</div></div>"
|
|
137
|
+
)
|
|
138
|
+
if not rows:
|
|
139
|
+
return ""
|
|
140
|
+
legend = (
|
|
141
|
+
'<div class="legend"><span><span class="swatch" style="background:var(--with)"></span>'
|
|
142
|
+
'with skill</span><span><span class="swatch" style="background:var(--without)"></span>'
|
|
143
|
+
"without skill</span></div>"
|
|
144
|
+
)
|
|
145
|
+
return f'<h2>Pass rate by case</h2>{legend}<div class="card chart">{"".join(rows)}</div>'
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _trigger(case: CaseSummary) -> str:
|
|
149
|
+
cell = fraction(case.with_skill.triggered, case.with_skill.trigger_known)
|
|
150
|
+
return cell + (" (should not)" if not case.should_trigger else "")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def case_table(summary: Summary) -> str:
|
|
154
|
+
head = (
|
|
155
|
+
"<tr><th>case</th><th class=num>triggered</th><th class=num>pass with</th>"
|
|
156
|
+
"<th class=num>pass without</th><th class=num>lift</th><th class=num>95% CI</th>"
|
|
157
|
+
"<th class=num>tokens Δ</th><th class=num>latency Δ</th></tr>"
|
|
158
|
+
)
|
|
159
|
+
rows = []
|
|
160
|
+
for c in summary.cases:
|
|
161
|
+
w, wo = c.with_skill, c.without_skill
|
|
162
|
+
graded = c.has_assertions
|
|
163
|
+
rows.append(
|
|
164
|
+
f"<tr><td>{escape(c.name)}</td><td class=num>{escape(_trigger(c))}</td>"
|
|
165
|
+
f"<td class=num>{fraction(w.passed, w.runs) if graded else '–'}</td>"
|
|
166
|
+
f"<td class=num>{fraction(wo.passed, wo.runs) if graded else '–'}</td>"
|
|
167
|
+
f"<td class=num>{signed_pct(c.lift)}</td><td class=num>{interval(c.lift_ci)}</td>"
|
|
168
|
+
f"<td class=num>{signed_pct(c.token_delta)}</td>"
|
|
169
|
+
f"<td class=num>{signed_pct(c.latency_delta)}</td></tr>"
|
|
170
|
+
)
|
|
171
|
+
return f'<h2>Cases</h2><div class="card table-wrap"><table>{head}{"".join(rows)}</table></div>'
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _result_cell(t: TrialResult) -> str:
|
|
175
|
+
if t.errored:
|
|
176
|
+
return '<span class="err">error</span>'
|
|
177
|
+
if t.run.timed_out:
|
|
178
|
+
return '<span class="fail">timeout</span>'
|
|
179
|
+
return '<span class="pass">pass</span>' if t.passed else '<span class="fail">fail</span>'
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def trial_details(case: CaseResult) -> str:
|
|
183
|
+
rows = []
|
|
184
|
+
for t in sorted(case.trials, key=lambda t: (t.trial, t.variant)):
|
|
185
|
+
checks = "".join(
|
|
186
|
+
f'<li><span class="{"pass" if a.passed else "fail"}">'
|
|
187
|
+
f"{'✓' if a.passed else '✗'}</span> {escape(a.message)}</li>"
|
|
188
|
+
for a in t.assertions
|
|
189
|
+
)
|
|
190
|
+
extra = f"<div class=err>{escape(t.run.error)}</div>" if t.run.error else ""
|
|
191
|
+
answer = t.run.final_text[:MAX_ANSWER_CHARS]
|
|
192
|
+
if len(t.run.final_text) > MAX_ANSWER_CHARS:
|
|
193
|
+
answer += "\n… (truncated; see the transcript)"
|
|
194
|
+
triggered = {True: "yes", False: "no", None: "–"}[t.run.triggered]
|
|
195
|
+
rows.append(
|
|
196
|
+
f"<tr><td>{t.variant} #{t.trial + 1}</td><td>{_result_cell(t)}</td>"
|
|
197
|
+
f"<td class=num>{triggered}</td><td class=num>{tokens(t.run.usage.total_tokens)}</td>"
|
|
198
|
+
f"<td class=num>{seconds(t.run.duration_s)}</td>"
|
|
199
|
+
f'<td>{extra}<ul class="asserts">{checks}</ul>'
|
|
200
|
+
f"<details><summary>answer</summary><pre>{escape(answer)}</pre></details>"
|
|
201
|
+
f"<div class=meta><code>{escape(t.artifact or '')}</code></div></td></tr>"
|
|
202
|
+
)
|
|
203
|
+
head = (
|
|
204
|
+
"<tr><th>run</th><th>result</th><th class=num>triggered</th><th class=num>tokens</th>"
|
|
205
|
+
"<th class=num>time</th><th>checks</th></tr>"
|
|
206
|
+
)
|
|
207
|
+
return (
|
|
208
|
+
f'<details class="card"><summary>{escape(case.name)} '
|
|
209
|
+
f'<span class="meta">({len(case.trials)} runs)</span></summary>'
|
|
210
|
+
f'<div class="table-wrap"><table>{head}{"".join(rows)}</table></div></details>'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def render(summary: Summary, suite: SuiteResult) -> str:
|
|
215
|
+
s = summary
|
|
216
|
+
verdict_class = VERDICT_CLASS.get(s.verdict, "neutral")
|
|
217
|
+
ci = f"95% CI {interval(s.lift_ci)}" if s.lift_ci else "no graded runs"
|
|
218
|
+
meta = [f"runner <code>{escape(s.runner)}</code>"]
|
|
219
|
+
if s.model:
|
|
220
|
+
meta.append(f"model <code>{escape(s.model)}</code>")
|
|
221
|
+
if s.judge:
|
|
222
|
+
meta.append(f"judge <code>{escape(s.judge)}</code>")
|
|
223
|
+
meta.append(escape(s.started_at.strftime("%Y-%m-%d %H:%M UTC")))
|
|
224
|
+
if s.approximate:
|
|
225
|
+
meta.append("<strong>approximate</strong>")
|
|
226
|
+
|
|
227
|
+
w, wo = s.with_skill, s.without_skill
|
|
228
|
+
tiles = [
|
|
229
|
+
_tile(
|
|
230
|
+
"Triggered",
|
|
231
|
+
fraction(s.triggers, s.trigger_runs),
|
|
232
|
+
f"false triggers {fraction(s.false_triggers, s.false_trigger_runs)}"
|
|
233
|
+
if s.false_trigger_runs
|
|
234
|
+
else "runs where the skill loaded",
|
|
235
|
+
),
|
|
236
|
+
_tile("Pass rate with skill", pct(w.pass_rate), f"{w.passed}/{w.runs} runs"),
|
|
237
|
+
_tile("Pass rate without", pct(wo.pass_rate), f"{wo.passed}/{wo.runs} runs"),
|
|
238
|
+
_tile(
|
|
239
|
+
"Tokens per run",
|
|
240
|
+
signed_pct(s.token_delta),
|
|
241
|
+
f"{tokens(w.mean_tokens)} vs {tokens(wo.mean_tokens)}",
|
|
242
|
+
),
|
|
243
|
+
_tile(
|
|
244
|
+
"Time per run",
|
|
245
|
+
signed_pct(s.latency_delta),
|
|
246
|
+
f"{seconds(w.mean_duration_s)} vs {seconds(wo.mean_duration_s)}",
|
|
247
|
+
),
|
|
248
|
+
]
|
|
249
|
+
if s.cost_delta is not None:
|
|
250
|
+
tiles.insert(
|
|
251
|
+
4,
|
|
252
|
+
_tile(
|
|
253
|
+
"Cost per run",
|
|
254
|
+
signed_pct(s.cost_delta),
|
|
255
|
+
f"{usd(w.mean_cost_usd)} vs {usd(wo.mean_cost_usd)}",
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
notes = "".join(f"<li>{escape(n)}</li>" for n in [*s.notes, *s.warnings[:5]])
|
|
259
|
+
notes_html = f'<ul class="card notes">{notes}</ul>' if notes else ""
|
|
260
|
+
details = "".join(trial_details(c) for c in suite.cases)
|
|
261
|
+
|
|
262
|
+
return f"""<!doctype html>
|
|
263
|
+
<html lang="en">
|
|
264
|
+
<head>
|
|
265
|
+
<meta charset="utf-8">
|
|
266
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
267
|
+
<title>skillproof: {escape(s.skill_name)}</title>
|
|
268
|
+
<style>{CSS}</style>
|
|
269
|
+
</head>
|
|
270
|
+
<body>
|
|
271
|
+
<main>
|
|
272
|
+
<h1>skillproof: {escape(s.skill_name)}</h1>
|
|
273
|
+
<div class="meta">{" · ".join(meta)}</div>
|
|
274
|
+
|
|
275
|
+
<div class="card hero">
|
|
276
|
+
<div>
|
|
277
|
+
<div class="label">Skill lift (pass rate with − without)</div>
|
|
278
|
+
<div class="value">{signed_pct(s.lift)}</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div>
|
|
281
|
+
<div class="verdict {verdict_class}">{VERDICT_ICON[s.verdict]} {VERDICT_TEXT[s.verdict]}</div>
|
|
282
|
+
<div class="meta">{ci}</div>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="tiles">{"".join(tiles)}</div>
|
|
286
|
+
{notes_html}
|
|
287
|
+
{chart(s)}
|
|
288
|
+
{case_table(s)}
|
|
289
|
+
<h2>Runs</h2>
|
|
290
|
+
{details}
|
|
291
|
+
<footer>Generated by <a href="https://github.com/akhilgarg07/skillproof">skillproof</a>
|
|
292
|
+
{escape(s.skillproof_version)} · transcripts in <code>{escape(s.run_dir)}</code></footer>
|
|
293
|
+
</main>
|
|
294
|
+
</body>
|
|
295
|
+
</html>
|
|
296
|
+
"""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Markdown report: for PR comments, READMEs and issue threads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from skillproof.report.format import (
|
|
6
|
+
VERDICT_TEXT,
|
|
7
|
+
fraction,
|
|
8
|
+
interval,
|
|
9
|
+
seconds,
|
|
10
|
+
signed_pct,
|
|
11
|
+
summary_line,
|
|
12
|
+
tokens,
|
|
13
|
+
usd,
|
|
14
|
+
)
|
|
15
|
+
from skillproof.report.summary import Summary
|
|
16
|
+
|
|
17
|
+
VERDICT_EMOJI = {
|
|
18
|
+
"helps": "✅",
|
|
19
|
+
"hurts": "❌",
|
|
20
|
+
"no clear effect": "➖",
|
|
21
|
+
"too few runs": "➖",
|
|
22
|
+
"not measured": "➖",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cell(text: str) -> str:
|
|
27
|
+
return text.replace("|", "\\|").replace("\n", " ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render(s: Summary) -> str:
|
|
31
|
+
lines = [f"## skillproof: `{s.skill_name}`", ""]
|
|
32
|
+
lines.append(f"**{VERDICT_EMOJI[s.verdict]} {VERDICT_TEXT[s.verdict]}** ")
|
|
33
|
+
lines.append(f"`{summary_line(s)}`")
|
|
34
|
+
lines.append("")
|
|
35
|
+
meta = f"runner `{s.runner}`"
|
|
36
|
+
if s.model:
|
|
37
|
+
meta += f" · model `{s.model}`"
|
|
38
|
+
if s.judge:
|
|
39
|
+
meta += f" · judge `{s.judge}`"
|
|
40
|
+
if s.approximate:
|
|
41
|
+
meta += " · **approximate**"
|
|
42
|
+
lines += [meta, ""]
|
|
43
|
+
|
|
44
|
+
lines.append(
|
|
45
|
+
"| case | triggered | pass with | pass without | lift (95% CI) | tokens Δ | latency Δ |"
|
|
46
|
+
)
|
|
47
|
+
lines.append("|---|---:|---:|---:|---:|---:|---:|")
|
|
48
|
+
for c in s.cases:
|
|
49
|
+
w, wo = c.with_skill, c.without_skill
|
|
50
|
+
trig = fraction(w.triggered, w.trigger_known)
|
|
51
|
+
if not c.should_trigger:
|
|
52
|
+
trig += " (should not)"
|
|
53
|
+
if c.has_assertions:
|
|
54
|
+
lift = f"{signed_pct(c.lift)} ({interval(c.lift_ci)})"
|
|
55
|
+
pw, pwo = fraction(w.passed, w.runs), fraction(wo.passed, wo.runs)
|
|
56
|
+
else:
|
|
57
|
+
lift, pw, pwo = "–", "–", "–"
|
|
58
|
+
lines.append(
|
|
59
|
+
f"| {_cell(c.name)} | {trig} | {pw} | {pwo} | {lift} | "
|
|
60
|
+
f"{signed_pct(c.token_delta)} | {signed_pct(c.latency_delta)} |"
|
|
61
|
+
)
|
|
62
|
+
lines.append("")
|
|
63
|
+
|
|
64
|
+
w, wo = s.with_skill, s.without_skill
|
|
65
|
+
lines.append("| | with skill | without |")
|
|
66
|
+
lines.append("|---|---:|---:|")
|
|
67
|
+
lines.append(f"| pass rate | {fraction(w.passed, w.runs)} | {fraction(wo.passed, wo.runs)} |")
|
|
68
|
+
lines.append(f"| tokens / run | {tokens(w.mean_tokens)} | {tokens(wo.mean_tokens)} |")
|
|
69
|
+
if w.mean_cost_usd is not None or wo.mean_cost_usd is not None:
|
|
70
|
+
lines.append(f"| cost / run | {usd(w.mean_cost_usd)} | {usd(wo.mean_cost_usd)} |")
|
|
71
|
+
lines.append(f"| time / run | {seconds(w.mean_duration_s)} | {seconds(wo.mean_duration_s)} |")
|
|
72
|
+
lines.append("")
|
|
73
|
+
|
|
74
|
+
if s.notes:
|
|
75
|
+
lines += [f"> {note} " for note in s.notes]
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append("<sub>Generated by [skillproof](https://github.com/akhilgarg07/skillproof)</sub>")
|
|
78
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""The report summary: every number a report shows, computed once.
|
|
2
|
+
|
|
3
|
+
All renderers (terminal, markdown, HTML, JSON, badge) read a `Summary`, so they
|
|
4
|
+
can never disagree. `summary.json` is also the machine-readable contract for CI:
|
|
5
|
+
add fields freely, but don't rename or remove them without bumping
|
|
6
|
+
SCHEMA_VERSION.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from skillproof import __version__
|
|
16
|
+
from skillproof.report.format import signed_pct
|
|
17
|
+
from skillproof.results import SuiteResult
|
|
18
|
+
from skillproof.stats import (
|
|
19
|
+
MIN_RUNS_FOR_CONFIDENCE,
|
|
20
|
+
CaseStats,
|
|
21
|
+
SuiteStats,
|
|
22
|
+
VariantStats,
|
|
23
|
+
Verdict,
|
|
24
|
+
suite_stats,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VariantSummary(BaseModel):
|
|
31
|
+
runs: int
|
|
32
|
+
errors: int
|
|
33
|
+
passed: int
|
|
34
|
+
pass_rate: float | None
|
|
35
|
+
pass_ci: tuple[float, float] | None
|
|
36
|
+
triggered: int
|
|
37
|
+
trigger_known: int
|
|
38
|
+
trigger_rate: float | None
|
|
39
|
+
mean_tokens: float | None
|
|
40
|
+
mean_cost_usd: float | None
|
|
41
|
+
mean_duration_s: float | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CaseSummary(BaseModel):
|
|
45
|
+
name: str
|
|
46
|
+
should_trigger: bool
|
|
47
|
+
has_assertions: bool
|
|
48
|
+
with_skill: VariantSummary
|
|
49
|
+
without_skill: VariantSummary
|
|
50
|
+
lift: float | None
|
|
51
|
+
lift_ci: tuple[float, float] | None
|
|
52
|
+
verdict: Verdict
|
|
53
|
+
token_delta: float | None
|
|
54
|
+
cost_delta: float | None
|
|
55
|
+
latency_delta: float | None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Summary(BaseModel):
|
|
59
|
+
schema_version: int = SCHEMA_VERSION
|
|
60
|
+
skillproof_version: str = __version__
|
|
61
|
+
skill_name: str
|
|
62
|
+
skill_path: str
|
|
63
|
+
runner: str
|
|
64
|
+
approximate: bool
|
|
65
|
+
model: str | None
|
|
66
|
+
judge: str | None
|
|
67
|
+
started_at: datetime
|
|
68
|
+
finished_at: datetime | None
|
|
69
|
+
run_dir: str
|
|
70
|
+
|
|
71
|
+
verdict: Verdict
|
|
72
|
+
lift: float | None
|
|
73
|
+
lift_ci: tuple[float, float] | None
|
|
74
|
+
with_skill: VariantSummary
|
|
75
|
+
without_skill: VariantSummary
|
|
76
|
+
triggers: int
|
|
77
|
+
trigger_runs: int
|
|
78
|
+
false_triggers: int
|
|
79
|
+
false_trigger_runs: int
|
|
80
|
+
token_delta: float | None
|
|
81
|
+
cost_delta: float | None
|
|
82
|
+
latency_delta: float | None
|
|
83
|
+
errors: int
|
|
84
|
+
notes: list[str]
|
|
85
|
+
warnings: list[str]
|
|
86
|
+
cases: list[CaseSummary]
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def trigger_rate(self) -> float | None:
|
|
90
|
+
return self.triggers / self.trigger_runs if self.trigger_runs else None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def variant_summary(v: VariantStats) -> VariantSummary:
|
|
94
|
+
return VariantSummary(
|
|
95
|
+
runs=v.runs,
|
|
96
|
+
errors=v.errors,
|
|
97
|
+
passed=v.passed,
|
|
98
|
+
pass_rate=v.pass_rate,
|
|
99
|
+
pass_ci=v.pass_interval,
|
|
100
|
+
triggered=v.triggered,
|
|
101
|
+
trigger_known=v.trigger_known,
|
|
102
|
+
trigger_rate=v.trigger_rate,
|
|
103
|
+
mean_tokens=v.mean_tokens,
|
|
104
|
+
mean_cost_usd=v.mean_cost_usd,
|
|
105
|
+
mean_duration_s=v.mean_duration_s,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def case_summary(c: CaseStats) -> CaseSummary:
|
|
110
|
+
return CaseSummary(
|
|
111
|
+
name=c.name,
|
|
112
|
+
should_trigger=c.should_trigger,
|
|
113
|
+
has_assertions=c.has_assertions,
|
|
114
|
+
with_skill=variant_summary(c.with_skill),
|
|
115
|
+
without_skill=variant_summary(c.without_skill),
|
|
116
|
+
lift=c.lift,
|
|
117
|
+
lift_ci=c.lift_interval,
|
|
118
|
+
verdict=c.verdict,
|
|
119
|
+
token_delta=c.token_delta,
|
|
120
|
+
cost_delta=c.cost_delta,
|
|
121
|
+
latency_delta=c.latency_delta,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def notes_for(suite: SuiteResult, stats: SuiteStats) -> list[str]:
|
|
126
|
+
"""Plain-English caveats. These matter as much as the numbers."""
|
|
127
|
+
notes = []
|
|
128
|
+
if suite.approximate:
|
|
129
|
+
notes.append(
|
|
130
|
+
"Approximate: this runner pastes SKILL.md into the system prompt instead of "
|
|
131
|
+
"letting the agent discover and load it, so trigger rates are not measured."
|
|
132
|
+
)
|
|
133
|
+
if stats.trigger_runs and stats.triggers == 0:
|
|
134
|
+
notes.append(
|
|
135
|
+
"The skill never triggered, so any difference in pass rate is noise "
|
|
136
|
+
"(or comes from the skill merely being listed). Improve the `description`."
|
|
137
|
+
)
|
|
138
|
+
elif stats.trigger_runs and stats.triggers < stats.trigger_runs:
|
|
139
|
+
missed = stats.trigger_runs - stats.triggers
|
|
140
|
+
notes.append(
|
|
141
|
+
f"The skill did not trigger in {missed} of {stats.trigger_runs} runs where it should "
|
|
142
|
+
"have. Those runs count against the lift."
|
|
143
|
+
)
|
|
144
|
+
for case in stats.cases:
|
|
145
|
+
if not case.should_trigger and case.verdict == "hurts":
|
|
146
|
+
notes.append(
|
|
147
|
+
f"The skill makes the unrelated case {case.name!r} worse "
|
|
148
|
+
f"({signed_pct(case.lift)}): it interferes with tasks it shouldn't touch."
|
|
149
|
+
)
|
|
150
|
+
if stats.false_triggers:
|
|
151
|
+
notes.append(
|
|
152
|
+
f"The skill triggered in {stats.false_triggers} of {stats.false_trigger_runs} runs "
|
|
153
|
+
"where it should not have (should_trigger: false)."
|
|
154
|
+
)
|
|
155
|
+
interval = stats.lift_interval
|
|
156
|
+
if interval is not None:
|
|
157
|
+
low, high = interval
|
|
158
|
+
if stats.verdict == "no clear effect":
|
|
159
|
+
notes.append(
|
|
160
|
+
f"The lift is not statistically clear: the 95% interval ({signed_pct(low)} to "
|
|
161
|
+
f"{signed_pct(high)}) includes zero."
|
|
162
|
+
)
|
|
163
|
+
elif stats.lift is None:
|
|
164
|
+
notes.append("No graded runs: add `assert:` checks to measure pass rates and lift.")
|
|
165
|
+
if 0 < stats.min_runs < MIN_RUNS_FOR_CONFIDENCE:
|
|
166
|
+
notes.append(
|
|
167
|
+
f"Small sample: as few as {stats.min_runs} completed run(s) per variant. Treat these "
|
|
168
|
+
f"numbers as rough; use --trials {MIN_RUNS_FOR_CONFIDENCE} or more before quoting them."
|
|
169
|
+
)
|
|
170
|
+
timeouts = sum(t.run.timed_out for c in suite.cases for t in c.trials)
|
|
171
|
+
if timeouts:
|
|
172
|
+
notes.append(
|
|
173
|
+
f"{timeouts} run(s) hit the time limit and count as failures. If the task needs "
|
|
174
|
+
"longer, raise `timeout:` in evals.yaml."
|
|
175
|
+
)
|
|
176
|
+
if not suite.complete:
|
|
177
|
+
notes.append("This run was interrupted or stopped early: results are partial.")
|
|
178
|
+
if stats.errors:
|
|
179
|
+
notes.append(
|
|
180
|
+
f"{stats.errors} run(s) errored (API, CLI or judge failures) and were excluded. "
|
|
181
|
+
"See the saved transcripts."
|
|
182
|
+
)
|
|
183
|
+
return notes
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def build_summary(suite: SuiteResult) -> Summary:
|
|
187
|
+
stats = suite_stats(suite)
|
|
188
|
+
models = sorted({t.run.model for c in suite.cases for t in c.trials if t.run.model})
|
|
189
|
+
warnings = sorted({w for c in suite.cases for t in c.trials for w in t.run.warnings})
|
|
190
|
+
return Summary(
|
|
191
|
+
skill_name=suite.skill_name,
|
|
192
|
+
skill_path=suite.skill_path,
|
|
193
|
+
runner=suite.runner,
|
|
194
|
+
approximate=suite.approximate,
|
|
195
|
+
model=suite.model or (", ".join(models) or None),
|
|
196
|
+
judge=suite.judge,
|
|
197
|
+
started_at=suite.started_at,
|
|
198
|
+
finished_at=suite.finished_at,
|
|
199
|
+
run_dir=suite.run_dir,
|
|
200
|
+
verdict=stats.verdict,
|
|
201
|
+
lift=stats.lift,
|
|
202
|
+
lift_ci=stats.lift_interval,
|
|
203
|
+
with_skill=variant_summary(stats.with_skill),
|
|
204
|
+
without_skill=variant_summary(stats.without_skill),
|
|
205
|
+
triggers=stats.triggers,
|
|
206
|
+
trigger_runs=stats.trigger_runs,
|
|
207
|
+
false_triggers=stats.false_triggers,
|
|
208
|
+
false_trigger_runs=stats.false_trigger_runs,
|
|
209
|
+
token_delta=stats.token_delta,
|
|
210
|
+
cost_delta=stats.cost_delta,
|
|
211
|
+
latency_delta=stats.latency_delta,
|
|
212
|
+
errors=stats.errors,
|
|
213
|
+
notes=notes_for(suite, stats),
|
|
214
|
+
warnings=warnings,
|
|
215
|
+
cases=[case_summary(c) for c in stats.cases],
|
|
216
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""The rich terminal report: a per-case table, a summary line and the notes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.markup import escape
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from skillproof.report.format import (
|
|
10
|
+
VERDICT_TEXT,
|
|
11
|
+
fraction,
|
|
12
|
+
interval,
|
|
13
|
+
signed_pct,
|
|
14
|
+
summary_line,
|
|
15
|
+
)
|
|
16
|
+
from skillproof.report.summary import CaseSummary, Summary
|
|
17
|
+
|
|
18
|
+
VERDICT_STYLE = {
|
|
19
|
+
"helps": "bold green",
|
|
20
|
+
"hurts": "bold red",
|
|
21
|
+
"no clear effect": "bold yellow",
|
|
22
|
+
"too few runs": "bold yellow",
|
|
23
|
+
"not measured": "dim",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lift_style(case: CaseSummary) -> str:
|
|
28
|
+
return {"helps": "green", "hurts": "red"}.get(case.verdict, "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def trigger_cell(case: CaseSummary) -> str:
|
|
32
|
+
v = case.with_skill
|
|
33
|
+
cell = fraction(v.triggered, v.trigger_known)
|
|
34
|
+
if not case.should_trigger:
|
|
35
|
+
style = "green" if v.triggered == 0 else "yellow"
|
|
36
|
+
return f"[{style}]{cell}[/] [dim](should not)[/]"
|
|
37
|
+
return cell
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def case_table(summary: Summary) -> Table:
|
|
41
|
+
table = Table(show_edge=False, header_style="bold")
|
|
42
|
+
table.add_column("case")
|
|
43
|
+
table.add_column("triggered", justify="right")
|
|
44
|
+
table.add_column("pass with", justify="right")
|
|
45
|
+
table.add_column("pass without", justify="right")
|
|
46
|
+
table.add_column("lift", justify="right")
|
|
47
|
+
table.add_column("tokens Δ", justify="right")
|
|
48
|
+
table.add_column("latency Δ", justify="right")
|
|
49
|
+
for case in summary.cases:
|
|
50
|
+
w, wo = case.with_skill, case.without_skill
|
|
51
|
+
graded = case.has_assertions
|
|
52
|
+
errors = w.errors + wo.errors
|
|
53
|
+
name = escape(case.name) + (f" [yellow]({errors} errored)[/]" if errors else "")
|
|
54
|
+
lift = signed_pct(case.lift)
|
|
55
|
+
if style := lift_style(case):
|
|
56
|
+
lift = f"[{style}]{lift}[/]"
|
|
57
|
+
table.add_row(
|
|
58
|
+
name,
|
|
59
|
+
trigger_cell(case),
|
|
60
|
+
fraction(w.passed, w.runs) if graded else "–",
|
|
61
|
+
fraction(wo.passed, wo.runs) if graded else "–",
|
|
62
|
+
lift,
|
|
63
|
+
signed_pct(case.token_delta),
|
|
64
|
+
signed_pct(case.latency_delta),
|
|
65
|
+
)
|
|
66
|
+
return table
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render(summary: Summary, console: Console | None = None) -> None:
|
|
70
|
+
console = console or Console()
|
|
71
|
+
title = f"[bold]{escape(summary.skill_name)}[/] · runner [bold]{summary.runner}[/]"
|
|
72
|
+
if summary.model:
|
|
73
|
+
title += f" · model {escape(summary.model)}"
|
|
74
|
+
console.print()
|
|
75
|
+
console.print(title)
|
|
76
|
+
console.print()
|
|
77
|
+
console.print(case_table(summary))
|
|
78
|
+
console.print()
|
|
79
|
+
console.print(f"[bold]{summary_line(summary)}[/]", highlight=False)
|
|
80
|
+
verdict = summary.verdict
|
|
81
|
+
line = f"verdict: [{VERDICT_STYLE[verdict]}]{VERDICT_TEXT[verdict]}[/]"
|
|
82
|
+
if summary.lift_ci is not None:
|
|
83
|
+
line += f" [dim](95% CI for lift: {interval(summary.lift_ci)})[/]"
|
|
84
|
+
console.print(line, highlight=False)
|
|
85
|
+
for note in summary.notes:
|
|
86
|
+
console.print(f"[yellow]•[/] {escape(note)}", highlight=False)
|
|
87
|
+
for warning in summary.warnings[:5]:
|
|
88
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}", highlight=False)
|
|
89
|
+
console.print(f"[dim]report: {summary.run_dir}/report.html[/]", highlight=False)
|