Skip to content

Write a contract

A contract is the JSON file that says what a campaign must achieve and how the judge decides. You write one to start campaigns from the command line or an agent. The New campaign page writes one for you.

A contract is frozen: to change anything, you write a new version. The contract reference lists every field with its default.

  • Choose the mode: a hill climb improves one measured number, a goal campaign works until a list of checks passes, and a research campaign collects sourced findings. See Campaign modes.
  • For a hill climb or a goal campaign, have a kit committed in its own repository. The contract names its cells (test cases), metrics and verifiers. See Write a kit for your project.
FieldWhat to write
schema_version1
campaignThe campaign’s name: lowercase letters, digits, dots, dashes and underscores
version1 for a new campaign. Raise it for every change
titleOne sentence for people. Agents see it too
modehill_climb, goal or research
project.repoThe project’s short name on the rig: up to 64 lowercase letters, digits, dots, dashes and underscores, such as project. Validation takes other text, but a run’s start refuses it. It is not an address: you give the address when you start a run, with --seed or the dashboard’s Repository
project.commitOptional: the full commit to start from, when --seed gives no @COMMIT
project.kitThe kit’s repo name and full commit. Needed for a hill climb or a goal campaign

Optional, in every mode:

  • lanes: count, how many lanes the planner plans (2 when lanes is left out), and trials_per_epoch, trials per lane per round (3). How many trials run at the same time is the rig’s Workers at a time, not count: see Start a campaign from the dashboard. If you write lanes, write count in it. lanes.limits raises a lane’s memory, disk, time and process limits; count is 16 at most.
  • budget: wall_hours, device_hours and llm_usd. None is set unless you set it. See Set a budget or a stop time.
  • notes: anything the planner and the workers should know.
  • models: model files the project needs, each with its name and sha256: data files your project loads that are not in its repository, such as a model’s weights.
contract.json
{
"schema_version": 1,
"campaign": "sort-speed",
"version": 1,
"title": "Make the sort faster on large inputs",
"mode": "hill_climb",
"project": {
"repo": "project",
"kit": { "repo": "project-kit", "commit": "89abcdef0123456789abcdef0123456789abcdef" }
},
"target": { "type": "cpu", "class": "generic-cpu" },
"editable_paths": ["src/sort/**"],
"forbidden_patterns": ["clock_gettime", "gettimeofday"],
"objective": {
"primary": { "metric": "items_per_s", "direction": "max", "min_effect": 0.05 }
},
"cells": ["small", "large"],
"held_out_cells": ["held"],
"acceptance": { "runs_per_cell": 3 },
"notes": "The sort must stay stable. Do not change the public header."
}
  • target is required in a hill climb, and optional in the other modes. type is cpu or cuda-gpu. class is a free name for the kind of device, such as generic-cpu: it is required with type, but nothing checks it yet.
  • editable_paths are the files a change may touch: ** matches any folders, * any part of a name. forbidden_patterns are regular expressions. A change outside the paths, or one that adds a matching line, is refused before it is built.
  • objective.primary names one of the kit’s metrics, which way is better (max or min), and min_effect, the smallest gain that counts, as a fraction: 0.05 is 5%.
  • cells are measured for every change. held_out_cells run only when the judge confirms a change that already looked better, so a change can’t win by helping only the cells agents see.
  • acceptance.runs_per_cell (1 to 9; 1 unless set) runs each cell several times per measurement and keeps the middle value. The New campaign page’s Runs per case sets 3 unless changed, so write 3 to judge as a campaign made there does. acceptance.equivalence_band (0.005) is how close counts as “no change”. On a noisy machine, such as a shared CPU, raise both: Thermite’s own smoke check uses 3 and 0.05.
  • acceptance.audit sets when a confirmed win waits for an audit: a gain above absolute (0.15, that is 15%), or above ratio_to_median (3) times the median of the run’s earlier wins, once it has three. See Decide what waits for you.

To keep a second metric from getting worse, add a guard. To refuse a change whose metric crosses a line, add a hard constraint. Both name kit metrics:

contract.json (part)
"guards": [
{ "metric": "peak_mib", "direction": "min", "max_step_regression": 0.02, "max_total_regression": 0.05 }
],
"hard_constraints": [
{ "kind": "max", "metric": "peak_mib", "value": 512 }
]
contract.json
{
"schema_version": 1,
"campaign": "new-format",
"version": 1,
"title": "Support the new 16-bit format end to end",
"mode": "goal",
"project": {
"repo": "project",
"kit": { "repo": "project-kit", "commit": "89abcdef0123456789abcdef0123456789abcdef" }
},
"editable_paths": ["src/**", "tests/**"],
"goals": [
{
"id": "builds",
"description": "The project builds with the new format",
"verify": { "type": "code", "verifier": "builds" }
},
{
"id": "correct",
"description": "Results match the reference on every golden input",
"verify": { "type": "code", "verifier": "goldens" }
},
{
"id": "fast",
"description": "At least 150 million items per second on the large input",
"verify": {
"type": "metric_threshold",
"metric": "items_per_s",
"op": ">=",
"value": 150000000,
"cells": ["large"]
}
},
{
"id": "readable",
"description": "The change is clear enough to maintain",
"required": false,
"verify": { "type": "review", "criteria": ["clear names", "no dead code"] }
}
],
"deliverable": "a commit that meets every required goal",
"insufficient": ["special-casing the golden inputs"],
"infeasibility": { "allowed": true, "min_refuted_approaches": 3 }
}

