Skip to content

Contract

A contract is the JSON file that says what a campaign is for and how its results are judged: the project, the kit, the mode, its objective, goals or questions, and its limits. Agents cannot change it. Any change makes a new version.

This page lists every field. Write a contract gives the steps, and Campaign modes says what each mode does.

contract.json
{
"schema_version": 1,
"campaign": "matmul-speed",
"version": 1,
"title": "Speed up the matrix multiply",
"mode": "hill_climb",
"project": {
"repo": "smoke-project",
"kit": { "repo": "smoke-cpu", "commit": "4f0c2a9e8d7b6c5a4f3e2d1c0b9a8f7e6d5c4b3a" }
},
"target": { "type": "cpu", "class": "generic-cpu" },
"editable_paths": ["src/kernel/**"],
"objective": {
"primary": { "metric": "flops_per_s", "direction": "max", "min_effect": 0.05 }
},
"cells": ["small", "large"],
"held_out_cells": ["held"]
}

Every field this example leaves out takes its default. The repository has one example for each mode in examples/contracts/.

  • Unknown fields are refused at every level, with Unrecognized key: "<name>".
  • Errors read <field path>: <message>. A rule about the contract as a whole has the path (root), for example (root): hill_climb campaigns need a target.
  • The content hash is the SHA-256 of the contract’s canonical JSON, after defaults are filled in. Two files that differ only in key order or in a default written out have the same hash.
  • thermite contract validate <file> checks a file on your computer. It prints ok <mode> <campaign> v<version> sha256=<hash>, or the errors, one per line, and exits with 1. For a hill climb or goal contract without project.kit, it also prints warning: no project.kit: the judge cannot measure this campaign until the contract names a kit repository and commit. See the command line reference.
  • When a run is assigned, the rig’s harness checks the contract again, then checks the kit against it. The refusals are listed under When a run is assigned.
TypeWhat is allowedMessage when it is not
idLowercase letters, digits, ., _ and -, starting with a letter or digit; 1 to 64 characterslowercase id
full commit id40 or 64 lowercase hexadecimal characters. Short ids are refusedfull git commit id (40 or 64 hex)
sha25664 lowercase hexadecimal characterssha256 hex
durationA number and a unit: s, m, h or d, such as 90s, 30m, 1.5h or 7dduration like "30m" or "8h"
regular expressionA JavaScript regular expression that compilesinvalid regular expression
globA repository path pattern: ** matches across folders, * within one folder name, ? one characternone: any non-empty text
folderA path from the top of a repository to a folder in it, its parts separated by /, such as examples/matmul-cpu/project. Letters, digits, ., _, -, @ and +; no / at either end, and no part that starts with a dot, so ., .. and .git are refused; at most 300 charactersa folder in the repository, such as examples/matmul-cpu/project: its path from the top, with no / at either end and no part that starts with a dot
kit cell, kit metricThe name of a cell or metric in the contract’s kit. Checked when a run is assigned, not by the schemasee the kit against the contract
FieldTypeDefaultMeaning
schema_version1requiredThe contract format
campaignidrequiredThe campaign’s name
versioninteger ≥ 1requiredThe contract’s version. Any change needs a new number: a rig refuses the same campaign and version with other content
titletextrequiredA title for people. Workers’ briefs, audits and research reports show it, and so do the dashboard’s Campaigns page and the campaign’s own page, as what the campaign is for
modehill_climb, goal or researchrequiredThe kind of campaign. It decides which other fields the contract has
project.repotextrequiredThe project’s repository name on the rig. A run’s starting commit is imported under this name, which must then be an id. The repository’s address is not in the contract: it comes with the run’s start (thermite assign --seed, or Repository on the New campaign page). Research contracts have no project, but still give a name, for example research
project.commitfull commit idnoneA starting commit. The harness does not read it; thermite assign uses it when you give no commit of your own
project.folderfoldernone: the whole repositoryThe project’s folder, when the project is one folder of its repository. The rig fetches the repository’s commit, and the run starts from that folder: from a commit whose files are the folder’s own, with no parent. Every rig makes the same commit from the same repository commit and folder. Agents see only the folder’s files, and editable_paths, the kit’s commands and a champion’s patch are relative to the folder. A commit without that folder is refused when the run is assigned: commit <commit> has no folder <folder>. Hill climb and goal campaigns use it; research campaigns ignore it
project.kit.repotextnoneThe kit’s repository name on the rig. A kit is imported under this name, which must then be an id
project.kit.commitfull commit idnoneThe kit commit the judge uses. Hill climb and goal campaigns need project.kit: without it the judge cannot measure
target.typetextnoneThe kind of device the campaign measures on, such as cpu or cuda-gpu. Required in hill climb (hill_climb campaigns need a target). Control compares it with the rig’s target when the dashboard starts a run: on the New campaign page, or with Start a run on the campaign’s page. The harness compares the kit’s own target with the rig
target.classtextnoneA name for the device class, such as generic-cpu. Required with target.type. Accepted, not used yet
modelslist of {name, sha256}[]Model files the campaign uses: data files the project loads that are not in its repository, such as a model’s weights. Not the agents’ AI model. Each must be on the rig, or imported by the command that starts the run. The kit reaches one as {models:<sha256>}, and agents are told where they are
lanesobject{"count": 2, "trials_per_epoch": 3}How the work is split. See Lanes
budgetobject{"max_open_attempts": 4}Optional budgets and back-pressure. See Budget
min_durationdurationnoneResearch: required. The time the campaign runs at least, and its end when max_duration is not set. Other modes: accepted, not used yet
max_durationdurationnoneResearch: when the campaign ends. It may not be shorter than min_duration. Other modes: accepted, not used yet
search_policyobjectnoneRules for research search queries. See Search policy
notestextnoneNotes from the campaign’s author. In hill climb and goal campaigns, the planner gets them, and every worker’s brief has them under “Notes from the campaign’s author”. Research campaigns do not use them

