Skip to content

Commit cfc60ae

Browse files
committed
Fix v1 task example id normalization
1 parent 43b5c14 commit cfc60ae

9 files changed

Lines changed: 110 additions & 20 deletions

File tree

docs/evaluation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ env.set_concurrency(256)
136136
| `--api-client-type` || `openai_chat_completions` | Client type: `openai_completions`, `openai_chat_completions`, `openai_chat_completions_token`, `openai_responses`, `renderer`, `anthropic_messages`, or `nemorl_chat_completions` |
137137
| `--endpoints-path` | `-e` | `./configs/endpoints.toml` | Path to TOML endpoints registry |
138138
| `--header` ||| Extra HTTP header (`Name: Value`), repeatable |
139-
| `--header-from-state` || `X-Session-ID: example_id` | Per-request header whose value is read from rollout state (`Name: state_key`), repeatable |
139+
| `--header-from-state` || framework session id | Per-request header whose value is read from rollout state (`Name: state_key`), repeatable |
140140

141141
The `renderer` client type requires the optional renderer package. Install it with `uv add "verifiers[renderers]"` before running evals with `--api-client-type renderer`.
142142

@@ -178,7 +178,7 @@ headers = { "X-Custom-Header" = "value" }
178178

179179
In `[[eval]]` TOML configs you can set extra headers as `headers = { ... }` and/or as a list `header = ["Name: Value", ...]` (same form as repeated `--header`). Merge order is: registry row, then the `headers` table, then each `header` / `--header` line, with later entries overriding the same name.
180180

181-
For per-request headers that need to vary per rollout (e.g. sticky DP-aware routing keyed off `example_id` or `trajectory_id`), use `headers_from_state = { "X-Name" = "state_key" }` and/or `header_from_state = ["X-Name: state_key", ...]` (same form as repeated `--header-from-state`). The value for each request is resolved at send time as `state[state_key]`. If unset, `X-Session-ID` defaults to `example_id`.
181+
For per-request headers that need to vary per rollout, use `headers_from_state = { "X-Name" = "state_key" }` and/or `header_from_state = ["X-Name: state_key", ...]` (same form as repeated `--header-from-state`). The value for each request is resolved at send time as `state[state_key]`. If unset, Verifiers supplies a framework-managed `X-Session-ID`.
182182

183183
To define equivalent replicas, add multiple `[[endpoint]]` entries with the same `endpoint_id`.
184184

docs/reference.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ Selects which `Client` implementation to use. Set via `ClientConfig.client_type`
126126

127127
```python
128128
class State(dict):
129-
INPUT_FIELDS = ["prompt", "answer", "info", "example_id"]
129+
INPUT_FIELDS = ["prompt", "answer", "info"]
130130
```
131131

132132
A `dict` subclass that tracks rollout information. Accessing keys in `INPUT_FIELDS` automatically forwards to the nested `input` object.
@@ -162,7 +162,6 @@ A `dict` subclass that tracks rollout information. Accessing keys in `INPUT_FIEL
162162
```python
163163
class RolloutInput(TypedDict):
164164
prompt: Messages # Required
165-
example_id: int # Framework-managed
166165
answer: str # Optional
167166
info: Info # Optional
168167
```
@@ -172,7 +171,6 @@ class RolloutInput(TypedDict):
172171
```python
173172
class RolloutOutput(dict):
174173
# Required fields
175-
example_id: int # Framework-managed
176174
prompt: Messages | None
177175
completion: Messages | None
178176
reward: float

