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.
Files changed (43) hide show
  1. skillproof/__init__.py +3 -0
  2. skillproof/__main__.py +3 -0
  3. skillproof/assertions/__init__.py +62 -0
  4. skillproof/assertions/base.py +44 -0
  5. skillproof/assertions/builtin.py +176 -0
  6. skillproof/assertions/llm_judge.py +24 -0
  7. skillproof/cli.py +313 -0
  8. skillproof/config.py +188 -0
  9. skillproof/engine.py +268 -0
  10. skillproof/errors.py +72 -0
  11. skillproof/estimate.py +83 -0
  12. skillproof/judges/__init__.py +43 -0
  13. skillproof/judges/anthropic_judge.py +59 -0
  14. skillproof/judges/base.py +69 -0
  15. skillproof/judges/mock.py +21 -0
  16. skillproof/judges/openai_judge.py +58 -0
  17. skillproof/parsers/__init__.py +6 -0
  18. skillproof/parsers/claude_code.py +144 -0
  19. skillproof/parsers/codex.py +112 -0
  20. skillproof/report/__init__.py +34 -0
  21. skillproof/report/badge.py +60 -0
  22. skillproof/report/format.py +75 -0
  23. skillproof/report/html_report.py +296 -0
  24. skillproof/report/json_report.py +9 -0
  25. skillproof/report/markdown.py +78 -0
  26. skillproof/report/summary.py +216 -0
  27. skillproof/report/terminal.py +89 -0
  28. skillproof/results.py +88 -0
  29. skillproof/runners/__init__.py +33 -0
  30. skillproof/runners/base.py +102 -0
  31. skillproof/runners/claude_code.py +117 -0
  32. skillproof/runners/codex.py +133 -0
  33. skillproof/runners/mock.py +91 -0
  34. skillproof/runners/openai_compat.py +238 -0
  35. skillproof/sandbox.py +85 -0
  36. skillproof/scaffold.py +153 -0
  37. skillproof/skill.py +97 -0
  38. skillproof/stats.py +228 -0
  39. skillproof-0.1.0.dist-info/METADATA +262 -0
  40. skillproof-0.1.0.dist-info/RECORD +43 -0
  41. skillproof-0.1.0.dist-info/WHEEL +4 -0
  42. skillproof-0.1.0.dist-info/entry_points.txt +2 -0
  43. 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,9 @@
1
+ """JSON report: the Summary as-is, for CI and scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from skillproof.report.summary import Summary
6
+
7
+
8
+ def render(s: Summary) -> str:
9
+ return s.model_dump_json(indent=2) + "\n"
@@ -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)