diff --git a/docs/v1/harbor.md b/docs/v1/harbor.md index c9b21d0cd7..5ac686c9d4 100644 --- a/docs/v1/harbor.md +++ b/docs/v1/harbor.md @@ -85,6 +85,25 @@ resource_multiplier = 2.0 The `timeout_multiplier` multiplies both the agent and verifier timeout, while the `resource_multiplier` multiplies the task's CPU, memory and disk space. You might want to use these multipliers when the tasks set too tight limits and/or the agent is slow. +## Rewards + +`reward.json` takes precedence over `reward.txt`. An existing JSON file must contain +an object of finite numeric values; invalid, unreadable, or oversized JSON fails +grading without falling back to text. When JSON is absent, `reward.txt` supplies +the numeric `reward` value. A verifier's nonzero exit does not override a valid +reward file. + +The `reward` key supplies the scalar reward by default. To select another key: + +```toml +[env.taskset.task] +reward_key = "accuracy" +``` + +Other named values are retained as trace metrics. A missing selected key fails +grading; named values are never implicitly summed. The same selection applies to +shared and separate verifiers. + ## Docker Compose Select `runtime.type = "docker"` to run Compose tasks locally. diff --git a/verifiers/v1/tasksets/harbor/taskset.py b/verifiers/v1/tasksets/harbor/taskset.py index 80e6f3123b..c96f036e78 100644 --- a/verifiers/v1/tasksets/harbor/taskset.py +++ b/verifiers/v1/tasksets/harbor/taskset.py @@ -28,7 +28,6 @@ from collections.abc import Iterator from functools import lru_cache from pathlib import Path, PurePosixPath -from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError @@ -50,12 +49,18 @@ REWARD_JSON = "/logs/verifier/reward.json" MAX_REWARD_BYTES = 1024 * 1024 REWARD_JSON_ADAPTER = TypeAdapter( - float | Annotated[dict[str, float], Field(min_length=1)], + dict[str, float], config=ConfigDict(strict=True, allow_inf_nan=False), ) +REWARD_SCALAR_ADAPTER = TypeAdapter( + float, config=ConfigDict(strict=True, allow_inf_nan=False) +) + class HarborTaskConfig(TaskConfig): + reward_key: str = Field("reward", min_length=1) + """Named verifier value used as the training reward; other values remain metrics.""" mcp_servers: list[dict] = Field(default_factory=list) """Task-declared connections, bound from HarborData during construction.""" @@ -380,44 +385,57 @@ async def run_verifier( ["bash", "/tests/test.sh"], resolve_env(self.data.verifier_env) ) scores = await self.read_reward_json(runtime) - if scores is not None: - if isinstance(scores, dict) and "reward" in scores: - trace.record_metrics( - {key: value for key, value in scores.items() if key != "reward"} - ) - return {"reward": scores["reward"]} - return scores - try: - reward = ( - ( - await runtime.read( - "/logs/verifier/reward.txt", max_bytes=MAX_REWARD_BYTES + if scores is None: + try: + value = ( + ( + await runtime.read( + "/logs/verifier/reward.txt", max_bytes=MAX_REWARD_BYTES + ) ) + .decode() + .strip() ) - .decode() - .strip() - ) - return REWARD_JSON_ADAPTER.validate_python(float(reward)) - except (SandboxError, OSError, ValueError) as exc: + scores = {"reward": REWARD_SCALAR_ADAPTER.validate_python(float(value))} + except (SandboxError, OSError, ValueError) as exc: + raise TaskError( + "Harbor verifier produced no reward.json and no valid reward.txt " + f"(exit {result.exit_code}): " + f"{(result.stderr or result.stdout).strip()[-500:]}" + ) from exc + key = self.config.reward_key + if key not in scores: raise TaskError( - "Harbor verifier produced no valid reward.json or reward.txt " - f"(exit {result.exit_code}): " - f"{(result.stderr or result.stdout).strip()[-500:]}" - ) from exc + f"Harbor verifier reward key {key!r} is missing; " + f"available keys: {sorted(scores)}. " + "Set task.reward_key to select a named verifier reward." + ) + trace.record_metrics( + {name: value for name, value in scores.items() if name != key} + ) + return {key: scores[key]} - async def read_reward_json( - self, runtime: Runtime - ) -> float | dict[str, float] | None: - """Read Harbor's scalar or keyed JSON reward, if it is valid. + async def read_reward_json(self, runtime: Runtime) -> dict[str, float] | None: + """JSON takes precedence whenever present; invalid or unreadable JSON fails. - Bounded: this is a grading input, and nothing guarantees its size. + Only absence permits text fallback. Reads remain bounded at the source. """ + exists = await runtime.run( + ["sh", "-c", 'test -e "$1" || test -L "$1"', "vf-reward", REWARD_JSON], {} + ) + if exists.exit_code == 1: + return None + if exists.exit_code: + raise TaskError(f"Cannot inspect Harbor reward file {REWARD_JSON}") try: return REWARD_JSON_ADAPTER.validate_json( await runtime.read(REWARD_JSON, max_bytes=MAX_REWARD_BYTES) ) - except (SandboxError, OSError, ValidationError): - return None + except (SandboxError, OSError, ValidationError) as exc: + raise TaskError( + "Harbor verifier produced invalid or unreadable reward.json; " + "expected an object of finite numeric rewards" + ) from exc def verifier_box_data(data: HarborData) -> HarborData: