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,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}"