Each goal has an id, a description and one way to verify it:

verify.typeMet whenNeeds
codeThe kit’s check passes on every cell of the named verifierverifier: a verifier in the kit’s kit.json
metric_thresholdIn every cell, the metric’s 95% interval over threshold_reps runs (5) is wholly on the passing side of op and valuemetric: a kit metric. cells: leave it out for every cell
reviewThe rig’s review panel passes it, and then you accept it. A panel chosen on Edit rig is three reviewers with one model; one in the advisors file has two to fivecriteria. See Configure advisors

The rest of a goal contract:

  • required is true unless you write false. success.policy is all_required (every required goal), or k_of_n with success.k.
  • deliverable says what a solution is, and insufficient lists results that must never count as success. The review panel reads both; the auditor reads insufficient.
  • adversarial_checklist, when not empty, sends every change that would become the new best to an audit first.
  • infeasibility lets the harness propose ending the campaign as “not possible” once min_refuted_approaches approaches have failed. Only you can accept it. Without it, the campaign goes on until its goals are met.
  • held_out_cells are checked only to confirm a change that has gained a goal.

Code goals are checked in the order of their kit verifiers’ priority.

The judge first checks which goals the starting point already meets. If it meets every required goal, the run closes at once with Every required goal is met: the campaign is complete. Measure where the project starts before you set a metric_threshold, as Your first goal campaign does.

contract.json
{
"schema_version": 1,
"campaign": "allocators",
"version": 1,
"title": "How allocators keep tail latency low",
"mode": "research",
"project": { "repo": "research" },
"topic": "How do modern memory allocators keep tail latency low under contention?",
"questions": [
"Which designs do production allocators use for thread-local caching?",
"What do published benchmarks show about their tail latency, and under which workloads?"
],
"seeds": ["https://example.com/allocators/overview"],
"scope": { "exclude_domains": ["example.net"] },
"min_duration": "8h",
"max_duration": "24h"
}
  • project.repo is required in every mode. A research campaign fetches no repository, so any short name does, such as research.
  • min_duration is required. The run ends after max_duration of running time, or after min_duration when there is no max_duration. Paused time doesn’t count. Durations look like 30m, 8h or 2d.
  • A claim can name the question it answers. The report counts the verified claims for each of the questions, and lists a question with none as open. There is no web search: agents start from the seeds and follow links.
  • scope.include_domains, when not empty, limits fetching to those domains. scope.exclude_domains are never fetched.
  • quality.min_independent_sources (2): the report lists a verified claim whose quotes come from at least this many domains apart from the others, and says for each finding how many of its claims do.

A research campaign needs a model on the rig. Without one, no agent works and the run never ends; the New campaign page won’t start agents on a rig that has none. Research workers run in Pi or OpenCode v2, never in Claude Code:

  • A Claude subscription can’t serve them. Nothing stops the start, but every trial ends with research workers run in Pi or OpenCode, and a Claude subscription runs only in Claude Code: give this rig another model for research (goal 061).
  • OpenCode’s own free models work when the rig’s agents run in OpenCode v2. Where they would run in Pi, the dashboard refuses to start the run and says to set the agents’ harness to OpenCode; a run started another way, such as with thermite assign, ends every trial with … is one of OpenCode's own models, which work only inside OpenCode: set this rig's agents' harness to OpenCode on its Edit rig page.
Terminal window
npx thermite contract validate contract.json

A valid contract prints ok <mode> <campaign> v<version> sha256=<hash>. Otherwise each mistake is printed as <field>: <message>, for example:

  • lanes.count: Invalid input: expected number, received undefined: add count to lanes.
  • (root): Unrecognized key: "<field>": remove the field or fix its spelling. Contracts accept only known fields.
  • campaign: lowercase id: use lowercase letters, digits, dots, dashes and underscores.

The contract reference lists every message. Validation checks the contract alone. When a run starts, the rig also checks it against the kit: every cell, metric and verifier it names must be in the kit, and the kit’s target must be the rig’s. If it is refused says what to do when that check fails.

Change anything, then raise version by one. A rig refuses a contract whose campaign and version it already has with other content: contract <campaign> v<n> exists with other content. Then start a run from the command line.