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.
- proofstep_cli/__init__.py +8 -0
- proofstep_cli/calibration.py +318 -0
- proofstep_cli/calibration_store.py +215 -0
- proofstep_cli/commands/__init__.py +1 -0
- proofstep_cli/main.py +562 -0
- proofstep_cli/publish.py +541 -0
- proofstep_cli/py.typed +0 -0
- proofstep_cli/registry.py +234 -0
- proofstep_cli/render/__init__.py +1 -0
- proofstep_cli/render/calibration.py +127 -0
- proofstep_cli/render/markdown.py +304 -0
- proofstep_cli/render/report.py +193 -0
- proofstep_cli/render/terminal.py +287 -0
- proofstep_cli/runner.py +374 -0
- proofstep_cli/suite/__init__.py +1 -0
- proofstep_cli/suite/loader.py +377 -0
- proofstep_cli/suite/schema.py +343 -0
- proofstep_cli-0.1.0.dist-info/METADATA +58 -0
- proofstep_cli-0.1.0.dist-info/RECORD +21 -0
- proofstep_cli-0.1.0.dist-info/WHEEL +4 -0
- proofstep_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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"
|