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.
Files changed (78) hide show
  1. synthbench/__init__.py +16 -0
  2. synthbench/__main__.py +5 -0
  3. synthbench/adapter.py +131 -0
  4. synthbench/anomaly.py +503 -0
  5. synthbench/baseline_floors.py +213 -0
  6. synthbench/baselines.py +902 -0
  7. synthbench/cli.py +2929 -0
  8. synthbench/config_id.py +424 -0
  9. synthbench/contamination.py +783 -0
  10. synthbench/convergence/__init__.py +67 -0
  11. synthbench/convergence/baseline.py +172 -0
  12. synthbench/convergence/bootstrap.py +60 -0
  13. synthbench/convergence/cli_report.py +556 -0
  14. synthbench/convergence/curves.py +111 -0
  15. synthbench/convergence/real_sampling.py +146 -0
  16. synthbench/convergence/thresholds.py +49 -0
  17. synthbench/datasets/__init__.py +37 -0
  18. synthbench/datasets/base.py +147 -0
  19. synthbench/datasets/eurobarometer.py +335 -0
  20. synthbench/datasets/globalopinionqa.py +252 -0
  21. synthbench/datasets/gss.py +323 -0
  22. synthbench/datasets/michigan.py +505 -0
  23. synthbench/datasets/ntia.py +357 -0
  24. synthbench/datasets/opinionsqa.py +412 -0
  25. synthbench/datasets/pewtech.py +334 -0
  26. synthbench/datasets/policy.py +142 -0
  27. synthbench/datasets/subpop.py +302 -0
  28. synthbench/datasets/wvs.py +229 -0
  29. synthbench/findings.py +988 -0
  30. synthbench/holdout.py +347 -0
  31. synthbench/human_distributions.py +243 -0
  32. synthbench/leaderboard.py +715 -0
  33. synthbench/leaderboard_pr.py +274 -0
  34. synthbench/metrics/__init__.py +38 -0
  35. synthbench/metrics/composite.py +72 -0
  36. synthbench/metrics/conditioning.py +39 -0
  37. synthbench/metrics/distributional.py +41 -0
  38. synthbench/metrics/ranking.py +37 -0
  39. synthbench/metrics/refusal.py +270 -0
  40. synthbench/metrics/subgroup.py +58 -0
  41. synthbench/private_holdout.py +240 -0
  42. synthbench/providers/__init__.py +43 -0
  43. synthbench/providers/_parsing.py +150 -0
  44. synthbench/providers/_retry.py +107 -0
  45. synthbench/providers/base.py +212 -0
  46. synthbench/providers/http.py +108 -0
  47. synthbench/providers/majority_baseline.py +23 -0
  48. synthbench/providers/ollama.py +109 -0
  49. synthbench/providers/openrouter.py +144 -0
  50. synthbench/providers/population_baseline.py +87 -0
  51. synthbench/providers/random_baseline.py +24 -0
  52. synthbench/providers/raw_anthropic.py +149 -0
  53. synthbench/providers/raw_gemini.py +145 -0
  54. synthbench/providers/raw_openai.py +139 -0
  55. synthbench/providers/synthpanel.py +978 -0
  56. synthbench/publish.py +2772 -0
  57. synthbench/r2_upload.py +178 -0
  58. synthbench/recompute.py +235 -0
  59. synthbench/report.py +493 -0
  60. synthbench/run_hash.py +111 -0
  61. synthbench/run_validity.py +232 -0
  62. synthbench/runner.py +834 -0
  63. synthbench/stats.py +1492 -0
  64. synthbench/submission.py +284 -0
  65. synthbench/submission_pr.py +436 -0
  66. synthbench/submit_adapter.py +748 -0
  67. synthbench/suite.py +424 -0
  68. synthbench/suites/__init__.py +94 -0
  69. synthbench/topics.py +256 -0
  70. synthbench/user_config.py +409 -0
  71. synthbench/validation.py +1556 -0
  72. synthbench/visualize.py +455 -0
  73. synthbench_eval-0.4.0.dist-info/METADATA +279 -0
  74. synthbench_eval-0.4.0.dist-info/RECORD +78 -0
  75. synthbench_eval-0.4.0.dist-info/WHEEL +5 -0
  76. synthbench_eval-0.4.0.dist-info/entry_points.txt +2 -0
  77. synthbench_eval-0.4.0.dist-info/licenses/LICENSE +21 -0
  78. 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"