Keep only gains that hold
hi agent best-of --validate checks every round's winner on data the agents never see, in a container of its own, and keeps a gain only if it holds there too. The flags, how it works, what agents see, choosing the slack, and troubleshooting.
Rounds keep whatever scores best. Over tens of rounds and several boxes a round, that search fits the data the score reads: changes that suit its quirks win, whether or not they would work anywhere else. The holdout catches this, but only once, at the end, when the run is over.
In the first long run of the quant template, the in-sample Sharpe rose from 0.86 to 1.53 while the holdout Sharpe fell, from 0.94 to 0.87. Nothing during the run could tell.
--validate gives the run a second test. The data is split three ways:
| Part | Who sees it | What it is for |
|---|---|---|
| Score data, in the repository | agents, all the time | the score they improve |
| Validation data, outside the repository | only hi, after each round | the gate: a gain must not lose here |
| Holdout, outside the repository | only you, once, at the end | the final word |
A round’s winner is kept only if it beats the best score and its validation number is no worse than the highest one so far.
hi agent best-of 3 --rounds 20 --score "make score" --higher --edit strategy.py \
--validate "make validate" --validate-data ../quant-validation --validate-slack 0.05 program.md
In the templates, make loop passes
these flags for you.
The flags
They go with --score, and --validate needs --edit.
--validate "<command>"
The command that measures a result on the hidden data, such as
--validate "make validate".
- It works like
--score: hi takes the last number it prints, and better is the same direction as the score (--loweror--higher). - The difference is where it runs.
--scoreruns inside the agent’s box.--validateruns after the round, in a container hi builds itself (see How it works). - A validation that fails, times out, or prints no number counts as not holding: that box can’t be kept.
--validate-data <dir>
The folder of hidden data, such as --validate-data ../quant-validation.
- It is on your machine, outside the project, so it is in no commit and in no agent’s worktree. hi refuses a folder inside the project.
- hi mounts it read-only at
/box/data, in validation containers only. Agents’ boxes never get it. - Your validation command reads it from there; the templates’
make validatedoes. - Without it, the command runs on what the project has, which helps only when the validation is hidden some other way. The templates always pass a folder.
--validate-slack <number>
How much lower than the highest so far the validation number may be and still pass. “The highest so far” is the best validation number of the baseline and every kept version. The default is 0: no lower at all.
- With 0, a change that is truly neutral on the hidden data fails about half the time, from noise alone. Set the slack to about the validation number’s noise (see Choosing the slack).
- It is in the score’s own units, like
--min-gain. - Example, higher is better: the highest validation so far is 0.81 and the slack is 0.05. A winner at 0.78 passes (0.03 lower) and is kept, but 0.81 stays the mark: a later winner at 0.77 still passes (0.04 lower), one at 0.75 doesn’t (0.06 lower), however close it is to 0.78. A winner at 0.90 passes and 0.90 becomes the new mark.
Why --validate needs --edit
To build the validation container, hi takes the harness, the Makefile,
and the data from the gains’ branch, and only the agent’s own files from
its box. --edit (such as --edit strategy.py) is what tells hi which
files are the agent’s. Without it, hi would have to copy everything,
including an evaluation the agent might have changed.
How it works
Validation never runs in an agent’s box. The code being validated is the
agent’s own, and in its box the agent could have prepared a way out: the
project’s .git is writable there, --edit is checked only after the
score has run, and the box’s proxy reaches the agent’s model. So:
- Agents’ boxes are unchanged. They work, check, and score as without
--validate. The validation data is never mounted in them. -
The baseline is validated first. After the baseline score, hi validates your last commit. A validation that fails or prints no number stops the run there, before it costs tokens.
Measuring the baseline on 3f2a91c0e4b1. Baseline: 0.86, in 41s. Validating the baseline: make validate, in a container without network. Baseline validation: 0.79, in 38s. - After each round, a fresh container per good box. When the round’s
boxes are scored, hi validates every box whose result could be kept
(check passed, a score, inside
--edit, something changed). For each it builds a folder outside the project:- the gains’ branch as it is (
git archive), so the harness, theMakefile, and the data are hi’s copies, whatever the agent did to its own; - with only the files the agent changed, all inside
--edit, taken from the box’s snapshot.
It runs the project’s box image on that folder with no network, no proxy, no
.git, nothing from the agent’s box, the data read-only at/box/data, and the folder at/box/project. The command runs under a time limit of twice the baseline validation’s time plus a minute. The container and its folder are removed when it exits. - the gains’ branch as it is (
- The gate. The round’s winner is chosen on the score alone, as
without
--validate. It is kept only if its score beats the best by more than--min-gainand its validation number is no lower than the highest so far (the baseline’s or any kept version’s) by more than--validate-slack. Otherwise the round isrejected.
hi doesn’t fall back to the second-best box when the winner is rejected: choosing among boxes by their validation numbers would fit the validation data too.
The gate compares with the highest validation number so far, not the latest kept version’s. Against the latest, each kept round could lose up to the slack, and the losses would add up: ten rounds at a slack of 0.05 could lose 0.5. Against the highest, validation can never end more than the slack below its best point, and it doesn’t have to rise every round either. (In v0.52.0 the gate compared with the latest. A run started then, and resumed after an update, starts its mark at its latest kept number, since the earlier ones weren’t recorded, and keeps the highest from there.)
Each good box is validated, not just the winner, so the report shows every
attempt’s number. Without the GPU the containers run at once; with
--gpu, one at a time, like the scores.
In the container
| Working folder | /box/project: the gains’ branch plus the box’s --edit files |
| Data | /box/data, read-only: --validate-data |
| Network | none |
.git |
none |
| Environment | HI_VALIDATE=1, HI_BOX (so a Makefile picks the image’s tools), HOME=/box/home, and the project’s containerEnv |
| Image | the one the run’s boxes use (.devcontainer/, --image, or hi’s) |
| Time limit | twice the baseline validation’s time plus a minute |
The image must hold everything the command needs, since there is no
network: the templates’ images have their dependencies in /opt/venv. A
project whose packages are fetched when a box starts fails at the
baseline validation, before any agent runs. The devcontainer’s
postCreateCommand doesn’t run in validation containers.
What agents see
Validation numbers and data are in nothing a box can read:
- The next round’s task shows each attempt’s score and result
(
kept,discarded,rejected,crash), never a validation number. It says that hi also checks the round’s best on data that isn’t in the box. - Commits on the gains’ branch and the attempt refs under
refs/best-of/<run>/, which boxes can read through the project’s.git, carry no validation number. - The data reaches only validation containers.
rejected tells agents one bit: a change that raised the score didn’t hold
up. That is the point of the gate; they learn which kinds of ideas fail, not
the numbers.
The numbers are in the run’s state, outside every box: hi agent best-of
watch, show, show --json, ls --json, and the run’s loop.log, so a
--then command can report them. Don’t commit a report with validation
numbers to the gains’ branch and then resume: the next boxes could read
it through .git.
The view
Best-of b-9 · "program.md" · 3 boxes a round · round 7 of 20 · 1h12m · $9.40 at API prices
Score: make score (higher is better) · validated by: make validate
Best: 1.12 (validation 0.81) from round 4, baseline 0.86 (validation 0.79) · on best-of/b-9/best
ROUND RESULT SCORE VALID. BOX IDEA
4 kept 1.12 0.81 quant-b9-r4-1 inverse-volatility weights
5 rejected 1.31 0.62 quant-b9-r5-3 six lookbacks fitted per asset
6 discarded 1.09 – quant-b9-r6-2 add a crash filter
Now: round 7: validating 2 boxes
VALID. is the round winner’s validation number. show --json has every
box’s under validation (its value, exit status, seconds, and the last
lines of its output) and the run’s baseline_validation,
best_validation (the latest kept version’s), and peak_validation (the
highest so far, which the gate compares with). With folds it also has
fold, folds, and fold_history: each change’s fold, the round after
which it came, its commit, and the new baseline’s score and validation.
A rejected round counts as a round without a gain for --patience.
Folds: a window that stays fresh
One validation window wears out: every gate it decides is a little
selection on it. With --folds, the run walks forward through several
windows, and agents get more data as it goes.
hi agent best-of 3 --rounds 60 --score "make score" --higher --edit strategy.py \
--validate "make validate" --validate-data ../quant-validation \
--folds 3 --fold "make fold" --fold-every 20 program.md
| Flag | Meaning |
|---|---|
--folds <n> |
the run moves through n folds, from fold 1; needs --validate |
--fold "<command>" |
moves to the next fold; hi runs it on this machine |
--fold-every <k> |
move on after k rounds; without it, when --patience runs out |
When a fold ends, hi:
- runs
--foldin a temporary worktree of the gains’ tip, withHI_FOLD(the new fold’s number),HI_FOLDS,HI_PROJECT(your checkout), andHI_VALIDATE_DATA(the validation folder) set. The command moves the window: the old validation block joins the data in the project, and the next block becomes the validation folder. It must not change the--editfiles; if it does, the run stops. - commits what it changed to the gains’ branch, with the message
best-of <run> fold <n>and nothing else; - scores the tip in a box and validates it, as the fold’s new baseline. The best so far is measured on the new windows, not carried over: the next round has to beat the tip on the new data;
- starts
--patienceagain, and goes on.
Agents see a fold row in their history, with the new baseline’s score,
never its validation. watch and show show fold 2 of 3 in the header
and a row for each fold change. With --patience and folds, the run ends
when patience runs out in the last fold.
The last fold’s validation block ends where the holdout starts, so no fold
touches the holdout. A run that ends early stays in its fold, and resume
goes on from there. If a fold change fails part way, resume runs it
again: write --fold so that it writes the windows for HI_FOLD, and a
second run changes nothing.
In autoresearch-ml, set FOLDS (and FOLD_EVERY, if you like) before
make data, which writes fold 1:
make data FOLDS=3 && git add eval && git commit -m "Data, fold 1 of 3"
make loop FOLDS=3 FOLD_EVERY=10
Fold 1 gives agents 60% of the text and validates on the next 10%; each
fold moves 10% into eval/. Fold 3 is the usual 70/10/10/10 split.
Noise, slack, and results with folds
Folds change how much data each number rests on, so set the noise thresholds for them before the run:
- Measure the noise once, on fold 1, right after
make data FOLDS=n. Fold 1 is what your checkout holds, and its numbers are the noisiest of the run: it has the least data agents see. A--min-gainmeasured there is a little larger than later folds need. That errs on the safe side: a few small real gains are ignored, but no noise is kept. - Make the windows the same length. Then one
--validate-slackfits every fold: the slack is about the noise of the validation number, and that depends on the window’s length, not on where it sits. Both templates do this. - A shorter window needs a larger slack. Splitting the same years into
more windows makes each one shorter, and a number measured on a shorter
window moves more by chance. A Sharpe ratio’s noise grows as one over the
square root of the years: a two-year window is about 1.4 times noisier
than a four-year one. Keep the slack you used without folds and the gate
rejects real gains, often. In the quant template,
make robustsuggested 0.20 for its two-year folds, against the 0.05 default for four years. - Results are measured on one set of windows. A results command that
re-scores every attempt, such as the quant template’s
make results, uses the windows of the fold the run ended in. Attempts from earlier folds were scored on other windows, so their recorded numbers won’t match the new ones. That is expected, not a bug: comparing every attempt on the same windows is the point of the report.
Choosing the slack
- Run the validation twice on the same code, on your machine:
make validate, twice. For a deterministic backtest the two numbers are equal and the noise is in the data instead: the quant template’smake robustsuggests a slack from the spread of the validation window’s tranches, as it does for--min-gain. - Set
--validate-slackto about that difference. Too small rejects real gains; too large lets fitted changes through. - In the templates, it is
VALIDATE_SLACKin theMakefileor on the command line:make loop VALIDATE_SLACK=0.01. - With folds, measure again after
make data FOLDS=n: each window is shorter, so its slack is larger.
Costs
- Time. Each round adds one validation per good box. For a backtest
that is seconds. For the ML template it is another training run per box,
since
make validatetrainstrain.pyagain before it scores; with the GPU, one box at a time. - Data. The validation window comes out of what agents used to see: less to fit, and noisier numbers if the window is short.
- The gate wears out slowly. Each gate is a question asked of the validation data: a run of 20 rounds asks about 20, against the hundreds of scores agents see. Folds spread the questions over several windows. The holdout stays the final word.
- Folds. Each fold change scores and validates the tip once more.
After a run
hi agent best-of show b-9 # every round's score and validation
git checkout best-of/b-9/best && make holdout && git checkout -
If the holdout moved with the validation numbers, merge. If validation held and the holdout still fell, the window was too short or too much like the score’s data; lengthen it before the next run.
Troubleshooting
| You see | It means |
|---|---|
--validate needs --edit |
Name the agents’ files: --edit strategy.py. |
--validate-data … is inside the project |
Move the folder next to the project, such as ../<folder>-validation, and pass that. |
the validation doesn't work on your last commit |
The command failed or printed no number in the container. Its output follows; the usual causes are a package the image lacks (no network there) or a path other than /box/data. |
Every round rejected |
The slack is too tight for the noise, or the agents are fitting the score’s data. Try a slack of about the noise; tell agents in the task that fitted ideas are rejected. |
make validate in a box says the text stays outside |
As it should: agents can’t run it. hi runs it in its own container. |
the validation ran out of time |
It took more than twice the baseline validation’s time plus a minute. |
say when a fold ends |
--folds needs --fold-every <k> or --patience <n>. |
fold 2: make fold changed strategy.py, inside --edit |
The fold command may move the data, never the agents’ files. Fix it, then hi agent best-of resume <run>. |
eval/ wasn't written for 3 folds |
make data FOLDS=3, commit eval/, then make loop FOLDS=3. |
The design and its reasons are in the specification.