proofstep-cli 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.
@@ -0,0 +1,193 @@
1
+ """The machine-readable report.
2
+
3
+ `report_version` is explicit and validated on every write. The GitHub Action parses
4
+ this file, so it is a contract: an unannounced shape change would break other
5
+ people's CI silently, which is the one failure this product exists to prevent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from proofstep_core import EvalResult
15
+ from proofstep_core.compare import Comparison
16
+ from proofstep_types import Verdict
17
+
18
+ REPORT_VERSION = 1
19
+
20
+ REQUIRED_TOP_LEVEL = (
21
+ "report_version",
22
+ "suite",
23
+ "verdict",
24
+ "exit_code",
25
+ "dataset",
26
+ "metrics",
27
+ "gates",
28
+ "totals",
29
+ )
30
+
31
+
32
+ class ReportError(ValueError):
33
+ """The report did not match its own contract."""
34
+
35
+
36
+ def build_report(
37
+ result: EvalResult,
38
+ *,
39
+ comparison: Comparison | None = None,
40
+ git_commit: str | None = None,
41
+ git_branch: str | None = None,
42
+ baseline_run_id: str | None = None,
43
+ experiment_url: str | None = None,
44
+ hints: list[str] | None = None,
45
+ ) -> dict[str, Any]:
46
+ deltas = {d.full_key: d for d in (comparison.deltas if comparison else [])}
47
+
48
+ metrics: list[dict[str, Any]] = []
49
+ for metric in sorted(result.metrics, key=lambda m: m.full_key):
50
+ delta = deltas.get(metric.full_key)
51
+ metrics.append(
52
+ {
53
+ "key": metric.key,
54
+ "slice": metric.slice,
55
+ "value": metric.value,
56
+ "count": metric.count,
57
+ # Reported separately from `count`, always. An errored evaluation is
58
+ # not a score of zero, and a consumer that cannot see the difference
59
+ # will draw the wrong conclusion.
60
+ "error_count": metric.error_count,
61
+ "unit": metric.unit,
62
+ "ci_low": metric.ci_low,
63
+ "ci_high": metric.ci_high,
64
+ "baseline": delta.baseline if delta else None,
65
+ "absolute_delta": delta.absolute_delta if delta else None,
66
+ "relative_delta": delta.relative_delta if delta else None,
67
+ "significant": delta.significant if delta else None,
68
+ }
69
+ )
70
+
71
+ # Paired tests, when any ran. Keyed by metric so a consumer can join them to the gate results
72
+ # without re-deriving which rule asked for what.
73
+ significance = {
74
+ key: {
75
+ "test": test.test,
76
+ "n_pairs": test.n_pairs,
77
+ "difference": test.difference,
78
+ "ci_low": test.ci_low,
79
+ "ci_high": test.ci_high,
80
+ "p_value": test.p_value,
81
+ "adjusted_p_value": test.adjusted_p_value,
82
+ "minimum_detectable_effect": test.minimum_detectable_effect,
83
+ "dropped": test.dropped,
84
+ "notes": list(test.notes),
85
+ }
86
+ for key, test in getattr(result, "significance", {}).items()
87
+ }
88
+
89
+ report: dict[str, Any] = {
90
+ "report_version": REPORT_VERSION,
91
+ "significance": significance,
92
+ "suite": result.suite,
93
+ "verdict": result.gates.verdict.value,
94
+ "exit_code": result.exit_code,
95
+ "aborted_reason": result.aborted_reason,
96
+ "git": {"commit": git_commit, "branch": git_branch},
97
+ "dataset": {
98
+ "name": result.dataset_name,
99
+ "version": result.dataset_version,
100
+ "content_hash": result.dataset_hash,
101
+ "example_count": len(result.results),
102
+ },
103
+ "baseline": {
104
+ "run_id": baseline_run_id,
105
+ "dataset_match": comparison.dataset_match if comparison else None,
106
+ "warnings": comparison.warnings if comparison else [],
107
+ },
108
+ "totals": {
109
+ "examples": len(result.results),
110
+ "errors": result.error_count,
111
+ "duration_s": round(result.duration_s, 3),
112
+ "total_cost": float(result.total_cost),
113
+ },
114
+ "metrics": metrics,
115
+ "gates": [
116
+ {
117
+ "metric_key": gate.metric_key,
118
+ "slice": gate.slice,
119
+ "verdict": gate.verdict,
120
+ "severity": gate.severity.value,
121
+ "blocking": gate.blocking,
122
+ "rule": gate.rule,
123
+ "threshold": gate.threshold,
124
+ "actual": gate.actual,
125
+ "baseline": gate.baseline,
126
+ "message": gate.message,
127
+ }
128
+ for gate in result.gates.results
129
+ ],
130
+ "regressed_examples": [
131
+ {
132
+ "example_id": r.example_id,
133
+ "metric": r.metric,
134
+ "baseline_score": r.baseline_score,
135
+ "candidate_score": r.candidate_score,
136
+ "trace_id": r.trace_id,
137
+ }
138
+ for r in (comparison.regressions[:100] if comparison else [])
139
+ ],
140
+ "failures": [
141
+ {
142
+ "example_id": r.example_id,
143
+ "status": r.status.value,
144
+ "error": r.error.message if r.error else None,
145
+ }
146
+ for r in result.failures()[:100]
147
+ ],
148
+ "hints": hints or [],
149
+ "experiment_url": experiment_url,
150
+ }
151
+
152
+ validate_report(report)
153
+ return report
154
+
155
+
156
+ def validate_report(report: dict[str, Any]) -> None:
157
+ """Check the report against its own contract before anyone depends on it."""
158
+ missing = [key for key in REQUIRED_TOP_LEVEL if key not in report]
159
+ if missing:
160
+ msg = f"report is missing required field(s): {', '.join(missing)}"
161
+ raise ReportError(msg)
162
+
163
+ if report["report_version"] != REPORT_VERSION:
164
+ msg = f"report_version must be {REPORT_VERSION}, got {report['report_version']!r}"
165
+ raise ReportError(msg)
166
+
167
+ if report["verdict"] not in {v.value for v in Verdict}:
168
+ msg = f"unknown verdict {report['verdict']!r}"
169
+ raise ReportError(msg)
170
+
171
+ # The exit code is what CI acts on, so a report whose verdict and exit code
172
+ # disagree is worse than no report at all.
173
+ expected = _exit_code_for(report["verdict"], report.get("aborted_reason"))
174
+ if report["exit_code"] != expected:
175
+ msg = (
176
+ f"verdict {report['verdict']!r} implies exit code {expected}, "
177
+ f"but the report says {report['exit_code']}"
178
+ )
179
+ raise ReportError(msg)
180
+
181
+
182
+ def _exit_code_for(verdict: str, aborted: str | None) -> int:
183
+ if aborted:
184
+ return 2
185
+ return {"pass": 0, "warn": 0, "fail": 1, "error": 2}[verdict]
186
+
187
+
188
+ def write_report(report: dict[str, Any], path: str | Path) -> Path:
189
+ validate_report(report)
190
+ target = Path(path)
191
+ target.parent.mkdir(parents=True, exist_ok=True)
192
+ target.write_text(json.dumps(report, indent=2, sort_keys=False) + "\n", encoding="utf-8")
193
+ return target
@@ -0,0 +1,287 @@
1
+ """The terminal report.
2
+
3
+ Design rule: the reader must never have to open the YAML to interpret a failure. So
4
+ every gate row carries its threshold, and every regressed example carries the
5
+ concrete reason. A report that says "failed" and stops has moved the work rather
6
+ than done it.
7
+
8
+ Plain text, not Rich tables. This output lands in CI logs and in bug reports, where
9
+ box-drawing characters and ANSI escapes are noise. Colour is opt-in and disabled
10
+ under `CI` or `NO_COLOR`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import sys
17
+ from typing import Any
18
+
19
+ from proofstep_core import EvalResult
20
+ from proofstep_core.compare import Comparison
21
+ from proofstep_types import Verdict
22
+
23
+ GREEN = "\033[32m"
24
+ RED = "\033[31m"
25
+ YELLOW = "\033[33m"
26
+ DIM = "\033[2m"
27
+ BOLD = "\033[1m"
28
+ RESET = "\033[0m"
29
+
30
+ MAX_LISTED_FAILURES = 10
31
+
32
+
33
+ def use_colour(stream: Any = None) -> bool:
34
+ """Colour only when a human is plausibly reading it.
35
+
36
+ `NO_COLOR` is honoured because it is the convention, and `CI` because escape
37
+ codes in a build log make the log harder to read, not easier.
38
+ """
39
+ if os.environ.get("NO_COLOR") or os.environ.get("CI"):
40
+ return False
41
+ target = stream or sys.stdout
42
+ return bool(getattr(target, "isatty", lambda: False)())
43
+
44
+
45
+ def use_unicode() -> bool:
46
+ encoding = (getattr(sys.stdout, "encoding", None) or "").lower()
47
+ return "utf" in encoding
48
+
49
+
50
+ class Style:
51
+ def __init__(self, *, colour: bool, unicode_: bool) -> None:
52
+ self.colour = colour
53
+ self.unicode = unicode_
54
+
55
+ def paint(self, text: str, code: str) -> str:
56
+ return f"{code}{text}{RESET}" if self.colour else text
57
+
58
+ @property
59
+ def tick(self) -> str:
60
+ return "✓" if self.unicode else "PASS"
61
+
62
+ @property
63
+ def cross(self) -> str:
64
+ return "✗" if self.unicode else "FAIL"
65
+
66
+ @property
67
+ def warn(self) -> str:
68
+ return "⚠" if self.unicode else "WARN"
69
+
70
+ @property
71
+ def query(self) -> str:
72
+ return "?" if self.unicode else "ERR"
73
+
74
+ def mark(self, verdict: str) -> str:
75
+ return {
76
+ "pass": self.paint(self.tick, GREEN),
77
+ "fail": self.paint(self.cross, RED),
78
+ "warn": self.paint(self.warn, YELLOW),
79
+ "error": self.paint(self.query, RED),
80
+ }.get(verdict, verdict)
81
+
82
+
83
+ def render(
84
+ result: EvalResult,
85
+ *,
86
+ comparison: Comparison | None = None,
87
+ hints: list[str] | None = None,
88
+ report_path: str | None = None,
89
+ experiment_url: str | None = None,
90
+ baseline_label: str | None = None,
91
+ style: Style | None = None,
92
+ verbose: bool = False,
93
+ ) -> str:
94
+ theme = style or Style(colour=use_colour(), unicode_=use_unicode())
95
+ lines: list[str] = []
96
+
97
+ lines.append(theme.paint(f"Proofstep · {result.suite}", BOLD))
98
+ dataset = result.dataset_name or "<inline>"
99
+ if result.dataset_version:
100
+ dataset = f"{dataset}@{result.dataset_version}"
101
+ lines.append(
102
+ theme.paint(
103
+ f"{dataset} ({len(result.results)} examples, sha {result.dataset_hash[:8]})", DIM
104
+ )
105
+ )
106
+ lines.append(theme.paint(f"baseline {baseline_label or 'none'}", DIM))
107
+ lines.append("")
108
+
109
+ lines.extend(_metric_table(result, comparison, theme, verbose=verbose))
110
+
111
+ if result.aborted_reason:
112
+ lines.append("")
113
+ lines.append(theme.paint(f"{theme.cross} run aborted: {result.aborted_reason}", RED))
114
+
115
+ lines.extend(_warnings(comparison, hints, theme))
116
+ lines.extend(_failures(result, comparison, theme))
117
+ lines.extend(_footer(result, report_path, experiment_url, theme))
118
+ return "\n".join(lines)
119
+
120
+
121
+ def _metric_table(
122
+ result: EvalResult, comparison: Comparison | None, theme: Style, *, verbose: bool = False
123
+ ) -> list[str]:
124
+ deltas = {d.full_key: d for d in (comparison.deltas if comparison else [])}
125
+ gates = {(g.metric_key, _slice_text(g.slice)): g for g in result.gates.results}
126
+
127
+ # A suite that slices by a dimension produces one row per class per metric, which
128
+ # buries four gates under forty rows. Show what someone is gating on plus the
129
+ # unsliced headline numbers, and say how many were folded away — hidden is fine,
130
+ # silently dropped is not.
131
+ shown = [
132
+ m
133
+ for m in result.metrics
134
+ if verbose or m.slice is None or (m.key, _slice_text(m.slice)) in gates
135
+ ]
136
+ hidden = len(result.metrics) - len(shown)
137
+
138
+ rows: list[tuple[str, str, str, str, str, str]] = []
139
+ for metric in sorted(shown, key=lambda m: m.full_key):
140
+ delta = deltas.get(metric.full_key)
141
+ gate = gates.get((metric.key, _slice_text(metric.slice)))
142
+
143
+ baseline = _number(delta.baseline) if delta and delta.baseline is not None else "—"
144
+ change = "—"
145
+ if delta and delta.absolute_delta is not None:
146
+ change = f"{delta.absolute_delta:+.4g}"
147
+
148
+ verdict = theme.mark(gate.verdict) if gate else " "
149
+ detail = _gate_detail(gate) if gate else ""
150
+ if metric.error_count:
151
+ # Surfaced inline: a metric computed from mostly-failed evaluations is
152
+ # not the number it appears to be.
153
+ detail = f"{detail} [{metric.error_count} errored]".strip()
154
+
155
+ rows.append((metric.full_key, baseline, _number(metric.value), change, verdict, detail))
156
+
157
+ if not rows:
158
+ return [theme.paint("no metrics were produced", YELLOW)]
159
+
160
+ width = max(len(r[0]) for r in rows)
161
+ width = min(max(width, 24), 44)
162
+
163
+ header = f"{'METRIC'.ljust(width)} {'BASELINE':>10} {'CANDIDATE':>10} {'DELTA':>9} GATE"
164
+ lines = [theme.paint(header, DIM)]
165
+ for key, baseline, candidate, change, verdict, detail in rows:
166
+ label = key if len(key) <= width else key[: width - 1] + "…"
167
+ line = f"{label.ljust(width)} {baseline:>10} {candidate:>10} {change:>9} {verdict}"
168
+ if detail:
169
+ line = f"{line} {detail}"
170
+ lines.append(line)
171
+
172
+ if hidden:
173
+ lines.append(
174
+ theme.paint(f"{hidden} sliced metric(s) hidden; --verbose or see the JSON report", DIM)
175
+ )
176
+ return lines
177
+
178
+
179
+ def _gate_detail(gate: Any) -> str:
180
+ """The threshold, inline, so nobody has to open the suite to read the row."""
181
+ parts: list[str] = []
182
+ if gate.rule == "minimum" or (gate.threshold is not None and gate.rule == "minimum"):
183
+ parts.append(f"min {gate.threshold:.4g}")
184
+ elif gate.rule == "maximum":
185
+ parts.append(f"max {gate.threshold:.4g}")
186
+ elif gate.rule in ("max_absolute_regression", "max_relative_regression"):
187
+ parts.append(f"maxΔ {gate.threshold:.4g}")
188
+ elif gate.rule == "error_rate":
189
+ parts.append("evaluator errors")
190
+ elif gate.rule == "metric_missing":
191
+ parts.append("metric not produced")
192
+ elif gate.rule == "no_data":
193
+ parts.append("nothing measured")
194
+
195
+ if not gate.blocking:
196
+ parts.append("non-blocking")
197
+ return f" {DIM}{', '.join(parts)}{RESET}" if parts and gate.severity else ", ".join(parts)
198
+
199
+
200
+ def _warnings(comparison: Comparison | None, hints: list[str] | None, theme: Style) -> list[str]:
201
+ lines: list[str] = []
202
+ for warning in comparison.warnings if comparison else []:
203
+ lines.append("")
204
+ lines.append(theme.paint(f"{theme.warn} {warning}", YELLOW))
205
+ for hint in hints or []:
206
+ lines.append("")
207
+ lines.append(theme.paint(f"{theme.warn} {hint}", YELLOW))
208
+ return lines
209
+
210
+
211
+ def _failures(result: EvalResult, comparison: Comparison | None, theme: Style) -> list[str]:
212
+ lines: list[str] = []
213
+ blocking = result.gates.blocking_failures
214
+ warnings = result.gates.warnings
215
+
216
+ summary: list[str] = []
217
+ if blocking:
218
+ summary.append(f"{len(blocking)} blocking failure{'s' if len(blocking) != 1 else ''}")
219
+ if warnings:
220
+ summary.append(f"{len(warnings)} warning{'s' if len(warnings) != 1 else ''}")
221
+ regressions = comparison.regressions if comparison else []
222
+ if regressions:
223
+ summary.append(
224
+ f"{len(regressions)} regressed example{'s' if len(regressions) != 1 else ''}"
225
+ )
226
+ if result.error_count:
227
+ summary.append(
228
+ f"{result.error_count} failed example{'s' if result.error_count != 1 else ''}"
229
+ )
230
+
231
+ if summary:
232
+ lines.append("")
233
+ lines.append(" · ".join(summary))
234
+
235
+ for gate in blocking:
236
+ lines.append("")
237
+ lines.append(f"{theme.mark(gate.verdict)} {gate.metric_key} {gate.message}")
238
+
239
+ # Concrete examples, not just aggregate movement: "which one broke" is the first
240
+ # question anyone asks, and answering it in the report saves a round trip.
241
+ for regression in regressions[:MAX_LISTED_FAILURES]:
242
+ lines.append(
243
+ f" {theme.paint(theme.cross, RED)} {regression.metric:<24} {regression.example_id}"
244
+ f" {_number(regression.baseline_score)} → {_number(regression.candidate_score)}"
245
+ )
246
+ if len(regressions) > MAX_LISTED_FAILURES:
247
+ remaining = len(regressions) - MAX_LISTED_FAILURES
248
+ lines.append(theme.paint(f" … {remaining} more in the JSON report", DIM))
249
+
250
+ for failure in result.failures()[:MAX_LISTED_FAILURES]:
251
+ message = failure.error.message if failure.error else failure.status.value
252
+ lines.append(f" {theme.paint(theme.query, RED)} {failure.example_id} {message}")
253
+
254
+ return lines
255
+
256
+
257
+ def _footer(
258
+ result: EvalResult, report_path: str | None, experiment_url: str | None, theme: Style
259
+ ) -> list[str]:
260
+ lines = [""]
261
+ cost = float(result.total_cost)
262
+ lines.append(
263
+ theme.paint(
264
+ f"{len(result.results)} examples in {result.duration_s:.1f}s · ${cost:.4f}", DIM
265
+ )
266
+ )
267
+ if report_path:
268
+ lines.append(theme.paint(f"Report: {report_path}", DIM))
269
+ if experiment_url:
270
+ lines.append(theme.paint(f"Experiment: {experiment_url}", DIM))
271
+
272
+ verdict = result.gates.verdict
273
+ colour = {Verdict.PASS: GREEN, Verdict.WARN: YELLOW}.get(verdict, RED)
274
+ lines.append(theme.paint(f"{verdict.value} (exit {result.exit_code})", colour))
275
+ return lines
276
+
277
+
278
+ def _slice_text(slice_: dict[str, str] | None) -> str:
279
+ return ",".join(f"{k}={v}" for k, v in sorted(slice_.items())) if slice_ else ""
280
+
281
+
282
+ def _number(value: float | None) -> str:
283
+ if value is None:
284
+ return "—"
285
+ if abs(value) >= 1000 or (value and abs(value) < 0.001):
286
+ return f"{value:.4g}"
287
+ return f"{value:.4f}".rstrip("0").rstrip(".") or "0"