Refinement: what ships
The design is tempered sequential Monte Carlo. What ships is a short local Metropolis–Hastings walk. This page is about the difference, because it is easy to overstate.
The design describes generation as sampling from
by tempered SMC.
That is an intention, not a description of the code. There is no crossover: every
child grows from one seed (walk.rs).
What runs is a 40-step adaptive single-site MH walk (refine_steps; a walk with
locked sites runs proportionally more steps, up to LOCK_SCALE_CAP (4×), in
walk.rs), warm-started from each of the best pool members and keeping the final
state: local hill-climbing on that target, rather than a draw from it.
What runs
A generation is jobs, then absorption. A walk reads the engine and changes nothing; only admitting its child changes the pool. So the engine hands the walks out as data and folds their results back in, in a fixed order:
pub fn refine<R: Rng>(&mut self, rng: &mut R) {
let Some((ctx, jobs)) = self.refine_jobs(rng) else { return };
for job in &jobs {
let result = run_walk(&ctx, job, &self.memo);
self.refine_absorb(result);
}
self.refine_finish();
}
refine_jobs advances the generation counter and makes one job for each of
the top refine_seeds candidates by posterior utility, best first. Every walk
shares one context: the grammar prior tilted by the posterior (computed
once, as the pool stands when the generation opens), the posterior, the
standardizer, the phrase, β, and the keep rule. Each job carries its seed tree,
its step budget, and its own RNG seed, derived from a single draw of the
caller’s refine stream. It returns nothing (and does not advance the
counter) when there is no posterior or no standardizer, because there is no
direction to climb in.
run_walk is the walk as a pure function of a context and a job: it walks
refine_steps MH steps from the seed and returns one state of that walk, or
the reason there is none. Because each walk owns its randomness and the
render memo only ever saves work, a walk returns the same child in the engine
or on a render-farm worker, early or late.
refine_absorb takes the results in job order, whatever order they
finished in, and does what the serial loop always did after its walk: it
drops a child the pool already holds, admits the rest if they earn a place,
and records the lineage. A result offered out of turn changes nothing.
refine_finish closes the generation, and it is where patches leave (see
who leaves, and when). Stopping a generation early is
the same call: it ends the generation with the children already absorbed,
which are then ranked with everyone else when the pool is trimmed.
Which state of the walk gets injected
A walk renders and featurizes ~40 candidates and injects one, and which
one is a free choice that had never been measured. SessionConfig::refine_keep
makes it selectable so the comparison stays runnable, the same rule the
acquisition enum follows:
RefineKeep::Last | the state the walk ended on (the default) |
RefineKeep::Best | the highest- state it occupied, seed included |
The archive is free. Every trace the kernel returns already carries its own
, so Best is one f64 compare per step and no extra render.
It is scored on the target, not on fitness alone, and that is the load-bearing choice: taking the argmax of would discard the parsimony half of the very distribution the walk is sampling, and would do it with a bias: a bigger term has more modules to score well with, so fitness-argmax systematically returns the largest tree the walk touched.
Under Best the seed is in the archive, so a walk that finds nothing better than
where it started injects nothing, rather than whatever it happened to be
standing on at step 40.
Best ships switched off. Argmax over a surrogate is the classic way to find
that surrogate's errors rather than the player's taste, and the always-on
gate has already caught this happening: over 16 seeds, two produced pools
−12.0 and −5.5 worse in the synthetic user's true utility after three
generations, because insert_candidate admits and evicts by the model. Turning
Best on without measuring it is the move most likely to make that worse.
refine_from(seed_id, locked) is the same thing from an explicit seed with
an explicit lock set, the ⚡ evolve from this path: a single job
over the same walk and the same absorption, with the seed never displaced by
its own child, nor by anything else while its walk is out: from
refine_from_job until refine_from_absorb (or refine_from_cancel, a stop)
the seed is exempt from eviction like a pinned patch.
Who leaves, and when
A child is admitted only if it beats the member it would displace: the
lowest-utility member not kept, counting the displacements the generation
already owes. Kept (Candidate::kept) is saved (pinned), or kept as new and
not yet in a pick
(persistence).
Nobody leaves while the generation runs. At its end the pool is
trimmed back to pool_size: its lowest-utility members not kept are replaced,
so the patches that leave are exactly the ones evicting one child at a time
would have removed, but a patch saved (pinned) at any point before the end is
never among them. Kept candidates are always exempt.
So are the sounds on the table: the app holds the pair EVOLVE shows and the
pair of a pick still in its undo window (set_on_table, at most
ON_TABLE_MAX, four; read at the end as pins are, and never saved), so the vote that pick becomes when
its window closes finds both its sides in the pool. One rule,
Engine::spared (kept, a ⚡ seed in flight, or on the table), is what every
eviction passes over: the bar a child must beat, the trim at the end, a
preset's or an import's insert, a ⚡ child's trim, and retiring() below. A
session that holds nothing evicts exactly as it did before the hold existed.
Both the bar a child must beat and the trim at the end are judged under the
posterior the generation opened with, the one in its context and the one
its walks climbed. A pick made while the generation runs reweights the
engine’s posterior at once, so the next pair responds to it, but not the
generation’s: which children are kept and which members leave do not depend
on when picks landed between absorptions (natively,
picks_during_a_generation_do_not_change_which_children_are_kept). The picks
count from the next refit, which waits for the generation to end.
Before a generation: its seeds, and what it may replace
Both are stated by the engine before EVOLVE POOL is pressed.
next_seeds()is the parentsrefine_jobswould take if it ran now: the toprefine_seedsof the ranked list, under the posterior as it stands, so they move with every pick between refits. A saved patch seeds like any other. Natively,refine_jobstakes them by the same rule (next_seeds_and_may_replace_are_what_a_generation_does).may_replace()is the members that generation could retire, lowest first. Each walk admits at most one child, and the trim takes only as many members as the children put the pool over size, lowest first. So with the pool at size, the trim would take nothing outside therefine_seedslowest members not kept nor on the table: those come first. But the trim reads the table as it finds it at the end, and the player keeps picking while the generation runs. A sound up at the press is put away long before the end, and up toON_TABLE_MAXof the sounds the trim would take can be up when it ends, each sending it one member further. So the list goes on with theON_TABLE_MAXnext lowest with the table put away, and nothing outside therefine_seeds+ON_TABLE_MAXlowest members not kept can leave, whatever is on the table, unless something else changes the pool meanwhile (a save, an edit, a preset) or a sound kept as new has its first pick, after which it competes. Natively,may_replace_bounds_a_generations_end_whatever_is_on_the_table_by_then. A patch the app has cut is in the pool and ranks low, so it is on this list too: the engine does not know about cuts.
The app is sent both with every pick, beside the ranked numbers
(the posterior),
and marks them in the bank while EVOLVE POOL is pointed at. Once a generation
is open both describe the next one, so the app marks the running
generation's own instead: its parents, which the worker posts with the
generation's progress (job i walks from seeds[i]), and retiring(), which
each absorbed walk's message carries with the job's parent_id, as does the
reply to a save, a preset, a kept edit or a change of what is on the table
while the generation is open: a saved or held member drops out of
eviction_order and the next lowest takes its place, and a new member can
move the lowest. The app says
will be replaced for retiring(), not may be: it is the lowest members
not kept, as many as the children put the pool over size, under the posterior
the generation opened with, so each child admitted adds one member and removes
none, and the end replaces them whether it is stopped or runs out, but for a
member saved meanwhile or on the table when it ends, whose place goes to the
next lowest. Each child still to come can add one more.
While ⚡ walks with no generation open, its child is admitted and the pool
trimmed at once (evict_to_size, the seed protected), lowest first by
eviction_order, which passes over a seed in flight. The first part of
may_replace() ranks the same way, the table passed over, so its first
pool + 1 − pool_size members (one with the pool at size) are what the child
would replace; the app marks those, and the seed.
The split is measured
Defaults (in engine.rs), both scaled from the grammar’s processor count
N_OPS = 20 (in prior.rs):
Riding N_OPS matters: a structural proposal picks a new operator from a
categorical that grew from six to twenty kinds, so a fixed budget would spend
the same number of proposals covering a far wider move set and land children in
a visibly thinner slice of it. The tuning survives a change to the processor
set.
The 40 × 10 split was an argument that could have been wrong in either
direction, so search_health --budget-ab was written to settle it. Over 8
seeds, 6 generations, graded against a synthetic user’s true utility, under
fugue-ppl 0.2.2 (“What has changed since”, below, says what is different now):
| steps | seeds | proposals | mean | max | |
|---|---|---|---|---|---|
| 40 | 10 | 400 | 1.714 | 8.154 | shipped |
| 40 | 3 | 120 | 1.241 | 6.178 | same depth, fewer seeds |
| 66 | 3 | 198 | 0.774 | 6.281 | same total, fewer seeds |
| 20 | 20 | 400 | 0.568 | 6.790 | half depth, double breadth |
The shipped split wins on both metrics, and moving off it in either direction is worse.
Two rows are worth more than the headline.
Depth from few seeds is harmful. 66 × 3 runs 65% more proposals than 40 × 3 and scores lower (0.774 against 1.241). A long chain from a bad starting point converges confidently on somewhere nobody wanted to be, and the extra steps are what get it there.
Breadth is not free either. 20 × 20 spends the full shipped budget and is
the worst row of the four. Under 0.2.2, twenty steps was not enough for a chain
to leave its seed, so the generation is twenty barely-moved copies of the
current top, which is also why it has the second-best max: it preserves the
frontier by never straying from it.
Re-run this before changing either number.
What has changed since. The four rows were measured before 2026-10-07, under
fugue-ppl 0.2.2 and with oscillator octaves drawn uniformly, and --budget-ab
has not been re-run. fugue-ppl 0.2.3 reflects a knob’s proposal into its bounds
(at 40 steps, knob moves are accepted 85% of the time against 53%, and are
about 1.8 times larger), and octaves are now drawn by weight.
Two measurements on 2026-10-07 (the share of locked ⚡ walks whose child beats
its parent, search_health --hits) found the shipped 40 steps not worse under
either change, measured one at a time: 66.7% to 70.3% on 48 seeds, paired, for
fugue-ppl 0.2.3, and 71.1% to 68.0% on 16 seeds, unpaired, for the weighted
octaves, which ran on fugue-ppl 0.2.2, before the branch that drew them met
0.2.3. The two together, which is the walk now, were never measured by this rate
at any length.
On 0.2.2, “twenty steps is not enough” read as 60.9% against 71.1% at 20 and 40 steps with octaves uniform, and did not show with them weighted (71.1% against 68.0%, inside the noise); what it reads on 0.2.3 with the weighted octaves nobody has measured. The 80-step row leans worse under each change, and the 160-step row under fugue-ppl 0.2.3, all inside the noise; longer walks are not shipped, and the weighted octaves’ 160-step row was never finished. Neither study was re-measured after the walk began to run its model once a step and the renders moved to quiver-dsp 0.5.1.
Why local climbing suits this anyway
The gap between design and implementation is real, but the implementation is not merely a shortcut.
A candidate pool is not a sample. The pool’s job is to hold a few dozen patches worth auditioning. A correct sample from would include low-utility regions in proportion to their (small but nonzero) probability mass, which is right for estimating an expectation and wrong for filling a shortlist a person will listen to.
Warm-starting from the best members is deliberate. It concentrates effort where the model already believes, which is what “propose toward me” means from the player’s side.
Diversity comes from elsewhere. The measured result below is that the pool does not concentrate over a session anyway, so the thing SMC would primarily buy (maintained diversity via a population kernel) is being supplied by frontier-biased injection plus worst-eviction.
What is lost: any claim about the distribution of the pool. That is the whole of it. The other thing this section used to claim was lost turns out not to be.
The islands are not separated by a valley
This page previously said that a player with two distant islands “may find that refinement from island A never discovers island B, and has to reach it by hand or by the prior”. That was an argument from the shape of a local walk, and it is false.
make islands teaches a bimodal synthetic user (two islands opposed on every
coordinate they share), runs real generations, and asks how often a child lands
on the island its parent was not on:
| refinement events that cross islands | 99 / 473 (20.9 %) |
| of those, decisive (both ends > 1.0 onto their island) | 64 (13.5 % of all events) |
| seeds whose pool ended on one island only | 0 / 8 |
The decisive column is the one that matters. A patch sitting on the decision boundary can flip island under an arbitrarily small change, and counting that as crossing a valley would be measuring nothing; the filter removes it and the answer survives. The pool share is reported beside it as a control, because both islands being occupied would say only that the prior scattered candidates over both.
Why the argument was wrong. It reasoned about a local walk in feature space. This is a reversible-jump walk over a tree grammar: one accepted structural move swaps a subtree, and that is a large jump in . The search does not have to travel through the low-utility space between the islands, so there is no valley for a tempering schedule to cross.
Tempered SMC may still be worth having for the distributional claim. It is no longer worth having for this.
The measured non-concentration
From the same harness, as a manipulation check that turned into a finding.
Final pool spread, measured as mean pairwise on the reference scale, was 7.7–7.9 evolving versus 7.2 static. Six generations over a 72-duel session did not concentrate the pool at all; it widened it slightly, because mutation pushes children into feature-space extremes faster than eviction trims them.
That has two consequences, and one of them decided a default:
- The diversity argument for SMC is weaker than expected at session horizon.
- The concentrated regime that BALD was hypothesized to win in never arises, so the measured tie between BALD and uniform pairing is not an artifact of a spread pool that only the static setup guaranteed. The product’s own dynamics keep the pool spread.
The screening cascade
is free (no compile, no render), so a structure-only
surrogate could prune candidates before the expensive path, and only the
survivors would be rendered and scored in full. No such cascade is built:
SurrogateFitness featurizes every candidate it is handed
(Directions §12).
This is designed into the feature split and is why is two-part rather than one vector. In the refinement path specifically, the affordability comes primarily from the render memo rather than from screening, because the walk re-scores its own current state on every step.
Lineage
Every injected child records a LineageEvent:
pub struct LineageEvent {
pub generation: usize, // the generation counter at the event
pub kind: String, // "refine" | "edit"
pub parent_id: u64,
pub child_id: u64,
pub diff: Vec<DiffEntry>, // what changed, in trace-address terms
pub parent_utility: f64, // posterior mean at event time
pub child_utility: f64,
}
tree_diff produces the address-level diff, which the app renders as attack 0.59→0.83, +noise, −distortion · liked +0.65, naming the parent and child
by their bank names. The bank shows each bred child's event under its name
(from Soft Pad · +delay, cutoff 1.2 kHz → 3.4 kHz). The diff is
positional, so a module inserted above others shifts their addresses and one
change to what the patch holds can read as several swaps, removals and
additions. The app counts the modules instead: every module site nets its old
name −1 and its new name +1, a swap is said only where it accounts for a lost
module and a gained one, and the rest is said as gained (+) or lost (−). A
module removed in one place and added in another nets to nothing. Compare
shows it whole:
both utilities, through the same logistic as the bank's percentages
(), and the diff. A refused child (NotAdmitted) records no
event; the app knows it only from the walk's reason and its job's
parent_id.
Utilities are recorded at event time: a later refit changes the model, and re-deriving these numbers afterward would rewrite history to look better-informed than it was.
Hand edits appear in the same log tagged "edit", because the lineage is a
record of everything that produced a patch and not only of what the machine
did.