experiment-loop · git:20260913.fb3aa4e · 2026-09-13 · sha256 02d126698053b8f8
experiment-loop git:20260913.fb3aa4eA
Immutable. This exact content is served forever at /api/v1/blob/02d126698053b8f8.
--- 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. **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.