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,424 @@
1
+ """Canonical config ID parsing for the run explorer.
2
+
3
+ Turns raw provider strings like
4
+ ``synthpanel/openrouter/anthropic/claude-haiku-4-5 t=0.85 tpl=current``
5
+ into a structured record (framework / base_provider / model / knobs) and a
6
+ stable slug ``framework--model--t<temp>--tpl<name>[--eff<level>]--<hash8>``
7
+ suitable for use as a URL path segment (the ``eff`` segment appears only
8
+ when the run set a reasoning-effort level).
9
+
10
+ Hash collisions are unlikely but protected against by including every
11
+ distinguishing field (dataset, samples_per_question, question_set_hash,
12
+ full knob map) in the hashed canonical JSON — so two runs that differ only
13
+ in a field the human-readable slug drops still land on different IDs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ BASELINE_SUFFIX = "-baseline"
26
+
27
+ # Path components that name a vendor but use a non-canonical label.
28
+ # E.g. ``raw-gemini/...`` refers to Google; ``openrouter/meta-llama/...``
29
+ # means Meta. Normalizing here keeps the base_provider field canonical so
30
+ # downstream dedup/grouping treats them as one vendor.
31
+ _BASE_VENDOR_NORMALIZE: dict[str, str] = {
32
+ "gemini": "google",
33
+ "meta-llama": "meta",
34
+ }
35
+
36
+ # Model-name prefix → canonical vendor. Used to infer base_provider when the
37
+ # path itself doesn't carry a vendor segment (e.g. ``synthpanel/claude-...``).
38
+ _MODEL_VENDOR_PREFIXES: tuple[tuple[str, str], ...] = (
39
+ ("claude-", "anthropic"),
40
+ ("gemini-", "google"),
41
+ ("gpt-", "openai"),
42
+ ("llama-", "meta"),
43
+ )
44
+
45
+ # Version-date suffixes appended to model aliases.
46
+ # Anthropic-style: ``-YYYYMMDD`` (8 digits) → ``claude-haiku-4-5-20251001``
47
+ # OpenAI-style: ``-YYYY-MM-DD`` → ``gpt-4o-mini-2024-07-18``
48
+ # These identify the same weights as the alias form. Stripping them in the
49
+ # config_id path collapses the dated and undated variants into one group on
50
+ # /explore and the leaderboard. Check the longer (OpenAI) pattern first so
51
+ # ``gpt-X-2024-07-18`` is not misread as an 8-digit suffix.
52
+ _OPENAI_DATE_SUFFIX = re.compile(r"-\d{4}-\d{2}-\d{2}$")
53
+ _ANTHROPIC_DATE_SUFFIX = re.compile(r"-\d{8}$")
54
+
55
+
56
+ def canonical_model(model: str) -> str:
57
+ """Strip trailing version-date suffixes from a model name.
58
+
59
+ ``claude-haiku-4-5-20251001`` → ``claude-haiku-4-5``
60
+ ``gpt-4o-mini-2024-07-18`` → ``gpt-4o-mini``
61
+
62
+ Models without a recognized date suffix pass through unchanged. The
63
+ function is idempotent: running it twice yields the same result.
64
+ """
65
+ if not model:
66
+ return model
67
+ stripped = _OPENAI_DATE_SUFFIX.sub("", model)
68
+ if stripped != model:
69
+ return stripped
70
+ return _ANTHROPIC_DATE_SUFFIX.sub("", model)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class ParsedConfig:
75
+ """Structured breakdown of a provider string + run config."""
76
+
77
+ framework: str
78
+ base_provider: str | None
79
+ model: str
80
+ knobs: dict[str, str] = field(default_factory=dict)
81
+
82
+ def as_canonical_dict(self) -> dict[str, Any]:
83
+ """Return a dict with stable key ordering for hashing."""
84
+ return {
85
+ "framework": self.framework,
86
+ "base_provider": self.base_provider,
87
+ "model": self.model,
88
+ "knobs": dict(sorted(self.knobs.items())),
89
+ }
90
+
91
+
92
+ def _split_provider_and_knobs(provider: str) -> tuple[str, list[str]]:
93
+ """Split the whitespace-separated provider/knob form.
94
+
95
+ ``synthpanel/.../claude-haiku-4-5 t=0.85 tpl=current`` →
96
+ (``synthpanel/.../claude-haiku-4-5``, [``t=0.85``, ``tpl=current``]).
97
+ """
98
+ tokens = provider.strip().split()
99
+ if not tokens:
100
+ return "", []
101
+ return tokens[0], tokens[1:]
102
+
103
+
104
+ def _parse_knob_tokens(tokens: list[str]) -> dict[str, str]:
105
+ """Turn ``[``t=0.85``, ``tpl=current``]`` into a dict."""
106
+ knobs: dict[str, str] = {}
107
+ for tok in tokens:
108
+ if "=" not in tok:
109
+ continue
110
+ key, value = tok.split("=", 1)
111
+ key = key.strip()
112
+ value = value.strip()
113
+ if key:
114
+ knobs[key] = value
115
+ return knobs
116
+
117
+
118
+ def _normalize_base_vendor(vendor: str | None) -> str | None:
119
+ """Map aliased vendor labels to their canonical name (gemini → google)."""
120
+ if vendor is None:
121
+ return None
122
+ return _BASE_VENDOR_NORMALIZE.get(vendor, vendor)
123
+
124
+
125
+ def _infer_vendor_from_model(model: str) -> str | None:
126
+ """Guess the vendor from a model name prefix (claude-* → anthropic, …).
127
+
128
+ Returns ``None`` for names that don't match a known family (baselines,
129
+ ensemble blends, bespoke identifiers).
130
+ """
131
+ lower = model.lower()
132
+ for prefix, vendor in _MODEL_VENDOR_PREFIXES:
133
+ if lower.startswith(prefix):
134
+ return vendor
135
+ return None
136
+
137
+
138
+ def _parse_path(path: str) -> tuple[str, str | None, str]:
139
+ """Parse the slash-separated path component into (framework, base, model).
140
+
141
+ Recognizes these shapes:
142
+ random-baseline → ("baseline", None, "random-baseline")
143
+ raw-anthropic/claude-haiku-4-5 → ("raw", "anthropic", "claude-haiku-4-5")
144
+ raw-gemini/gemini-2.5-flash-lite → ("raw", "google", "gemini-2.5-flash-lite")
145
+ ensemble/3-model-blend → ("ensemble", None, "3-model-blend")
146
+ synthpanel/claude-haiku-4-5-20251001 → ("synthpanel", "anthropic", "claude-haiku-4-5-20251001")
147
+ synthpanel/gemini-2.5-flash-lite → ("synthpanel", "google", "gemini-2.5-flash-lite")
148
+ openrouter/openai/gpt-4o-mini → ("raw", "openai", "gpt-4o-mini")
149
+ synthpanel/openrouter/anthropic/claude-haiku-4-5
150
+ → ("synthpanel", "anthropic", "claude-haiku-4-5")
151
+
152
+ OpenRouter is a gateway, not a framework — paths that lead with
153
+ ``openrouter/`` collapse to ``framework=raw`` so the same model reached
154
+ via the gateway vs a direct call dedupes on ``base_provider``/``model``.
155
+ """
156
+ path = path.strip()
157
+ if not path:
158
+ return "unknown", None, "unknown"
159
+
160
+ if path.endswith(BASELINE_SUFFIX) and "/" not in path:
161
+ return "baseline", None, path
162
+
163
+ parts = path.split("/")
164
+ n = len(parts)
165
+
166
+ if n == 1:
167
+ return "raw", None, parts[0]
168
+
169
+ first = parts[0]
170
+
171
+ if first.startswith("raw-"):
172
+ return "raw", _normalize_base_vendor(first[4:]), parts[-1]
173
+
174
+ if first == "openrouter":
175
+ if n >= 3:
176
+ return "raw", _normalize_base_vendor(parts[-2]), parts[-1]
177
+ # openrouter/<model> — no explicit vendor segment, infer from model.
178
+ model = parts[1]
179
+ return "raw", _infer_vendor_from_model(model), model
180
+
181
+ if n == 2:
182
+ model = parts[1]
183
+ return first, _infer_vendor_from_model(model), model
184
+
185
+ if n == 3:
186
+ return first, _normalize_base_vendor(parts[1]), parts[2]
187
+
188
+ # n >= 4 — treat middle segments as a sub-framework we don't surface,
189
+ # take the last two as (base, model).
190
+ return first, _normalize_base_vendor(parts[-2]), parts[-1]
191
+
192
+
193
+ def parse_provider(provider: str) -> ParsedConfig:
194
+ """Parse a provider string into a structured ParsedConfig.
195
+
196
+ Whitespace-tolerant. Unknown shapes degrade gracefully to framework='raw'.
197
+ """
198
+ head, knob_tokens = _split_provider_and_knobs(provider)
199
+ framework, base, model = _parse_path(head)
200
+ knobs = _parse_knob_tokens(knob_tokens)
201
+ return ParsedConfig(
202
+ framework=framework,
203
+ base_provider=base,
204
+ model=model,
205
+ knobs=knobs,
206
+ )
207
+
208
+
209
+ def _runnable_model_slug(head: str) -> str:
210
+ """Build an OpenRouter-runnable ``<vendor>/<model>`` slug from a raw path.
211
+
212
+ Unlike :func:`parse_provider`'s ``base_provider`` (which canonicalizes
213
+ vendors for dedup — ``meta-llama`` → ``meta``), this preserves the on-path
214
+ vendor verbatim for gateway routes so the slug stays a valid OpenRouter
215
+ model id, and keeps the model segment verbatim (version dates intact) so it
216
+ can be piped straight to the gateway:
217
+
218
+ openrouter/google/gemini-2.5-flash-lite → google/gemini-2.5-flash-lite
219
+ openrouter/meta-llama/llama-3.3-70b-instruct → meta-llama/llama-3.3-70b-instruct
220
+ synthpanel/openrouter/anthropic/claude-haiku-4-5 → anthropic/claude-haiku-4-5
221
+ synthpanel/gemini-2.5-flash-lite → google/gemini-2.5-flash-lite
222
+ raw-anthropic/claude-haiku-4-5-20251001 → anthropic/claude-haiku-4-5-20251001
223
+ raw-gemini/gemini-2.5-flash-lite → google/gemini-2.5-flash-lite
224
+ ensemble/3-model-blend → 3-model-blend
225
+ random-baseline → random-baseline
226
+ """
227
+ head = head.strip()
228
+ if not head:
229
+ return "unknown"
230
+ if head.endswith(BASELINE_SUFFIX) and "/" not in head:
231
+ return head
232
+
233
+ parts = head.split("/")
234
+ # Drop a leading runner segment that isn't part of the model id.
235
+ if parts and parts[0] in ("synthpanel", "ensemble"):
236
+ parts = parts[1:]
237
+ # Drop an OpenRouter gateway hop — the remaining segments ARE the slug.
238
+ if parts and parts[0] == "openrouter":
239
+ parts = parts[1:]
240
+
241
+ if parts and parts[0].startswith("raw-"):
242
+ # Direct vendor API (e.g. raw-gemini/...): map to the OpenRouter author.
243
+ raw_vendor = parts[0][len("raw-") :]
244
+ parts = [_normalize_base_vendor(raw_vendor) or raw_vendor, *parts[1:]]
245
+ elif len(parts) == 1:
246
+ # Bare model with no vendor segment — infer the OpenRouter author so the
247
+ # slug is pipeable (e.g. gemini-2.5-flash-lite → google/...).
248
+ vendor = _infer_vendor_from_model(parts[0])
249
+ if vendor:
250
+ parts = [vendor, *parts]
251
+
252
+ return "/".join(p for p in parts if p) or head
253
+
254
+
255
+ def runnable_ids(provider: str) -> tuple[str, str]:
256
+ """Derive machine-runnable ``(provider_id, model_id)`` from a provider string.
257
+
258
+ These are the runnable counterparts to the human-facing display labels:
259
+
260
+ - ``provider_id`` — the execution framework/runner taxonomy from
261
+ :func:`parse_provider` (``raw`` / ``synthpanel`` / ``ensemble`` /
262
+ ``baseline``). Tells a consumer whether a score reflects a raw model, the
263
+ SynthPanel product layer, an ensemble, or a statistical baseline.
264
+ - ``model_id`` — an OpenRouter-runnable ``<vendor>/<model>`` slug a consumer
265
+ can pipe straight to the gateway without parsing display names. See
266
+ :func:`_runnable_model_slug` for the exact derivation per shape.
267
+
268
+ Hyperparameter knobs (``t=``, ``tpl=``, ``profile=``) are stripped.
269
+ """
270
+ head, _ = _split_provider_and_knobs(provider)
271
+ framework, _base, _model = _parse_path(head)
272
+ return framework, _runnable_model_slug(head)
273
+
274
+
275
+ _SLUG_UNSAFE = re.compile(r"[^a-z0-9.\-]+")
276
+
277
+
278
+ def _slugify(value: str) -> str:
279
+ """Lowercase and strip characters that would break a URL segment.
280
+
281
+ Preserves dots for version numbers (``t=0.85`` → ``t0.85``).
282
+ """
283
+ out = _SLUG_UNSAFE.sub("-", value.lower())
284
+ return out.strip("-")
285
+
286
+
287
+ def _format_temperature(temp: str | float | int | None) -> str:
288
+ """Render the temperature portion of the slug.
289
+
290
+ ``0.85 → 't0.85'``, ``1.0 → 't1.0'``, ``None → 'tdefault'``.
291
+ """
292
+ if temp is None or temp == "":
293
+ return "tdefault"
294
+ if isinstance(temp, (int, float)):
295
+ return f"t{temp}"
296
+ return f"t{temp}"
297
+
298
+
299
+ def _format_template(tpl: str | None) -> str:
300
+ if not tpl:
301
+ return "tplcurrent"
302
+ return f"tpl{_slugify(tpl)}"
303
+
304
+
305
+ def _hash_canonical(payload: dict[str, Any]) -> str:
306
+ """Stable 8-char hex hash over a canonical JSON dump."""
307
+ serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
308
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()[:8]
309
+
310
+
311
+ def _format_effort(effort: str | None) -> str:
312
+ """Render the reasoning-effort portion of the slug.
313
+
314
+ ``'high' → 'effhigh'``. Returns ``""`` when effort is absent — the
315
+ segment is omitted entirely so pre-effort config_ids keep their exact
316
+ historical slug shape (and hash, see :func:`build_config_id`).
317
+ """
318
+ if not effort:
319
+ return ""
320
+ return f"eff{_slugify(str(effort))}"
321
+
322
+
323
+ def build_config_id(
324
+ provider: str,
325
+ *,
326
+ dataset: str | None = None,
327
+ temperature: float | None = None,
328
+ template: str | None = None,
329
+ effort: str | None = None,
330
+ samples_per_question: int | None = None,
331
+ question_set_hash: str | None = None,
332
+ extra_knobs: dict[str, Any] | None = None,
333
+ ) -> tuple[str, ParsedConfig]:
334
+ """Return (config_id_slug, parsed) for a run.
335
+
336
+ Accepts structured fields extracted from the result file's ``config``
337
+ block. Values passed explicitly override knobs parsed from the provider
338
+ string (e.g. ``t=0.85`` vs the real ``config.temperature``).
339
+
340
+ ``effort`` (reasoning effort: low/medium/high) is additive-only: when
341
+ absent it contributes nothing to the hash payload or the slug, so every
342
+ config_id minted before the effort dimension existed is unchanged.
343
+ """
344
+ parsed = parse_provider(provider)
345
+ canon_model = canonical_model(parsed.model)
346
+
347
+ # Normalize template: drop path + extension
348
+ tpl_norm = None
349
+ if template:
350
+ tpl_norm = (
351
+ Path(template).stem
352
+ if ("/" in template or template.endswith(".md"))
353
+ else template
354
+ )
355
+ elif "tpl" in parsed.knobs:
356
+ tpl_norm = parsed.knobs["tpl"]
357
+
358
+ # Temperature: prefer explicit config value over knob string
359
+ temp_val: float | str | None = temperature
360
+ if temp_val is None and "t" in parsed.knobs:
361
+ try:
362
+ temp_val = float(parsed.knobs["t"])
363
+ except ValueError:
364
+ temp_val = parsed.knobs["t"]
365
+
366
+ # Reasoning effort: prefer explicit config value over knob string.
367
+ # Absent effort adds NO knob — pre-effort runs must hash identically.
368
+ effort_val: str | None = effort
369
+ if effort_val is None and "effort" in parsed.knobs:
370
+ effort_val = parsed.knobs["effort"]
371
+
372
+ # Canonicalize the full knob map for the hash
373
+ canonical_knobs: dict[str, Any] = dict(parsed.knobs)
374
+ if temp_val is not None:
375
+ canonical_knobs["t"] = temp_val
376
+ if tpl_norm is not None:
377
+ canonical_knobs["tpl"] = tpl_norm
378
+ if effort_val is not None:
379
+ canonical_knobs["effort"] = effort_val
380
+ if extra_knobs:
381
+ canonical_knobs.update(extra_knobs)
382
+
383
+ hash_payload: dict[str, Any] = {
384
+ "framework": parsed.framework,
385
+ "base_provider": parsed.base_provider,
386
+ "model": canon_model,
387
+ "knobs": {k: canonical_knobs[k] for k in sorted(canonical_knobs)},
388
+ }
389
+ if dataset is not None:
390
+ hash_payload["dataset"] = dataset
391
+ if samples_per_question is not None:
392
+ hash_payload["samples_per_question"] = samples_per_question
393
+ if question_set_hash is not None:
394
+ hash_payload["question_set_hash"] = question_set_hash
395
+
396
+ hash8 = _hash_canonical(hash_payload)
397
+
398
+ slug_model = _slugify(canon_model)
399
+ slug_framework = _slugify(parsed.framework)
400
+ temp_part = _format_temperature(temp_val)
401
+ tpl_part = _format_template(tpl_norm)
402
+ effort_part = _format_effort(effort_val)
403
+
404
+ parts = [slug_framework, slug_model, temp_part, tpl_part]
405
+ if effort_part:
406
+ parts.append(effort_part)
407
+ parts.append(hash8)
408
+ slug = "--".join(parts)
409
+
410
+ # Refresh parsed.knobs to include normalized t/tpl for downstream callers
411
+ resolved_knobs: dict[str, str] = dict(parsed.knobs)
412
+ if temp_val is not None:
413
+ resolved_knobs["t"] = str(temp_val)
414
+ if tpl_norm is not None:
415
+ resolved_knobs["tpl"] = tpl_norm
416
+ if effort_val is not None:
417
+ resolved_knobs["effort"] = effort_val
418
+
419
+ return slug, ParsedConfig(
420
+ framework=parsed.framework,
421
+ base_provider=parsed.base_provider,
422
+ model=canon_model,
423
+ knobs=resolved_knobs,
424
+ )