synthbench-eval 0.4.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.
- synthbench/__init__.py +16 -0
- synthbench/__main__.py +5 -0
- synthbench/adapter.py +131 -0
- synthbench/anomaly.py +503 -0
- synthbench/baseline_floors.py +213 -0
- synthbench/baselines.py +902 -0
- synthbench/cli.py +2929 -0
- synthbench/config_id.py +424 -0
- synthbench/contamination.py +783 -0
- synthbench/convergence/__init__.py +67 -0
- synthbench/convergence/baseline.py +172 -0
- synthbench/convergence/bootstrap.py +60 -0
- synthbench/convergence/cli_report.py +556 -0
- synthbench/convergence/curves.py +111 -0
- synthbench/convergence/real_sampling.py +146 -0
- synthbench/convergence/thresholds.py +49 -0
- synthbench/datasets/__init__.py +37 -0
- synthbench/datasets/base.py +147 -0
- synthbench/datasets/eurobarometer.py +335 -0
- synthbench/datasets/globalopinionqa.py +252 -0
- synthbench/datasets/gss.py +323 -0
- synthbench/datasets/michigan.py +505 -0
- synthbench/datasets/ntia.py +357 -0
- synthbench/datasets/opinionsqa.py +412 -0
- synthbench/datasets/pewtech.py +334 -0
- synthbench/datasets/policy.py +142 -0
- synthbench/datasets/subpop.py +302 -0
- synthbench/datasets/wvs.py +229 -0
- synthbench/findings.py +988 -0
- synthbench/holdout.py +347 -0
- synthbench/human_distributions.py +243 -0
- synthbench/leaderboard.py +715 -0
- synthbench/leaderboard_pr.py +274 -0
- synthbench/metrics/__init__.py +38 -0
- synthbench/metrics/composite.py +72 -0
- synthbench/metrics/conditioning.py +39 -0
- synthbench/metrics/distributional.py +41 -0
- synthbench/metrics/ranking.py +37 -0
- synthbench/metrics/refusal.py +270 -0
- synthbench/metrics/subgroup.py +58 -0
- synthbench/private_holdout.py +240 -0
- synthbench/providers/__init__.py +43 -0
- synthbench/providers/_parsing.py +150 -0
- synthbench/providers/_retry.py +107 -0
- synthbench/providers/base.py +212 -0
- synthbench/providers/http.py +108 -0
- synthbench/providers/majority_baseline.py +23 -0
- synthbench/providers/ollama.py +109 -0
- synthbench/providers/openrouter.py +144 -0
- synthbench/providers/population_baseline.py +87 -0
- synthbench/providers/random_baseline.py +24 -0
- synthbench/providers/raw_anthropic.py +149 -0
- synthbench/providers/raw_gemini.py +145 -0
- synthbench/providers/raw_openai.py +139 -0
- synthbench/providers/synthpanel.py +978 -0
- synthbench/publish.py +2772 -0
- synthbench/r2_upload.py +178 -0
- synthbench/recompute.py +235 -0
- synthbench/report.py +493 -0
- synthbench/run_hash.py +111 -0
- synthbench/run_validity.py +232 -0
- synthbench/runner.py +834 -0
- synthbench/stats.py +1492 -0
- synthbench/submission.py +284 -0
- synthbench/submission_pr.py +436 -0
- synthbench/submit_adapter.py +748 -0
- synthbench/suite.py +424 -0
- synthbench/suites/__init__.py +94 -0
- synthbench/topics.py +256 -0
- synthbench/user_config.py +409 -0
- synthbench/validation.py +1556 -0
- synthbench/visualize.py +455 -0
- synthbench_eval-0.4.0.dist-info/METADATA +279 -0
- synthbench_eval-0.4.0.dist-info/RECORD +78 -0
- synthbench_eval-0.4.0.dist-info/WHEEL +5 -0
- synthbench_eval-0.4.0.dist-info/entry_points.txt +2 -0
- synthbench_eval-0.4.0.dist-info/licenses/LICENSE +21 -0
- synthbench_eval-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Submission-PR validation checks (refs sb-6gw / GH#256).
|
|
2
|
+
|
|
3
|
+
Pure functions used by ``scripts/validate-submission-pr.py`` and the
|
|
4
|
+
``.github/workflows/validate-submission.yml`` workflow. Each check returns
|
|
5
|
+
a :class:`CheckResult` so the runner can aggregate them into a sticky PR
|
|
6
|
+
comment without re-running anything.
|
|
7
|
+
|
|
8
|
+
What this module is responsible for (the *adapter* submission flow only —
|
|
9
|
+
legacy ``provider``-keyed submissions stay on the existing
|
|
10
|
+
``validate-submissions`` job in ``ci.yml``):
|
|
11
|
+
|
|
12
|
+
1. Adapter identity is in ``data/accepted_adapters.json``.
|
|
13
|
+
2. Re-derived run hash matches the ``run_hash`` field in ``run.json``
|
|
14
|
+
(skipped gracefully until ``synthbench.run_hash`` lands — see sb-1ix).
|
|
15
|
+
3. ``submission.md`` carries the required headings + scores table shape.
|
|
16
|
+
|
|
17
|
+
Diff-style mismatch messages name *which* input drifted so a reviewer can
|
|
18
|
+
tell adapter-identity drift apart from raw-response drift.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import dataclasses
|
|
24
|
+
import json
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Iterable, Mapping
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CheckStatus = str # "pass" | "fail" | "skip"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True)
|
|
35
|
+
class CheckResult:
|
|
36
|
+
"""One check's outcome. ``status`` drives the runner's exit code:
|
|
37
|
+
any ``fail`` → non-zero. ``skip`` is non-fatal but surfaced in the
|
|
38
|
+
sticky comment so reviewers can see why a check did not run."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
status: CheckStatus
|
|
42
|
+
summary: str
|
|
43
|
+
details: tuple[str, ...] = ()
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def passed(self) -> bool:
|
|
47
|
+
return self.status == "pass"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def failed(self) -> bool:
|
|
51
|
+
return self.status == "fail"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Adapter identity registry
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_accepted_adapters(registry_path: Path) -> list[dict[str, str]]:
|
|
60
|
+
"""Return the list of accepted ``{name, version}`` entries.
|
|
61
|
+
|
|
62
|
+
Missing file is treated as an empty registry rather than an error: the
|
|
63
|
+
failure mode is a useful one (no adapters accepted yet → all
|
|
64
|
+
submissions fail the check with a clear message).
|
|
65
|
+
"""
|
|
66
|
+
if not registry_path.exists():
|
|
67
|
+
return []
|
|
68
|
+
raw = json.loads(registry_path.read_text())
|
|
69
|
+
adapters = raw.get("adapters", [])
|
|
70
|
+
if not isinstance(adapters, list):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"{registry_path}: 'adapters' must be a list, got {type(adapters).__name__}"
|
|
73
|
+
)
|
|
74
|
+
out: list[dict[str, str]] = []
|
|
75
|
+
for i, entry in enumerate(adapters):
|
|
76
|
+
if not isinstance(entry, Mapping):
|
|
77
|
+
raise ValueError(f"{registry_path}: adapters[{i}] must be an object")
|
|
78
|
+
name = entry.get("name")
|
|
79
|
+
version = entry.get("version")
|
|
80
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"{registry_path}: adapters[{i}] must have string 'name' and 'version'"
|
|
83
|
+
)
|
|
84
|
+
out.append({"name": name, "version": version})
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_adapter_registry(
|
|
89
|
+
run: Mapping[str, Any], accepted: Iterable[Mapping[str, str]]
|
|
90
|
+
) -> CheckResult:
|
|
91
|
+
"""Verify the submission's adapter identity is in the registry."""
|
|
92
|
+
adapter = run.get("adapter")
|
|
93
|
+
if not isinstance(adapter, Mapping):
|
|
94
|
+
return CheckResult(
|
|
95
|
+
name="adapter-registry",
|
|
96
|
+
status="fail",
|
|
97
|
+
summary="run.json has no `adapter` block",
|
|
98
|
+
details=(
|
|
99
|
+
"Submissions produced by `synthbench submit-adapter` must "
|
|
100
|
+
"carry an `adapter` object with at least `name` and `version`. "
|
|
101
|
+
"If this is a legacy provider-keyed submission, this workflow "
|
|
102
|
+
"should have skipped it — file a bug.",
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
name = adapter.get("name")
|
|
107
|
+
version = adapter.get("version")
|
|
108
|
+
if not isinstance(name, str) or not isinstance(version, str):
|
|
109
|
+
return CheckResult(
|
|
110
|
+
name="adapter-registry",
|
|
111
|
+
status="fail",
|
|
112
|
+
summary="`adapter.name` and `adapter.version` must be strings",
|
|
113
|
+
details=(
|
|
114
|
+
f" got name={name!r} version={version!r}",
|
|
115
|
+
" Vendors: re-run `synthbench submit-adapter` against the "
|
|
116
|
+
"latest scaffold.",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
for entry in accepted:
|
|
121
|
+
if entry["name"] == name and entry["version"] == version:
|
|
122
|
+
return CheckResult(
|
|
123
|
+
name="adapter-registry",
|
|
124
|
+
status="pass",
|
|
125
|
+
summary=f"adapter `{name}` v`{version}` is in the registry",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return CheckResult(
|
|
129
|
+
name="adapter-registry",
|
|
130
|
+
status="fail",
|
|
131
|
+
summary=f"adapter `{name}` v`{version}` is NOT in `data/accepted_adapters.json`",
|
|
132
|
+
details=(
|
|
133
|
+
"New adapters require explicit maintainer approval. Open a "
|
|
134
|
+
'**separate** PR adding `{"name": '
|
|
135
|
+
+ json.dumps(name)
|
|
136
|
+
+ ', "version": '
|
|
137
|
+
+ json.dumps(version)
|
|
138
|
+
+ "}` to `data/accepted_adapters.json`, wait for it to merge, "
|
|
139
|
+
"then re-push this submission.",
|
|
140
|
+
"Bundling a registry change with a submission change in one PR "
|
|
141
|
+
"defeats the gate — reviewers cannot tell which approval is for "
|
|
142
|
+
"which artifact.",
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Run-hash re-derivation
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Public so the runner can render the same skip reason in the sticky
|
|
153
|
+
# comment without re-importing.
|
|
154
|
+
RUN_HASH_SKIP_REASON = (
|
|
155
|
+
"`synthbench.run_hash` not present yet — check will activate once "
|
|
156
|
+
"[sb-1ix](../../issues?q=sb-1ix) lands the content-addressed run-hash "
|
|
157
|
+
"module."
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _import_run_hash() -> Optional[Any]:
|
|
162
|
+
"""Return the ``derive_run_hash`` callable from ``synthbench.run_hash``,
|
|
163
|
+
or ``None`` if the module/function is not yet implemented (sb-1ix)."""
|
|
164
|
+
try:
|
|
165
|
+
module = __import__("synthbench.run_hash", fromlist=["derive_run_hash"])
|
|
166
|
+
except ImportError:
|
|
167
|
+
return None
|
|
168
|
+
return getattr(module, "derive_run_hash", None)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def check_run_hash(
|
|
172
|
+
run: Mapping[str, Any],
|
|
173
|
+
suite_manifest: Optional[Mapping[str, Any]],
|
|
174
|
+
*,
|
|
175
|
+
derive: Optional[Any] = None,
|
|
176
|
+
) -> CheckResult:
|
|
177
|
+
"""Re-derive the run hash and compare it to ``run["run_hash"]``.
|
|
178
|
+
|
|
179
|
+
``derive`` is injectable for tests. In production it is resolved via
|
|
180
|
+
:func:`_import_run_hash`, so the check skips gracefully while sb-1ix
|
|
181
|
+
is in flight.
|
|
182
|
+
"""
|
|
183
|
+
derive_fn = derive if derive is not None else _import_run_hash()
|
|
184
|
+
if derive_fn is None:
|
|
185
|
+
return CheckResult(
|
|
186
|
+
name="run-hash",
|
|
187
|
+
status="skip",
|
|
188
|
+
summary="run-hash re-derivation skipped",
|
|
189
|
+
details=(RUN_HASH_SKIP_REASON,),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
declared = run.get("run_hash")
|
|
193
|
+
if declared is None:
|
|
194
|
+
# The scaffold writes null; not a fail until the eval pipeline
|
|
195
|
+
# actually populates it. Skip with an explicit note so reviewers
|
|
196
|
+
# see *why* nothing was checked.
|
|
197
|
+
return CheckResult(
|
|
198
|
+
name="run-hash",
|
|
199
|
+
status="skip",
|
|
200
|
+
summary="`run.json` has `run_hash: null`",
|
|
201
|
+
details=(
|
|
202
|
+
"This is the scaffold default. Once the eval pipeline "
|
|
203
|
+
"(sb-bas) and run-hash module (sb-1ix) both land, real "
|
|
204
|
+
"submissions will carry a hex string here and this check "
|
|
205
|
+
"will fail-closed.",
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
if not isinstance(declared, str):
|
|
209
|
+
return CheckResult(
|
|
210
|
+
name="run-hash",
|
|
211
|
+
status="fail",
|
|
212
|
+
summary=f"`run_hash` must be a string, got {type(declared).__name__}",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
adapter = run.get("adapter") or {}
|
|
216
|
+
raw_responses = run.get("raw_responses") or []
|
|
217
|
+
persona_seeds = run.get("persona_seeds")
|
|
218
|
+
try:
|
|
219
|
+
recomputed = derive_fn(
|
|
220
|
+
adapter=adapter,
|
|
221
|
+
suite_manifest=suite_manifest or {},
|
|
222
|
+
persona_seeds=persona_seeds,
|
|
223
|
+
raw_responses=raw_responses,
|
|
224
|
+
)
|
|
225
|
+
except Exception as exc: # noqa: BLE001 — surface vendor-facing error
|
|
226
|
+
return CheckResult(
|
|
227
|
+
name="run-hash",
|
|
228
|
+
status="fail",
|
|
229
|
+
summary=f"failed to re-derive run hash: {exc}",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if recomputed == declared:
|
|
233
|
+
return CheckResult(
|
|
234
|
+
name="run-hash",
|
|
235
|
+
status="pass",
|
|
236
|
+
summary=f"run hash re-derives to `{declared[:12]}…`",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Mismatch: try to pinpoint which input drifted. The sb-1ix module is
|
|
240
|
+
# expected to expose a ``components()`` helper that hashes each input
|
|
241
|
+
# group independently; if it's not there, we fall back to a generic
|
|
242
|
+
# diff message rather than guessing.
|
|
243
|
+
details = [
|
|
244
|
+
f" declared: {declared}",
|
|
245
|
+
f" re-derived: {recomputed}",
|
|
246
|
+
]
|
|
247
|
+
components = getattr(derive_fn, "components", None)
|
|
248
|
+
if components is None:
|
|
249
|
+
try:
|
|
250
|
+
run_hash_mod = __import__("synthbench.run_hash", fromlist=["components"])
|
|
251
|
+
except ImportError:
|
|
252
|
+
run_hash_mod = None
|
|
253
|
+
if run_hash_mod is not None:
|
|
254
|
+
components = getattr(run_hash_mod, "components", None)
|
|
255
|
+
if callable(components):
|
|
256
|
+
try:
|
|
257
|
+
recomputed_parts = components(
|
|
258
|
+
adapter=adapter,
|
|
259
|
+
suite_manifest=suite_manifest or {},
|
|
260
|
+
persona_seeds=persona_seeds,
|
|
261
|
+
raw_responses=raw_responses,
|
|
262
|
+
)
|
|
263
|
+
declared_parts = run.get("run_hash_components") or {}
|
|
264
|
+
drifted: list[str] = []
|
|
265
|
+
for key, value in recomputed_parts.items():
|
|
266
|
+
if declared_parts.get(key) != value:
|
|
267
|
+
drifted.append(key)
|
|
268
|
+
if drifted:
|
|
269
|
+
details.append(" inputs that drifted: " + ", ".join(sorted(drifted)))
|
|
270
|
+
except Exception as exc: # noqa: BLE001
|
|
271
|
+
details.append(f" (component diff unavailable: {exc})")
|
|
272
|
+
|
|
273
|
+
return CheckResult(
|
|
274
|
+
name="run-hash",
|
|
275
|
+
status="fail",
|
|
276
|
+
summary="run-hash mismatch — submission inputs do not match declared hash",
|
|
277
|
+
details=tuple(details),
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
# submission.md format lint
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
REQUIRED_H1_PREFIX = "# SynthBench"
|
|
287
|
+
REQUIRED_SECTIONS = ("## SynthBench Parity Score", "## Raw Metrics")
|
|
288
|
+
# Scores table is recognized by these column headers (in order). Matches the
|
|
289
|
+
# canonical format produced by `synthbench publish-runs` and the
|
|
290
|
+
# legacy report.py renderer.
|
|
291
|
+
SCORES_TABLE_HEADERS = ("Metric", "Score")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _find_scores_table(lines: list[str]) -> Optional[tuple[int, int]]:
|
|
295
|
+
"""Return (header_line_idx, separator_line_idx) for the first table
|
|
296
|
+
whose first two pipe-delimited columns match :data:`SCORES_TABLE_HEADERS`,
|
|
297
|
+
or ``None`` if no such table exists."""
|
|
298
|
+
for i in range(len(lines) - 1):
|
|
299
|
+
header = lines[i].strip()
|
|
300
|
+
sep = lines[i + 1].strip()
|
|
301
|
+
if not header.startswith("|") or not sep.startswith("|"):
|
|
302
|
+
continue
|
|
303
|
+
# Separator row: each cell must be dashes/colons/spaces only.
|
|
304
|
+
sep_cells = [c.strip() for c in sep.strip("|").split("|")]
|
|
305
|
+
if not all(re.fullmatch(r":?-+:?", c) for c in sep_cells if c):
|
|
306
|
+
continue
|
|
307
|
+
header_cells = [c.strip() for c in header.strip("|").split("|")]
|
|
308
|
+
if (
|
|
309
|
+
len(header_cells) >= 2
|
|
310
|
+
and header_cells[0] == SCORES_TABLE_HEADERS[0]
|
|
311
|
+
and header_cells[1] == SCORES_TABLE_HEADERS[1]
|
|
312
|
+
):
|
|
313
|
+
return (i, i + 1)
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def lint_submission_md(md_text: str) -> CheckResult:
|
|
318
|
+
"""Lint the format of an adapter submission's ``submission.md``."""
|
|
319
|
+
issues: list[str] = []
|
|
320
|
+
lines = md_text.splitlines()
|
|
321
|
+
stripped = [ln.strip() for ln in lines]
|
|
322
|
+
|
|
323
|
+
h1s = [ln for ln in stripped if ln.startswith("# ")]
|
|
324
|
+
if not h1s:
|
|
325
|
+
issues.append("missing top-level `# ` heading")
|
|
326
|
+
elif not h1s[0].startswith(REQUIRED_H1_PREFIX):
|
|
327
|
+
issues.append(f"H1 must start with `{REQUIRED_H1_PREFIX}` (got `{h1s[0]}`)")
|
|
328
|
+
|
|
329
|
+
for section in REQUIRED_SECTIONS:
|
|
330
|
+
if not any(ln.startswith(section) for ln in stripped):
|
|
331
|
+
issues.append(f"missing required section `{section}`")
|
|
332
|
+
|
|
333
|
+
if _find_scores_table(lines) is None:
|
|
334
|
+
issues.append(
|
|
335
|
+
"missing scores table — expected a markdown table whose first "
|
|
336
|
+
f"two column headers are `{SCORES_TABLE_HEADERS[0]}` and "
|
|
337
|
+
f"`{SCORES_TABLE_HEADERS[1]}`"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if issues:
|
|
341
|
+
return CheckResult(
|
|
342
|
+
name="submission-md",
|
|
343
|
+
status="fail",
|
|
344
|
+
summary=f"{len(issues)} format issue(s) in submission.md",
|
|
345
|
+
details=tuple(f" - {msg}" for msg in issues),
|
|
346
|
+
)
|
|
347
|
+
return CheckResult(
|
|
348
|
+
name="submission-md",
|
|
349
|
+
status="pass",
|
|
350
|
+
summary="submission.md format OK",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
# Submission detection
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def is_adapter_submission(run: Mapping[str, Any]) -> bool:
|
|
360
|
+
"""True iff ``run.json`` looks like output of ``synthbench submit-adapter``.
|
|
361
|
+
|
|
362
|
+
Detection key is the presence of an ``adapter`` block — legacy
|
|
363
|
+
``provider``-keyed submissions don't carry one, so they bypass this
|
|
364
|
+
workflow and go through ``ci.yml::validate-submissions`` instead.
|
|
365
|
+
"""
|
|
366
|
+
return isinstance(run.get("adapter"), Mapping)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def load_suite_manifest(suite: str, suites_dir: Path) -> Optional[dict[str, Any]]:
|
|
370
|
+
"""Load ``suites/<suite>.json`` for run-hash re-derivation.
|
|
371
|
+
|
|
372
|
+
Returns ``None`` if the manifest is missing — the run-hash check then
|
|
373
|
+
fails with a clear message rather than crashing.
|
|
374
|
+
"""
|
|
375
|
+
candidate = suites_dir / f"{suite}.json"
|
|
376
|
+
if not candidate.exists():
|
|
377
|
+
return None
|
|
378
|
+
return json.loads(candidate.read_text())
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Sticky-comment rendering
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
STICKY_MARKER = "<!-- synthbench: validate-submission v1 -->"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def render_sticky_comment(
|
|
390
|
+
*,
|
|
391
|
+
submissions: list[tuple[str, list[CheckResult]]],
|
|
392
|
+
pr_number: Optional[int] = None,
|
|
393
|
+
) -> str:
|
|
394
|
+
"""Render the per-submission check matrix as the sticky PR comment body.
|
|
395
|
+
|
|
396
|
+
The marker on the first line lets the workflow's sticky-comment
|
|
397
|
+
action find and overwrite the previous comment instead of stacking.
|
|
398
|
+
"""
|
|
399
|
+
out = [STICKY_MARKER, "", "## SynthBench submission validation", ""]
|
|
400
|
+
if not submissions:
|
|
401
|
+
out.append(
|
|
402
|
+
"_No adapter-based submissions detected in this PR_ — legacy "
|
|
403
|
+
"provider-keyed submissions are validated by the `ci.yml :: "
|
|
404
|
+
"validate-submissions` job."
|
|
405
|
+
)
|
|
406
|
+
return "\n".join(out) + "\n"
|
|
407
|
+
|
|
408
|
+
any_fail = any(r.failed for _, results in submissions for r in results)
|
|
409
|
+
status_line = (
|
|
410
|
+
"❌ One or more checks failed." if any_fail else "✅ All checks passed."
|
|
411
|
+
)
|
|
412
|
+
out.append(status_line)
|
|
413
|
+
out.append("")
|
|
414
|
+
|
|
415
|
+
for path, results in submissions:
|
|
416
|
+
out.append(f"### `{path}`")
|
|
417
|
+
out.append("")
|
|
418
|
+
out.append("| Check | Result | Summary |")
|
|
419
|
+
out.append("|-------|--------|---------|")
|
|
420
|
+
for r in results:
|
|
421
|
+
emoji = {"pass": "✅", "fail": "❌", "skip": "⏭️"}[r.status]
|
|
422
|
+
summary = r.summary.replace("|", "\\|")
|
|
423
|
+
out.append(f"| `{r.name}` | {emoji} {r.status} | {summary} |")
|
|
424
|
+
out.append("")
|
|
425
|
+
for r in results:
|
|
426
|
+
if r.details:
|
|
427
|
+
out.append(f"<details><summary>`{r.name}` details</summary>")
|
|
428
|
+
out.append("")
|
|
429
|
+
out.append("```")
|
|
430
|
+
out.extend(r.details)
|
|
431
|
+
out.append("```")
|
|
432
|
+
out.append("")
|
|
433
|
+
out.append("</details>")
|
|
434
|
+
out.append("")
|
|
435
|
+
|
|
436
|
+
return "\n".join(out) + "\n"
|