Skip to content

Commit a9471f9

Browse files
authored
feat(v1)!: cross-agent judgement is finalize() — env-level decorated signals removed (#2090)
1 parent f801dcf commit a9471f9

14 files changed

Lines changed: 75 additions & 173 deletions

File tree

assets/lab/environments/AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,7 @@ class DebateEnv(vf.Environment[DebateConfig]):
295295
"""Sibling-dependent judgement over the finished episode (per-trace
296296
judgement already ran on each trace's own task); `episode.traces` is
297297
the flat episode, each trace's `agent_name` stamp naming its agent.
298-
Attach via record_reward/record_metric; the env's own
299-
`@vf.reward`/`@vf.metric` methods run after this."""
298+
Attach via record_reward/record_metric, in program order."""
300299
by_agent = {t.agent_name: t for t in episode.traces}
301300
winner = (by_agent["judge"].last_reply or "").strip().lower()
302301
by_agent["pro"].record_reward("won", float(winner == "pro"))

docs/v1/environments.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ class DebateEnv(vf.Environment[DebateConfig]):
4444
"""Sibling-dependent judgement over the finished episode (per-trace
4545
judgement already ran on each trace's own task); `episode.traces` is
4646
the flat episode, each trace's `agent_name` stamp naming its agent.
47-
Attach via record_reward/record_metric; the env's own
48-
`@vf.reward`/`@vf.metric` methods run after this."""
47+
Attach via record_reward/record_metric, in program order."""
4948
by_agent = {t.agent_name: t for t in episode.traces}
5049
winner = (by_agent["judge"].last_reply or "").strip().lower()
5150
by_agent["pro"].record_reward("won", float(winner == "pro"))

environments/AGENTS.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,7 @@ class DebateEnv(vf.Environment[DebateConfig]):
294294
"""Sibling-dependent judgement over the finished episode (per-trace
295295
judgement already ran on each trace's own task); `episode.traces` is
296296
the flat episode, each trace's `agent_name` stamp naming its agent.
297-
Attach via record_reward/record_metric; the env's own
298-
`@vf.reward`/`@vf.metric` methods run after this."""
297+
Attach via record_reward/record_metric, in program order."""
299298
by_agent = {t.agent_name: t for t in episode.traces}
300299
winner = (by_agent["judge"].last_reply or "").strip().lower()
301300
by_agent["pro"].record_reward("won", float(winner == "pro"))

environments/code_golf_v1/code_golf_v1/taskset.py

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
Each task asks for a tiny program with a known output. The env fans one env-rollout
44
into `--env.attempts` independent attempts by the same "golfer" role and scores them
55
against each other. Anything that needs the runtime is measured per attempt, box-live,
6-
into that attempt's trace; the env's `score()` then just compares the recorded
6+
into that attempt's trace; the env's `finalize()` then just compares the recorded
77
metadata across the finished siblings:
88
99
- `evaluate` per-attempt `@metric`: runs the program once in that attempt's
1010
runtime and records `passed` + `latency`. (task, trace, runtime)
1111
- `correct` per-attempt `@reward`: reads `passed` off the trace. (trace)
12-
- `most_concise` env `score()`: of the PASSING attempts, the shortest source wins.
13-
- `fastest` env `score()`: of the PASSING attempts, the lowest recorded
12+
- `most_concise` env `finalize()`: of the PASSING attempts, the shortest source wins.
13+
- `fastest` env `finalize()`: of the PASSING attempts, the lowest recorded
1414
`latency` wins — a comparison of trace metadata.
1515
1616
So one env-rollout produces, per attempt: did it work, was it the shorter one, was it
@@ -78,23 +78,23 @@ async def run(self, task: vf.Task, agents: vf.Agents) -> None:
7878
for _ in range(self.config.attempts):
7979
tg.create_task(agents.golfer.run(task))
8080

81-
@vf.reward(weight=0.5)
82-
async def most_concise(self, trace: vf.Trace, traces: list[vf.Trace]) -> float:
83-
"""The sibling comparison: shortest source among the passing attempts wins
84-
(ties share); a failed attempt earns nothing."""
85-
if not trace.metrics.get("passed"):
86-
return 0.0
87-
lengths = [len(extract_program(t)) for t in traces if t.metrics.get("passed")]
88-
return float(len(extract_program(trace)) == min(lengths))
89-
90-
@vf.reward(weight=0.5)
91-
async def fastest(self, trace: vf.Trace, traces: list[vf.Trace]) -> float:
92-
"""The sibling comparison: lowest run latency among the passing attempts
93-
wins (ties share); a failed attempt earns nothing."""
94-
if not trace.metrics.get("passed"):
95-
return 0.0
96-
latencies = [t.metrics["latency"] for t in traces if t.metrics.get("passed")]
97-
return float(trace.metrics["latency"] == min(latencies))
81+
async def finalize(self, task: vf.Task, episode: vf.Episode) -> None:
82+
"""The sibling comparison: among the PASSING attempts, the shortest source
83+
wins `most_concise` and the lowest recorded latency wins `fastest` (ties
84+
share); a failed attempt earns nothing on either."""
85+
passing = [t for t in episode.traces if t.metrics.get("passed")]
86+
min_length = min((len(extract_program(t)) for t in passing), default=None)
87+
min_latency = min((t.metrics["latency"] for t in passing), default=None)
88+
for trace in episode.traces:
89+
if trace not in passing:
90+
trace.record_reward("most_concise", 0.0, 0.5)
91+
trace.record_reward("fastest", 0.0, 0.5)
92+
continue
93+
concise = float(len(extract_program(trace)) == min_length)
94+
trace.record_reward("most_concise", concise, 0.5)
95+
trace.record_reward(
96+
"fastest", float(trace.metrics["latency"] == min_latency), 0.5
97+
)
9898

9999

100100
class CodeGolfTaskset(vf.Taskset[CodeGolfTask, vf.TasksetConfig]):

environments/proposer_solver_v1/proposer_solver_v1/taskset.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,16 +154,15 @@ def _solve_rate(traces: list[vf.Trace]) -> float:
154154
return 0.0
155155
return sum(t.rewards.get("correct", 0.0) for t in solves) / len(solves)
156156

157-
@vf.reward(agent="proposer")
158-
async def learnability(self, trace: vf.Trace, traces: list[vf.Trace]) -> float:
159-
"""The curriculum signal: 1.0 when half the solvers crack the problem, 0
160-
when it's trivial or impossible for them (4p(1-p))."""
161-
rate = self._solve_rate(traces)
162-
return 4.0 * rate * (1.0 - rate)
163-
164-
@vf.metric(agent="proposer")
165-
async def solve_rate(self, trace: vf.Trace, traces: list[vf.Trace]) -> float:
166-
return self._solve_rate(traces)
157+
async def finalize(self, task: vf.Task, episode: vf.Episode) -> None:
158+
"""The proposer is judged by what its problem DOES to the solvers:
159+
`learnability` — the curriculum signal — is 1.0 when half of them crack
160+
the problem, 0 when it's trivial or impossible for them (4p(1-p))."""
161+
rate = self._solve_rate(episode.traces)
162+
for trace in episode.traces:
163+
if trace.agent_name == "proposer":
164+
trace.record_metric("solve_rate", rate)
165+
trace.record_reward("learnability", 4.0 * rate * (1.0 - rate))
167166

168167

169168
class ProposerSolverTaskset(vf.Taskset[ProposeTask, vf.TasksetConfig]):

skills/create-environments/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Runtime config chooses where code executes. Task hooks should use the `vf.Runtim
136136
- Prefer deterministic verification grounded in the task's actual artifact or answer.
137137
- Use an LLM judge only when semantic judgment is unavoidable.
138138
- Metrics are for observability and do not contribute to reward, but are useful. Use them deliberately and appropriately!
139-
- Judgement that compares the sibling traces of one env-rollout (best-of-n selection, zero-sum payoffs) lives on `Environment.finalize(task, episode)` or the env's `@vf.reward`/`@vf.metric` methods — attach via `trace.record_reward`/`record_metric`; no live runtime there.
139+
- Judgement that compares the sibling traces of one env-rollout (best-of-n selection, zero-sum payoffs) lives on `Environment.finalize(task, episode)` — attach via `trace.record_reward`/`record_metric`, in program order; no live runtime there.
140140
- Raise ordinary Python exceptions from rollout hooks and scoring. The rollout records them as `TaskError`.
141141

142142
## Validation and lifecycle
@@ -194,7 +194,7 @@ The selected harness must support user simulation, which a lot of the built-in,
194194

195195
## Multi-agent environments
196196

197-
When one rollout is more than one agent run, export an `Environment` subclass next to the taskset: declare each agent as a `vf.AgentConfig` field with a default instance on a `vf.EnvConfig` subclass (bound via `Environment[YourConfig]`, read as `self.config`, addressed as `--env.<agent>.*`) — the field name is the agent's name, the only naming site, and per-run caps (turns, tokens, stage timeouts, retries) are agent fields. A declared pin is its author default; an unpinned agent runs the taskset's default harness, and its model context defaults to the run's own. Task x agent fit validates per run, on the task each agent actually receives (an env-minted task carries its own `tools`/`NEEDS_CONTAINER`, so a bare verdict task pairs with any taskset). Then write `run(task, agents)` (imperative control flow, returning nothing — every finished run joins the episode automatically, stamped with its standing), and optionally `setup(agents)` (env-hardcoded standing, e.g. `agents.judge.trainable = False`) and `finalize(task, episode)` (sibling-dependent judgement over `episode.traces`; `trace.agent_name` names each agent; the env's `@vf.reward`/`@vf.metric` methods run after it). Before writing one, check the bundled envs (`--env.id best-of-n | agentic-judge`) and the reference implementation (`environments/code_golf_v1`). See docs/v1/environments.md.
197+
When one rollout is more than one agent run, export an `Environment` subclass next to the taskset: declare each agent as a `vf.AgentConfig` field with a default instance on a `vf.EnvConfig` subclass (bound via `Environment[YourConfig]`, read as `self.config`, addressed as `--env.<agent>.*`) — the field name is the agent's name, the only naming site, and per-run caps (turns, tokens, stage timeouts, retries) are agent fields. A declared pin is its author default; an unpinned agent runs the taskset's default harness, and its model context defaults to the run's own. Task x agent fit validates per run, on the task each agent actually receives (an env-minted task carries its own `tools`/`NEEDS_CONTAINER`, so a bare verdict task pairs with any taskset). Then write `run(task, agents)` (imperative control flow, returning nothing — every finished run joins the episode automatically, stamped with its standing), and optionally `setup(agents)` (env-hardcoded standing, e.g. `agents.judge.trainable = False`) and `finalize(task, episode)` (sibling-dependent judgement over `episode.traces`, via `record_reward`/`record_metric`; `trace.agent_name` names each agent). Before writing one, check the bundled envs (`--env.id best-of-n | agentic-judge`) and the reference implementation (`environments/code_golf_v1`). See docs/v1/environments.md.
198198

199199
## Custom harnesses
200200

skills/evaluate-environments/references/REFERENCE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ trace.
117117
| `id` | `ID` | `""` | Which `Environment` (control flow between agents) runs: a bundled env (`best-of-n`, `agentic-judge`), a local package, or a Hub `org/name[@version]`. Empty = the taskset's own story (its exported `Environment` subclass, else `SingleAgentEnv`). |
118118
| `taskset` | `TasksetConfig \| None` | `None` | The seed taskset every rollout starts from; resolved to its concrete subclass by `--env.taskset.id` (positional shorthand: `eval <taskset-id>`). See [Taskset config](#taskset-config). `None` only for a taskset-less env; every bundled env requires one. |
119119
| *(agents)* | `AgentConfig` | env-declared | Each declared agent: `agent` on `SingleAgentEnvConfig`, `solver`/`judge` on the agentic-judge env, the env's own names elsewhere. See [Agent config](#agent-config). |
120-
| `timeout` | `EnvTimeoutConfig` | `EnvTimeoutConfig()` | The env's own hook bounds: `--env.timeout.episode` (the whole `run()` hook) and `--env.timeout.finalize` (`finalize()` plus the decorated signals). Per-run stage timeouts are each agent's (`--env.<agent>.timeout.*`). |
120+
| `timeout` | `EnvTimeoutConfig` | `EnvTimeoutConfig()` | The env's own hook bounds: `--env.timeout.episode` (the whole `run()` hook) and `--env.timeout.finalize` (the `finalize()` hook). Per-run stage timeouts are each agent's (`--env.<agent>.timeout.*`). |
121121
| `retries` | `RolloutRetryConfig` | `RolloutRetryConfig()` | Whole-EPISODE retries, the coarse fallback for faults no agent owns. See [Retry config](#retry-config). Set via `--env.retries.*`. |
122122
| `interception` | `InterceptionConfig` | `ElasticInterceptionPoolConfig()` | The interception shape: `elastic` (default — servers grown on demand, `multiplex` rollouts each), `server` (one server, tunnel choice incl. bring-your-own endpoint), or `static` (a fixed list). |
123123

@@ -181,7 +181,7 @@ fields. Shared by the `serve` CLI, server-backed eval, and prime-rl's orchestrat
181181
| `finalize` | `float \| None` | `None` | Max wall-clock for the task's `finalize` hook. |
182182
| `scoring` | `float \| None` | `None` | Max wall-clock for task rewards/metrics/judges and harness metrics. |
183183

184-
`EnvTimeoutConfig` (the env's `--env.timeout.*`) keeps only `episode` — the bound on the whole `run()` interaction — and `finalize` — the bound on the env's `finalize()` hook plus its decorated signals.
184+
`EnvTimeoutConfig` (the env's `--env.timeout.*`) keeps only `episode` — the bound on the whole `run()` interaction — and `finalize` — the bound on the env's `finalize()` hook.
185185

186186
> Remote sandboxes cap any harness timeout at 24 hours (provider max lifetime).
187187

skills/train-with-environments/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ can override it.
112112
- If groups are mostly all-one, increase difficulty or sample a harder task distribution.
113113
- Choose `batch_size` with whole groups, packing, sequence length, and GPU memory in mind.
114114

115-
Sibling-dependent scoring (best-of-n selection, zero-sum payoffs) lives on `Environment.finalize(task, episode)` and the env's decorated signals, and runs inside the env — every trace carries its agent name, trainability, and episode stamp, so a flat batch regroups without a side lookup. `-r` stays purely the trainer's group size: n independent env-rollouts per task; env-internal fan-out is the env's own knob (`--env.n`).
115+
Sibling-dependent scoring (best-of-n selection, zero-sum payoffs) lives on `Environment.finalize(task, episode)`, and runs inside the env — every trace carries its agent name, trainability, and episode stamp, so a flat batch regroups without a side lookup. `-r` stays purely the trainer's group size: n independent env-rollouts per task; env-internal fan-out is the env's own knob (`--env.n`).
116116

117117
## Difficulty filtering
118118

verifiers/v1/decorators.py

Lines changed: 17 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -88,49 +88,37 @@ def decorator(f: F) -> F:
8888

8989

9090
@overload
91-
def metric(func: F, priority: int = 0, agent: str | None = None) -> F: ...
91+
def metric(func: F, priority: int = 0) -> F: ...
9292
@overload
93-
def metric(
94-
func: None = None, priority: int = 0, agent: str | None = None
95-
) -> Callable[[F], F]: ...
96-
def metric(
97-
func: F | None = None, priority: int = 0, agent: str | None = None
98-
) -> F | Callable[[F], F]:
99-
"""Mark a metric `(self, trace) -> float` (recorded, not summed). On an
100-
`Environment` it's a cross-agent signal: run once per episode trace with the
101-
finished sibling set in reach (`trace` = the target, `traces` = all of them);
102-
`agent=` narrows the targets to one seat's traces (env-only — a task has no
103-
agents)."""
93+
def metric(func: None = None, priority: int = 0) -> Callable[[F], F]: ...
94+
def metric(func: F | None = None, priority: int = 0) -> F | Callable[[F], F]:
95+
"""Mark a `Task`/`Harness` metric `(self, trace) -> float` (recorded, not
96+
summed) — per-trace judgement; it declares what it needs by name (`task`,
97+
`trace`, `runtime`). Cross-agent judgement is an `Environment`'s `finalize()`,
98+
imperatively."""
10499

105100
def decorator(f: F) -> F:
106-
return mark("metric", metric_priority=priority, _vf_agent=agent)(
107-
_async_only("metric")(f)
108-
)
101+
return mark("metric", metric_priority=priority)(_async_only("metric")(f))
109102

110103
return decorator if func is None else decorator(func)
111104

112105

113106
@overload
114-
def reward(
115-
func: F, weight: float = 1.0, priority: int = 0, agent: str | None = None
116-
) -> F: ...
107+
def reward(func: F, weight: float = 1.0, priority: int = 0) -> F: ...
117108
@overload
118109
def reward(
119-
func: None = None, weight: float = 1.0, priority: int = 0, agent: str | None = None
110+
func: None = None, weight: float = 1.0, priority: int = 0
120111
) -> Callable[[F], F]: ...
121112
def reward(
122-
func: F | None = None,
123-
weight: float = 1.0,
124-
priority: int = 0,
125-
agent: str | None = None,
113+
func: F | None = None, weight: float = 1.0, priority: int = 0
126114
) -> F | Callable[[F], F]:
127-
"""Mark a weighted per-rollout reward returning a float or keyed scores. On an
128-
`Environment` it's a cross-agent signal — see `metric` for the env semantics
129-
(`agent=` picks whose traces it records onto)."""
115+
"""Mark a weighted `Task` reward returning a float or keyed scores — per-trace
116+
judgement over the trace's own run. Cross-agent judgement is an
117+
`Environment`'s `finalize()`, imperatively."""
130118

131119
def decorator(f: F) -> F:
132-
return mark(
133-
"reward", reward_priority=priority, _vf_weight=weight, _vf_agent=agent
134-
)(_async_only("reward")(f))
120+
return mark("reward", reward_priority=priority, _vf_weight=weight)(
121+
_async_only("reward")(f)
122+
)
135123

136124
return decorator if func is None else decorator(func)

0 commit comments

Comments
 (0)