A lane is one worker’s line of search. An epoch is one round of planning: the planner gives each lane an experiment from the run’s experiment queue, and each lane then runs its trials one after another, each later one on the next queued experiment. The dashboard calls an epoch a round.

FieldTypeDefaultMeaning
lanes.countinteger, 1–16required when lanes is given; 2 when lanes is left outLanes planned in each epoch. A lane_count directive can lower it. A rig has users for lanes 1 to 16 only, so more is refused. How many trials run at the same time is the rig’s own setting, Workers at a time, which is 1 unless it is changed: see Settings
lanes.trials_per_epochinteger ≥ 13Trials each lane runs in one epoch
lanes.limitsobjectnoneWhat each lane may use. Every field is optional, and the defaults below apply to what is left out

lanes.limits:

FieldRangeDefaultMeaning
run_timeout_s10–7200noneTime limit, in seconds, for one command a worker runs. Without it, a command runs until it ends or the trial ends
run_rss_mib256–163842048Memory, in MiB, for everything a lane runs at once, OpenCode, Claude Code or Codex included when the lane uses one
workspace_mib256–655362048Disk, in MiB, for the trial’s workspace, build trees included. Each development run gets the same amount
dev_run_timeout_s10–3600none on a development device of its own; 300 on a device the judge sharesTime limit, in seconds, for one development run
dev_run_rss_mib256–163844096Memory, in MiB, for one development run
trial_minutes1–1440the rig’s trial length (20 minutes unless changed)One trial’s length, in minutes
processes64–65536256Processes and threads that each of a lane’s users may have at once: the user of its commands (with OpenCode, Claude Code or Codex when the lane uses one), of its development runs, and of its Pi worker. At the limit, starting one more fails (Resource temporarily unavailable); nothing is stopped. Raise it for builds or tests that start many threads

A limit ends one command or one trial, never the campaign. Limits and pacing lists every limit Thermite has.

No budget is set unless you set one, and without one a run keeps going. A spent budget pauses the run once its running trials have ended: the run’s status becomes paused, with the reason shown, and the judge’s queued jobs for it wait too. Set a budget or a stop time gives the steps.

