Authoring tasks & benchmarks
Tasks are 100% stock Harbor tasks, validated against Harbor's
TaskConfig by tests/test_task_suite.py; tide adds conventions around
the format, never fields inside it. Start from
tasks/_template, a working
placeholder task (maximize x in [0, 1]) that passes the suite before
you change anything. harbor trial start -p <dir> runs it standalone.
An autoresearch task ships its own judge, which is most of this page. A continual-learning task is any stock Harbor task; see below.
Anatomy of an autoresearch task
Two containers: the agent's, and the judge's. All scoring lives with the judge; the agent only sees scores come back over HTTP.
my-task/
├── task.toml # stock Harbor config; network allows only the judge
├── instruction.md # the problem + the submission protocol
├── environment/
│ ├── Dockerfile # the AGENT's container (its data, no scoring)
│ ├── Dockerfile.judge # the JUDGE's container
│ ├── docker-compose.yaml # wires the judge in as a sidecar, sets $JUDGE_URL
│ ├── judge_server.py # generic HTTP server; never edited per task
│ ├── score.py # THE scoring rule: grade(path) → {"reward", "reason"}
│ ├── final.py # optional final judge (hidden tests); see below
│ └── judge_config.json # {"max_submissions": N, "min_interval_sec": s}
├── tests/
│ ├── test.sh · grade.py # generic verifier: asks the judge for /final
│ └── grader_tests.json # unit tests for score.py (and final.py)
└── solution/solve.sh # reference solution; submits once, proves the pipeline
The judge protocol
The agent gets $JUDGE_URL and a submission budget:
| Request | Who calls it | What happens |
|---|---|---|
POST /submit (body = the solution file) |
the agent, at will | score.py grades it and records the result; over budget → 429 |
GET /status |
the agent | submissions used / remaining, best so far |
GET /final |
nobody, on this port | 403: finalization is a verifier-only capability (see below) |
Finalization runs on a separate verifier port (VERIFIER_PORT, default
PORT + 1) behind a per-session token generated at startup. The token is
written to {DATA_DIR}/.verifier_token, inside the judge's own
filesystem, never in the agent's environment. The local executor reads it
from there; the container verifier fetches it via GET /token on the
verifier port (which the agent should not be able to reach; the network
policy keeps the agent on the agent port only).
| Request | Who calls it | What happens |
|---|---|---|
GET /final |
the verifier, once, at the end (verifier port + token) | the final grade + the full submission log. Terminal: the first call locks the session; later submissions are refused, repeat calls return the cached grade. |
GET /token |
the verifier (verifier port) | returns the per-session verifier token |
Scoring code and data belong only in the judge image, and the agent's
network reaches only the judge's agent port (network_mode =
"allowlist", allowed_hosts = ["judge"]); see
design.
At the end the verifier calls GET /final, the judge freezes the best
submission by session score, runs final.py on it if present, and locks
the session, so the agent never observes the final evaluation.
The submission budget
judge_config.json sets the cap. With a public metric and cheap scoring
(circle-packing, tsp-tour), a generous cap of around 1000 just bounds
judge compute. When scoring is expensive (string-compression runs the
agent's program), set it tighter. When the metric has a secret, keep the
session feedback on public data and put the secret in the final judge
below, so the cap carries no secrecy burden.
The final judge (final.py, optional)
Same signature as score.py, run exactly once, on the best submission,
when the verifier finalizes. This is where hidden tests live: held-out
data, stricter checks, anything the session score must not leak. In
symbolic-regression
the session scores on training points and the final judge scores once on
held-out points, so no submission budget can probe them. Without
final.py, the final grade is the best session score.
The scoring contract
score.py (and final.py) expose one function:
def grade(artifact: Path | None) -> dict:
# {"reward": float, "reason": str}; invalid input → 0.0 with a reason
Rules: recompute everything from the submitted file, never trust anything
inside it; validate conservatively (exact arithmetic where floats can be
gamed; reject, don't round); malformed input scores 0.0 with a reason,
never an exception. Data files sit next to the script and are read via
Path(__file__).parent, so the same file works in the judge image and in
--local runs.
Testing your task: tests/grader_tests.json
Each case is one sentence: this solution must score this reward,
because… Write one case for your reference solution and one for each
rule your scorer enforces (expected reward 0.0). The template's file:
{
"solve_sh_scores": 0.5,
"cases": [
{"solution": {"x": 0.5}, "reward": 0.5, "why": "the reference solution scores exactly this"},
{"solution": {"x": 1.5}, "reward": 0.0, "why": "out-of-bounds values must be rejected"},
{"solution": {"nonsense": true}, "reward": 0.0, "why": "malformed input must score zero, not crash"}
]
}
casesexercisescore.pyoffline, in milliseconds, on every CI run; addfinal_cases(same shape) when the task has a final judge.solve_sh_scoresdrives the containerized E2E gate: what the reference solution must earn end to end, either a number or{"min": …, "max": …}where the live run legitimately varies.- Optional per-case
"tolerance"for float comparison (default 1e-9).
pytest tests/test_task_suite.py picks up any folder with this file. In
containers, --agent oracle must reproduce solve_sh_scores (the E2E
gate) and --agent nop must score 0; if it doesn't, the environment leaks
the answer.
Network policy
The default is the tightest thing that still works: the agent may reach the judge and nothing else.
[environment]
network_mode = "allowlist"
allowed_hosts = ["judge"]
[agent] # optional phase override, e.g. for
network_mode = "allowlist" # in-container LLM loops
allowed_hosts = ["judge", "api.openai.com"]
Never give the agent open internet on a task whose answers can be looked up.
A GPU task
Declare the requirement in task.toml ([environment] gpus = 1),
which Harbor validates and its cloud backends honor. For local Docker,
add the standard nvidia device reservation
(deploy.resources.reservations.devices) to the main service in the
task's environment/docker-compose.yaml; give the judge service the
reservation too when scoring itself needs the GPU. For kernel-timing
tasks, wall-clock speedups on shared hosts are noisy: score against a
reference implementation run in the same container and session, and record
the GPU model as a tag so curves never mix hardware.
A continual-learning task
Any stock Harbor task runs in a stream unchanged: tide mounts the carried memory directory itself, and a pass/fail verifier is a fine score (a pass is a 0-or-1 reward). Two conventions from the committed benchmarks are worth copying:
- Hidden state lives in a sidecar. When something must stay out of the agent's reach at runtime (CL-Bench's metered database, its poker deck), reuse the judge pattern: an HTTP sidecar holds the secret and enforces the interaction budget.
- Scoring is the upstream metric. A converted benchmark keeps its published scorer, ported verbatim where possible, so numbers stay comparable with the source paper.
Define a benchmark
A benchmark is a directory whose immediate children are task folders. That is the whole format:
A path runs as-is, and a folder placed in the catalog resolves by name:
tide run path/to/my-bench --agent oracle # any directory of tasks
tide run my-bench --agent oracle # once it sits in tasks/<regime>/my-bench
In a checkout, tests/test_task_suite.py picks up every task under
tasks/, so a new benchmark is validated the moment the folder exists.
To distribute one, publish the directory in any git repository and
register the pin, the way gym environments register:
from tide import fetch
fetch.register("my-bench", "https://github.com/me/my-bench.git", "<commit or tag>")
tasks = fetch.benchmark("my-bench") # downloads on first use, then cached
subdir= points inside the repo when the tasks are not at its root. The
registry is per process, the way gym's is: a script that calls register
can pass fetch.benchmark("my-bench") / "task-01" straight to Lab.run,
while the shell tide command sees only the built-ins, so give it the
fetched path. Ship the register call in your package's import;
registering an existing name replaces it, which is how a fork takes over
a built-in.
A benchmark converter
A converter turns a published external format into a folder of task
dirs. It lives beside the benchmark it maintains, next to a fetch.py
pinned to an upstream commit so the committed tasks can be regenerated.
Check one real upstream spec into tests/fixtures/ and validate the
emitted task under Harbor's TaskConfig;
edgebench/convert.py
is the reference.