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.
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/PKG-INFO +1 -1
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/pyproject.toml +1 -1
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/__init__.py +1 -1
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/__init__.py +2 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/refusal.py +101 -3
- synthbench_eval-0.8.0/src/synthbench/providers/_parsing.py +280 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/runner.py +4 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/PKG-INFO +1 -1
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_provider_parsing.py +208 -1
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_config_id_consistency.py +12 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_runner.py +10 -0
- synthbench_eval-0.6.2/src/synthbench/providers/_parsing.py +0 -150
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/LICENSE +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/README.md +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/setup.cfg +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/__main__.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/adapter.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/anomaly.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/baseline_floors.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/baselines.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/cli.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/config_id.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/contamination.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/__init__.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/baseline.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/bootstrap.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/cli_report.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/curves.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/real_sampling.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/convergence/thresholds.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/__init__.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/base.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/eurobarometer.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/globalopinionqa.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/gss.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/michigan.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/ntia.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/opinionsqa.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/pewtech.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/policy.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/subpop.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/datasets/wvs.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/findings.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/holdout.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/human_distributions.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/leaderboard.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/leaderboard_pr.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/composite.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/conditioning.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/distributional.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/ranking.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/metrics/subgroup.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/private_holdout.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/__init__.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/_retry.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/althing.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/base.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/http.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/majority_baseline.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/ollama.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/openrouter.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/population_baseline.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/random_baseline.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/raw_anthropic.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/raw_gemini.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/providers/raw_openai.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/publish.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/r2_upload.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/recompute.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/report.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/run_hash.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/run_validity.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/stats.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/submission.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/submission_pr.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/submit_adapter.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/suite.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/suites/__init__.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/topics.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/user_config.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/validation.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench/visualize.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/SOURCES.txt +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/dependency_links.txt +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/entry_points.txt +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/requires.txt +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/src/synthbench_eval.egg-info/top_level.txt +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_anomaly.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_baseline_floors.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_baselines.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_cli_run_submit.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_cli_submit.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_cli_submit_adapter.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_config_id.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_contamination.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_convergence_baseline.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_convergence_bootstrap.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_eurobarometer.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_globalopinionqa.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_gss.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_michigan.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_ntia.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_policy.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_datasets_wvs.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_effort.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_findings_drift.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_findings_elicitation.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_findings_nonresponse.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_holdout.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_human_distributions.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_integration_tokens.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_metrics.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_microdata.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_private_holdout.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_providers.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_cost.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_cross_provider_jsd.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_demographic_scorecard.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_gated_fail_closed.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_holdout.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_invalid_runs.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_normalized.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_policy.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_questions.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_r2_routing.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_rehydration.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_run_counts.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_runnable_ids.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_text_rehydration.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_publish_topic_metrics.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_r2_upload.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_recompute_integrity.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_report.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_run_hash.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_run_validity.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_site_dataset_cards.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_stats.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_stats_golden.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_strip_gated_guard.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_submission.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_submission_pr.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_suite.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_suites.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_suites_novel_products.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_topics.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_user_config.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_validation.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_validation_holdout.py +0 -0
- {synthbench_eval-0.6.2 → synthbench_eval-0.8.0}/tests/test_validation_stripped.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
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 =
|
|
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.
|
|
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
|