FieldTypeDefaultMeaningReason shown
budget.wall_hoursnumber > 0noneHours since the run was created, paused time included. It also shapes the search: an epoch’s time cap is 6% of it, kept between 30 minutes and 3 hours (3 hours without it), and every lane starts again from the champion at a third and two thirds of it (every 20 epochs without it)the wall-time budget is spent
budget.device_hoursnumber > 0noneHours the judge’s finished jobs for this run have takenthe device-hours budget is spent
budget.llm_usdnumber ≥ 0noneUS dollars of model tokens the run’s trials have used. Counted only when the rig sets both THERMITE_LLM_USD_PER_MTOK_IN and THERMITE_LLM_USD_PER_MTOK_OUT (prices per million tokens); without them, it has no effectthe model budget is spent ($<amount>)
budget.max_open_attemptsinteger ≥ 14Back-pressure, not a budget: no new trial starts while this many of the run’s attempts wait for the judge (queued, building, screening, confirming, verifying, waiting for an audit or for approval)none: lanes wait
FieldTypeDefaultMeaning
search_policy.allowed_purposeslist of text[]What public search may be used for. Accepted, not used yet
search_policy.blocked_patternslist of regular expressions[]A research search query that matches one, ignoring case, is refused and logged with blocked by the search policy (<pattern>). Rigs have no web search, so every query returns no results

Hill climb and goal: what a candidate may change

Section titled “Hill climb and goal: what a candidate may change”

A candidate is a change an agent submits. These two fields are checked before anything is built, in the judge’s first gate (V0).

FieldTypeDefaultMeaning
editable_pathslist of globs, at least onerequiredPaths a candidate may change. A change to any other path is refused with path not editable: <path>. Binary files, symbolic links and submodules are always refused. When a worker submits, only these paths are taken
forbidden_patternslist of regular expressions[]An added line that matches one (case matters) is refused with forbidden pattern <pattern>: <line>

"mode": "hill_climb": improve one metric, step by step, while guards hold. Each candidate is first screened against the champion (the run’s current best) in interleaved blocks: one or more timed runs of each arm (candidate and champion) on each cell (one measured input from the kit). A win is then confirmed on fresh blocks before it is promoted. Verdicts and states lists the verdicts.

FieldTypeDefaultMeaning
objective.primary.metrickit metricrequiredThe metric to improve
objective.primary.directionmax or minrequiredWhich way is better
objective.primary.min_effectnumber > 0requiredThe smallest gain that counts, as a fraction: 0.05 is 5%. A gain that is clearly above zero, but whose whole interval is below this, is neutral (“gain below min_effect”)
objective.policysingle or lexicographicsingleAccepted, not used yet
objective.secondarylist of {metric, direction}[]Accepted, not used yet. Each metric is checked against the kit when a run is assigned, but the judge does not use it
guardslist of guards (below)[]Metrics that may not get worse beyond set fractions
hard_constraintslist of constraints (below)[]Absolute bounds on a metric
monitor_onlylist of metric names[]Accepted, not used yet. Not checked against the kit either
cellslist of kit cells, at least onerequiredThe cells (test cases; the dashboard calls them cases) measured in every block
held_out_cellslist of kit cells[]Cells measured only at confirmation. Their results are never shown to agents: a failed confirmation says only confirmation failed. A candidate must be correct on each, and its primary metric no worse than twice acceptance.equivalence_band, on the median of three runs
acceptanceobjectall defaults belowHow the judge decides

A guard:

FieldTypeMeaning
metrickit metricThe guarded metric
directionmax or minWhich way is better for it
max_step_regressionnumber ≥ 0How much worse, as a fraction, it may get in one promotion. A guard that is clearly worse makes the verdict tradeoff; one that is not yet clear keeps screening going
max_total_regressionnumber ≥ 0How much worse it may get over all promotions since the run’s starting commit. Spending it makes the confirmation a tradeoff (“cumulative guard allowance spent”)

A hard constraint is one of:

FormMeaning
{"kind": "min", "metric": "<m>", "value": <number>}The candidate’s mean of <m> on each cell must be at least value, or the verdict is infeasible
{"kind": "max", "metric": "<m>", "value": <number>}The mean must be at most value, or the verdict is infeasible
{"kind": "check", "check": "<name>"}Accepted, not used yet. The kit’s check runs on every run anyway

acceptance:

FieldTypeDefaultMeaning
acceptance.alpha_screennumber between 0 and 10.1Error rate for screening, split evenly across the looks
acceptance.alpha_confirmnumber between 0 and 10.05Error rate for confirmation
acceptance.equivalence_bandnumber > 00.005A difference whose whole interval is within ± this fraction is neutral (“equivalent”)
acceptance.lookslist of integers ≥ 1, at least one[3, 6, 9]Block counts at which screening may decide. The largest is the most blocks screening runs
acceptance.confirm_blocksinteger ≥ 15Fresh blocks for confirmation
acceptance.runs_per_cellinteger, 1–91Runs of each arm on each cell in one block. The block uses their median. For a kit described on the New campaign page, its Runs per case sets it, 3 unless changed
acceptance.audit.ratio_to_mediannumber > 03A confirmed gain more than this many times the median of the run’s earlier confirmed gains waits for an audit. It applies once there are three earlier gains
acceptance.audit.absolutenumber > 00.15A confirmed gain above this fraction waits for an audit, and then for the owner

