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,234 @@
|
|
|
1
|
+
"""Build evaluator instances from suite specs.
|
|
2
|
+
|
|
3
|
+
The registry lives in the CLI rather than in `evaluation-core`, because it is
|
|
4
|
+
config-driven: it turns YAML into objects. Keeping it out of the core is what lets
|
|
5
|
+
the core stay a pure library with no notion of a file format.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from proofstep_core.evaluators import (
|
|
14
|
+
CalibrationEvaluator,
|
|
15
|
+
ClassificationEvaluator,
|
|
16
|
+
Contains,
|
|
17
|
+
DiscriminationEvaluator,
|
|
18
|
+
ExactMatch,
|
|
19
|
+
JsonSchemaMatch,
|
|
20
|
+
LengthWithin,
|
|
21
|
+
LLMJudge,
|
|
22
|
+
NumericRange,
|
|
23
|
+
OperationalEvaluator,
|
|
24
|
+
RankingEvaluator,
|
|
25
|
+
RegexMatch,
|
|
26
|
+
SetComparison,
|
|
27
|
+
)
|
|
28
|
+
from proofstep_trajectory.evaluator import TrajectoryEvaluator
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from proofstep_cli.suite.loader import LoadedSuite
|
|
32
|
+
from proofstep_cli.suite.schema import EvaluatorSpec
|
|
33
|
+
|
|
34
|
+
CORPUS_TYPES = frozenset(
|
|
35
|
+
{"classification", "ranking", "calibration", "discrimination", "operational"}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RegistryError(ValueError):
|
|
40
|
+
"""An evaluator spec could not be turned into an evaluator."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_evaluators(loaded: LoadedSuite) -> tuple[list[Any], list[Any]]:
|
|
44
|
+
"""Return (per-example evaluators, corpus evaluators).
|
|
45
|
+
|
|
46
|
+
The split is not cosmetic. Corpus metrics — F1, NDCG, percentiles — are not
|
|
47
|
+
means of per-example scores, and running them through per-example aggregation
|
|
48
|
+
would produce plausible, wrong numbers.
|
|
49
|
+
"""
|
|
50
|
+
per_example: list[Any] = []
|
|
51
|
+
corpus: list[Any] = []
|
|
52
|
+
|
|
53
|
+
for spec in loaded.suite.evaluators:
|
|
54
|
+
built = _build_one(spec, loaded)
|
|
55
|
+
if spec.type in CORPUS_TYPES:
|
|
56
|
+
corpus.append(built)
|
|
57
|
+
else:
|
|
58
|
+
per_example.append(built)
|
|
59
|
+
return per_example, corpus
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_one(spec: EvaluatorSpec, loaded: LoadedSuite) -> Any: # noqa: PLR0911, PLR0912
|
|
63
|
+
field = spec.field or "output"
|
|
64
|
+
|
|
65
|
+
if spec.type == "exact_match":
|
|
66
|
+
return ExactMatch(
|
|
67
|
+
name=spec.name,
|
|
68
|
+
field=field,
|
|
69
|
+
expected_field=spec.expected_field,
|
|
70
|
+
normalize=spec.normalize,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if spec.type == "json_schema":
|
|
74
|
+
return JsonSchemaMatch(_load_schema(spec, loaded), name=spec.name, field=field)
|
|
75
|
+
|
|
76
|
+
if spec.type == "regex":
|
|
77
|
+
return RegexMatch(name=spec.name, field=field, allow=spec.allow, deny=spec.deny)
|
|
78
|
+
|
|
79
|
+
if spec.type == "contains":
|
|
80
|
+
return Contains(
|
|
81
|
+
spec.substrings,
|
|
82
|
+
name=spec.name,
|
|
83
|
+
field=field,
|
|
84
|
+
mode=spec.mode or "all", # type: ignore[arg-type]
|
|
85
|
+
case_sensitive=spec.case_sensitive,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if spec.type == "length":
|
|
89
|
+
return LengthWithin(
|
|
90
|
+
name=spec.name,
|
|
91
|
+
field=field,
|
|
92
|
+
minimum=int(spec.minimum) if spec.minimum is not None else None,
|
|
93
|
+
maximum=int(spec.maximum) if spec.maximum is not None else None,
|
|
94
|
+
unit=spec.unit,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if spec.type == "numeric_range":
|
|
98
|
+
return NumericRange(
|
|
99
|
+
name=spec.name,
|
|
100
|
+
field=field,
|
|
101
|
+
minimum=spec.minimum,
|
|
102
|
+
maximum=spec.maximum,
|
|
103
|
+
inclusive=spec.inclusive,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if spec.type == "set_comparison":
|
|
107
|
+
return SetComparison(
|
|
108
|
+
name=spec.name,
|
|
109
|
+
field=field,
|
|
110
|
+
expected_field=spec.expected_field,
|
|
111
|
+
mode=spec.mode or "equals", # type: ignore[arg-type]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if spec.type == "llm_judge":
|
|
115
|
+
assert spec.model is not None # guaranteed by schema validation
|
|
116
|
+
return LLMJudge(
|
|
117
|
+
name=spec.name,
|
|
118
|
+
rubric=_load_rubric(spec, loaded),
|
|
119
|
+
model=spec.model,
|
|
120
|
+
inputs=spec.inputs,
|
|
121
|
+
mode="classify" if spec.labels else "rubric",
|
|
122
|
+
labels=spec.labels or None,
|
|
123
|
+
passing_labels=_passing_labels(spec),
|
|
124
|
+
scale=(spec.scale.min, spec.scale.max),
|
|
125
|
+
normalize=spec.scale.normalize,
|
|
126
|
+
temperature=spec.temperature,
|
|
127
|
+
seed=spec.seed,
|
|
128
|
+
votes=spec.votes,
|
|
129
|
+
timeout_s=spec.timeout_s,
|
|
130
|
+
max_retries=spec.max_retries,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if spec.type == "trajectory":
|
|
134
|
+
assert spec.policy is not None
|
|
135
|
+
return TrajectoryEvaluator(loaded.resolve_path(spec.policy), name=spec.name)
|
|
136
|
+
|
|
137
|
+
if spec.type == "classification":
|
|
138
|
+
return ClassificationEvaluator(
|
|
139
|
+
name=spec.name,
|
|
140
|
+
prediction_field=spec.prediction_field or "intent",
|
|
141
|
+
label_field=spec.label_field,
|
|
142
|
+
averaging=spec.averaging,
|
|
143
|
+
labels=spec.labels or None,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if spec.type == "ranking":
|
|
147
|
+
return RankingEvaluator(
|
|
148
|
+
name=spec.name,
|
|
149
|
+
k=spec.k,
|
|
150
|
+
ranking_field=spec.ranking_field or "results",
|
|
151
|
+
relevant_field=spec.relevant_field or "relevant",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if spec.type == "discrimination":
|
|
155
|
+
return DiscriminationEvaluator(
|
|
156
|
+
name=spec.name,
|
|
157
|
+
score_field=spec.prediction_field or "predicted",
|
|
158
|
+
outcome_field=spec.label_field or "correct",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
if spec.type == "calibration":
|
|
162
|
+
return CalibrationEvaluator(
|
|
163
|
+
correct_field=spec.correct_field,
|
|
164
|
+
name=spec.name,
|
|
165
|
+
confidence_field=spec.confidence_field or "confidence",
|
|
166
|
+
prediction_field=spec.prediction_field or "intent",
|
|
167
|
+
label_field=spec.label_field or "intent",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if spec.type == "operational":
|
|
171
|
+
return OperationalEvaluator(name=spec.name, percentiles=spec.percentiles)
|
|
172
|
+
|
|
173
|
+
msg = f"no builder for evaluator type {spec.type!r}"
|
|
174
|
+
raise RegistryError(msg)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _load_schema(spec: EvaluatorSpec, loaded: LoadedSuite) -> dict[str, Any]:
|
|
178
|
+
if spec.schema_ is not None:
|
|
179
|
+
return spec.schema_
|
|
180
|
+
if spec.schema_path is None:
|
|
181
|
+
msg = f"evaluator {spec.name!r} needs `schema` or `schema_path`"
|
|
182
|
+
raise RegistryError(msg)
|
|
183
|
+
path = loaded.resolve_path(spec.schema_path)
|
|
184
|
+
try:
|
|
185
|
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
|
186
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
187
|
+
msg = f"evaluator {spec.name!r}: cannot read schema {path}: {exc}"
|
|
188
|
+
raise RegistryError(msg) from exc
|
|
189
|
+
if not isinstance(parsed, dict):
|
|
190
|
+
msg = f"evaluator {spec.name!r}: schema at {path} is not a JSON object"
|
|
191
|
+
raise RegistryError(msg)
|
|
192
|
+
return parsed
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _passing_labels(spec: EvaluatorSpec) -> list[str] | None:
|
|
196
|
+
"""The judge's passing labels, from the evaluator or its calibration block.
|
|
197
|
+
|
|
198
|
+
Two places because a suite that only calibrates does not need the evaluator-level field, and
|
|
199
|
+
one that only gates does not need the calibration block. Preferring the evaluator keeps the
|
|
200
|
+
scoring definition next to the scoring.
|
|
201
|
+
"""
|
|
202
|
+
if spec.passing_labels:
|
|
203
|
+
return spec.passing_labels
|
|
204
|
+
if spec.calibration and spec.calibration.passing_labels:
|
|
205
|
+
return list(spec.calibration.passing_labels)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_rubric_text(spec: EvaluatorSpec, loaded: LoadedSuite) -> str:
|
|
210
|
+
"""The rubric as text, wherever it came from.
|
|
211
|
+
|
|
212
|
+
Public because the evaluator's version hash is computed over the rubric *text*, not
|
|
213
|
+
its path: editing `rubrics/groundedness.md` in place redefines the metric, and a hash
|
|
214
|
+
over the path would leave the old calibration blessing a different ruler.
|
|
215
|
+
"""
|
|
216
|
+
return _load_rubric(spec, loaded)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _load_rubric(spec: EvaluatorSpec, loaded: LoadedSuite) -> str:
|
|
220
|
+
if spec.rubric:
|
|
221
|
+
return spec.rubric
|
|
222
|
+
assert spec.rubric_path is not None
|
|
223
|
+
return loaded.resolve_path(spec.rubric_path).read_text(encoding="utf-8")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def estimate_judge_calls(loaded: LoadedSuite, example_count: int) -> int:
|
|
227
|
+
"""How many model calls a run will make, for `--dry-run`.
|
|
228
|
+
|
|
229
|
+
Being able to see the cost before paying it is the point: a suite can be
|
|
230
|
+
expensive, and discovering that after the fact is a bad way to learn.
|
|
231
|
+
"""
|
|
232
|
+
return sum(
|
|
233
|
+
example_count * spec.votes for spec in loaded.suite.evaluators if spec.type == "llm_judge"
|
|
234
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Report renderers."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Terminal rendering of a calibration report.
|
|
2
|
+
|
|
3
|
+
Ordered by what a reader has to decide: does this judge pass, why not, and what should
|
|
4
|
+
change. κ comes before agreement because agreement alone is the number that misleads —
|
|
5
|
+
90 % agreement on a 90 %-majority set means nothing, and putting it first invites exactly
|
|
6
|
+
that reading.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from proofstep_cli.render.terminal import GREEN, RED, YELLOW, Style
|
|
12
|
+
from proofstep_core.calibration import CalibrationReport, RequirementCheck
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def render( # noqa: PLR0912 — a report is a sequence of small sections
|
|
16
|
+
report: CalibrationReport,
|
|
17
|
+
check: RequirementCheck,
|
|
18
|
+
*,
|
|
19
|
+
evaluator: str,
|
|
20
|
+
version_hash: str,
|
|
21
|
+
style: Style | None = None,
|
|
22
|
+
) -> str:
|
|
23
|
+
theme = style or Style(colour=False, unicode_=True)
|
|
24
|
+
lines: list[str] = []
|
|
25
|
+
|
|
26
|
+
verdict = "pass" if check.satisfied else "fail"
|
|
27
|
+
lines.append(
|
|
28
|
+
f"{theme.mark(verdict)} calibration {evaluator} "
|
|
29
|
+
f"{theme.paint(version_hash, YELLOW if not check.satisfied else GREEN)}"
|
|
30
|
+
)
|
|
31
|
+
lines.append("")
|
|
32
|
+
|
|
33
|
+
kappa = _kappa_line(report)
|
|
34
|
+
lines.append(f" {'κ':<22} {kappa}")
|
|
35
|
+
lines.append(
|
|
36
|
+
f" {'agreement':<22} {report.agreement:.3f} "
|
|
37
|
+
f"[{report.agreement_ci[0]:.3f}, {report.agreement_ci[1]:.3f}]"
|
|
38
|
+
)
|
|
39
|
+
lines.append(f" {'examples':<22} {report.n_examples}")
|
|
40
|
+
if report.n_errored:
|
|
41
|
+
lines.append(
|
|
42
|
+
f" {'errored calls':<22} "
|
|
43
|
+
f"{theme.paint(f'{report.n_errored} ({report.error_rate:.1%})', YELLOW)}"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# The directional rates, always both, always labelled with what they mean. Reporting
|
|
47
|
+
# a single "error rate" here would hide the only distinction that matters.
|
|
48
|
+
lines.append(
|
|
49
|
+
f" {'false pass':<22} {_rate(report.false_pass_rate)}"
|
|
50
|
+
" (judge passed what a human failed — ships defects)"
|
|
51
|
+
)
|
|
52
|
+
lines.append(
|
|
53
|
+
f" {'false fail':<22} {_rate(report.false_fail_rate)}"
|
|
54
|
+
" (judge failed what a human passed — erodes trust)"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if report.human_kappa is not None:
|
|
58
|
+
ceiling = f"{report.human_kappa:.3f} on {report.n_ceiling_examples} doubly-labelled"
|
|
59
|
+
if report.at_human_ceiling:
|
|
60
|
+
ceiling += theme.paint(" — judge is at the ceiling", GREEN)
|
|
61
|
+
lines.append(f" {'human ceiling κ':<22} {ceiling}")
|
|
62
|
+
|
|
63
|
+
if report.leniency is not None:
|
|
64
|
+
lines.append(f" {'leniency':<22} {report.leniency:+.2f} scale points vs humans")
|
|
65
|
+
if report.scale_compression is not None:
|
|
66
|
+
lines.append(f" {'scale used':<22} {report.scale_compression:.0%} of the human spread")
|
|
67
|
+
if report.verbosity_bias is not None:
|
|
68
|
+
lines.append(f" {'verbosity bias':<22} {report.verbosity_bias:+.2f}")
|
|
69
|
+
|
|
70
|
+
if report.position_bias is not None:
|
|
71
|
+
bias = report.position_bias
|
|
72
|
+
text = (
|
|
73
|
+
f"{bias.inconsistency_rate:.1%} inconsistent, "
|
|
74
|
+
f"{bias.first_position_rate:.1%} first-position"
|
|
75
|
+
)
|
|
76
|
+
lines.append(f" {'order effects':<22} {theme.paint(text, RED) if bias.biased else text}")
|
|
77
|
+
|
|
78
|
+
lines.append(f" {'cost':<22} {report.total_cost} total, {report.mean_cost} per example")
|
|
79
|
+
lines.append(
|
|
80
|
+
f" {'latency':<22} p50 {report.p50_latency_ms:.0f}ms p95 {report.p95_latency_ms:.0f}ms"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if report.per_class:
|
|
84
|
+
lines.append("")
|
|
85
|
+
lines.append(" per class")
|
|
86
|
+
lines.append(f" {'label':<20} {'n':>5} {'recall':>8} {'precision':>10} confused with")
|
|
87
|
+
for entry in report.per_class:
|
|
88
|
+
confusion = (
|
|
89
|
+
f"{entry.top_confusion[0]} x{entry.top_confusion[1]}" if entry.top_confusion else ""
|
|
90
|
+
)
|
|
91
|
+
lines.append(
|
|
92
|
+
f" {entry.label:<20} {entry.support:>5} {entry.recall:>8.3f} "
|
|
93
|
+
f"{entry.precision:>10.3f} {confusion}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if check.failures:
|
|
97
|
+
lines.append("")
|
|
98
|
+
lines.append(theme.paint(" requirement not met", RED))
|
|
99
|
+
for failure in check.failures:
|
|
100
|
+
lines.append(f" {theme.paint(theme.cross, RED)} {failure}")
|
|
101
|
+
|
|
102
|
+
if check.warnings:
|
|
103
|
+
lines.append("")
|
|
104
|
+
for warning in check.warnings:
|
|
105
|
+
lines.append(f" {theme.paint(theme.warn, YELLOW)} {warning}")
|
|
106
|
+
|
|
107
|
+
if report.notes:
|
|
108
|
+
lines.append("")
|
|
109
|
+
for note in report.notes:
|
|
110
|
+
lines.append(f" {theme.paint('note', YELLOW)} {note}")
|
|
111
|
+
|
|
112
|
+
return "\n".join(lines)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _kappa_line(report: CalibrationReport) -> str:
|
|
116
|
+
if report.kappa is None:
|
|
117
|
+
# Never printed as a number. "κ 0.000" would read as a measured result rather
|
|
118
|
+
# than an undefined one, and the reason is the actionable part.
|
|
119
|
+
return f"undefined — {report.kappa_undefined_reason}"
|
|
120
|
+
interval = f" [{report.kappa_ci[0]:.3f}, {report.kappa_ci[1]:.3f}]" if report.kappa_ci else ""
|
|
121
|
+
return f"{report.kappa:.3f}{interval} ({report.kappa_kind})"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rate(value: float | None) -> str:
|
|
125
|
+
# "unmeasured" rather than 0.000: a rate over an empty denominator is not zero, and a
|
|
126
|
+
# calibration set with no negatives cannot show whether the judge catches anything.
|
|
127
|
+
return "unmeasured" if value is None else f"{value:.3f}"
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Render a report as the pull-request comment.
|
|
2
|
+
|
|
3
|
+
Written from the JSON report rather than from live objects, so the Action can post a
|
|
4
|
+
comment for a run it did not execute — and so this renderer is a pure function of a
|
|
5
|
+
file, which makes it snapshot-testable with no GitHub involved.
|
|
6
|
+
|
|
7
|
+
Two constraints shape the output:
|
|
8
|
+
|
|
9
|
+
- **GitHub caps a comment at 65,536 characters.** Exceeding it fails the API call,
|
|
10
|
+
which would mean no comment at all. Truncation is therefore deliberate, ordered
|
|
11
|
+
worst-first, and always states what was dropped.
|
|
12
|
+
- **The reader is skimming a PR.** The verdict and the blocking reason must be
|
|
13
|
+
visible without expanding anything; everything else goes in `<details>`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
# Comments are found and updated by this marker rather than by author or position,
|
|
21
|
+
# so a re-run edits its own comment instead of appending a new one.
|
|
22
|
+
MARKER = "<!-- proofstep-report -->"
|
|
23
|
+
|
|
24
|
+
GITHUB_COMMENT_LIMIT = 65_536
|
|
25
|
+
TRUNCATION_BUDGET = GITHUB_COMMENT_LIMIT - 2_000
|
|
26
|
+
|
|
27
|
+
VERDICT_HEADLINE = {
|
|
28
|
+
"pass": ("✅", "Quality gates passed"),
|
|
29
|
+
"warn": ("⚠️", "Quality gates passed with warnings"),
|
|
30
|
+
"fail": ("❌", "Quality gates failed"),
|
|
31
|
+
"error": ("🚨", "Evaluation could not be completed"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
MAX_METRIC_ROWS = 40
|
|
35
|
+
MAX_REGRESSIONS = 15
|
|
36
|
+
MAX_FAILURES = 10
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render(report: dict[str, Any], *, run_url: str | None = None) -> str:
|
|
40
|
+
"""Build the comment body. Always returns something postable."""
|
|
41
|
+
verdict = str(report.get("verdict", "error"))
|
|
42
|
+
icon, headline = VERDICT_HEADLINE.get(verdict, VERDICT_HEADLINE["error"])
|
|
43
|
+
|
|
44
|
+
parts: list[str] = [MARKER, "", f"### {icon} Proofstep — {headline}", ""]
|
|
45
|
+
parts.extend(_summary(report))
|
|
46
|
+
|
|
47
|
+
if aborted := report.get("aborted_reason"):
|
|
48
|
+
parts += ["", f"> **Run aborted.** {aborted}", ""]
|
|
49
|
+
|
|
50
|
+
parts.extend(_blocking(report))
|
|
51
|
+
parts.extend(_metrics(report))
|
|
52
|
+
parts.extend(_regressions(report))
|
|
53
|
+
parts.extend(_failures(report))
|
|
54
|
+
parts.extend(_warnings(report))
|
|
55
|
+
parts.extend(_footer(report, run_url))
|
|
56
|
+
|
|
57
|
+
return _fit("\n".join(parts))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render_error(message: str, *, suite: str | None = None, run_url: str | None = None) -> str:
|
|
61
|
+
"""A comment for a run that never produced a report.
|
|
62
|
+
|
|
63
|
+
Posting something is essential: an absent comment reads as "no problems found",
|
|
64
|
+
which is the opposite of what happened.
|
|
65
|
+
"""
|
|
66
|
+
parts = [
|
|
67
|
+
MARKER,
|
|
68
|
+
"",
|
|
69
|
+
"### 🚨 Proofstep — evaluation did not run",
|
|
70
|
+
"",
|
|
71
|
+
f"The evaluation{f' for `{suite}`' if suite else ''} failed before producing a report.",
|
|
72
|
+
"",
|
|
73
|
+
"```",
|
|
74
|
+
message.strip()[:3000],
|
|
75
|
+
"```",
|
|
76
|
+
"",
|
|
77
|
+
"_No quality gates were evaluated, so this result says nothing about the change "
|
|
78
|
+
"itself — only that the evaluation could not complete._",
|
|
79
|
+
]
|
|
80
|
+
if run_url:
|
|
81
|
+
parts += ["", f"[View workflow run]({run_url})"]
|
|
82
|
+
return "\n".join(parts)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ----------------------------------------------------------------------- sections
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _summary(report: dict[str, Any]) -> list[str]:
|
|
89
|
+
totals = report.get("totals", {})
|
|
90
|
+
dataset = report.get("dataset", {})
|
|
91
|
+
gates = report.get("gates", [])
|
|
92
|
+
|
|
93
|
+
blocking = sum(1 for g in gates if g.get("verdict") in ("fail", "error") and g.get("blocking"))
|
|
94
|
+
warnings = sum(
|
|
95
|
+
1 for g in gates if g.get("verdict") in ("fail", "error") and not g.get("blocking")
|
|
96
|
+
)
|
|
97
|
+
passed = sum(1 for g in gates if g.get("verdict") == "pass")
|
|
98
|
+
|
|
99
|
+
rows = [
|
|
100
|
+
f"| Suite | `{report.get('suite', '?')}` |",
|
|
101
|
+
f"| Gates | {passed} passed, {blocking} blocking, {warnings} warning |",
|
|
102
|
+
f"| Examples | {totals.get('examples', 0)} ({totals.get('errors', 0)} failed) |",
|
|
103
|
+
]
|
|
104
|
+
if dataset.get("content_hash"):
|
|
105
|
+
label = dataset.get("name") or "dataset"
|
|
106
|
+
if dataset.get("version"):
|
|
107
|
+
label = f"{label}@{dataset['version']}"
|
|
108
|
+
rows.append(f"| Dataset | `{label}` · `{dataset['content_hash'][:12]}` |")
|
|
109
|
+
if (cost := totals.get("total_cost")) is not None:
|
|
110
|
+
rows.append(f"| Cost | ${float(cost):.4f} |")
|
|
111
|
+
if commit := (report.get("git") or {}).get("commit"):
|
|
112
|
+
rows.append(f"| Commit | `{commit[:8]}` |")
|
|
113
|
+
|
|
114
|
+
return ["| | |", "|---|---|", *rows]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _blocking(report: dict[str, Any]) -> list[str]:
|
|
118
|
+
"""The reason the build failed, above the fold and never collapsed."""
|
|
119
|
+
failures = [
|
|
120
|
+
g
|
|
121
|
+
for g in report.get("gates", [])
|
|
122
|
+
if g.get("verdict") in ("fail", "error") and g.get("blocking")
|
|
123
|
+
]
|
|
124
|
+
if not failures:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
lines = ["", "#### Blocking failures", ""]
|
|
128
|
+
for gate in failures:
|
|
129
|
+
key = _metric_label(gate)
|
|
130
|
+
lines.append(f"- **`{key}`** — {gate.get('message', 'failed')}")
|
|
131
|
+
return lines
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _metrics(report: dict[str, Any]) -> list[str]:
|
|
135
|
+
metrics = report.get("metrics", [])
|
|
136
|
+
if not metrics:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
gated = {(g.get("metric_key"), _slice_text(g.get("slice"))) for g in report.get("gates", [])}
|
|
140
|
+
interesting = [
|
|
141
|
+
m
|
|
142
|
+
for m in metrics
|
|
143
|
+
if m.get("slice") is None or (m.get("key"), _slice_text(m.get("slice"))) in gated
|
|
144
|
+
]
|
|
145
|
+
hidden = len(metrics) - len(interesting)
|
|
146
|
+
shown = interesting[:MAX_METRIC_ROWS]
|
|
147
|
+
|
|
148
|
+
lines = [
|
|
149
|
+
"",
|
|
150
|
+
"<details><summary>Metrics</summary>",
|
|
151
|
+
"",
|
|
152
|
+
"| Metric | Baseline | Candidate | Δ | Gate |",
|
|
153
|
+
"|---|---:|---:|---:|---|",
|
|
154
|
+
]
|
|
155
|
+
gates_by_key = {
|
|
156
|
+
(g.get("metric_key"), _slice_text(g.get("slice"))): g for g in report.get("gates", [])
|
|
157
|
+
}
|
|
158
|
+
for metric in sorted(shown, key=_metric_label):
|
|
159
|
+
gate = gates_by_key.get((metric.get("key"), _slice_text(metric.get("slice"))))
|
|
160
|
+
lines.append(
|
|
161
|
+
"| `{key}` | {baseline} | {candidate} | {delta} | {gate} |".format(
|
|
162
|
+
key=_metric_label(metric),
|
|
163
|
+
baseline=_number(metric.get("baseline")),
|
|
164
|
+
candidate=_number(metric.get("value")),
|
|
165
|
+
delta=_delta(metric.get("absolute_delta")),
|
|
166
|
+
gate=_gate_cell(gate),
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
trailing = []
|
|
171
|
+
if hidden or len(interesting) > MAX_METRIC_ROWS:
|
|
172
|
+
folded = hidden + max(0, len(interesting) - MAX_METRIC_ROWS)
|
|
173
|
+
trailing = ["", f"_{folded} further metric(s) in the JSON artifact._"]
|
|
174
|
+
|
|
175
|
+
return [*lines, *trailing, "", "</details>"]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _regressions(report: dict[str, Any]) -> list[str]:
|
|
179
|
+
regressions = report.get("regressed_examples", [])
|
|
180
|
+
if not regressions:
|
|
181
|
+
return []
|
|
182
|
+
|
|
183
|
+
lines = [
|
|
184
|
+
"",
|
|
185
|
+
f"<details><summary>Regressed examples ({len(regressions)})</summary>",
|
|
186
|
+
"",
|
|
187
|
+
"| Example | Metric | Baseline | Candidate |",
|
|
188
|
+
"|---|---|---:|---:|",
|
|
189
|
+
]
|
|
190
|
+
for entry in regressions[:MAX_REGRESSIONS]:
|
|
191
|
+
lines.append(
|
|
192
|
+
f"| `{entry.get('example_id')}` | `{entry.get('metric')}` | "
|
|
193
|
+
f"{_number(entry.get('baseline_score'))} | {_number(entry.get('candidate_score'))} |"
|
|
194
|
+
)
|
|
195
|
+
if len(regressions) > MAX_REGRESSIONS:
|
|
196
|
+
lines += ["", f"_{len(regressions) - MAX_REGRESSIONS} more in the JSON artifact._"]
|
|
197
|
+
return [*lines, "", "</details>"]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _failures(report: dict[str, Any]) -> list[str]:
|
|
201
|
+
failures = report.get("failures", [])
|
|
202
|
+
if not failures:
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
lines = ["", f"<details><summary>Failed examples ({len(failures)})</summary>", "", "```"]
|
|
206
|
+
for entry in failures[:MAX_FAILURES]:
|
|
207
|
+
lines.append(
|
|
208
|
+
f"{entry.get('example_id')} {entry.get('status')} {entry.get('error') or ''}"
|
|
209
|
+
)
|
|
210
|
+
if len(failures) > MAX_FAILURES:
|
|
211
|
+
lines.append(f"… {len(failures) - MAX_FAILURES} more")
|
|
212
|
+
return [*lines, "```", "", "</details>"]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _warnings(report: dict[str, Any]) -> list[str]:
|
|
216
|
+
notes = list(report.get("hints", []))
|
|
217
|
+
notes += list((report.get("baseline") or {}).get("warnings", []))
|
|
218
|
+
if not notes:
|
|
219
|
+
return []
|
|
220
|
+
return ["", "#### Notes", "", *[f"- {note}" for note in notes]]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _footer(report: dict[str, Any], run_url: str | None) -> list[str]:
|
|
224
|
+
links = []
|
|
225
|
+
if url := report.get("experiment_url"):
|
|
226
|
+
links.append(f"[View experiment]({url})")
|
|
227
|
+
if run_url:
|
|
228
|
+
links.append(f"[Workflow run]({run_url})")
|
|
229
|
+
|
|
230
|
+
baseline = report.get("baseline") or {}
|
|
231
|
+
if baseline.get("dataset_match") is False:
|
|
232
|
+
links.append("⚠️ compared against a different dataset")
|
|
233
|
+
|
|
234
|
+
footer = ["", "---", ""]
|
|
235
|
+
footer.append(" · ".join(links) if links else "_Run `proofstep eval` locally to reproduce._")
|
|
236
|
+
return footer
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------------ helpers
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _fit(body: str) -> str:
|
|
243
|
+
"""Keep the comment postable.
|
|
244
|
+
|
|
245
|
+
Truncating is not ideal, but a comment that exceeds the limit is rejected
|
|
246
|
+
outright — and no comment is far worse than a shortened one.
|
|
247
|
+
"""
|
|
248
|
+
if len(body) <= TRUNCATION_BUDGET:
|
|
249
|
+
return body
|
|
250
|
+
|
|
251
|
+
notice = (
|
|
252
|
+
"\n\n---\n\n_This comment was truncated to fit GitHub's size limit. "
|
|
253
|
+
"The complete report is attached as a workflow artifact._\n"
|
|
254
|
+
)
|
|
255
|
+
keep = TRUNCATION_BUDGET - len(notice)
|
|
256
|
+
# Cut at a line boundary so the result is not half a table row.
|
|
257
|
+
trimmed = body[:keep].rsplit("\n", 1)[0]
|
|
258
|
+
return trimmed + notice
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _metric_label(entry: dict[str, Any]) -> str:
|
|
262
|
+
key = entry.get("key") or entry.get("metric_key") or "?"
|
|
263
|
+
slice_text = _slice_text(entry.get("slice"))
|
|
264
|
+
return f"{key}[{slice_text}]" if slice_text else str(key)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _slice_text(slice_: dict[str, str] | None) -> str:
|
|
268
|
+
return ",".join(f"{k}={v}" for k, v in sorted(slice_.items())) if slice_ else ""
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _gate_cell(gate: dict[str, Any] | None) -> str:
|
|
272
|
+
if gate is None:
|
|
273
|
+
return ""
|
|
274
|
+
icon = {"pass": "✅", "fail": "❌", "warn": "⚠️", "error": "🚨"}.get(gate.get("verdict", ""), "")
|
|
275
|
+
threshold = gate.get("threshold")
|
|
276
|
+
rule = gate.get("rule")
|
|
277
|
+
detail = ""
|
|
278
|
+
if threshold is not None and rule == "minimum":
|
|
279
|
+
detail = f" min {threshold:g}"
|
|
280
|
+
elif threshold is not None and rule == "maximum":
|
|
281
|
+
detail = f" max {threshold:g}"
|
|
282
|
+
elif threshold is not None and rule and "regression" in rule:
|
|
283
|
+
detail = f" maxΔ {threshold:g}"
|
|
284
|
+
if not gate.get("blocking"):
|
|
285
|
+
detail += " (warn)"
|
|
286
|
+
return f"{icon}{detail}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _number(value: Any) -> str:
|
|
290
|
+
if value is None:
|
|
291
|
+
return "—"
|
|
292
|
+
try:
|
|
293
|
+
number = float(value)
|
|
294
|
+
except (TypeError, ValueError):
|
|
295
|
+
return str(value)
|
|
296
|
+
if number and (abs(number) >= 1000 or abs(number) < 0.001):
|
|
297
|
+
return f"{number:.4g}"
|
|
298
|
+
return f"{number:.4f}".rstrip("0").rstrip(".") or "0"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _delta(value: Any) -> str:
|
|
302
|
+
if value is None:
|
|
303
|
+
return "—"
|
|
304
|
+
return f"{float(value):+.4g}"
|