git:20260817.4239173 to git:20260913.fb3aa4e

31 added, 1 removed. Audit A to A.

---
model_tier: inherit
name: experiment-loop
description: "Use to drive a scalar metric down or up across bounded iterations — keep on strict improvement, revert otherwise, state on disk. Triggers 'minimize X', 'optimize until it stops improving'."
domain: process
workspaces:
- agent-config-maintainer
packs:
- meta
---
# experiment-loop
> A bounded **change → commit → evaluate → keep-or-revert** cycle against a
> **scalar metric**, where the decision input is an
> [evaluator-output](../../../docs/contracts/evaluator-output.md) verdict and the
> loop's entire state lives in an append-only register on disk.
This file **routes**. The protocol, the register format, and the pivot ladder are
reference files loaded on demand — a loop doctrine read in full on every session
that merely mentions optimization is a payload nobody asked for.
| Need | Load |
|---|---|
| The iteration protocol and its two exit conditions | [`references/protocol.md`](references/protocol.md) |
| The register's row shape and write ordering | [`references/register-format.md`](references/register-format.md) |
| What to do when the metric stops moving | [`references/pivot-ladder.md`](references/pivot-ladder.md) |
## When to use
* The goal is expressible as **"minimize X"** or **"maximize X"** against a
mechanical verifier — bundle size, violation count, query count, runtime.
* A verifier already emits, or can be wrapped to emit, the evaluator-output
contract. Wrapping is usually free: three of this repo's verifiers were wrapped
with zero changes to any of them.
Do **NOT** use when:
* The target is a **known end state**, not a number — "make the tests pass" has a
destination, so use [`verify-repair-loop`](../verify-repair-loop/SKILL.md),
which converges toward it. This skill has no destination; it has a direction.
* The verdict is **subjective craft** — no scalar, nothing to optimize.
* The metric is a **proxy nobody agreed on**. Optimizing an unowned proxy is how
a loop makes a system worse while its number improves.
## The Iron Law
```
THE METRIC IS NEVER SOVEREIGN. `pass` IS.
KEEP ONLY ON `pass && score > baseline`. A CHANGE THAT IMPROVES THE NUMBER
AND BREAKS BEHAVIOUR IS REVERTED — MEASURED, NOT ASSUMED.
COMMIT BEFORE YOU EVALUATE, SO EVERY REVERT IS A GIT OPERATION.
THE REGISTER IS WRITTEN BEFORE THE ACTION IT RECORDS, NEVER AFTER.
STATE LIVES ON DISK AND IS RE-READ EACH CYCLE — NEVER IN THE CONVERSATION.
```
Each line is a measured failure, not a preference. Spike s01 ran a change that
improved its metric by 67 % and broke the behaviour it measured; only `pass`
reverted it. The same run crashed between its commit and its register append,
twice, leaving the branch one iteration ahead of its own record.
## Procedure
1. **Inspect the verifier before trusting it.** Run the evaluator once on the
unchanged tree and check its verdict: `pass` must be true and `metric_state`
must be `present`. A baseline taken from an evaluator that is already red, or
whose metric is unreadable, is not a baseline — every later comparison
inherits the defect silently.
2. **Fix the metric and the verifier** before the first change. Name the
evaluator, its direction, and the baseline score.
3. **Declare the bound** — a maximum iteration count, written into the register's
first row. An unbounded loop is not this skill.
4. **Iterate** per [`references/protocol.md`](references/protocol.md): one focused
change, commit, evaluate, keep or revert, append the outcome.
5. **Exit** on either condition (both machine-checkable, never prose): the bound
is reached, or the explicit exit signal fires.
6. **Report** from the register, not from memory. If the register cannot
reconstruct the run without the transcript, the run is not reportable.
## Validation
* The register reconstructs the run with the transcript discarded — every kept
change traceable to a row, every row to a commit.
* Both branches ran at least once. **A loop whose revert branch never executed
is a one-branch loop with a comment**; spike s01 found its revert broken on
first use after four green iterations.
* The final tree passes the evaluator with `pass: true`.
## Output format
1. **The register path**, and the iteration count actually run against the bound.
2. **A metric line**: baseline → final, and the number of keeps versus reverts.
3. **The exit condition that fired**, named — bound reached, or exit signal.
- 4. **Every reverted iteration listed** with its reason. A revert is a result the
+ 4. **A `run_terminal` value** — the exit in the vocabulary every other loop in
+ this tree reports in, so a reader can count this run's stop alongside the
+ continuation hook's and the self-fix lanes' without a per-surface
+ translation table.
+
+ | exit condition | `run_terminal` |
+ |---|---|
+ | `score >= target` — the run had a destination and reached it | `success` |
+ | `iterations_run >= max_iterations` — the declared bound | `exhausted` |
+ | `consecutive_reverts >= N` — the exit signal fired | `stagnated` |
+ | the evaluator was red on the unchanged tree, so no iteration ran | `clean-no-op` |
+ | the run stopped on a missing precondition (metric unreadable, verifier absent) | `blocked` |
+
+ The value set is `RunTerminalState` — `success`, `clean-no-op`, `blocked`,
+ `approval-required`, `exhausted`, `stagnated`, `premise-invalidated` —
+ declared once at `src/scripts/_lib/outcome_vocabularies.ts`
+ (`RUN_TERMINAL_STATES`) and described in
+ [`terminal-states`](../../agent-src/contexts/execution/terminal-states.md).
+ A new exit condition maps onto an existing member or it is not a terminal
+ state; never invent a value here.
+
+ `exhausted` and `stagnated` stay distinct on purpose. This skill's own pivot
+ ladder already treats them differently — a spent bound may deserve a larger
+ one, while consecutive reverts mean the hypothesis class is done and route to
+ the ladder instead. Reporting both as "stopped" throws away the part that
+ decides what happens next.
+
+ Reaching the bound is a normal exit, not a failure, and `exhausted` does not
+ say otherwise — it says where the run stopped, never whether it was worth
+ running. A published null is still a result.
+ 5. **Every reverted iteration listed** with its reason. A revert is a result the
next run needs, not noise to summarise away.
## Gotcha
* **A metric that improves while the system degrades.** The reason `pass` is
sovereign. Never widen a verifier to make an iteration keep.
* **A revert path that has never run.** Exercise it deliberately once.
* **A register appended after the mutation.** A crash in between leaves the
branch ahead of the record; write the row first, then update its outcome.
* **Loop state in the conversation.** It decays silently and the loop keeps
comparing against a baseline it no longer holds.
* **Iterating past the bound because the number is still moving.** The bound is
the contract; extending it is a new run with a new baseline.
## Do NOT
* Do NOT keep a change on `score` alone.
* Do NOT edit the verifier to move the metric.
* Do NOT batch several changes into one iteration — a keep you cannot attribute
is a keep you cannot revert.
* Do NOT delete a register row. Rows are append-only, including the embarrassing
ones.