synthbench-eval 0.6.2__tar.gz → 0.8.0__tar.gz

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 (150) hide show
  1. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/PKG-INFO +1 -1
  2. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/pyproject.toml +1 -1
  3. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/__init__.py +1 -1
  4. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/__init__.py +2 -0
  5. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/refusal.py +101 -3
  6. synthbench_eval-0.8.0/src/synthbench/providers/_parsing.py +280 -0
  7. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/runner.py +4 -0
  8. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/PKG-INFO +1 -1
  9. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_provider_parsing.py +208 -1
  10. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_config_id_consistency.py +12 -0
  11. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_runner.py +10 -0
  12. synthbench_eval-0.6.2/src/synthbench/providers/_parsing.py +0 -150
  13. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/LICENSE +0 -0
  14. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/README.md +0 -0
  15. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/setup.cfg +0 -0
  16. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/__main__.py +0 -0
  17. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/adapter.py +0 -0
  18. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/anomaly.py +0 -0
  19. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/baseline_floors.py +0 -0
  20. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/baselines.py +0 -0
  21. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/cli.py +0 -0
  22. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/config_id.py +0 -0
  23. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/contamination.py +0 -0
  24. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/__init__.py +0 -0
  25. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/baseline.py +0 -0
  26. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/bootstrap.py +0 -0
  27. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/cli_report.py +0 -0
  28. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/curves.py +0 -0
  29. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/real_sampling.py +0 -0
  30. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/thresholds.py +0 -0
  31. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/__init__.py +0 -0
  32. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/base.py +0 -0
  33. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/eurobarometer.py +0 -0
  34. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/globalopinionqa.py +0 -0
  35. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/gss.py +0 -0
  36. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/michigan.py +0 -0
  37. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/ntia.py +0 -0
  38. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/opinionsqa.py +0 -0
  39. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/pewtech.py +0 -0
  40. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/policy.py +0 -0
  41. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/subpop.py +0 -0
  42. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/wvs.py +0 -0
  43. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/findings.py +0 -0
  44. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/holdout.py +0 -0
  45. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/human_distributions.py +0 -0
  46. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/leaderboard.py +0 -0
  47. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/leaderboard_pr.py +0 -0
  48. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/composite.py +0 -0
  49. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/conditioning.py +0 -0
  50. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/distributional.py +0 -0
  51. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/ranking.py +0 -0
  52. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/subgroup.py +0 -0
  53. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/private_holdout.py +0 -0
  54. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/__init__.py +0 -0
  55. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/_retry.py +0 -0
  56. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/althing.py +0 -0
  57. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/base.py +0 -0
  58. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/http.py +0 -0
  59. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/majority_baseline.py +0 -0
  60. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/ollama.py +0 -0
  61. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/openrouter.py +0 -0
  62. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/population_baseline.py +0 -0
  63. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/random_baseline.py +0 -0
  64. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/raw_anthropic.py +0 -0
  65. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/raw_gemini.py +0 -0
  66. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/raw_openai.py +0 -0
  67. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/publish.py +0 -0
  68. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/r2_upload.py +0 -0
  69. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/recompute.py +0 -0
  70. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/report.py +0 -0
  71. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/run_hash.py +0 -0
  72. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/run_validity.py +0 -0
  73. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/stats.py +0 -0
  74. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/submission.py +0 -0
  75. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/submission_pr.py +0 -0
  76. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/submit_adapter.py +0 -0
  77. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/suite.py +0 -0
  78. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/suites/__init__.py +0 -0
  79. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/topics.py +0 -0
  80. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/user_config.py +0 -0
  81. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/validation.py +0 -0
  82. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/visualize.py +0 -0
  83. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/SOURCES.txt +0 -0
  84. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/dependency_links.txt +0 -0
  85. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/entry_points.txt +0 -0
  86. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/requires.txt +0 -0
  87. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/top_level.txt +0 -0
  88. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_anomaly.py +0 -0
  89. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_baseline_floors.py +0 -0
  90. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_baselines.py +0 -0
  91. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_cli_run_submit.py +0 -0
  92. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_cli_submit.py +0 -0
  93. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_cli_submit_adapter.py +0 -0
  94. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_config_id.py +0 -0
  95. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_contamination.py +0 -0
  96. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_convergence_baseline.py +0 -0
  97. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_convergence_bootstrap.py +0 -0
  98. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_eurobarometer.py +0 -0
  99. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_globalopinionqa.py +0 -0
  100. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_gss.py +0 -0
  101. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_michigan.py +0 -0
  102. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_ntia.py +0 -0
  103. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_policy.py +0 -0
  104. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_wvs.py +0 -0
  105. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_effort.py +0 -0
  106. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_findings_drift.py +0 -0
  107. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_findings_elicitation.py +0 -0
  108. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_findings_nonresponse.py +0 -0
  109. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_holdout.py +0 -0
  110. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_human_distributions.py +0 -0
  111. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_integration_tokens.py +0 -0
  112. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_metrics.py +0 -0
  113. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_microdata.py +0 -0
  114. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_private_holdout.py +0 -0
  115. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_providers.py +0 -0
  116. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_cost.py +0 -0
  117. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_cross_provider_jsd.py +0 -0
  118. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_demographic_scorecard.py +0 -0
  119. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_gated_fail_closed.py +0 -0
  120. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_holdout.py +0 -0
  121. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_invalid_runs.py +0 -0
  122. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_normalized.py +0 -0
  123. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_policy.py +0 -0
  124. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_questions.py +0 -0
  125. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_r2_routing.py +0 -0
  126. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_rehydration.py +0 -0
  127. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_run_counts.py +0 -0
  128. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_runnable_ids.py +0 -0
  129. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_text_rehydration.py +0 -0
  130. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_topic_metrics.py +0 -0
  131. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_r2_upload.py +0 -0
  132. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_recompute_integrity.py +0 -0
  133. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_report.py +0 -0
  134. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_run_hash.py +0 -0
  135. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_run_validity.py +0 -0
  136. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_site_dataset_cards.py +0 -0
  137. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_stats.py +0 -0
  138. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_stats_golden.py +0 -0
  139. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_strip_gated_guard.py +0 -0
  140. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_submission.py +0 -0
  141. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_submission_pr.py +0 -0
  142. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_suite.py +0 -0
  143. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_suites.py +0 -0
  144. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_suites_novel_products.py +0 -0
  145. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_topics.py +0 -0
  146. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_user_config.py +0 -0
  147. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_validation.py +0 -0
  148. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_validation_holdout.py +0 -0
  149. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_validation_stripped.py +0 -0
  150. {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_verify_publish_integrity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synthbench-eval
3
- Version: 0.6.2
3
+ Version: 0.8.0
4
4
  Summary: Open benchmark harness for synthetic survey respondent quality
5
5
  License-Expression: MIT
6
6
  Keywords: benchmark,survey,synthetic-data,llm,evaluation,opinionsqa,globalopinionqa,gss,convergence,althing
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
8
8
  name = "synthbench-eval"
9
9
  # Placeholder — the publish job stamps the real version from the release tag
10
10
  # at build time (see publish-pypi in .github/workflows/auto-tag.yml).
11
- version = "0.6.2"
11
+ version = "0.8.0"
12
12
  description = "Open benchmark harness for synthetic survey respondent quality"
13
13
  readme = "README.md"
14
14
  license = "MIT"
@@ -6,7 +6,7 @@ from synthbench.convergence.baseline import (
6
6
  load_convergence_baseline,
7
7
  )
8
8
 
9
- __version__ = "0.6.2"
9
+ __version__ = "0.8.0"
10
10
 
11
11
  __all__ = [
12
12
  "BaselineGatedError",
@@ -11,6 +11,7 @@ from synthbench.metrics.refusal import (
11
11
  refusal_calibration,
12
12
  detect_refusal,
13
13
  detect_refusal_v2,
14
+ detect_refusal_v3,
14
15
  extract_human_refusal_rate,
15
16
  refusal_rate,
16
17
  p_refuse,
@@ -30,6 +31,7 @@ __all__ = [
30
31
  "refusal_calibration",
31
32
  "detect_refusal",
32
33
  "detect_refusal_v2",
34
+ "detect_refusal_v3",
33
35
  "extract_human_refusal_rate",
34
36
  "refusal_rate",
35
37
  "p_refuse",
@@ -6,13 +6,24 @@ Two detector versions coexist:
6
6
  pre-v2 runs). Un-anchored substring patterns; known precision failure:
7
7
  chatty in-character answers ("I don't get sick much, I'd say good") match
8
8
  ``\\bI don't\\b`` anywhere in the text and are mis-flagged as refusals.
9
- * :func:`detect_refusal_v2` — **v2** (current default via
10
- :data:`REFUSAL_DETECTOR_VERSION`). Patterns must be answer-INITIAL
9
+ * :func:`detect_refusal_v2` — **v2**. Patterns must be answer-INITIAL
11
10
  (anchored to the start of the trimmed response, tolerating leading
12
11
  pleasantries like "Well," / "Hmm,"), and never fire when the response
13
12
  contains a valid option for the current question (option-echo exemption —
14
13
  required because some instruments carry substantive options containing
15
14
  refusal-ish text, e.g. GSS GOD's "don't know, no way to find out").
15
+ * :func:`detect_refusal_v3` — **v3** (current default via
16
+ :data:`REFUSAL_DETECTOR_VERSION`, synthbench#356). v2 missed common
17
+ character breaks from newer models — "I don't have a *personal* opinion
18
+ …", "I don't have a country …", "I don't think I should pick …", and
19
+ non-initial declines such as "The premise is outdated … so I can't pick
20
+ one of these options". Those fell through as parse failures instead of
21
+ refusals. v3 adds answer-initial "no stake / no standing" openers and
22
+ an explicit non-selection pattern searched anywhere in the first
23
+ paragraph. v2's option-echo exemption applies only when exactly one
24
+ option is echoed (a response weighing both sides chose neither), and
25
+ v3 adds a single-label exemption: a response carrying exactly one
26
+ option label (``(B)``) picked an answer, so it is never a refusal.
16
27
 
17
28
  Runs are stamped with ``config.refusal_detector_version``; the key is
18
29
  additive — files without it were parsed under v1. The stamp is metadata
@@ -26,7 +37,7 @@ import re
26
37
 
27
38
  #: Detector version stamped into run metadata (``config.refusal_detector_version``).
28
39
  #: Absent key on a committed run file means v1.
29
- REFUSAL_DETECTOR_VERSION = 2
40
+ REFUSAL_DETECTOR_VERSION = 3
30
41
 
31
42
  # Patterns that indicate an LLM-style refusal when explicit refusal field is
32
43
  # unavailable. v1 — un-anchored; kept callable for reproducibility.
@@ -159,6 +170,93 @@ def detect_refusal_v2(text: str, options: list[str] | None = None) -> bool:
159
170
  return True
160
171
 
161
172
 
173
+ # ---------------------------------------------------------------------------
174
+ # v3 detector
175
+ # ---------------------------------------------------------------------------
176
+
177
+ # Additional answer-initial openers: the respondent says it has no opinion,
178
+ # stake, or standing to answer ("I don't have a personal opinion of ...",
179
+ # "I don't have a country ..."), or that answering isn't its place.
180
+ _REFUSAL_PATTERNS_V3_INITIAL = [
181
+ re.compile(
182
+ r"\AI (?:do not|don'?t) have (?:a |an |any )?(?:personal |real |genuine |strong |own )?"
183
+ r"(?:opinions?|views?|stake|preferences?|country|nationality|personal experience|"
184
+ r"lived experience|political (?:views?|opinions?|affiliation))\b",
185
+ re.IGNORECASE,
186
+ ),
187
+ re.compile(
188
+ r"\AI (?:do not|don'?t) think (?:I should|it'?s (?:my place|appropriate|right|fair) to|"
189
+ r"it would be (?:appropriate|right|fair) (?:for me )?to) "
190
+ r"(?:pick|choose|select|answer|take a side|weigh in|rate)\b",
191
+ re.IGNORECASE,
192
+ ),
193
+ re.compile(
194
+ r"\AI'?m (?:just )?an? (?:AI|artificial intelligence|(?:large )?language model)\b",
195
+ re.IGNORECASE,
196
+ ),
197
+ ]
198
+
199
+ # Explicit non-selection anywhere in the first paragraph ("..., so I can't
200
+ # pick one of these options", "I won't pick (A) or (B)"). Requires a
201
+ # selection verb, so "I can't believe ..." / "I won't lie, ..." never match.
202
+ _NON_SELECTION_RE = re.compile(
203
+ r"\bI (?:can'?t|cannot|won'?t|will not|shouldn'?t|should not|am not going to|'?m not going to)\s+"
204
+ r"(?:honestly |really |fairly |just )?(?:pick|choose|select|rate|give (?:a|an|my) (?:rating|answer))\b",
205
+ re.IGNORECASE,
206
+ )
207
+
208
+ _OPTION_LABEL_RE = re.compile(r"\(([A-Za-z])\)")
209
+
210
+
211
+ def _first_paragraph(text: str) -> str:
212
+ for block in re.split(r"\n\s*\n", text.strip()):
213
+ if block.strip():
214
+ return block
215
+ return ""
216
+
217
+
218
+ def _single_option_label(text: str, options: list[str]) -> bool:
219
+ """True when *text* names exactly one in-range option label like ``(B)``."""
220
+ letters = {m.group(1).upper() for m in _OPTION_LABEL_RE.finditer(text)}
221
+ return len(letters) == 1 and 0 <= ord(next(iter(letters))) - ord("A") < len(options)
222
+
223
+
224
+ def detect_refusal_v3(text: str, options: list[str] | None = None) -> bool:
225
+ """Detect refusal — v3: v2 plus character-break openers and non-selection.
226
+
227
+ Classification order:
228
+
229
+ 1. A response that echoes exactly one declared option (v2's exemption,
230
+ narrowed: discussing *both* sides is not choosing one) or names
231
+ exactly one option label (``(B)``) chose an answer: not a refusal.
232
+ 2. Any v2 answer-initial pattern, or a v3 answer-initial "no opinion /
233
+ no stake / not my place" opener: refusal.
234
+ 3. An explicit non-selection ("I can't pick one of these options",
235
+ "I won't pick (A) or (B)") anywhere in the first paragraph: refusal.
236
+
237
+ Args:
238
+ text: Raw response text from the provider.
239
+ options: Declared answer options for the current question.
240
+
241
+ Returns:
242
+ True if the response declines to choose an option.
243
+ """
244
+ raw = str(text)
245
+ stripped = raw.strip(_WRAPPING_CHARS)
246
+ if not stripped:
247
+ return False
248
+ if options:
249
+ echoed = sum(1 for opt in options if _mentions_option(raw, [opt]))
250
+ if echoed == 1 or _single_option_label(raw, options):
251
+ return False
252
+ opening = _PLEASANTRY_PREFIX_RE.sub("", stripped)
253
+ if any(p.search(opening) for p in _REFUSAL_PATTERNS_V2):
254
+ return True
255
+ if any(p.search(opening) for p in _REFUSAL_PATTERNS_V3_INITIAL):
256
+ return True
257
+ return bool(_NON_SELECTION_RE.search(_first_paragraph(stripped)))
258
+
259
+
162
260
  def refusal_calibration(
163
261
  provider_refusal_rates: list[float],
164
262
  human_refusal_rates: list[float],
@@ -0,0 +1,280 @@
1
+ """Shared option-response parsing for all providers.
2
+
3
+ Historically every provider carried its own private ``_parse_letter`` copy
4
+ with two systematic bugs:
5
+
6
+ 1. The letter match was un-anchored (``^\\(?([A-Z])\\)?``), so a model that
7
+ echoed option *text* was parsed by its first character: options
8
+ ``["Better", "Worse"]`` + response ``"Better"`` parsed as **"Worse"**
9
+ (``B`` -> index 1).
10
+ 2. The fallback was a raw substring test, so ``"Disagree"`` matched
11
+ ``"Agree"`` first, and refusal text like ``"I cannot answer"`` matched
12
+ ``"No"`` (``"no" in "cannot"``).
13
+
14
+ Parse failures were then silently coerced to ``options[0]``, biasing every
15
+ published distribution toward the first option.
16
+
17
+ This module is the single replacement. Matching order:
18
+
19
+ 1. Exact (normalized) full-string equality against the option text — an
20
+ unambiguous echo of an option always wins.
21
+ 2. Refusal detection (:func:`synthbench.metrics.refusal.detect_refusal`)
22
+ runs BEFORE any fuzzy option matching, so refusal text can never be
23
+ substring-matched into a vote.
24
+ 3. Bare option letter, anchored as a full match (``"B"``, ``"(c)"``,
25
+ ``"A."`` — but never the leading character of a longer word).
26
+ 4. Word-boundary containment against the option text, longest option
27
+ first, as a last resort (``"I'd say Disagree"`` -> ``"Disagree"``).
28
+
29
+ Anything else is a parse failure: ``option is None`` and ``refusal`` is
30
+ False. Callers must NOT substitute a default option.
31
+
32
+ Parser versions (``config.option_parser_version``; absent key = v1):
33
+
34
+ * **v1** — the matching order above, verbatim. Kept callable so historical
35
+ runs re-parse bit-for-bit.
36
+ * **v2** (current, synthbench#352) — newer models answer in markdown prose
37
+ (``**(D) Depends on the situation.**`` followed by an explanation). v1
38
+ counted those as parse failures, and its whole-text containment could
39
+ mis-score them by matching an option mentioned in the explanation. v2:
40
+
41
+ - strips markdown emphasis/heading/quote markup before matching;
42
+ - accepts a *leading* option label (``(D) ...``, ``D. ...``, ``D) ...``)
43
+ as the answer, unless the text right after it exactly names a
44
+ different option (contradictory → parse failure);
45
+ - restricts containment to the first line, falling back to the whole
46
+ response only when exactly one option is contained in it.
47
+
48
+ Refusal detection is unchanged (it is versioned separately).
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import re
54
+ from dataclasses import dataclass
55
+
56
+ from synthbench.metrics.refusal import (
57
+ REFUSAL_DETECTOR_VERSION,
58
+ detect_refusal,
59
+ detect_refusal_v2,
60
+ detect_refusal_v3,
61
+ )
62
+
63
+ # Full-match bare letter: optional "(", one ASCII letter, optional ")",
64
+ # optional trailing ".", ")" or ":", nothing else but whitespace.
65
+ _BARE_LETTER_RE = re.compile(r"^\s*\(?([A-Za-z])\)?[.):]?\s*$")
66
+
67
+ _WS_RE = re.compile(r"\s+")
68
+
69
+ #: Parser version stamped into run metadata (``config.option_parser_version``).
70
+ #: Absent key on a committed run file means v1.
71
+ OPTION_PARSER_VERSION = 2
72
+
73
+ # v2: markdown markup to drop before matching — emphasis runs (``**``,
74
+ # ``__``, ``*``), and line-leading heading / blockquote / bullet markers.
75
+ _MD_EMPHASIS_RE = re.compile(r"(\*{1,3}|_{2,3})")
76
+ _MD_LINE_PREFIX_RE = re.compile(
77
+ r"^[ \t]*(?:#{1,6}[ \t]+|>[ \t]*|[-+][ \t]+)", re.MULTILINE
78
+ )
79
+
80
+ # v2: leading option label, optionally after an "Answer:" style prefix.
81
+ # Parenthesized letters may be either case; bare letters must be uppercase
82
+ # and delimited (checked in _leading_label_match) so "I think..." or
83
+ # "A lot of people..." never parse as a label.
84
+ _LEADING_LABEL_RE = re.compile(
85
+ r"^\s*(?:(?:answer|my answer|choice)\s*[:\-]\s*)?"
86
+ r"(?:\(([A-Za-z])\)|([A-Za-z])[.):](?=\s|$))\s*(.*)",
87
+ re.IGNORECASE | re.DOTALL,
88
+ )
89
+ _PAREN_LABEL_RE = re.compile(r"\(([A-Za-z])\)")
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ParsedResponse:
94
+ """Outcome of parsing one raw model response.
95
+
96
+ Exactly one of three states:
97
+
98
+ * option selected: ``option`` is the matched option text, ``refusal``
99
+ is False.
100
+ * refusal: ``option`` is None, ``refusal`` is True.
101
+ * parse failure: ``option`` is None, ``refusal`` is False.
102
+ """
103
+
104
+ option: str | None = None
105
+ refusal: bool = False
106
+
107
+ @property
108
+ def is_parse_failure(self) -> bool:
109
+ return self.option is None and not self.refusal
110
+
111
+
112
+ def _normalize(text: str) -> str:
113
+ """Case-fold, collapse whitespace, and strip surrounding punctuation."""
114
+ text = _WS_RE.sub(" ", str(text)).strip().lower()
115
+ # Strip wrapping punctuation/quotes but keep interior punctuation so
116
+ # options like "Don't know" still compare correctly.
117
+ return text.strip("\"'`.,;:!()[]{} ")
118
+
119
+
120
+ def _containment_match(text: str, options: list[str]) -> str | None:
121
+ """Word-boundary containment, longest option first.
122
+
123
+ Longest-first ordering prevents "Agree" from shadowing "Disagree"-style
124
+ superstring options; the word boundary (non-word characters on both
125
+ sides) prevents "no" from matching inside "cannot".
126
+ """
127
+ text_norm = _normalize(text)
128
+ if not text_norm:
129
+ return None
130
+ for opt in sorted(options, key=lambda o: len(_normalize(o)), reverse=True):
131
+ opt_norm = _normalize(opt)
132
+ if not opt_norm:
133
+ continue
134
+ pattern = r"(?<!\w)" + re.escape(opt_norm) + r"(?!\w)"
135
+ if re.search(pattern, text_norm):
136
+ return opt
137
+ return None
138
+
139
+
140
+ def _strip_markdown(text: str) -> str:
141
+ """Drop markdown emphasis and line-leading markup (v2)."""
142
+ return _MD_EMPHASIS_RE.sub("", _MD_LINE_PREFIX_RE.sub("", text))
143
+
144
+
145
+ def _leading_label_match(text: str, options: list[str]) -> ParsedResponse | None:
146
+ """Resolve a leading ``(D) ...`` / ``D. ...`` label (v2).
147
+
148
+ Returns ``None`` when there is no label; a parse failure when the label
149
+ is out of range or contradicted by an exact option name right after it.
150
+ The lowercase bare form (``a. ...``) is not a label — only ``(a)``.
151
+ """
152
+ match = _LEADING_LABEL_RE.match(text)
153
+ if not match:
154
+ return None
155
+ paren, bare, rest = match.groups()
156
+ if bare is not None and not bare.isupper():
157
+ return None
158
+ letter = (paren or bare).upper()
159
+ idx = ord(letter) - ord("A")
160
+ if not 0 <= idx < len(options):
161
+ return ParsedResponse()
162
+ # "(B) Favor" where B is "Oppose": the label and the echoed text
163
+ # disagree, so neither can be trusted.
164
+ first_line = rest.strip().split("\n", 1)[0]
165
+ by_norm = {_normalize(opt): opt for opt in options}
166
+ echoed = by_norm.get(_normalize(first_line))
167
+ if echoed is not None and echoed != options[idx]:
168
+ return ParsedResponse()
169
+ return ParsedResponse(option=options[idx])
170
+
171
+
172
+ def _parse_v2_options(stripped: str, options: list[str]) -> ParsedResponse:
173
+ """v2 option matching on markdown-stripped text (after refusal checks)."""
174
+ text = _strip_markdown(stripped).strip()
175
+ if not text:
176
+ return ParsedResponse()
177
+
178
+ by_norm = {_normalize(opt): opt for opt in options}
179
+ text_norm = _normalize(text)
180
+ if text_norm in by_norm:
181
+ return ParsedResponse(option=by_norm[text_norm])
182
+
183
+ match = _BARE_LETTER_RE.match(text)
184
+ if match:
185
+ idx = ord(match.group(1).upper()) - ord("A")
186
+ if 0 <= idx < len(options):
187
+ return ParsedResponse(option=options[idx])
188
+ return ParsedResponse()
189
+
190
+ labelled = _leading_label_match(text, options)
191
+ if labelled is not None:
192
+ return labelled
193
+
194
+ lines = [ln for ln in text.splitlines() if ln.strip()]
195
+ # A parenthesized label after a short lead-in ("I'd say (B) agree",
196
+ # possibly below a roleplay line like "*thinks*"). Only when the opening
197
+ # lines carry exactly one distinct label; several means a list or a
198
+ # comparison, not an answer.
199
+ lead = "\n".join(lines[:2])
200
+ labels = list(_PAREN_LABEL_RE.finditer(lead))
201
+ if labels and len({m.group(1).upper() for m in labels}) == 1:
202
+ inline = _leading_label_match(lead[labels[0].start() :], options)
203
+ if inline is not None:
204
+ return inline
205
+
206
+ first_line = lines[0] if lines else ""
207
+ contained = _containment_match(first_line, options)
208
+ if contained is not None:
209
+ return ParsedResponse(option=contained)
210
+
211
+ # Whole-response containment only when unambiguous: an explanation that
212
+ # mentions several options must not be scored as whichever matched first.
213
+ hits = [opt for opt in options if _containment_match(text, [opt]) is not None]
214
+ if len(hits) == 1:
215
+ return ParsedResponse(option=hits[0])
216
+ return ParsedResponse()
217
+
218
+
219
+ def parse_option_response(
220
+ text: str,
221
+ options: list[str],
222
+ *,
223
+ refusal_detector_version: int = REFUSAL_DETECTOR_VERSION,
224
+ option_parser_version: int = OPTION_PARSER_VERSION,
225
+ ) -> ParsedResponse:
226
+ """Parse a raw model response into an option selection, refusal, or failure.
227
+
228
+ Never falls back to ``options[0]``. See module docstring for the
229
+ matching order and rationale.
230
+
231
+ ``refusal_detector_version`` selects the refusal heuristic: 3 (default,
232
+ v2 plus character-break openers and explicit non-selection), 2
233
+ (answer-initial anchoring + option-echo exemption) or 1 (the legacy
234
+ un-anchored patterns). Older versions stay callable so historical runs
235
+ can be reproduced bit-for-bit.
236
+
237
+ ``option_parser_version`` selects option matching the same way: 2
238
+ (default, markdown-aware) or 1 (legacy).
239
+ """
240
+ raw = str(text)
241
+ stripped = raw.strip()
242
+ if not stripped:
243
+ return ParsedResponse()
244
+
245
+ # 1. Exact normalized equality — an unambiguous echo of an option wins
246
+ # even over refusal-pattern heuristics (an option worded in the first
247
+ # person, e.g. "I don't know", is a legitimate selection when echoed
248
+ # verbatim).
249
+ text_norm = _normalize(stripped)
250
+ by_norm = {_normalize(opt): opt for opt in options}
251
+ if text_norm in by_norm:
252
+ return ParsedResponse(option=by_norm[text_norm])
253
+
254
+ # 2. Refusal detection BEFORE fuzzy option matching.
255
+ if refusal_detector_version >= 3:
256
+ is_refusal = detect_refusal_v3(stripped, options)
257
+ elif refusal_detector_version >= 2:
258
+ is_refusal = detect_refusal_v2(stripped, options)
259
+ else:
260
+ is_refusal = detect_refusal(stripped)
261
+ if is_refusal:
262
+ return ParsedResponse(refusal=True)
263
+
264
+ if option_parser_version >= 2:
265
+ return _parse_v2_options(stripped, options)
266
+
267
+ # 3. Bare letter, anchored full-match only.
268
+ match = _BARE_LETTER_RE.match(stripped)
269
+ if match:
270
+ idx = ord(match.group(1).upper()) - ord("A")
271
+ if 0 <= idx < len(options):
272
+ return ParsedResponse(option=options[idx])
273
+ return ParsedResponse()
274
+
275
+ # 4. Word-boundary containment, longest option first.
276
+ contained = _containment_match(stripped, options)
277
+ if contained is not None:
278
+ return ParsedResponse(option=contained)
279
+
280
+ return ParsedResponse()
@@ -22,6 +22,7 @@ from synthbench.metrics import (
22
22
  extract_human_refusal_rate,
23
23
  conditioning_fidelity,
24
24
  )
25
+ from synthbench.providers._parsing import OPTION_PARSER_VERSION
25
26
  from synthbench.providers.base import Distribution, PersonaSpec, Provider, Response
26
27
  from synthbench.stats import bootstrap_ci, question_set_hash
27
28
 
@@ -476,6 +477,9 @@ class BenchmarkRunner:
476
477
  # this run's raw responses. Absent on pre-v2 files (= v1).
477
478
  # Never feeds build_config_id, so config_ids are stable.
478
479
  "refusal_detector_version": REFUSAL_DETECTOR_VERSION,
480
+ # Same contract for option matching (synthbench#352): absent
481
+ # on pre-v2 files (= v1); never feeds build_config_id.
482
+ "option_parser_version": OPTION_PARSER_VERSION,
479
483
  **_provider_reproducibility_hashes(self.provider),
480
484
  },
481
485
  elapsed_seconds=elapsed,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synthbench-eval
3
- Version: 0.6.2
3
+ Version: 0.8.0
4
4
  Summary: Open benchmark harness for synthetic survey respondent quality
5
5
  License-Expression: MIT
6
6
  Keywords: benchmark,survey,synthetic-data,llm,evaluation,opinionsqa,globalopinionqa,gss,convergence,althing