Experiments
mecha exp runs a designed comparison over a chosen set of runs: arms
that vary the harness, a control they are measured against, a prediction each
treatment arm makes before anything runs, and one isolated home per arm so a
trial's learning never touches your real store.
An arm is a model and a harness configuration, and an experiment may vary
either or both. mecha eval is the special case
of arms that name models under the bare preset, run in-process and printed
as a scorecard; it shares this feature's case file, fixture and graders, and
its own A/B flags are two-arm manifests waiting to be written as such.
The manifest
The design is a TOML file, written once:
name = "boredom"
control = "full"
split_seed = 7
seeds = [1, 2, 3]
[tasks]
cases = "eval/cases.jsonl"
fixture = "eval/workspace"
tags = ["files"]
[arms.full]
preset = "full"
[arms.quiet]
levers_off = ["boredom"]
[arms.quiet.prediction]
metric = "turns"
rationale = "without the notice, the run does not spend turns answering it"
[arms.rules-only]
preset = "bare"
levers_on = ["learned_rules"] # add-one-to-bare
[arms.rules-only.prediction]
metric = "failure"
rationale = "the rules alone, over nothing else"
[arms.bare]
preset = "bare"
[arms.bare.prediction]
metric = "failure"
rationale = "everything off should fail more"
[arms.small-model]
provider = "small" # a key in your [providers] table
model = "gemma-4-e4b"
[arms.small-model.prediction]
metric = "failure"
rationale = "the same harness on a smaller model fails more"
An arm may name its provider (a key in your config's [providers] table)
and model; absent, it runs against your default. Beyond that it may only
vary the closed set: levers by name in levers_off, or turned back on
after a preset in levers_on (bare plus learned_rules is the
add-one-to-bare design; a name in both lists is on — the list of levers is
mecha_core::harness::Lever), knobs as KEY=VALUE over the same override set
harness ruminate uses, and a preset — bare is what eval runs, full is
every lever on. An unknown lever name is a load error. The approval_rules
lever is refused: a forbid in your rules file is your standing word, and an
experiment does not lift it.
The control carries no prediction. Every other arm must, and the metric is
always a cost — the task outcome enters as failure (1 − passed), the rest
are the gate's own (turns, tool_error_rate, cut_short, compactions,
ended_on_failed_call, malformed_args).
Running
mecha exp new boredom.toml # writes ~/.mecha/experiments/boredom/
mecha exp run boredom # one child `mecha run` per trial; resumable
mecha exp status boredom
mecha exp judge boredom # each treatment arm against the control
mecha exp export boredom > out.json
Every trial is its own mecha process, started with MECHA_HOME pointing at
its arm's home under the experiment directory and with the staged workspace
as its working directory. That home's config.toml is the arm: your whole
config with every inline provider key scrubbed (the environment variable the
key names still reaches the child), your sandbox, security and approval rules
intact, and the arm's switches and knobs applied — so nothing about an arm is
ambient, and your forbid list still stands. Your learning store, skills and
charter are copied into the arm's home once, when it is first created, so a
full arm runs the harness as your machine has it; nothing is ever copied
back. The child gets a clean environment with only what it needs, the runner
refuses a home that is, or contains, your real one.
A trial's session is marked experiment. In your real store that kind is
hidden from every readout, like a smoke test; in the trial home it is admitted,
so reflect, learn and the run-quality corpus read a trial's sessions there
without a flag. The session's config record carries which trial it was.
Judging
judge pairs each treatment arm with the control by task, seed and
repetition, draws the holdout with the manifest's split_seed, and rules
through the same gate harness ruminate uses: wins on the selection slice,
confirmed on the holdout, under the work guardrail. A trial with no grade or
no stats drops its pair rather than counting as zero. Below the gate's floors
the verdict is propose, and says so.
Lifetimes
A single trial is one run per arm × task × seed × repetition, which is the
shape that answers "does this disposition help inside a run". Everything the
appraisal loop does acts across runs — reflections become rules in the next
run's prefix, run counters become config overrides — so the unit that can
measure it is a lifetime: an ordered task sequence sharing one home, with
the loop's stages run between tasks.
name = "loop"
kind = "lifetime"
control = "full"
split_seed = 11
seeds = [1, 2]
repetitions = 1
[schedule] # every N tasks; 0 = never. This is the default.
reflect = 1
validate = 5
learn = 5
retire = 5
ruminate = 10
[tasks]
cases = "eval/cases.jsonl"
fixture = "eval/workspace"
ids = ["hello", "files-read", "files-write", "shell-ls"] # the sequence, in order (required)
[arms.full]
preset = "full"
[arms.deaf]
preset = "full"
stages_off = ["ruminate", "sensors_in_brief"]
[arms.deaf.prediction]
metric = "failure"
rationale = "without rumination the loop cannot move a knob, so failures do not fall over the sequence"
Each lifetime — one per arm × seed × repetition — gets its own home under the
experiment directory, seeded like an arm's. The driver walks the sequence in
order: after each task it runs the stages the schedule makes due, as child
mecha verbs against that home — reflect, validate --unprocessed-only,
learn --holdout 0.25 --auto, rules propose-retirements --apply,
harness ruminate, the nightly's own order and flags, so what a lifetime measures is the loop that ships (validate
measures before learn consumes) — one after another and never beside a
task, and writes
each to the lifetime's stage ledger (stages/<lifetime>.jsonl) with its
exit status and where its output went. The ledger is what says a stage ran;
the manifest says only what was scheduled. Resume reads both: a finished task
is not rerun, and a stage the ledger lacks after a finished position runs
before the next task starts — but only while no later position has finished;
past that it is recorded as skipped out of sequence, which holds the verdict,
since a stage run after later tasks would act on sessions those tasks never
ran under.
Stage levers are a second closed set, beside the per-run levers, and a
lifetime's arm may name them off in stages_off: reflect, learn,
validate, retire, ruminate, and sensors_in_brief — the last is not a stage but
the switch ([agent] sensors_in_brief) that hands the homeostat's and guilt's
readings to the diagnostician's brief, which is those sensors' only reader.
A single manifest refuses them. Stage levers off are part of a row's
condition hash; an arm with every stage on hashes as its single-trial twin.
status shows each lifetime's sequence by position (✓ ✗ ! ~ ·) with its
stage counts; judge pairs positions across arms as it pairs tasks, since the
sequence is shared, and reads the ledger: a stage that failed, was
interrupted, or could not be read on either side holds the verdict at
propose, because a treatment not known to have run cannot claim its
effect. Read the trajectory, not the mean: a loop that learns has
a slope.
The principal
Every reflection trigger is an owner's act — a steer, a denial, a follow-up, an edited draft — and a lifetime run with nobody answering mines nothing. The principal plays the owner: an executable the manifest names, called before and after every task with the trial's state on stdin, answering with the owner's verbs to run and the refusals to script.
[principal]
command = ["/home/me/mecha/scripts/principal-gold.py", "/home/me/mecha/eval/principal.toml"]
timeout_secs = 600
The principal must read its whole state before answering; one that exits
without draining stdin is a failed call, recorded on the ledger. The
principal is pure: it never runs a verb itself. The driver runs each one
as a child mecha against the trial home, from a closed set — tasks set|steer|stop, outbox reject|edit, questions answer|abandon,
never a session, a reflection or a rule — and records the call and every act
with its exit status on the lifetime's ledger, so a principal that could not
act holds the verdict like a failed stage. Refusals it scripts before a task
land in the home's principal/denials.toml; the run reads them ahead of its
own approver and renders each as "Denied by the user", which the learning
loop mines as a correction — the owner's word, inside the trial home. Only an
experiment's run honours that file; any other run started with it refuses to
start, since a scripted refusal on your real home would author corrections
nobody made.
scripts/principal-gold.py is the gold-verdict version: a draft addressed off
the fixture cast is rejected and one on the cast is released, a board task
is closed by the task's grade, a parked question is answered from a table,
and refusals come from the policy file it is given.
Two of the principal's verbs reach a server — outbox approve executes the
routed tool for real, and tasks set writes the board, which lives in the
knowledge graph over MCP. A full arm carries your live servers into the
trial home, so without more a release would send from your account and a
closure would close a real task. The driver therefore permits those two
verbs only under a manifest that names fixture servers, and vets a
release against the draft it names: the draft's tool must be a fixture
server's, by its <name>__ prefix.
Task sources
A manifest's tasks come from an eval case file, or from a task source: an executable that answers three verbs.
[tasks]
source = ["python3", "eval/fixtures/dojo.py", "--suite", "workspace"]
fixture = "eval/workspace"
source_timeout_secs = 600
list prints the tasks as JSON (id, prompt, tags, an optional turn ceiling,
an optional expect block); setup <task> puts the world in the task's
starting state before the run; grade <task> reads the run's --json
result on stdin and prints a verdict with the checks behind it. The driver
calls each with MECHA_HOME, MECHA_FIXTURES (the home's fixture-store
root), MECHA_EXPERIMENT_WORKSPACE and MECHA_EXPERIMENT_TASK set, and
every edge fails the trial rather than passing it: a non-zero exit, a
timeout, no JSON, an unknown shape, or a verdict that disagrees with its own
checks. eval/fixtures/source_stub.py is the whole contract in forty lines.
eval/fixtures/dojo.py is AgentDojo as a fixture world — the same program
serves a suite's tools over MCP and acts as the task source for its user
tasks, each also paired with an injection task, graded by the suite's own
utility and security functions. It needs the venv scripts/dojo-venv.sh
builds. eval/dojo-workspace.toml runs the workspace suite:
scripts/dojo-venv.sh
mecha exp new eval/dojo-workspace.toml
mecha exp run dojo-workspace
Executable correction pilot
Recorded replay returns saved tool results. To measure whether a changed harness
actually completes work, eval/executable-validation.toml instead runs real file
tools on eight registered corrective tasks. Each trial starts in a fresh workspace
with an incorrect output artifact. Independent JSON and preserved-input checks
accept any correct tool sequence.
From a source checkout with Python 3.11 or later and a built binary:
python3 scripts/executable-validation.py --out /tmp/executable-pilot
The registered design uses the local qwen3.6-35b-a3b server, three seeds and four
arms: frozen learned rules off/on crossed with turn limits 12/10. The script checks
that the configured limit is 12 before calling it the control. These are model-turn
limits, not counts of individual tool calls; a turn may contain several calls.
--binary, --config and --rules choose the runtime and snapshot inputs.
--candidate /path/to/candidate.json ties the report to a staged max_turns=10
proposal without changing that proposal.
The output directory contains the native experiment, transcripts, resulting files, input/runtime hashes and a paired scorecard. It also contains a private snapshot of the operator's rules and config. A cheaper unfinished task counts as a regression; cost differences are also reported for pairs that both completed. No learning stages run and no override is installed.
This is a synthetic pilot of corrective task execution. It starts from known incorrect artifacts, not reconstructed historical conversations. It does not test live-service effects or the process that learned the frozen rules. Repeated seeds are repeats of the same tasks, not unseen task families; the native gate's selection/holdout split is not evidence of transfer to different work.
Fixture servers
A manifest may carry a [fixtures] table naming MCP servers the trial home
runs instead of yours. When it names any, the home's [[mcp]] is
exactly that list, for every arm — no live server reaches it — and each
server keeps its state under the home (fixtures/<name>/, handed to it as
MECHA_FIXTURE_DIR), seeded once from a directory you name. The outbox
route is the world's too: outbox_tools names the fixture tools whose calls
are staged as drafts, and it must be spelled — your own [outbox] tools
names live tools that are not in this world, so it is not inherited.
Relative paths are resolved against the checkout you run mecha exp from.
[fixtures]
charter = "eval/fixtures/home/charter.toml" # written over the home's before every task
outbox_tools = ["mail__mail_send", "mail__mail_reply"] # the world's staged sinks; required, [] for none
[[fixtures.mcp]]
name = "graph"
command = "python3"
args = ["eval/fixtures/board_server.py"]
prefix_tools = false # the board is kg_task_*, as in production
seed = "eval/fixtures/home/board"
[fixtures.mcp.capabilities]
untrusted_input = true
[[fixtures.mcp]]
name = "mail"
command = "python3"
args = ["eval/fixtures/mail_server.py"]
seed = "eval/fixtures/home/mail"
[fixtures.mcp.capabilities]
untrusted_input = true
Two fixture servers ship with the repository, stateful where the eval
rig's graph_server.py is deliberately not: board_server.py is the graph's
task board with the real server's argument and answer shapes (what mecha tasks parses) plus the canned graph reads, and mail_server.py is the mail
and calendar surface, with every send a line in sent.jsonl and nothing
delivered. Both refuse to start without a store directory — a board that
forgets is not a fixture. The fixture names are part of every row's
condition hash, so a trial against a fixture board never pairs with one
against your live graph.
eval/fixtures/home/ is a synthetic assistant home built on them: a
board with a task per case, a mailbox on a fictional cast (including one
message that tries to instruct the assistant), a calendar and a charter.
eval/home-lifetime.toml runs it as a lifetime with the gold principal
releasing drafts to the cast and closing each case's task by its grade:
mecha exp new eval/home-lifetime.toml
mecha exp run home-loop
Gossip follow-up comparison
The developer fixture in eval/fixtures/gossip/ compares ordinary peer questions
with follow-ups generated from each reader's own evidence. Build the native actor
with cargo build -p mecha-core --example gossip_compare, then run
python3 scripts/gossip-comparison.py --out /tmp/new-gossip-comparison.
Both conditions have the same request ceilings. Actual calls and tokens are
recorded, and an explicit citation audit supplies supported/contradicted labels
before scoring. The sources are synthetic and frozen; this experiment does not
file graph verdicts or change the ordinary mecha gossip behavior. Its README
specifies the local model requirements, audit format and limits of the comparison.