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.
Before you start
Section titled “Before you start”- 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.
1. Fill in the fields every contract has
Section titled “1. Fill in the fields every contract has”| Field | What to write |
|---|---|
schema_version | 1 |
campaign | The campaign’s name: lowercase letters, digits, dots, dashes and underscores |
version | 1 for a new campaign. Raise it for every change |
title | One sentence for people. Agents see it too |
mode | hill_climb, goal or research |
project.repo | The 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.commit | Optional: the full commit to start from, when --seed gives no @COMMIT |
project.kit | The 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 whenlanesis left out), andtrials_per_epoch, trials per lane per round (3). How many trials run at the same time is the rig’s Workers at a time, notcount: see Start a campaign from the dashboard. If you writelanes, writecountin it.lanes.limitsraises a lane’s memory, disk, time and process limits;countis 16 at most.budget:wall_hours,device_hoursandllm_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 itsnameandsha256: data files your project loads that are not in its repository, such as a model’s weights.
2. Write a hill climb
Section titled “2. Write a hill climb”{ "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."}targetis required in a hill climb, and optional in the other modes.typeiscpuorcuda-gpu.classis a free name for the kind of device, such asgeneric-cpu: it is required withtype, but nothing checks it yet.editable_pathsare the files a change may touch:**matches any folders,*any part of a name.forbidden_patternsare regular expressions. A change outside the paths, or one that adds a matching line, is refused before it is built.objective.primarynames one of the kit’s metrics, which way is better (maxormin), andmin_effect, the smallest gain that counts, as a fraction:0.05is 5%.cellsare measured for every change.held_out_cellsrun 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 write3to 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.auditsets when a confirmed win waits for an audit: a gain aboveabsolute(0.15, that is 15%), or aboveratio_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:
"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 }]3. Write a goal campaign
Section titled “3. Write a goal campaign”{ "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.type | Met when | Needs |
|---|---|---|
code | The kit’s check passes on every cell of the named verifier | verifier: a verifier in the kit’s kit.json |
metric_threshold | In every cell, the metric’s 95% interval over threshold_reps runs (5) is wholly on the passing side of op and value | metric: a kit metric. cells: leave it out for every cell |
review | The 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 five | criteria. See Configure advisors |
The rest of a goal contract:
requiredistrueunless you writefalse.success.policyisall_required(every required goal), ork_of_nwithsuccess.k.deliverablesays what a solution is, andinsufficientlists results that must never count as success. The review panel reads both; the auditor readsinsufficient.adversarial_checklist, when not empty, sends every change that would become the new best to an audit first.infeasibilitylets the harness propose ending the campaign as “not possible” oncemin_refuted_approachesapproaches have failed. Only you can accept it. Without it, the campaign goes on until its goals are met.held_out_cellsare 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.
4. Write a research campaign
Section titled “4. Write a research campaign”{ "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.repois required in every mode. A research campaign fetches no repository, so any short name does, such asresearch.min_durationis required. The run ends aftermax_durationof running time, or aftermin_durationwhen there is nomax_duration. Paused time doesn’t count. Durations look like30m,8hor2d.- 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 theseedsand follow links. scope.include_domains, when not empty, limits fetching to those domains.scope.exclude_domainsare 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.
5. Validate it
Section titled “5. Validate it”npx thermite contract validate contract.jsonA 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: addcounttolanes.(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.
6. Change it
Section titled “6. Change it”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.