The two loops
A machine-paced loop and a human-paced loop, sharing one observation stream.
┌─ patch loop (fast, silent, machine-paced) ─────────────────┐
│ grammar prior → vet → pool │
│ local MH toward π_β: subtree moves → struct-screen → │
│ render survivors → feature-score │
└──────────────┬─────────────────────────────────────────────┘
│ candidate pool (`pool_size`: 48 by default, 40 in the app)
▼
acquisition: choose what to play
(uniform by default; BALD selectable)
│ audition + feedback events
▼
┌─ taste loop (slow, human-paced, persistent) ───────────────┐
│ observe events → posterior over (θ, τ, cutpoints) │
│ persisted across sessions = the user model │
└──────────────┬─────────────────────────────────────────────┘
│ θ reshapes the prior's proposal weights
└──────────────► back into the patch loop
The two loops run at different speeds on purpose. The machine can evaluate thousands of candidates against a learned surrogate silently, and surface only a curated few. That addresses interactive evolution's classic failure mode: the human bottleneck, where a user is asked to rate a whole population per generation and quits from fatigue.
The patch loop
Machine-paced. No human in it.
- Fill. Sample terms from the grammar prior, compile, render, vet,
featurize. Pool target is
SessionConfig::pool_sizevetted candidates (48 by default, though the web app passes 40 inapps/web/main.js), with at most 400 draws attempted per fill, since vet failures burn attempts. - Refine. Once a posterior exists, take the top
refine_seedscandidates and runrefine_stepsMetropolis–Hastings steps from each. Defaults are 10 seeds × 40 steps, both scaled from the palette's operator count so a palette change does not silently change the search's character. - Inject. Each surviving child displaces the pool's lowest-utility member. Pinned candidates are exempt.
The 10 × 40 split is measured; moving in either direction is worse.
The taste loop
Human-paced, and persistent across sessions.
- Observe. Every duel, star, keep/kill and edit claim appends to the observation log, as raw , never standardized. That is what lets the standardizer be re-fit later without invalidating history.
- Reweight, immediately. Each new observation folds into the existing posterior by importance sampling. Exact, , and it is what makes the next question respond to the last answer.
- Refit, occasionally. Full MCMC over the log: 10 000 post-warmup steps after 3 000 warmup, thinned to at most 500 retained draws.
The refit trigger is the interesting part. It is not "every duels": it fires when the reweighted posterior's effective sample size has degraded far enough that resampling was needed. See The posterior.
Where they meet
Acquisition picks what to show you. The proposal tilt carries back into the grammar.
The tilt is the part that makes this more than a scored search. The fitted structural coefficients reshape the categorical proposal weights the search draws new modules from:
with each multiplier clamped to so no module kind is ever starved or monopolized. Details and the shrinkage applied to are in Proposals.
So the loop is genuinely closed: your answers change what gets proposed, not only what scores well once proposed.
Why this is preferential Bayesian optimization
There is a latent objective (your utility), an expensive oracle (you), a cheap surrogate (the posterior), and a generator of candidates (the grammar prior plus MH). The acquisition step is where 's posterior uncertainty earns its keep: early sessions can ask informative questions (duels the model cannot rank), and a confident model can mostly serve things you will like.
Whether it is worth asking informative questions rather than random ones is an empirical question. See Acquisition.
The gate on all of it
auracle-session's closed-loop test runs the engine against a SyntheticUser
with known ground-truth , end to end through the real grammar →
render → vet → feature pipeline, and asserts that the learned taste ranks
genuinely preferred patches on top.
It is slow, and it is the only test that can fail when the loop is broken while every component is individually correct.