docs/training.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,20 @@ configs/
4242
│ ├── qwen-3-5.toml
4343
│ ├── qwen-3-5-moe.toml
4444
│ ├── nemotron-3.toml
45-
│ └── llama-3.toml
45+
│ ├── llama-3.toml
46+
│ └── gpt-oss.toml
4647
├── rl/
4748
│ ├── qwen-3-5.toml
4849
│ ├── qwen-3-5-moe.toml
4950
│ ├── nemotron-3.toml
50-
│ └── llama-3.toml
51+
│ ├── llama-3.toml
52+
│ └── gpt-oss.toml
5153
└── gepa/
5254
├── qwen-3-5.toml
5355
├── qwen-3-5-moe.toml
5456
├── nemotron-3.toml
55-
└── llama-3.toml
57+
├── llama-3.toml
58+
└── gpt-oss.toml
5659
```
5760

5861
Example configuration file for the `primeintellect/reverse-text` environment with `Qwen/Qwen3.5-4B`:

tests/test_gepa_cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,14 @@ def test_load_gepa_toml_config_requires_env_table(tmp_path: Path):
189189
load_gepa_toml_config(config_path)
190190

191191

192+
def test_repo_gepa_example_configs_are_valid():
193+
config_paths = sorted(Path("configs/gepa").glob("*.toml"))
194+
assert config_paths
195+
for config_path in config_paths:
196+
loaded = load_gepa_toml_config(config_path)
197+
assert loaded["envs"], f"{config_path} should contain at least one [[env]]"
198+
199+
192200
def test_resolve_gepa_config_args_supports_plain_env_id():
193201
args = argparse.Namespace(env_id_or_config="primeintellect/wordle")
194202

tests/test_save_utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,13 @@ def test_states_to_outputs(self, make_state):
258258
assert result[0].get("foo") == "bar" # custom field from make_state fixture
259259
assert result[0]["reward"] == 1.0
260260

261+
def test_states_to_outputs_requires_example_id(self, make_state):
262+
state = make_state()
263+
del state["example_id"]
264+
265+
with pytest.raises(KeyError):
266+
states_to_outputs([state], state_columns=[])
267+
261268
def test_states_to_outputs_completion_keeps_messages(self, make_state):
262269
states = [
263270
make_state(
@@ -647,6 +654,22 @@ def test_builder_uses_custom_threshold(self):
647654
# 1 of 4 correct at threshold=0.7: pass^1 = C(1,1)/C(4,1) = 0.25
648655
assert metadata["pass_all_k"]["1"] == pytest.approx(0.25)
649656

657+
def test_builder_requires_example_id(self):
658+
builder = GenerateOutputsBuilder(
659+
env_id="test-env",
660+
env_args={},
661+
model="test-model",
662+
client=ClientConfig(api_base_url="http://localhost:8000/v1"),
663+
num_examples=1,
664+
rollouts_per_example=1,
665+
state_columns=[],
666+
sampling_args={},
667+
results_path=Path("/tmp/test-results"),
668+
)
669+
670+
with pytest.raises(KeyError):
671+
builder.add_outputs([{"reward": 1.0, "metrics": {}}])
672+
650673

651674
class TestMetricProtocol:
652675
def test_all_metrics_satisfy_protocol(self):

tests/test_v1_taskset_utils.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import json
2+
3+
from datasets import Dataset
4+
5+
from verifiers.v1.utils.taskset_utils import dataset_from_result
6+
7+
8+
def task_payload(row: dict) -> dict:
9+
return json.loads(row["info"]["task"])
10+
11+
12+
def test_dataset_from_result_assigns_example_id_to_iterable_records():
13+
dataset = dataset_from_result(
14+
[
15+
{"question": "Reverse abc.", "answer": "cba"},
16+
{"question": "Reverse xyz.", "answer": "zyx"},
17+
],
18+
"ReverseTextTaskset",
19+
)
20+
21+
rows = list(dataset)
22+
payloads = [task_payload(row) for row in rows]
23+
24+
assert [row["example_id"] for row in rows] == [0, 1]
25+
assert [payload["example_id"] for payload in payloads] == [0, 1]
26+
assert all(len(payload["task_id"]) == 32 for payload in payloads)
27+
assert {payload["task_id"] for payload in payloads}.isdisjoint({"0", "1"})
28+
29+
30+
def test_dataset_from_result_overwrites_existing_example_id_column():
31+
raw_dataset = Dataset.from_list(
32+
[
33+
{"question": "Reverse abc.", "answer": "cba", "example_id": None},
34+
{"question": "Reverse xyz.", "answer": "zyx", "example_id": 99},
35+
]
36+
)
37+
38+
dataset = dataset_from_result(raw_dataset, "ReverseTextTaskset")
39+
40+
rows = list(dataset)
41+
payloads = [task_payload(row) for row in rows]
42+
43+
assert [row["example_id"] for row in rows] == [0, 1]
44+
assert [payload["example_id"] for payload in payloads] == [0, 1]
45+
assert all(len(payload["task_id"]) == 32 for payload in payloads)
46+
assert {payload["task_id"] for payload in payloads}.isdisjoint({"0", "1", "99"})

verifiers/utils/metric_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,12 @@ def __init__(self, rollouts_per_example: int, threshold: float = 0.5) -> None:
162162
self.reset()
163163

164164
def add_output(self, output: RolloutOutput) -> None:
165+
example_id = output["example_id"]
166+
if example_id is None:
167+
raise ValueError("output['example_id'] is required.")
165168
if not self._k_values:
166169
return
167170

168-
example_id = output.get("example_id", 0)
169171
self._example_counts[example_id] += 1
170172
if output.get("reward", 0.0) >= self.threshold:
171173
self._example_correct[example_id] += 1

verifiers/utils/save_utils.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,12 @@ def state_to_output(
218218
else:
219219
raise TypeError("state['timing'] must be a RolloutTiming or mapping.")
220220

221+
example_id = state["example_id"]
222+
if example_id is None:
223+
raise ValueError("state['example_id'] is required.")
224+
221225
output = RolloutOutput(
222-
example_id=state.get("example_id", 0),
226+
example_id=example_id,
223227
prompt=state.get("prompt"),
224228
completion=state.get("completion"),
225229
answer=state.get("answer", ""),
@@ -671,9 +675,16 @@ def build_metadata(self) -> GenerateMetadata:
671675
def build_outputs(self, sort_by_example_id: bool = False) -> list[RolloutOutput]:
672676
"""Return (sorted) accumulated outputs"""
673677
if sort_by_example_id:
674-
return sorted(self.outputs, key=lambda o: o.get("example_id", 0))
678+
return sorted(self.outputs, key=self.output_example_id)
675679
return self.outputs
676680

681+
@staticmethod
682+
def output_example_id(output: RolloutOutput) -> int:
683+
example_id = output["example_id"]
684+
if example_id is None:
685+
raise ValueError("output['example_id'] is required.")
686+
return example_id
687+
677688
def build(self, sort_by_example_id: bool = False) -> GenerateOutputs:
678689
"""Build GenerateOutputs from accumulated outputs."""
679690
return GenerateOutputs(

verifiers/v1/utils/taskset_utils.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,8 @@ def prepare_task(task: Task, taskset_id: str) -> Task:
3838
raise TypeError("v1 task loaders must return Task objects.")
3939
prepared = Task(cast(JsonData, dict(task)))
4040
prepared["taskset_id"] = taskset_id
41-
if "task_id" in prepared:
41+
if prepared.get("task_id") is not None:
4242
prepared["task_id"] = str(prepared["task_id"])
43-
elif "example_id" in prepared:
44-
prepared["task_id"] = str(prepared["example_id"])
4543
else:
4644
prepared["task_id"] = uuid.uuid4().hex
4745
return prepared.freeze()
@@ -51,13 +49,13 @@ def dataset_record_from_task(
5149
task: Task,
5250
taskset_id: str,
5351
index: int,
54-
source: JsonData | None = None,
52+
record: JsonData | None = None,
5553
) -> JsonData:
5654
data = Task(cast(JsonData, dict(task)))
57-
data.setdefault("example_id", source.get("example_id") if source else index)
55+
data["example_id"] = index
5856
normalized = prepare_task(data, taskset_id)
5957
task_payload = dict(normalized)
60-
dataset_record = deepcopy(dict(source or {}))
58+
dataset_record = deepcopy(dict(record or {}))
6159
dataset_record["prompt"] = task_payload["prompt"]
6260
dataset_record["example_id"] = task_payload["example_id"]
6361
info = dataset_record.get("info")
@@ -82,9 +80,10 @@ def dataset_from_result(result: Tasks, taskset_id: str) -> Dataset:
8280
if isinstance(result, Dataset):
8381
records: list[JsonData] = []
8482
for index, record in enumerate(result):
85-
source = cast(JsonData, dict(record))
86-
task = task_from_dataset_record(source, taskset_id)
87-
records.append(dataset_record_from_task(task, taskset_id, index, source))
83+
row = cast(JsonData, dict(record))
84+
row["example_id"] = index
85+
task = task_from_dataset_record(row, taskset_id)
86+
records.append(dataset_record_from_task(task, taskset_id, index, row))
8887
return Dataset.from_list(records)
8988
tasks = tasks_from_result(result, taskset_id)
9089
return Dataset.from_list(dataset_records_from_tasks(tasks, taskset_id))

0 commit comments

Comments
 (0)