Decide what waits for you covers audits and approvals.

"mode": "goal": meet a list of goals, each checked by the judge or, for subjective goals, by a review panel and the owner.

FieldTypeDefaultMeaning
goalslist of goals (below), at least onerequiredThe goals. Each id must be unique
success.policyall_required or k_of_nall_requiredWhen the campaign is complete. all_required: every goal with required: true is met, and at least one goal must be required. k_of_n: at least k goals are met
success.kinteger ≥ 1noneFor k_of_n: how many goals must be met, at most the number of goals
deliverabletextrequiredWhat a verified solution is: a commit, an artifact or a report. The review panel gets it
insufficientlist of text[]Outcomes that must never count as success. The auditor and the review panel get the list; code does not check it
adversarial_checklistlist of text[]Failure modes to audit for. When the list is not empty, every promotion waits for an audit, and the auditor gets the list
infeasibility.allowedbooleanfalseWhether the harness may propose that the goals are not possible. The owner accepts or rejects the proposal
infeasibility.min_refuted_approachesinteger ≥ 1noneRequired when allowed is true. The proposal comes only after this many approach families each had 3 or more judged attempts and no progress
independence_epochsinteger ≥ 02Accepted, not used yet
min_live_familiesinteger ≥ 12Accepted, not used yet
threshold_repsinteger, 2–205Runs on each cell when a metric_threshold goal is checked
held_out_cellslist of kit cells[]Cells measured only at confirmation, for the goals a candidate newly meets. A verifier whose cells are all held out never shows as met in the goal report

Each goal:

FieldTypeDefaultMeaning
ididrequiredUnique among the goals
descriptiontextrequiredWhat the goal means. Workers, the planner and the goal report show it
priorityinteger ≥ 11Accepted, not used yet: it is stored with the goal. The order goals are checked in comes from the kit verifier’s priority
requiredbooleantrueWhether all_required needs this goal
parentid of another goalnoneAccepted, not used yet: it is stored with the goal. It must name a goal in the list
verifyone of the three forms belowrequiredHow the goal is checked

verify is one of:

FormMeaning
{"type": "code", "verifier": "<name>"}A verifier in the kit: the goal is met when the kit’s check passes on every run of the verifier’s cells. A verifier can require others to pass first. See the kit’s verifiers
{"type": "metric_threshold", "metric": "<m>", "op": ">=", "value": <number>, "cells": ["<cell>"]}<m> must be a metric in the kit. On each cell, the judge runs the build threshold_reps times and works out a 95% interval of the mean. The goal is met when every cell’s whole interval passes, unmet when any cell’s whole interval fails, and inconclusive otherwise. op is >=, <=, > or <. Without cells, every cell of the kit that is not held out is used
{"type": "review", "criteria": ["<criterion>", ...]}A subjective goal, with at least one criterion. A blind panel of models reviews each new champion, and the goal is met only when the owner accepts

A goal campaign ends when the success policy is met on two validations of the champion, or when the owner accepts a “not possible” proposal. Its budgets pause it, as in every mode; min_duration and max_duration have no effect.

"mode": "research": collect sources, verified claims and findings on a topic for a set time. Research contracts have no editable_paths or cells, and use no kit. They need min_duration.

