falsepass 0.3.1__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.
- falsepass/__init__.py +14 -0
- falsepass/_demo/falsepass_toy_env.py +29 -0
- falsepass/_demo/pyproject.toml +13 -0
- falsepass/adapter.py +61 -0
- falsepass/auto.py +394 -0
- falsepass/cli.py +381 -0
- falsepass/container.py +98 -0
- falsepass/container_probe.py +233 -0
- falsepass/draft.py +229 -0
- falsepass/hub.py +201 -0
- falsepass/hud_support.py +156 -0
- falsepass/hygiene.py +57 -0
- falsepass/inspect_support.py +284 -0
- falsepass/mcp_server.py +122 -0
- falsepass/model.py +82 -0
- falsepass/probes.py +553 -0
- falsepass/propose.py +164 -0
- falsepass/report.py +227 -0
- falsepass/repro.py +286 -0
- falsepass/rewardlint_bridge.py +56 -0
- falsepass/rollout.py +205 -0
- falsepass/runner.py +154 -0
- falsepass/scorers.py +349 -0
- falsepass/search.py +276 -0
- falsepass/static.py +399 -0
- falsepass/upstream.py +225 -0
- falsepass/verify.py +127 -0
- falsepass-0.3.1.dist-info/METADATA +358 -0
- falsepass-0.3.1.dist-info/RECORD +32 -0
- falsepass-0.3.1.dist-info/WHEEL +4 -0
- falsepass-0.3.1.dist-info/entry_points.txt +2 -0
- falsepass-0.3.1.dist-info/licenses/LICENSE +202 -0
falsepass/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""falsepass — probe RL environment verifiers for false passes and false fails.
|
|
2
|
+
|
|
3
|
+
The verifier is the system under test. falsepass feeds it completions whose
|
|
4
|
+
correctness is known in advance (the reference answer, an empty string, a
|
|
5
|
+
well-formatted wrong answer, ...) and reports every case where the reward
|
|
6
|
+
disagrees with what a correct grader would return. No model rollouts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from falsepass.adapter import Adapter
|
|
10
|
+
from falsepass.model import Completion, Example, Expect, ProbeResult, Score
|
|
11
|
+
from falsepass import hygiene, probes, report # noqa: E402,F401
|
|
12
|
+
|
|
13
|
+
__all__ = ["Adapter", "Completion", "Example", "Expect", "ProbeResult", "Score"]
|
|
14
|
+
__version__ = "0.3.1"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Sums of two numbers, answered in <answer> tags.
|
|
2
|
+
|
|
3
|
+
The grader has one planted defect for trying falsepass: it pays when the reference
|
|
4
|
+
appears anywhere in the answer (`answer in reply`) instead of comparing the two, so a
|
|
5
|
+
reply that lists several numbers is paid whenever one of them is right.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
|
|
10
|
+
import verifiers as vf
|
|
11
|
+
from datasets import Dataset
|
|
12
|
+
|
|
13
|
+
PROMPT = "Add the two numbers. Reply with the sum only, inside <answer></answer> tags."
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_environment(n: int = 200, seed: int = 0) -> vf.Environment:
|
|
17
|
+
rng = random.Random(seed)
|
|
18
|
+
rows = []
|
|
19
|
+
for _ in range(n):
|
|
20
|
+
a, b = rng.randint(10, 999), rng.randint(10, 999)
|
|
21
|
+
rows.append({"question": f"{a} + {b}", "answer": str(a + b)})
|
|
22
|
+
parser = vf.XMLParser(["answer"], answer_field="answer")
|
|
23
|
+
|
|
24
|
+
def correct(completion, answer, **kwargs) -> float:
|
|
25
|
+
reply = parser.parse_answer(completion) or ""
|
|
26
|
+
return 1.0 if answer in reply else 0.0 # planted defect: should be reply.strip() == answer
|
|
27
|
+
|
|
28
|
+
rubric = vf.Rubric(funcs=[correct], weights=[1.0], parser=parser)
|
|
29
|
+
return vf.SingleTurnEnv(dataset=Dataset.from_list(rows), system_prompt=PROMPT, parser=parser, rubric=rubric)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "falsepass-toy-env"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A tiny arithmetic environment with one planted grading defect, for trying falsepass."
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = ["verifiers>=0.3.1", "datasets>=3.0.0"]
|
|
7
|
+
|
|
8
|
+
[build-system]
|
|
9
|
+
requires = ["hatchling"]
|
|
10
|
+
build-backend = "hatchling.build"
|
|
11
|
+
|
|
12
|
+
[tool.hatch.build]
|
|
13
|
+
include = ["falsepass_toy_env.py"]
|
falsepass/adapter.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Per-environment adapter: the only env-specific code a new audit needs.
|
|
2
|
+
|
|
3
|
+
Subclass `Adapter` in `audits/<env>/adapter.py` and expose it as `ADAPTER`.
|
|
4
|
+
The generic probes only ever talk to the environment through this interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from falsepass.model import Completion, Example, ProbeResult
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from falsepass.scorers import Scorer
|
|
16
|
+
|
|
17
|
+
ExtraProbe = Callable[["Adapter", "Scorer", Example], Awaitable[list[ProbeResult]]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Adapter:
|
|
21
|
+
name: str = ""
|
|
22
|
+
max_reward: float = 1.0
|
|
23
|
+
# Reward a known-bad completion may receive before it counts as a false pass.
|
|
24
|
+
# Leave at 0 unless the env grades by similarity and partial credit is by design.
|
|
25
|
+
bad_tolerance: float = 0.0
|
|
26
|
+
has_judge: bool = False # enables P8 (judge injection)
|
|
27
|
+
|
|
28
|
+
# --- loading -----------------------------------------------------------------
|
|
29
|
+
def load(self, n: int, seed: int) -> tuple["Scorer", list[Example]]:
|
|
30
|
+
"""Return the scorer and `n` examples, sampled deterministically from `seed`."""
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
|
|
33
|
+
def hygiene_examples(self, limit: int) -> list[Example]:
|
|
34
|
+
"""Examples for the dataset checks (P12); defaults to none."""
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
# --- how this env wants answers written ----------------------------------------
|
|
38
|
+
def render(self, answer: str, example: Example) -> Completion:
|
|
39
|
+
"""Write `answer` the way the env's prompt asks for it."""
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def wrong_answer(self, example: Example) -> str:
|
|
43
|
+
"""An answer that is definitely not correct for `example`."""
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
|
|
46
|
+
def shotgun_answers(self, example: Example) -> list[str]:
|
|
47
|
+
"""Candidate answers to spray (P5); must include the correct one."""
|
|
48
|
+
return [self.wrong_answer(example), example.answer]
|
|
49
|
+
|
|
50
|
+
def equivalent_forms(self, example: Example) -> dict[str, Completion]:
|
|
51
|
+
"""Semantically identical ways to give the correct answer (P2)."""
|
|
52
|
+
return {}
|
|
53
|
+
|
|
54
|
+
def with_reference(self, example: Example, reference: str) -> Example | None:
|
|
55
|
+
"""A copy of `example` whose gold answer is `reference` (for rewardlint's
|
|
56
|
+
corpus). Return None when references cannot be swapped in this env."""
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# --- env-specific checks (T1–T5 and anything the static review suggests) --------
|
|
60
|
+
extra_probes: list[ExtraProbe] = [] # run on the first `--few` examples
|
|
61
|
+
per_example_probes: list[ExtraProbe] = [] # cheap ones: run on every sampled example
|
falsepass/auto.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Adapter inference: audit an environment without writing an adapter.
|
|
2
|
+
|
|
3
|
+
A hand-written adapter answers three questions. `AutoAdapter` answers them itself:
|
|
4
|
+
|
|
5
|
+
1. *How does this env want an answer written?* Candidate formats are collected from the
|
|
6
|
+
env's parser and prompt text, the reference answer is scored in each, and the format
|
|
7
|
+
that the env's own reward code pays best is chosen. The verifier tells us its format.
|
|
8
|
+
2. *What is a wrong answer?* Another label when answers form a small closed set, a
|
|
9
|
+
shifted value when numeric, otherwise the reference of a different example.
|
|
10
|
+
3. *What are harmless variations?* Generic ones only: surrounding whitespace, a trailing
|
|
11
|
+
sentence, punctuation and padding inside the answer slot.
|
|
12
|
+
|
|
13
|
+
Works for single-turn environments that carry a reference answer (verifiers v0 rubric
|
|
14
|
+
envs and v1 tasksets). Multi-turn, tool-use and judge-only environments still need a
|
|
15
|
+
hand-written adapter; `load` says so instead of guessing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import itertools
|
|
21
|
+
import random
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from falsepass.adapter import Adapter
|
|
27
|
+
from falsepass.model import Example
|
|
28
|
+
from falsepass.scorers import Scorer, V0RolloutScorer, V0RubricScorer, V1TaskScorer
|
|
29
|
+
|
|
30
|
+
REASONING = (
|
|
31
|
+
"Reading the problem carefully, the relevant facts are identified first and each "
|
|
32
|
+
"condition is checked in turn. The intermediate steps are worked through one at a "
|
|
33
|
+
"time, alternatives are compared against the constraints given, and the result is "
|
|
34
|
+
"verified once more before the final answer is stated below."
|
|
35
|
+
)
|
|
36
|
+
MAX_ROWS = 2000
|
|
37
|
+
CLOSED_SET_MAX = 12
|
|
38
|
+
INPUT_WRAPPER_MIN = 200 # characters between <tag> and </tag>
|
|
39
|
+
Format = Callable[[str], str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def candidate_formats(prompt_text: str, parser: Any = None) -> dict[str, Format]:
|
|
43
|
+
"""Ways to write an answer, most specific first. Insertion order is the tie-break."""
|
|
44
|
+
found: dict[str, Format] = {}
|
|
45
|
+
wants_think = "<think>" in prompt_text or "think" in (getattr(parser, "fields", None) or [])
|
|
46
|
+
tags = [t for t in dict.fromkeys(re.findall(r"<([A-Za-z_][\w-]*)>", prompt_text))
|
|
47
|
+
if t != "think" and not wraps_input(prompt_text, t)]
|
|
48
|
+
answer_field = getattr(parser, "answer_field", None)
|
|
49
|
+
if isinstance(answer_field, str) and answer_field not in tags:
|
|
50
|
+
tags.insert(0, answer_field)
|
|
51
|
+
|
|
52
|
+
def add(name: str, body: Format) -> None:
|
|
53
|
+
if wants_think:
|
|
54
|
+
found[f"think+{name}"] = lambda a, body=body: f"<think>\n{REASONING}\n</think>\n{body(a)}"
|
|
55
|
+
found[f"reasoning+{name}"] = lambda a, body=body: f"{REASONING}\n{body(a)}"
|
|
56
|
+
found[name] = body
|
|
57
|
+
|
|
58
|
+
for tag in tags[:4]:
|
|
59
|
+
add(f"<{tag}>", lambda a, tag=tag: f"<{tag}>{a}</{tag}>")
|
|
60
|
+
if "boxed" in prompt_text:
|
|
61
|
+
add("boxed", lambda a: f"\\boxed{{{a}}}")
|
|
62
|
+
if "####" in prompt_text:
|
|
63
|
+
add("####", lambda a: f"#### {a}")
|
|
64
|
+
if re.search(r"\[[A-Za-z]\w*\]", prompt_text): # games that take moves as [move]
|
|
65
|
+
add("[brackets]", lambda a: f"[{a}]")
|
|
66
|
+
for tag in tags[:2]:
|
|
67
|
+
add(f"<{tag}>[brackets]", lambda a, tag=tag: f"<{tag}>[{a}]</{tag}>")
|
|
68
|
+
if "```" in prompt_text:
|
|
69
|
+
add("fenced", lambda a: f"```\n{a}\n```")
|
|
70
|
+
if "ANSWER:" in prompt_text: # Inspect's templates ask for "ANSWER: $ANSWER"
|
|
71
|
+
add("ANSWER:", lambda a: f"ANSWER: {a}")
|
|
72
|
+
# Nothing below was asked for by the prompt: the plain answer goes first, so that it wins a tie.
|
|
73
|
+
found["bare"] = lambda a: a
|
|
74
|
+
add("boxed", lambda a: f"\\boxed{{{a}}}")
|
|
75
|
+
add("Answer:", lambda a: f"Answer: {a}")
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def wraps_input(prompt_text: str, tag: str) -> bool:
|
|
80
|
+
"""A tag the prompt uses to enclose material it hands over (a document, a conversation),
|
|
81
|
+
as opposed to one it shows as the place for the answer."""
|
|
82
|
+
enclosed = re.search(rf"<{re.escape(tag)}>(.*?)</{re.escape(tag)}>", prompt_text, re.S)
|
|
83
|
+
return bool(enclosed) and len(enclosed.group(1)) > INPUT_WRAPPER_MIN
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def own_markup(answer: str) -> bool:
|
|
87
|
+
"""The reference already carries an answer wrapper of its own."""
|
|
88
|
+
return bool(re.search(r"<[A-Za-z_][\w-]*>|\\boxed|####", answer))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_number(text: str) -> bool:
|
|
92
|
+
return bool(re.fullmatch(r"-?\$?\d[\d,]*(\.\d+)?", text.strip()))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ContainerTask(TypeError):
|
|
96
|
+
"""A v1 task that needs a container. Raised so the caller does not fall back to v0 loading."""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AutoAdapter(Adapter):
|
|
100
|
+
def __init__(self, env: dict[str, Any]) -> None:
|
|
101
|
+
self.name = env["id"]
|
|
102
|
+
self.framework = env.get("framework", "verifiers")
|
|
103
|
+
self.task_spec = env.get("task", "")
|
|
104
|
+
self.answers = env.get("answers")
|
|
105
|
+
self.answer_free = False # no reference answers (HUD without an answers file): answer-free probes only
|
|
106
|
+
self.module = env.get("module") or env.get("package") or self.task_spec
|
|
107
|
+
self.load_args = env.get("load_args", {})
|
|
108
|
+
self.multi_turn = False
|
|
109
|
+
self.tool_mode = False # a tool-use environment: played with policies, no answer format to infer
|
|
110
|
+
self.open_ended = False
|
|
111
|
+
self.loader: dict[str, Any] = {}
|
|
112
|
+
self.per_example_probes = [other_task_reference]
|
|
113
|
+
self.format_name, self.format = "bare", (lambda a: a)
|
|
114
|
+
self.inferred: dict[str, Any] = {}
|
|
115
|
+
|
|
116
|
+
# --- loading -------------------------------------------------------------------
|
|
117
|
+
def load(self, n: int, seed: int) -> tuple[Scorer, list[Example]]:
|
|
118
|
+
if self.framework == "inspect":
|
|
119
|
+
return self._load_inspect(n, seed)
|
|
120
|
+
if self.framework == "hud":
|
|
121
|
+
return self._load_hud(n, seed)
|
|
122
|
+
try:
|
|
123
|
+
scorer, self.pool, self.hint = self._load_v1()
|
|
124
|
+
except ContainerTask as needs_box:
|
|
125
|
+
raise RuntimeError(str(needs_box)) from needs_box
|
|
126
|
+
except Exception as v1_error:
|
|
127
|
+
try:
|
|
128
|
+
scorer, self.pool, self.hint = self._load_v0()
|
|
129
|
+
except Exception as v0_error:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
f"could not load {self.module!r} as a v1 taskset ({v1_error}) or a v0 environment ({v0_error})"
|
|
132
|
+
) from v0_error
|
|
133
|
+
self._classify_pool()
|
|
134
|
+
return scorer, random.Random(seed).sample(self.pool, min(n, len(self.pool)))
|
|
135
|
+
|
|
136
|
+
def _classify_pool(self) -> None:
|
|
137
|
+
if not (self.tool_mode or self.answer_free):
|
|
138
|
+
self.pool = [e for e in self.pool if e.answer.strip()]
|
|
139
|
+
if not self.pool:
|
|
140
|
+
raise RuntimeError("no reference answers found: this environment needs a hand-written adapter")
|
|
141
|
+
lengths = sorted(len(e.answer) for e in self.pool)
|
|
142
|
+
# Open-ended tasks (write code, explain, document): the reference is a passage, not a value.
|
|
143
|
+
self.open_ended = lengths[len(lengths) // 2] > 60 or sum("\n" in e.answer for e in self.pool) > len(self.pool) / 2
|
|
144
|
+
self.labels = sorted({e.answer for e in self.pool})
|
|
145
|
+
self.closed = len(self.labels) <= CLOSED_SET_MAX and len(self.pool) > 2 * len(self.labels)
|
|
146
|
+
|
|
147
|
+
def _load_hud(self, n: int, seed: int) -> tuple[Scorer, list[Example]]:
|
|
148
|
+
"""A HUD task template: its own grader scores the reply (hud_support.py)."""
|
|
149
|
+
from falsepass import hud_support
|
|
150
|
+
|
|
151
|
+
scorer, self.pool = hud_support.load(self.task_spec, MAX_ROWS, self.answers)
|
|
152
|
+
self.answer_free = not any(e.answer.strip() for e in self.pool)
|
|
153
|
+
self._classify_pool()
|
|
154
|
+
sampled = random.Random(seed).sample(self.pool, min(n, len(self.pool)))
|
|
155
|
+
self.hint = (sampled[0].prompt, None)
|
|
156
|
+
self.loader = {"kind": "hud", "task": self.task_spec, "answers": self.answers}
|
|
157
|
+
return scorer, sampled
|
|
158
|
+
|
|
159
|
+
def _load_inspect(self, n: int, seed: int) -> tuple[Scorer, list[Example]]:
|
|
160
|
+
"""An Inspect AI task: its solver chain and scorers grade the completion (inspect_support.py)."""
|
|
161
|
+
from falsepass import inspect_support
|
|
162
|
+
|
|
163
|
+
scorer, self.pool = inspect_support.load(self.task_spec, self.load_args, MAX_ROWS)
|
|
164
|
+
self._classify_pool()
|
|
165
|
+
sampled = random.Random(seed).sample(self.pool, min(n, len(self.pool)))
|
|
166
|
+
for example in sampled: # what the model would see: the task's templates applied
|
|
167
|
+
example.prompt = scorer.rendered_prompt(example)
|
|
168
|
+
self.hint = (sampled[0].prompt, None)
|
|
169
|
+
self.loader = {"kind": "inspect", "task": self.task_spec, "load_args": self.load_args}
|
|
170
|
+
return scorer, sampled
|
|
171
|
+
|
|
172
|
+
def _load_v0(self) -> tuple[Scorer, list[Example], tuple[str, Any]]:
|
|
173
|
+
import verifiers as vf
|
|
174
|
+
|
|
175
|
+
env = vf.load_environment(self.module, **self.load_args)
|
|
176
|
+
if not hasattr(env, "rubric"):
|
|
177
|
+
raise TypeError("not a rubric environment")
|
|
178
|
+
lineage = {cls.__name__ for cls in type(env).__mro__}
|
|
179
|
+
needs_infrastructure = lineage & {"SandboxEnv", "PythonEnv", "CliAgentEnv", "MCPEnv", "BrowserEnv", "HarborEnv", "EnvGroup"}
|
|
180
|
+
if needs_infrastructure:
|
|
181
|
+
raise TypeError(f"{type(env).__name__} uses a sandbox or an outside service ({sorted(needs_infrastructure)}): write an adapter")
|
|
182
|
+
self.multi_turn = "SingleTurnEnv" not in lineage and "MultiTurnEnv" in lineage
|
|
183
|
+
# Tools that run in this process: the answer is given through tool calls, so there is
|
|
184
|
+
# no text format to infer. Such an environment is played with policies (probe T6).
|
|
185
|
+
self.tool_mode = bool(lineage & {"ToolEnv", "StatefulToolEnv"})
|
|
186
|
+
rows = None
|
|
187
|
+
for getter in ("get_eval_dataset", "get_dataset"):
|
|
188
|
+
try:
|
|
189
|
+
rows = getattr(env, getter)()
|
|
190
|
+
if rows is not None and len(rows):
|
|
191
|
+
break
|
|
192
|
+
except Exception:
|
|
193
|
+
continue
|
|
194
|
+
if rows is None:
|
|
195
|
+
raise TypeError("environment exposes no dataset")
|
|
196
|
+
rows = rows.select(range(min(len(rows), MAX_ROWS)))
|
|
197
|
+
pool = [
|
|
198
|
+
Example(f"row-{i}", self._last_user(row["prompt"]), str(row.get("answer") or ""), row)
|
|
199
|
+
for i, row in enumerate(rows)
|
|
200
|
+
]
|
|
201
|
+
system = getattr(env, "system_prompt", None) or ""
|
|
202
|
+
first = " ".join(str(m.get("content") or "") for m in rows[0]["prompt"])
|
|
203
|
+
scorer = V0RolloutScorer(env) if self.multi_turn else V0RubricScorer(env)
|
|
204
|
+
self.loader = {"kind": "v0-rollout" if self.multi_turn else "v0", "module": self.module,
|
|
205
|
+
"load_args": self.load_args, "getter": getter} # fmt: skip
|
|
206
|
+
return scorer, pool, (f"{system}\n{first}", getattr(env, "parser", None))
|
|
207
|
+
|
|
208
|
+
def _load_v1(self) -> tuple[Scorer, list[Example], tuple[str, Any]]:
|
|
209
|
+
import verifiers.v1 as vf
|
|
210
|
+
from verifiers.v1.utils.loaders import taskset_class
|
|
211
|
+
|
|
212
|
+
taskset = taskset_class(self.module)(vf.taskset_config_type(self.module)(id=self.module, **self.load_args))
|
|
213
|
+
tasks = list(itertools.islice(iter(taskset.load()), MAX_ROWS))
|
|
214
|
+
if tasks and type(tasks[0]).NEEDS_CONTAINER:
|
|
215
|
+
# Graded on what is left in a box, not on text: text probes would all score 0
|
|
216
|
+
# and say nothing. See falsepass/container.py.
|
|
217
|
+
raise ContainerTask(
|
|
218
|
+
f"{type(tasks[0]).__name__} is graded on files left in a container: run `falsepass container "
|
|
219
|
+
"<env>` (needs Docker), or write submissions with falsepass.container.score_submission"
|
|
220
|
+
)
|
|
221
|
+
from falsepass.scorers import scripted_user_turns
|
|
222
|
+
|
|
223
|
+
if tasks and not tasks[0].data.prompt_text and scripted_user_turns(tasks[0].data):
|
|
224
|
+
raise ContainerTask( # not a container, but equally not something to fall back to v0 for
|
|
225
|
+
f"{type(tasks[0]).__name__} has no prompt: its env plays a scripted user over several turns, and the "
|
|
226
|
+
"answer for each turn cannot be inferred. Write an adapter whose render() returns the conversation; "
|
|
227
|
+
"V1TaskScorer opens the trace with the first scripted turn (write an adapter.py with the scripted turns)"
|
|
228
|
+
)
|
|
229
|
+
pool = [
|
|
230
|
+
Example(f"task-{i}", t.data.prompt_text, str(getattr(t.data, "answer", "") or ""), t)
|
|
231
|
+
for i, t in enumerate(tasks)
|
|
232
|
+
]
|
|
233
|
+
self.loader = {"kind": "v1", "module": self.module, "load_args": self.load_args}
|
|
234
|
+
system = getattr(tasks[0].data, "system_prompt", None) or ""
|
|
235
|
+
return V1TaskScorer(tasks[0]), pool, (f"{system}\n{tasks[0].data.prompt_text}", None)
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _last_user(messages: Any) -> str:
|
|
239
|
+
if isinstance(messages, str):
|
|
240
|
+
return messages
|
|
241
|
+
users = [m for m in messages if m.get("role") == "user"]
|
|
242
|
+
return str((users or messages)[-1].get("content") or "")
|
|
243
|
+
|
|
244
|
+
def hygiene_examples(self, limit: int) -> list[Example]:
|
|
245
|
+
return self.pool[:limit]
|
|
246
|
+
|
|
247
|
+
# --- format inference ------------------------------------------------------------
|
|
248
|
+
async def prepare(self, scorer: Scorer, examples: list[Example], k: int = 5) -> None:
|
|
249
|
+
"""Score the reference in every candidate format and keep the best-paid one."""
|
|
250
|
+
if self.answer_free:
|
|
251
|
+
self.inferred = {
|
|
252
|
+
"validity": "no control: the tasks carry no reference answer (HUD keeps it inside the grader), so only "
|
|
253
|
+
"answer-free probes ran (P3, P4, A1, P10, P11) and the maximum reward is assumed to be 1.0. "
|
|
254
|
+
"Give reference answers with `falsepass init ... --answers file.json` to run every probe",
|
|
255
|
+
"format": "plain reply", "reference_reward_by_format": {}, "examples_tried": 0, "answers": "not available",
|
|
256
|
+
"turns": "single answer, graded by the task template", "note": "",
|
|
257
|
+
} # fmt: skip
|
|
258
|
+
return
|
|
259
|
+
if self.tool_mode:
|
|
260
|
+
self.inferred = {
|
|
261
|
+
"validity": "no control: the reference cannot be replayed without a policy that solves the task, so the "
|
|
262
|
+
"maximum reward is assumed to be 1.0 and only the tool-policy probes (T5, T6) were run",
|
|
263
|
+
"format": "tool calls", "reference_reward_by_format": {}, "examples_tried": 0, "answers": "given through tool calls",
|
|
264
|
+
"turns": "multi-turn with tools: generic policies call the environment's own tools", "note": "",
|
|
265
|
+
} # fmt: skip
|
|
266
|
+
return
|
|
267
|
+
trial = examples[:k]
|
|
268
|
+
table: dict[str, float] = {}
|
|
269
|
+
for name, fmt in candidate_formats(*self.hint).items():
|
|
270
|
+
rewards = [(await scorer.score(ex, fmt(ex.answer))).reward or 0.0 for ex in trial]
|
|
271
|
+
table[name] = sum(rewards) / len(rewards)
|
|
272
|
+
best = max(table, key=lambda name: table[name]) # max() keeps the first of equals
|
|
273
|
+
self.format_name, self.format = best, candidate_formats(*self.hint)[best]
|
|
274
|
+
if table[best] <= 0:
|
|
275
|
+
raise RuntimeError(
|
|
276
|
+
f"no candidate format earned any reward for the reference answer ({table}): "
|
|
277
|
+
"write an adapter with `falsepass init --manual`"
|
|
278
|
+
)
|
|
279
|
+
self.max_reward = round(table[best], 6) if abs(table[best] - 1.0) > 1e-9 else 1.0
|
|
280
|
+
# Control: an audit built on the reference is only meaningful if the reward tells
|
|
281
|
+
# the reference apart from saying nothing.
|
|
282
|
+
empty = [(await scorer.score(ex, "")).reward or 0.0 for ex in trial]
|
|
283
|
+
blank = sum(empty) / len(empty)
|
|
284
|
+
# Separated when saying nothing earns less than half of what the reference earns
|
|
285
|
+
# and the gap is material. (An absolute threshold wrongly rejected environments
|
|
286
|
+
# whose own reference answers score only 0.4.)
|
|
287
|
+
separated = blank < 0.5 * table[best] and table[best] - blank >= 0.2
|
|
288
|
+
if not separated:
|
|
289
|
+
validity = (
|
|
290
|
+
f"inconclusive: the reference scored {table[best]:.3f} and an empty completion "
|
|
291
|
+
f"{blank:.3f}. Either this reward does not depend on the `answer` field "
|
|
292
|
+
"(a game, a simulation, a judge), or it pays regardless of the answer. Automatic mode "
|
|
293
|
+
"cannot tell which; the probe results below are not evidence either way"
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
validity = "ok"
|
|
297
|
+
notes = []
|
|
298
|
+
if abs(table[best] - 1.0) > 1e-9:
|
|
299
|
+
notes.append(f"the reference did not score exactly 1.0; the best format averaged {table[best]:.3f} and is used as the maximum")
|
|
300
|
+
if sum(own_markup(ex.answer) for ex in trial) > len(trial) / 2:
|
|
301
|
+
# Seen in curvebench: wrapping such a reference again, or a wrong value next to it,
|
|
302
|
+
# leaves the grader reading the reference's own tags.
|
|
303
|
+
notes.append("the reference answers carry answer markup of their own, so probes that place a wrong "
|
|
304
|
+
"answer in the format may not present a competing answer: check P5, P6 and P9 rows by hand")
|
|
305
|
+
self.inferred = {
|
|
306
|
+
"validity": validity,
|
|
307
|
+
"format": best,
|
|
308
|
+
"reference_reward_by_format": table,
|
|
309
|
+
"examples_tried": len(trial),
|
|
310
|
+
"answers": "closed set: " + ", ".join(self.labels) if self.closed
|
|
311
|
+
else "open-ended passages" if self.open_ended else "open",
|
|
312
|
+
"turns": "multi-turn: the answer is given at every turn through the env's own rollout loop"
|
|
313
|
+
if self.multi_turn else "single-turn",
|
|
314
|
+
"note": "; ".join(notes),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# --- the three adapter questions -----------------------------------------------
|
|
318
|
+
def render(self, answer: str, example: Example) -> str:
|
|
319
|
+
return self.format(answer)
|
|
320
|
+
|
|
321
|
+
def other_reference(self, example: Example) -> str | None:
|
|
322
|
+
"""The reference answer of a different task, as far from this one's as the pool allows."""
|
|
323
|
+
answer = example.answer.strip().lower()
|
|
324
|
+
for other in self.pool:
|
|
325
|
+
if other.key != example.key and other.answer.strip().lower() != answer:
|
|
326
|
+
return other.answer
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def wrong_answer(self, example: Example) -> str:
|
|
330
|
+
answer = example.answer.strip()
|
|
331
|
+
if self.open_ended:
|
|
332
|
+
# A passage that answers nothing. Another task's reference is probed separately
|
|
333
|
+
# (X1): for open-ended tasks that measures task-independence, not wrongness.
|
|
334
|
+
return "I am not sure how to approach this, so I will not give an answer."
|
|
335
|
+
if self.closed:
|
|
336
|
+
return self.labels[(self.labels.index(example.answer) + 1) % len(self.labels)]
|
|
337
|
+
if is_number(answer):
|
|
338
|
+
value = float(answer.replace(",", "").replace("$", ""))
|
|
339
|
+
return f"{value + 7:g}"
|
|
340
|
+
for other in self.pool:
|
|
341
|
+
if other.answer.strip().lower() != answer.lower():
|
|
342
|
+
return other.answer
|
|
343
|
+
return "none of the above"
|
|
344
|
+
|
|
345
|
+
def shotgun_answers(self, example: Example) -> list[str]:
|
|
346
|
+
if self.closed:
|
|
347
|
+
return list(self.labels)
|
|
348
|
+
if is_number(example.answer):
|
|
349
|
+
value = float(example.answer.replace(",", "").replace("$", ""))
|
|
350
|
+
return [f"{value + d:g}" for d in (-3, -2, -1, 1, 2, 3)] + [example.answer]
|
|
351
|
+
return [self.wrong_answer(example), example.answer]
|
|
352
|
+
|
|
353
|
+
def equivalent_forms(self, example: Example) -> dict[str, str]:
|
|
354
|
+
a, correct = example.answer, self.format(example.answer)
|
|
355
|
+
forms = {
|
|
356
|
+
"surrounding_whitespace": f"\n\n {correct} \n\n",
|
|
357
|
+
"sentence_after": f"{correct}\nThat is my final answer.",
|
|
358
|
+
"crlf_line_endings": correct.replace("\n", "\r\n"),
|
|
359
|
+
"padding_inside_slot": self.format(f" {a} "),
|
|
360
|
+
"trailing_period_inside_slot": self.format(f"{a}."),
|
|
361
|
+
}
|
|
362
|
+
if a.isalpha() and a.swapcase() != a:
|
|
363
|
+
forms["answer_case_swapped"] = self.format(a.swapcase())
|
|
364
|
+
return forms
|
|
365
|
+
|
|
366
|
+
def with_reference(self, example: Example, reference: str) -> Example | None:
|
|
367
|
+
if not is_number(example.answer):
|
|
368
|
+
return None
|
|
369
|
+
if self.framework == "inspect":
|
|
370
|
+
from falsepass import inspect_support
|
|
371
|
+
|
|
372
|
+
return inspect_support.with_reference(example, reference)
|
|
373
|
+
raw = example.raw
|
|
374
|
+
if hasattr(raw, "data"): # v1 task
|
|
375
|
+
data = raw.data.model_copy(update={"answer": reference})
|
|
376
|
+
return Example(example.key, example.prompt, reference, type(raw)(data, raw.config))
|
|
377
|
+
row = dict(raw)
|
|
378
|
+
row["answer"] = reference
|
|
379
|
+
return Example(example.key, example.prompt, reference, row)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def other_task_reference(adapter: AutoAdapter, scorer: Scorer, ex: Example) -> list:
|
|
383
|
+
"""X1 — submit the reference answer of a different task. For value answers this is
|
|
384
|
+
simply a wrong answer. For open-ended tasks it asks a different question: does the
|
|
385
|
+
reward depend on which task was set, or only on what a good answer looks like?"""
|
|
386
|
+
from falsepass.model import Expect
|
|
387
|
+
from falsepass.probes import targeted
|
|
388
|
+
|
|
389
|
+
other = adapter.other_reference(ex)
|
|
390
|
+
if other is None:
|
|
391
|
+
return []
|
|
392
|
+
return await targeted("X1", "task_independence", {
|
|
393
|
+
"other_task_reference": (Expect.ZERO, adapter.format(other)),
|
|
394
|
+
}, adapter, scorer, ex, answer_free=("other_task_reference",)) # fmt: skip
|