FieldTypeDefaultMeaning
topictextrequiredWhat to research. Briefs and reports show it
questionslist of text, at least onerequiredThe questions. Claims name the question they answer by its place in this list, from 0, and the plan works on the least-covered questions first
seedslist of URLs[]Starting pages, given to workers in their brief. With no web search, workers follow links from these
mcp_serverslistnoneThe MCP servers the campaign’s agents use, as a kit’s mcp_servers names them: a research campaign has no kit. The New campaign page writes it from The question › MCP servers for the agents. Control refuses to start a run that names a server Settings lacks
scope.include_domainslist of domain names, in lowercase[]When not empty, only these domains and their subdomains are fetched. Others are refused with outside the campaign's domains: <host>
scope.exclude_domainslist of domain names, in lowercase[]These domains and their subdomains are never fetched: excluded domain <host>
scope.source_typeslist of text[]Accepted, not used yet
scope.languageslist of text[]Accepted, not used yet
report_cadence.digest_minutesinteger ≥ 160Minutes between digests
report_cadence.report_hoursnumber > 024Accepted, not used yet. A research run writes digests and one final report
quality.verbatim_quotestruetrueAlways on: a claim counts only when every quote is found word for word in its archived source
quality.min_independent_sourcesinteger ≥ 12Different domains a verified claim’s quotes must come from to be listed as independently sourced. The report lists those claims apart from the rest, and says for each finding how many of its claims are
fetch_policy.respect_robotsbooleantrueWhether fetches obey each site’s robots.txt
fetch_policy.per_domain_per_minuteinteger ≥ 16Fetches per domain per minute. Further fetches wait; they are not refused
fetch_policy.max_bytesinteger ≥ 15000000The largest response fetched, in bytes. A larger one is refused: larger than <n> bytes
fetch_policy.allow_loginsfalsefalseAlways off

A research run closes when its running time reaches max_duration, or min_duration when max_duration is not set. Time while the run is paused does not count. It then writes its final report. A finding needs no contract field: code accepts one when every claim it cites is verified and every number in it appears in those claims (Campaign modes).

These fields pass validation and become part of the contract and its hash, but nothing in Thermite acts on them yet:

ModeFields
Every modetarget.class; search_policy.allowed_purposes
Hill climb and goalmin_duration, max_duration
Hill climbobjective.policy; objective.secondary (checked against the kit only); monitor_only; hard constraints of kind check
Goalindependence_epochs; min_live_families; each goal’s priority and parent (stored only)
Researchscope.source_types; scope.languages; report_cadence.report_hours
MessageWhen
lowercase idAn id field is not an id
sha256 hexA model’s sha256 is not 64 lowercase hexadecimal characters
full git commit id (40 or 64 hex)A commit is short or not hexadecimal
duration like "30m" or "8h"min_duration or max_duration has no unit, or another unit
invalid regular expressionA pattern in forbidden_patterns or search_policy.blocked_patterns does not compile
Unrecognized key: "<name>"A field the contract does not have
duplicate goal id <id>Two goals have the same id
goal <id> has unknown parent <parent>A goal’s parent names no goal in the list
all_required needs at least one required goalsuccess.policy is all_required and no goal is required
k_of_n needs 1 <= k <= number of goalssuccess.policy is k_of_n and k is missing or larger than the number of goals
infeasibility.allowed requires min_refuted_approachesinfeasibility.allowed is true without min_refuted_approaches
max_duration is shorter than min_durationBoth are set, and max_duration is shorter
at most 16: a rig has users for lanes 1 to 16 onlylanes.count is more than 16
research campaigns need min_durationA research contract has no min_duration
hill_climb campaigns need a targetA hill climb contract has no target

The assign_campaign signed command carries the contract and its hash. Control does not sign one whose contract asks for more than 16 lanes: it answers This contract asks for <n> lanes. A rig runs 16 at most: it has users for lanes 1 to 16 only. Set lanes.count to 16 or fewer. The rig’s harness refuses the command with one of these, and creates no run:

MessageCause
invalid contract: <errors>The contract does not validate
contract hash does not match contract_shaThe hash in the command is not the contract’s
run <run> already existsThe rig has a run with that name
queue experiment <n>: <problem>The command copies experiments into the new run’s experiment queue (Start a run › Copy the untried experiments of), and the <n>th of them, counting from 1, does not fit an experiment’s limits
contract <campaign> v<version> exists with other contentThe rig has this campaign and version with a different hash
model <name> is not listed in the contractThe command imports a model that models does not list
model <name> (<sha256>) has not been importedA model in models is not on the rig
a <mode> campaign needs a seed commitA hill climb or goal run was sent without a starting commit
kit commit <commit> is not in kit <repo>; import its bundle firstThe kit commit is not on the rig
kit <repo> at <commit> has no valid kit.json: <error>The kit’s manifest is missing or refused
the kit does not fit the contract: <problems>The kit lacks a cell, metric or verifier the contract names, or measures on another target. See the kit against the contract
seed commit <commit> is not in <repo>; import its bundle firstThe starting commit is not on the rig