How to read this
What Auracle computes, in enough detail to disagree with.
This book is the technical companion to the User Guide. The guide tells you what the instrument does; this tells you how, with the math written out and pointers into the code that implements it.
It is organised as a pipeline, because that is what it is:
and a loop that closes over it: your answers condition , and reshapes how the next term is proposed.
Three commitments
Every number is sourced. Thresholds, dimensions, defaults and step counts are quoted from the code, with the constant named so you can check. Where a figure came out of a measurement, the measurement is named too.
Design and implementation are distinguished. Several things in Auracle are intended as one algorithm and currently implemented as a simpler one. The clearest case is refinement: the design is tempered sequential Monte Carlo, and what ships is a short local Metropolis–Hastings walk. Those pages say so in their first paragraph. See Refinement.
Known weaknesses are stated. Where a coefficient is unidentified, a variance inflation factor is uncomfortably high, or a memory spike is unfixable without forking a dependency, it is written down.
If you read four pages
- A typed PCFG over patch terms is the representation decision everything else follows from. Because the genome is a typed term rather than a parameter vector or a raw graph, all three levels of evolution (settings, connectivity, module set) live in one object, and every sample is valid by construction.
- Trace addresses are the naming scheme shared by panel knobs, hand edits, locks, live parameter handles and search proposals. Nothing else stays coherent without it.
- Utility as a max of experts explains why taste is a maximum over lenses rather than a mixture, and what that buys.
- The vetting gate explains why randomly composed DSP graphs are safe to put in front of a person.
Conventions
- Code references name the crate and the item:
auracle_features::vet::VetConfig. The API documentation has the generated rustdoc for all of them. - Math follows Notation. is a patch term, its feature vector, the taste parameters, the latent utility.
- Measured claims cite the harness that produced them, usually an example
binary such as
auracle-session/examples/search_health.rs, runnable from a checkout.
What lives elsewhere
- The generated API documentation is the rustdoc.
- Using the instrument is the User Guide.
- Working on Auracle — layout, the quality bar, the sharp edges, cutting a
release — is
CONTRIBUTING.md. - What changed when is
CHANGELOG.md.
Design decisions, rejected alternatives, the milestones, the open questions and
the directions nobody has raised yet are in this
book, under Design. They used to be a
DESIGN.md at the repo root, which made the reasoning and the maths it
justifies two documents that could disagree.
The two libraries underneath
Auracle is thin on top of two in-house libraries:
- fugue-evo does evolution as
Bayesian inference. Priors as probabilistic programs, typed
Metropolis–Hastings with automatic reversible jump, grammar-based genetic
programming, tempered SMC in trace space. Auracle's grammar is a
GenomePrior; its search is fugue-evo's inference machinery with a learned fitness plugged in. - quiver does modular synthesis. Arrow-style combinators, typed ports (Audio / V-Oct / Gate / CV), patch graphs, headless rendering, first-class WebAssembly. Auracle's genome is a term in quiver's combinator algebra; its "compiler" targets a quiver patch graph.
Where a guarantee comes from one of them, this book says so.
Notation
Fixed throughout. Where a symbol appears in the code under a different name, the code's name is given.
Objects
| Symbol | Is | In the code |
|---|---|---|
| A patch term — a tree in the typed grammar | PatchTree | |
A tree path, e.g. node/0/1 | path keys | |
| A trace — the execution record of the grammar program | fugue::Trace | |
| The feature vector of | Features::phi() | |
| Perceptual descriptors of the render | AudioFeatures | |
| Structural descriptors of the term | StructFeatures | |
| A standardized feature vector, | phi_std |
is always the concatenation , in that order. It is written rather than
throughout; the code's phi is this vector.
The taste model
| Symbol | Is | In the code |
|---|---|---|
| Number of style lenses () | TasteConfig::k_styles | |
| Feature dimension () | TasteConfig::n_features | |
| Lens 's weight vector | TasteSample::theta[k] | |
| All of them, | TasteSample::theta | |
| Latent utility of | utility_mix | |
| Lens 's utility, | utility(phi, k) | |
| Session 's keep/kill threshold | TasteSample::tau[s] | |
| Star cutpoint | TasteSample::cuts[j] | |
| Prior SD of one coordinate | TasteConfig::sigma_theta() | |
| Max-of--normals SD correction | MAX_NORMAL_SD | |
| Number of sessions in the log | FitSet::n_sessions() |
Search
| Symbol | Is | In the code |
|---|---|---|
| Prior probability of term | PatchGrammarPrior | |
| Boltzmann sharpness | SessionConfig::beta | |
| The target, | — | |
| Taste-tilt strength (a tilt of the prior; see Proposals) | SessionConfig::proposal_tilt | |
| The set of locked addresses | locked: HashSet<String> |
Conventions
- is the logistic function , never a standard deviation. Standard deviations are always subscripted () or written as .
- is natural. Log-losses are in nats.
- Indices are 0-based, matching the code, including cutpoint indices, which matters for reading the ordinal likelihood.
- Weights are always normalized unless stated: importance weights sum to one, recency weights are relative to the newest observation being .
- "Standardized" always means after the affine transform in Standardization. The taste model never sees raw ; the observation log never stores anything else.
KaTeX macros
Defined in www/reference/book.toml so a symbol cannot mean two things on two
pages:
| Macro | Renders |
|---|---|
\R | |
\E | |
\phivec | |
\thetak | |
\sig |
The crates
Core-library-first. Every frontend is a thin shell over the same engine.
crates/
auracle-grammar the genome: typed PCFG over quiver combinator terms,
trace codec, term → Patch compiler, structural edit ops,
rack description, presets
auracle-features phrase render → vet → LUFS-normalize → φ extraction
auracle-taste max-of-experts utility, three likelihoods, MCMC posterior,
standardization, portable profiles
auracle-session the two-loop engine: pool, acquisition, refinement,
lineage, calibration, persistence, migration
auracle-wasm WasmEngine (worker-side brain) + LivePoly (worklet-side
instrument)
apps/web the instrument — vanilla JS, no build step
Dependencies run strictly downward: grammar knows nothing of features,
features nothing of taste, taste nothing of the engine. session is the
only crate that sees all of them, and wasm is a binding surface with no logic
of its own.
auracle-grammar
The representation, and the crate everything else is built on.
| Module | Owns |
|---|---|
term | PatchTree, AudioNode, ModNode — the genome type. The Audio/Mod sort split is enforced by Rust's type system, so ill-sorted terms are unrepresentable |
prior | PatchGrammarPrior — the PCFG as a fugue program. Implements fugue-evo's GenomePrior |
genome | The canonical trace codec. This is the addressing scheme, and a round-trip property test keeps it from drifting |
compile | Term → quiver Patch, with live parameter handles. 4 500 lines; the largest single thing in the workspace |
mutate | Structural edit operations, and their validity gate |
edit | Single-site parameter writes by address |
diff | Human-readable diffs between two terms — what the lineage log prints |
describe | The rack description the panel draws from |
presets | The 62-patch hand-made library, in seven families |
genome's codec is the grammar's addressing. It is one scheme rather
than two kept in sync, which is what makes a knob turn, a lock and an MH
proposal refer to the same thing.
auracle-features
The measurement crate. One render serves three purposes: the vet report, the feature vector, and the audition buffer the user hears.
| Module | Owns |
|---|---|
phrase | PhraseSpec — the standard stimulus |
render | Deterministic headless rendering through quiver |
vet | The quarantine gate |
loudness | ITU-R BS.1770 K-weighting, gated integrated loudness, and the peak ceiling |
audio | — 18 perceptual descriptors |
structural | — 25 structural descriptors |
pipeline | The composition, in the one order that is safe |
cache | Render memoization and the persistent-cache namespace; what makes the MH walk affordable |
pipeline::featurize is the whole crate in forty lines, and the order it
composes them in matters; see
The vetting gate.
auracle-taste
The model. No knowledge of patches at all: it consumes standardized feature vectors and feedback events.
| Module | Owns |
|---|---|
model | TasteModel as a fugue program; TastePosterior and its summaries |
observe | Feedback, ObservationLog, FitSet — and the by-name projection that migrates old logs |
standardize | The affine transform, with runaway-column detection |
synthetic | SyntheticUser — the non-negotiable validation gate |
synthetic is not a test helper that happens to live in src/. It validates
the taste model against a simulated user with known ground truth: assert the
posterior concentrates on , and that acquisition regret shrinks. That
makes the core falsifiable headlessly.
auracle-session
The engine every frontend drives.
| Module | Owns |
|---|---|
engine | Engine — pool, log, posterior, refinement, workbench, lineage. 2 800 lines |
surrogate | The learned taste as a fugue-evo Fitness |
calib | Prequential forecast scoring and reliability diagrams |
map | The 2D projection behind the taste map |
naming | Generated patch and style names |
farm | Indexed draw seeding — what makes parallel filling reproducible |
migrate | Loading sessions written by older versions |
auracle-wasm
Two objects, on two threads, and the split matters:
WasmEngineis the wholeauracle-sessionengine, in a Web Worker. Pool filling, posterior fits, refinement, workbench edits. Nothing real-time.LivePolyis the instrument, in an AudioWorklet. It holds compiled copies of the current patch, via the samecompile()path evolution uses, limiter included.
So what you play is not a re-implementation of what was evolved; it is the same compiled artifact. See The web runtime.
apps/web
Vanilla JavaScript, no build step, no framework, no dependencies. Four files
carry it: main.js (UI and Web Audio), worker.js (the engine), farm.js (a
stateless render worker), live-audio.js (worklet assembly).
Its own architecture notes are in
apps/web/README.md;
the parts that constrain the engine are in The web runtime.
Foundations, from crates.io
quiver-dsp 0.2.0 | Modular DSP. Library name is quiver |
fugue-evo 0.3.1 | Evolution as inference. default-features = false — checkpoint/parallel do not compile on wasm32 |
fugue-ppl 0.2.1 | The probabilistic programming layer |
All three come from the registry. To hack on them alongside Auracle, add a
[patch.crates-io] block at the bottom of the workspace manifest.
Two build settings worth knowing, both in Cargo.toml:
- Release builds use
lto = "fat",codegen-units = 1,panic = "abort". Everything the user waits on is render-bound.panic = "abort"also drops unwinding tables from the wasm bundle. None of these can change float results; only--fast-math-style options could, and none is enabled. serde_jsonwithfloat_roundtrip. The observation log is the profile's source of truth and must reload bit-identically; serde_json's fast float parse can be off by one ULP.
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 taste 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 weights of the grammar the search draws new modules from — and, because the tilted grammar is installed as the prior, of the target it climbs (see Proposals):
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 and what the search counts as parsimonious, 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.
Trace addresses
The spine. Six subsystems refer to the genome, and they all use this one naming scheme.
A trace address names one probabilistic choice site in the grammar program. Every site in a term has one, and it is derived from the site's position in the tree rather than assigned:
| Address | Names |
|---|---|
node | The root audio node |
node/0, node/0/1 | Children, by index |
node/0/m | The modulation slot hanging off node/0 |
node/0/m/0 | A subterm of that modulation term |
node/0#cut | The cut parameter of the node at node/0 |
node/0/m#rate | The rate parameter of that modulation term |
amp#attack | The amplitude envelope's attack |
The pattern is <path>#<param> for parameters and <path> for structure.
Paths are /-separated child indices from the root; a /m segment enters a
modulation slot, and because every modulation key sits below a /m, the child
convention is reused there without ambiguity.
What shares it
| Subsystem | Uses an address to |
|---|---|
| Panel knobs | Identify what a knob writes |
| Hand edits | Write one site: set_param(addr, value) |
| Locks | Name the frozen set |
| Live parameter handles | Map a knob to an atomic in the running voices |
| MH proposals | Name the site a move touches |
| The lineage diff | Print what changed (node/0#cut 0.31→0.78) |
Six subsystems, one vocabulary. The alternative is three schemes that drift: a UI parameter id, a genome index and a DSP handle, mapped to each other. The drift surfaces as a knob that edits the wrong thing after a structural change.
Why it cannot drift
Because the canonical trace codec is the addressing scheme, not a
translation of it. auracle_grammar::genome encodes a PatchTree to a
fugue::Trace by walking the tree and emitting exactly the addresses the
grammar program samples at. The same walk decodes.
A round-trip property test pins it: encode a random term, decode it, and require the result be identical. If the codec and the grammar ever disagreed about what a site is called, that test fails.
Structure is encoded in its own choices
The reason a tree can live in a flat trace at all: the structure of an
execution is determined by the choices the execution makes. The value at
node#leaf decides whether node is a source or a processor, which decides
whether node/0 exists at all.
That is what lets fugue's generic trace machinery work unchanged: subtree regeneration, subtree-swap crossover, and reversible-jump Metropolis–Hastings all operate on traces without knowing anything about synthesizers. Auracle contributes a grammar; it does not contribute an inference algorithm.
Live parameter handles
When a term is compiled, each continuous parameter site yields a ParamHandle,
an atomic the audio thread reads. Turning a knob does two things:
- Writes the atomic, so the running voices change without a recompile.
- Writes the genome at the same address, so the edit is real rather than cosmetic.
Both, always. Writing only the atomic gives you a knob whose change disappears on the next patch swap; writing only the genome gives you a knob you have to recompile to hear.
Not every address has a live handle. Structural sites do not, and a few
parameters feed compile-time decisions. window.__aur.nonLiveAddrs in the web
app is the set that requires a recompile.
Locks, precisely
is a set of exact address strings, typically snapshotted from the UI. A proposal is rejected if it changes, deletes or creates any address in .
All three, and the third is the one that is easy to omit. Scanning only the current trace lets a birth at a locked address through while rejecting the death that would undo it. That is an asymmetric constraint region: it breaks detailed balance, makes the exactness argument false, and lets the chain drift into locked structure it can never leave.
One limit: a structural move can grow a brand-new address inside a locked module, present in neither trace, so it cannot be in . That case is symmetric (unmatched in both directions), so it costs nothing in detailed balance. A lock is a guarantee about addresses, not about subtrees.
Persisted UI state must be JS-owned
A rule from the web app, and it belongs here because it is about this scheme. UI state that persists must be held in JavaScript and never scraped from the DOM at save time. A phantom DOM slider (present but not the live control) once reset a value and poisoned an autosave with it.
The address scheme makes the genome authoritative; the rule keeps the interface from quietly disagreeing with it.
A typed PCFG over patch terms
The genome is a term in quiver's combinator algebra, generated by a probabilistic context-free grammar whose non-terminals are signal kinds.
The representation decision
The genome is a tree: a term in quiver's Layer-1 combinator algebra. It is not a raw patch graph and not a parameter vector, and the patch graph is the compilation target.
Everything follows from this. One representation covers what usually needs three:
| Level of evolution | In the term grammar |
|---|---|
| Node settings | Leaf parameter sites — an f64 or usize draw at each module node |
| Connectivity | Interior structure — chains, parallel branches, modulation attachments |
| Node set | Which productions fire — the module choice sites |
A parameter-vector genome cannot change topology. A raw-graph genome can, but most of its mutations produce invalid graphs, so it needs a repair step, which is a second, undocumented grammar. A typed term needs neither: every sampled term compiles to a valid, sound-making patch, because the type system constrains which productions can fire where.
The sorts are quiver's signal kinds: Audio, V/Oct, Gate, CV. Auracle's
PatchTree splits into AudioNode and ModNode, and the split is enforced by
Rust's own type system: an ill-sorted term is not rejected at runtime; it
cannot be constructed.
The grammar as a probabilistic program
PatchGrammarPrior is a fugue program. Every node at tree path emits real
probabilistic choices at path-keyed addresses:
| Site | Address | Distribution |
|---|---|---|
| source-vs-processor | <p>#leaf | , forced at max depth |
| source kind | <p>#src | , 6 kinds |
| processor kind | <p>#op | , 20 kinds |
| modulation kind | <p>/m#mod | , 9 kinds |
| CV-processor kind | <p>/m#modop | Uniform over ModOp::ALL |
| CV-combiner kind | <p>/m#pairop | Uniform over PairOp::ALL |
| discrete params | <p>#wave, #oct, #color, #fkind, #table, #dmode | Uniform categoricals |
| continuous params | <p>#cut, #res, #det, … |
The amplitude envelope is fixed at amp#attack … amp#release.
The three categorical orders (7 sources, 20 processors, 9 modulation kinds) are the persisted wire format, because the codec writes the chosen index into the trace. They are append-only.
Parsimony is the prior, not a penalty
Deeper terms pay more prior mass by construction: each additional level of
recursion multiplies in another #leaf Bernoulli that came out "processor",
and each processor node draws its own parameters. There is no size penalty term
anywhere.
Ad-hoc parsimony penalties are the norm in genetic programming and a persistent source of trouble: they need tuning, they interact badly with fitness scaling, and they leave the target distribution unwritten. Here the target is written down: , and is exactly the parsimony pressure.
Modulation is a recursive sort
A modulation input does not take "an LFO". It takes a modulation term, which can itself be built from modulation terms:
- Six leaves: LFO, envelope, random (sample-and-hold), envelope follower, Euclidean, step sequencer.
Opwraps one modulation term: quantize, slew, rectify, hold.Paircombines two.
The #mod order is None, Lfo, Env, Rand, Follow, Euclid, Op, Pair, Steps.
The step sequencer is a leaf that sits after the two branches, because the
order is append-only wire format and it arrived last. So "is this kind a leaf"
is a predicate (mod_kind_is_leaf), not an index range: the range it replaced
(kind < 6) would have switched the new leaf off at the depth bound along with
the branches, and a term forced to bottom out could never have drawn it.
The step sequencer's values are latent
Steps carries eleven continuous sites: #srate, #slen, #sslew, and one
per step, #s0 … #s7. Each step value is its own
draw and plays as ;
#slen decides how many of the eight play (2 to 8, seven equal bins of the
knob). This is the Mutable Instruments Marbles design, turned into a genome:
- One site, one step. An MH proposal that moves
#s3re-voices step four and nothing else, so evolution edits a pattern the way a hand does. - Hidden, not deleted. The steps past
#slenstay in the trace. A proposal that shortens the pattern and a later one that lengthens it give back the steps that were hidden instead of inventing new ones.
The module behind it is Auracle's own (auracle_grammar::steps::StepsCv)
rather than quiver's StepSequencer, whose values are internal state with no
ports: every one of the eleven sites is a port driven by a live knob, so a bar
drag in the rack is an atomic write, not a recompile. Its clock is free-running
(#srate is steps per second), and every audition hears it
that way; tempo sync is a live-instrument concern and is not in the genome.
With the dock's sync on, the live engine snaps each sequencer's rate to the
nearest division of the tempo in octaves (straight, triplet or dotted, a
quarter-step per beat up to eight) and drives every voice's clock from one
transport through a sync port the term never sees (<key>#~sync). The
transport restarts on the first key down or on MIDI start, and it counts steps,
not bars, so a five-step pattern keeps its polymeter against a four-beat arp.
So s&h rand → quantize → slew is a legal modulation term, and the rack draws
the whole chain. Subterms live at <p>/m/0 and <p>/m/1, the same child
convention the audio tree uses; it is unambiguous because every modulation key
sits below a /m.
Its parsimony pressure is max_mod_depth, and the renormalizations that
enforce it live in mod_weights_at: at maximum depth only leaves remain
available, and below a processor the "no modulation" option is removed so a
slot that must be filled is filled.
A modulation slot hangs off every module with somewhere to send it. The
exceptions are the ones without: Noise, whose only site is a colour switch,
and Mix / RingMod, whose two inputs are both audio and whose single knob is
the blend. Having two audio children is not itself an exception: the four
dynamics productions take two subterms and carry a slot as well.
The palette
Forty-three modules: 7 sources, 20 processors, 16 modulators.
sources Vco Supersaw NoiseGenerator Wavetable KarplusStrong FormantOsc
Silence
processors Mix Filter Fold Delay Chorus Reverb Distortion Bitcrush
Phaser RingMod Flanger Tremolo Vibrato Eq Granular Shift
Comp Duck Gate Vocoder
modulators Lfo Adsr SampleAndHold SlewLimiter EnvelopeFollower …
StepsCv (Auracle's own: a step sequencer whose values are ports)
Six processors are binary:
| Production | Second input | |
|---|---|---|
Mix, RingMod | Audio | Merges two chains into one |
Comp, Duck, Gate, Vocoder | Control | Real sidechaining, in a typed tree |
A compressor's sidechain is not an audio input, and the type system makes wiring it as one impossible.
What is not in the grammar
Feedback. Terms are acyclic: there are no feedback combinator productions. Modules with internal feedback (delay, chorus, reverb) are fine, and there are plenty of them.
This is a v1 constraint rather than a principle. A tamed feedback production, with a mandatory attenuator and limiter in the loop path, is the intended v2 grammar extension. Until then cycles are unrepresentable, which is why cable dragging in the UI does not offer them.
Strict validation as an oracle
Grammar output is compiled with quiver's ValidationMode::Strict in the test
suite. Because the grammar is typed, a SignalMismatch is by construction a
bug in our grammar, so Strict doubles as a property-test oracle: sample
terms, compile all of them, and any error fails the test with quiver's
actionable message.
Patches are wired in Warn mode, though, with an allowlist test pinning the
warning classes. Strict rejects two warning-class pairings the compiler
deliberately uses, the clearest being a constant bipolar Offset feeding a
unipolar knob. The allowlist test is what keeps "we know about these two" from
quietly becoming "we ignore all warnings".
Where this comes from
The design mirrors fugue-evo's ArithmeticGrammarPrior, with quiver signal
sorts in place of arithmetic types. That is deliberate: Auracle's genome gets
subtree mutation, subtree-swap crossover, reversible-jump MH and tempered SMC
from fugue-evo unchanged, because they operate on traces and this genome's
trace encoding is faithful.
Parameter sites and their domains
Every continuous knob in the genome is a draw from . The musical meaning is the compiler's job.
One domain, everywhere
pub const PARAM_DOMAIN: std::ops::Range<f64> = 0.0..1.0;
pub const PARAM_MAX: f64 = 1.0 - f64::EPSILON;
pub fn in_domain(v: f64) -> bool {
v.is_finite() && PARAM_DOMAIN.contains(&v)
}
Every continuous site is normalized to and the mapping to Hz, seconds,
dB or cents happens in the compiler. Half-open, because that is what
is: fugue's log_prob is at . The
domain used to be 0.0..=1.0, which made exactly 1.0 legal here and
impossible under the prior — a knob dragged to its stop, two shipped presets
and the default vibrato insert all had , so init_from
refused them and ⚡ evolve silently did nothing. Every clamp in the crate now
lands on PARAM_MAX, never on 1.0, and it is one f64::EPSILON below rather
than the next float down so that a JSON round trip cannot put it back on the
boundary. No mapping in the compiler can hear the difference. Three things fall
out of the shared domain:
- The prior is trivially correct. at every site, with no per-parameter range table to get wrong.
- A proposal cannot leave the domain. MH moves are in normalized space.
- The panel can read in musical units (
840 Hz,24 ms,−6.0 dB,+12 ¢) while the genome stays uniform. The knob and the number under it are two representations of the same site.
Note that in_domain requires finite: NaN compares false against every
bound, and an infinity is exactly the runaway the gate exists to stop.
Bounded by the mapping
Because the mapping is the compiler's, the musically dangerous regions are excluded by how is spent rather than by a downstream guard. Filter resonance maps to a range that stops short of self-oscillation; delay feedback stops short of 1; V/Oct maps into an audible band.
So the grammar cannot express the most degenerate settings at all, which leaves no pathological region for the search to keep sampling and be penalised for.
It is not a substitute for vetting, which catches pathology that arises from composition: a bounded resonant filter fed by a bounded distortion fed by a bounded fold can still scream.
Latent sites
Not every continuous site is heard. A step sequencer (ModNode::Steps, see
the grammar) always
carries eight step values #s0 … #s7, and #slen decides how many of them
play. The rest are latent: in the trace, in the domain, drawn from the
prior and moved by MH like any other site, but inert until a longer #slen
reveals them.
That is deliberate, and it costs nothing the search has to pay for. A proposal
on a latent step changes neither the sound nor , so the tempered
target accepts it as a neutral move (drift, not selection: nothing about taste
has acted on it yet), and the value it leaves behind is what a later #slen
proposal reveals. Shrinking a pattern never throws a step away, and growing it
again brings back the step that was there.
Discrete sites
Uniform categoricals, each with a named domain:
| Site | Domain |
|---|---|
#wave | Waveform: saw, square, triangle, sine |
#oct | Octave offset |
#color | Noise colour |
#fkind | Filter kind |
#table | Wavetable shape |
#dmode | Drive mode: soft, hard, tube |
Plus the structural categoricals (#src, #op, #mod, #modop, #pairop),
whose orders are the persisted wire format and therefore append-only.
Enumerating the sites
domain_violations() returns every out-of-domain continuous site as (address, value), in address order. It reads the trace, not the term:
self.to_trace().choices.iter().filter_map(|(a, c)| match c.value {
ChoiceValue::F64(v) if !in_domain(v) => Some((a.to_string(), v)),
_ => None,
})
The trace enumerates exactly the continuous sites, by construction, from the same walk the prior samples. A hand-written match over the productions would be a second table of "which fields are knobs", and the first module somebody forgot to add to it would be the one the next bad value escaped through.
This is the address scheme paying for itself: there is one enumeration of the genome's sites, and it is the one inference uses.
Repair, not refusal
clamp_domains() pulls every out-of-domain site back in and returns how many
it fixed. NaN goes to the domain's midpoint; anything else is clamped, with
1.0 and above landing on PARAM_MAX. It runs on every session load, which is
what mends a save written by a build that still let a knob rest on the stop.
The asymmetry with the size ceilings is deliberate:
| Violation | Response | Because |
|---|---|---|
| A knob outside | Repaired, exactly and locally | There is one right answer |
| A term over the module/depth ceilings | Refused | Fixing it means deciding which modules to delete |
Repair wins for parameters on product grounds: a saved session that already contains a bad value must not become an app the player cannot edit, load, or evolve their way out of. Corruption must not be load-bearing.
The sentinel incident
The gates above are not hypothetical. A shipped session contained amp.sustain = 1e30, an out-of-domain sentinel that had escaped into the genome and then
into the observation log.
What one bad cell did:
- The value rendered fine. The limiter bounds the output, so the audio was unremarkable and vetting passed it. The vet gate is a gate on the sound, not on the term.
- Its entered the observation log, with
amp_sustain. - The standardizer fit on that column produced a mean of and an SD of , which standardized every real patch in the pool to .
- The coordinate was dead. The model could never learn from it again, and the belief line still printed a contribution for it.
- The panel read
SUSTAIN 1200.0 dB, and the HELD tray printed1e+30.
The fixes are at three layers:
clamp_domainson load, which repairs the corruption that exists.FeaturizeError::OutOfDomain, which refuses to measure a term whose φ would be a lie, before spending the render. This is the gate that keeps the log clean; every row in the log came through it.- Runaway-column detection in the standardizer, so the next escape costs a coordinate's precision rather than the coordinate.
Layers 1 and 2 should make layer 3 unnecessary. It exists anyway, because the value got through everything that was supposed to stop it.
Budgets
Separately from domains, the search is bounded in size:
| Ceiling | Default | Because |
|---|---|---|
| Modules | 24 | the realtime voice |
| Term depth | 6 | the prior's max_depth (5) + 1 — the deepest term it can score |
| Modulation depth | 3 | the prior's max_mod_depth (2) + 1 |
Shown in the app as 8/24 modules · 4/6 depth · 1/3 mod depth. A hand-built
patch past a ceiling is refused, and one at a ceiling has no room to grow,
which is a common reason a generation reports "no proposal beat its parent".
See the validity gate for why the two depth
ceilings are derived from the prior rather than set above it.
Structural edits
Hand edits and search proposals walk the same lattice, which is what makes the workbench trustworthy.
The vocabulary
Because the genome is a typed tree, rewiring is a small closed set of operations that are type-safe by construction: an LFO can never end up in an audio slot, and a filter always has exactly one audio input.
| Op | Does |
|---|---|
Replace { key, kind } | Swap the node's kind. Subtrees are preserved where the sorts allow; replacing a source with a processor wraps the source |
Insert { key, kind } | Insert a processor into the wire between this node and its parent |
Delete { key } | Remove the node, splicing its primary input up to take its place |
SetMod { key, kind } | Set the modulation slot on an audio module. A source kind replaces the slot's term; a shaper wraps it |
SwapMix { key } | Swap the two audio inputs of a binary node |
ReplaceTree { key, node } | Install an explicit fragment, discarding what was there |
InsertTree { key, node } | Graft an explicit fragment into the wire; the old subtree becomes its primary input |
SetModTree { key, m } | Install an explicit modulation term wholesale |
Nodes are addressed by trace key: node,
node/0, node/0/1, node/0/m.
The *Tree variants exist for the wiring gestures: "plug this staged chain in
here". Callers park the displaced subtree client-side, which is what the HELD
tray is.
Wrap versus replace
The distinction shows up twice and is the same idea both times:
Replaceon a source with a processor kind wraps the source rather than deleting it, because a processor needs an input and the obvious one is what was already there.SetModwith a shaper kind wraps the existing modulation term rather than evicting it, which is what makess&h rand → quantize → slewa three-click build.
The socket in the UI says which of fill / replace / wrap it is about to do, so the choice is never implicit.
Hand edits and MH proposals are the same moves
These are the operations evolution's structural proposals make. There is no separate mutation vocabulary.
Consequences:
- Anything you can build by hand, the search can reach. Anything the search produces, you can edit.
- A structural edit cannot produce a term the search would consider invalid, because validity is one predicate.
⚡ evolve from thison a hand-built patch is not a special case.
Parameter edits
Separately, edit::set_param(tree, addr, value) writes one continuous or
discrete site by address. This is what a knob drag is: a one-site write, then a
re-render and re-vet before the result can be auditioned.
The validity gate
validate_tree is the predicate every edit result must satisfy, and it is what
the
structural-edit gate test exercises.
Hard ceilings on hand-built patches:
pub const MAX_SIZE: usize = 24; // modules
pub const MAX_DEPTH: usize = PRIOR_MAX_DEPTH + 1; // audio tree depth: 6
pub const MAX_MOD_DEPTH: usize = PRIOR_MAX_MOD_DEPTH + 1; // modulation nesting: 3
MAX_SIZE protects the realtime voice and the feature pipeline. The two depth
ceilings are derived from the prior's support, and that is a correction: they
used to be 9 and 4 against a prior whose max_depth is 5 and max_mod_depth is
2, on the reasoning that a person stacking modules by hand knows what they are
building and the ceiling only protects the voice. What that reasoning missed is
that the prior forces #leaf at max_depth and zeroes Op/Pair at
max_mod_depth, so the deepest term it can score has depth max_depth + 1.
A hand edit past that had , EvolutionChain::init_from
returned None, and ⚡ evolve on the patch did nothing and said nothing — the
very failure the grammar gives Silence non-zero weight to prevent. Now the
ceiling is the support, stated once in prior.rs and read from there.
A session saved under the old ceilings may hold a deeper tree. It still loads
and plays — no load path re-checks the ceilings, because corruption must not be
load-bearing — but refinement reports it as outside_support rather than
pretending to walk, and a structural edit that leaves it over the ceiling is
refused until one brings it under.
MAX_MOD_DEPTH stops well short of the audio ceiling for a concrete reason: a
Pair branches, so depth 3 is up to eight leaves on one cable, and each is
another level of the compiler's by-value recursion stacked on top of the audio
tree's. That is a stack-depth argument rather than an aesthetic one; see
the wasm stack note.
The gate test
The structural-edit suite is a gate rather than a set of unit assertions:
Apply every operation at every node of randomly generated trees, and require the result to stay compilable.
This catches the class of bug that unit tests miss: an operation that is
individually correct but produces an invalid term in combination with a
particular tree shape. The codebase leans on gates like this generally; the
preference is stated in
CONTRIBUTING.md:
prefer extending a gate over asserting implementation details.
Naming stability
NodeKind serializes as snake_case, and that string is also what
describe::RackModule::kind reports and what the frontend keys its palette
off.
RingMod is renamed by hand, because the derived spelling would be ring_mod
while the module is ringmod everywhere else, and one module with two
spellings is a defect waiting for a caller.
Node identity
Nodes carry a Uid assigned on the way into the pool. This is what makes the
rack's hand positions and locks survive a structural edit. Without them a node
is its position, so any structural change wipes the locks and destroys the
hand-build → pin → breed loop the editor exists to serve.
A node is a thing with an identity that has a position, not a position that has contents.
Compilation to a patch
Term → quiver `Patch`. One path, used by both the search and the live instrument.
auracle_grammar::compile is the largest single module in the workspace, and
its job is narrow: turn a PatchTree into a playable quiver patch graph, with
handles for every live parameter.
The mandatory output chain
Every compiled voice ends the same way, and none of it is optional:
Plus two external controls (pitch in V/Oct and gate in volts) fanned out to
every pitched source and every envelope.
No evolved patch can bypass the limiter or end up unplayable. That is safety layer 3, and it is enforced by the compiler emitting the chain, not by asking the grammar not to.
The tail is built once per channel, so a subtree that produces true stereo (reverb, chorus) keeps both tanks all the way to the output rather than having the right one discarded on the way to a mono sum.
Parameter mapping
The compiler owns the musical meaning of every normalized site, and the ranges are deliberately bounded away from pathology:
| Bound | |
|---|---|
| Filter resonance | max 0.85 |
| Delay feedback | max 0.7 |
So the grammar cannot express self-oscillating resonance or a runaway delay. This is the same argument as parameter domains, one layer down: excluding a region is better than generating it and rejecting it.
Two details worth knowing when reading the code:
- Some quiver inputs are gates, not amounts.
Adsr.shape,Vca.responseandLimiter.softare read at a 2.5 V threshold, so 5 V and 10 V do the same thing. The compiler uses named constantsGATE_TRUE = 5.0/GATE_FALSE = 0.0rather than bare numbers, because "5.0" at one of those ports does not mean what it looks like. - Filter keytracking is fixed at 0.5. quiver applies , so 0.5 moves the corner half an octave per octave played: enough that a patch still speaks two octaves above where it was dialled in, which is what the audition phrase's C5 stab measures.
The DC blocker, and makes_dc
The output chain includes a DC blocker, and the compiler decides whether it is needed by walking the term:
fn makes_dc(node: &AudioNode) -> bool {
match node {
AudioNode::Filter { kind, input, .. } =>
matches!(kind, FilterKind::Ladder) || makes_dc(input),
AudioNode::Distortion { mode, input, .. } =>
matches!(mode, DriveMode::Tube) || makes_dc(input),
AudioNode::Mix { a, b, .. } | AudioNode::RingMod { a, b, .. } =>
makes_dc(a) || makes_dc(b),
// sources produce none; dynamics inherit from their audio input
…
}
}
Two productions generate a DC offset (the ladder filter and tube-mode distortion), and it propagates up through anything downstream of them.
Without the blocker, a tube-drive patch measures 1–8% DC as a fraction of RMS. That is nowhere near the vet gate's 0.6 limit, which is the point worth recording: the vet gate was never what protected the feature extractor from that offset. The blocker was.
Validation mode
Patches are wired under ValidationMode::Warn, not Strict.
quiver's Strict rejects warning-class pairings, and two of them are idioms
this compiler leans on deliberately:
- a unipolar modulation envelope driving a bipolar FM input,
- the bipolar pitch
Offsetdriving V/Oct inputs.
The type discipline Strict would enforce is already guaranteed by
construction: the term's Audio/Mod sorts are Rust types, and the compiler
only emits known-good connection shapes.
Compile errors (invalid ports, cycles) remain hard failures. Accumulated warnings are returned for inspection, and a property test asserts they stay within the expected classes. That test is what stops "we know about these two" from drifting into "we ignore all warnings".
Separately, the grammar's output is compiled under Strict in the test
suite, where a SignalMismatch is by construction a bug in the grammar and
therefore a useful oracle. Two different modes for two different questions.
Live parameter handles
Compilation returns a ParamMap: address → ParamHandle, each wrapping an
AtomicF64 the audio thread reads.
This is what makes knob turns free. Turning a knob writes the atomic, so the running voices change on the next block with no recompile, and writes the genome at the same address. Both, always; see Trace addresses.
Structural changes do require a recompile, and so do the handful of parameters that feed compile-time decisions.
One compiler, two callers
- The search compiles a term to render and measure it.
LivePolycompiles the same term, through the same function, to play it: copies for voices, limiter included.
So what you hear under your fingers is the patch that was evolved, vetted and featurized. There is no separate "playback engine" that could disagree with the one the model learned from.
Cost
The compiler is recursive and builds by value: every level of
Compiler::build constructs quiver modules before moving them into the patch,
and some of those carry large inline buffers. A PitchShifter holds [f64; 4800], which is 38 KB, and a Granular holds more.
On a native main thread this is invisible. On wasm32, whose default stack is 1
MB, a dozen-module patch overflows it, and it does so as memory access out of bounds, nowhere near the flag that caused it. See
the stack size for the fix and why it lives in the
Makefile.
The standard phrase
Audio features are only comparable under an identical stimulus. This module owns that stimulus.
The spec
PhraseSpec::default() is four notes, ~5.05 seconds, 44 100 Hz, RNG seed
0xE05_F00D:
| # | Note | Gate on | Gate off | Chord | Reveals |
|---|---|---|---|---|---|
| 1 | C4 | 1.80 s | 0.20 s | — | Slow attacks; sub-Hz modulation over a register-constant sustain |
| 2 | C5 | 0.30 s | 0.15 s | — | Whether the patch speaks at all an octave up |
| 3 | C4 | 0.50 s | 0.20 s | +E4 | Intermodulation and mud when voices stack |
| 4 | C3 | 0.80 s | 1.10 s | — | Bass register, and the release / delay / reverb tail |
Pitches are V/Oct offsets from C4. The seed is installed into quiver's thread-local RNG before rendering, so noise and analog drift are bit-reproducible: a patch's features are the same every time it is measured.
tail_ratio is
measured in, which is why the low note is last.Why each segment
The original phrase was three short notes (0.6 s stab, 0.25 s stab, 0.8 s low note), and it was the loop's weakest link. It could not discriminate
- slow pads: a 2-second attack was silent for most of the stimulus,
- anything modulated below ~1 Hz: no register-constant segment long enough to hold a modulation cycle,
- anything above Eb4, its highest note,
- how a patch stacks polyphonically: it was strictly monophonic.
So the grammar could express patches the audition could never reveal, and the taste model was being asked to learn preferences over evidence that was not in . No amount of model improvement fixes that; it is a measurement problem.
That reasoning closed four holes and then stopped. It is still true of everything
the v2 phrase did not reach — velocity above all, since NoteSpan has no such
field and the live instrument responds to it. What the audition cannot
hear is the register of what is still outside
the stimulus, and this paragraph is the argument it is built on.
The v2 default covers each hole with the cheapest segment that reveals it:
- C4 held 1.8 s. The attack measurement window (onset → next onset) is now
2.0 s rather than 0.75 s, and the sustain is long enough that sub-Hz
modulation completes most of a cycle.
held_centroid_stdis measured here specifically, which is what makes it register-constant by construction. - C5 stab. One octave above the old ceiling. With the compiler's fixed 0.5
keytracking, this is where dark patches reveal whether they speak up high
(
high_ratio). - C4+E4 dyad. A second compiled voice, gate-synced with the main voice,
reveals intermodulation (
chord_flatness_delta). A dyad rather than a triad because render cost is per voice-second and pairwise intermodulation is the first-order phenomenon. - C3 with a 1.1 s release window, kept last. Bass register, and its position matters: the tail measurement is the final 300 ms, so putting this note last is what makes the tail see release length and reverb rather than a truncated chord decay.
Cost: about 2× the v1 render, measured. The dyad's second voice is the difference between wall seconds and rendered voice-seconds.
Chord voices
Note::chord carries additional simultaneous pitches, each rendered by its
own compiled voice, gate-synced with the main note.
Two behaviours worth knowing:
- Chord voices start cold at the note's onset, exactly how live voice allocation behaves, so the measurement matches what a player would hear.
- After the shared gate closes they keep ticking until their own output parks on silence. A truncated release tail is a broadband click, and a click would poison every spectral feature in the frame it lands in.
max_voices() reports the largest simultaneous count (2 for the default spec),
and the vet gate's peak ceiling scales with
it.
The :p2 stimulus tag
Every audio feature name carries a generation tag:
centroid_mean:p2 rms_std:p2 attack_s:p2 …
This is the migration mechanism, not a version comment.
A stimulus change changes what every audio value means, even when the formula
is untouched. A slow pad's rms_mean under a phrase that never lets it open is
a different quantity from the same field under one that does. The observation
log stores raw by name, and FitSet::build projects old logs
onto the current names on the rule same name ⇒ same coordinate.
So tagging the name with the stimulus generation means votes recorded under the v1 phrase:
- keep their structural coordinates, which are stimulus-independent;
- have their old-stimulus audio coordinates imputed as "no evidence" rather than mixed into a standardizer they were never commensurable with.
Bump the tag whenever PhraseSpec::default() changes audibly. Failing to bump
it is worse than a wrong number: it is old evidence presented as current
evidence.
What the phrase still does not reveal
Stated because the model cannot learn what the stimulus does not show:
- Velocity response. The phrase plays at one velocity.
- Fast passages. No segment tests how the patch behaves in a run.
- Long-term behaviour. Five seconds cannot reveal a 30-second evolving pad.
- Stereo width. The render is summed to mono for feature extraction, and there is no width coordinate in at all. The chorus module's spec card says so outright in the app.
The intended direction is per-style audition phrases (a discovered bass
style picks a bassline, a pad style picks a chord swell), which would make the
stimulus adaptive rather than fixed. That is a design note, not shipped code,
and the :p2 tag is the mechanism that would let it happen without
invalidating history.
Loudness normalization
Louder reliably wins A/B tests. Without normalization the model would learn "I like loud" and present it as a preference about timbre.
Every render is normalized to −18 LUFS (TARGET_LUFS) before audition
and before feature extraction. Unnormalized loudness would poison ,
and it would do so in a way that looks like a real result.
Why LUFS and not RMS
Because the confound is perceived loudness. K-weighting approximates the ear's sensitivity (a high-shelf boost above ~1.7 kHz plus a ~38 Hz highpass), and 400 ms gated blocks keep silence and release tails from dragging the measurement down. Plain RMS would under-measure a bright patch and over-measure a bass-heavy one, and then the "loudness" the model learned about would be a spectral preference in disguise.
The implementation follows ITU-R BS.1770 (auracle_features::loudness).
K-weighting
Two biquads in direct form 1, derived parametrically from the BS.1770 analog prototype by the RBJ bilinear transform, the same approach pyloudnorm takes, so any sample rate works and the coefficients match the spec's published 48 kHz values at 48 kHz.
Stage 1, the high shelf:
Stage 2, the highpass:
With , and , the shelf's coefficients are
and the highpass is the standard RBJ form. Deriving rather than tabulating is what makes the measurement correct at 44 100 Hz, which is the rate the phrase renders at.
Block loudness and the two gates
Blocks are 400 ms with 75% overlap. Each block's loudness is
where is the K-weighted signal. The dB offset is the spec's calibration constant.
Then two gates, in order:
- Absolute gate. Discard blocks with LUFS. If none survive,
the signal is silent and the function returns
None. - Relative gate. Compute the mean energy of the surviving blocks, and discard blocks more than 10 LU below it:
The integrated loudness is the same expression over the twice-gated set:
Note that gating averages in the energy domain, not the dB domain, which is why the implementation exponentiates each retained block loudness back before averaging rather than taking a mean of decibels.
The relative gate is what makes this robust for the phrase specifically: the phrase ends with 1.1 seconds of release tail by design, and a plain average would let that tail pull the measurement down and then be compensated for by a boost.
Applying the gain
let wanted_db = (target_lufs - lufs).min(MAX_GAIN_DB); // MAX_GAIN_DB = 30.0
let headroom_db = 20.0 * (PEAK_CEILING / peak_before).log10(); // PEAK_CEILING = 1.0
let gain_db = wanted_db.min(headroom_db);
let gain = 10f64.powf(gain_db / 20.0);
The boost is capped at +30 dB. A patch needing more than that is a vetting problem, not something to amplify, and vetting runs first, so in practice the cap is a backstop.
Loudness is a target; the peak is a limit
Matching integrated loudness says nothing about the peak, and crest factor spans tens of dB across this grammar — a pad and a pluck at the same LUFS are nowhere near the same peak. A pure loudness match therefore sends percussive patches over full scale, and it did. Measured over 150 vetted prior draws:
| before | after | |
|---|---|---|
| peak p50 | 0.623 | 0.623 |
| peak p90 / p99 / max | 1.061 / 2.098 / 4.063 | 1.000 / 1.000 / 1.000 |
| over full scale | 22 (15%) | 0 |
over 1.25 — where the app's master.gain = 0.8 clips | 11 (8%) | 0 |
| gave up gain | — | 22 (15%), mean 3.0 dB, worst 12.2 dB |
The two 22s are the same twenty-two patches, and the unmoved median is the check that this is a fault stop rather than a re-levelling of the pool.
This is not a matter of audio polish. Preference data is elicited on this exact buffer, so a clipped audition collects a vote about clipping rather than about the patch — precisely the confound loudness normalization exists to remove, one stage later and silent. The live voice was never exposed to it; its master limiter has always held a 0.98 ceiling. The offline path took the volt divisor and not the limiter.
A smaller gain, not a limiter. A scalar keeps render_playback
bit-identical by construction — the property its bit-identity test exists to
protect — and cannot change timbre at all. A limiter would reshape the waveform,
moving crest, flatness_mean and flux_mean as well as the RMS pair, and
would need a second copy of itself inside the replay path forever.
What it costs is on the record rather than hidden: the ~15% that reach the
ceiling audition below target, so loudness matching degrades exactly where
crest is highest. Quieter is a smaller bias on a preference judgment than
clipped. Features::peak_reduction_db carries the amount, so a surface can say
"pulled down 3 dB so it would not clip" instead of presenting a peak-limited
patch as merely quiet.
make norm-peak reproduces the table.
rms_mean and rms_std are the only audio coordinates that are not
scale-invariant, so the change carries the standing
revalidation. Paired 16-seed make climb:
+1.877 ± 0.362 → +2.457 ± 0.298 mean gain, paired difference
+0.579 ± 0.350 (1 se), 95% CI [−0.121, +1.280]. That crosses zero, so no
improvement is claimed — what the run establishes is that the change costs the
search nothing. Every seed now climbs (16/16 against 15/16) and the generation
curve stopped turning over.
The report carries lufs_before, gain_db and peak_reduction_db, all of
which survive into Features. They are diagnostics rather than model inputs:
they are not coordinates of , because "how quiet was this before we
fixed it" is exactly the information normalization exists to discard.
Where it sits in the pipeline
After vetting and before feature extraction:
Vetting inspects the raw render, deliberately: its thresholds are about the patch's real output level, and measuring them post-normalization would make the peak ceiling meaningless. See the order is the design.
The normalized buffer is also exactly what the user hears. One buffer serves the health check, the measurement and the playback, which is what makes "you never hear an unvetted patch" true by construction rather than by discipline.
Mono
The measurement is mono, and so is the buffer is computed from. A patch that produces true stereo keeps both channels through to the live output, because the compiler builds the tail per channel, but the measurement path sums.
So stereo width is invisible to the model. There is no width coordinate, so no amount of voting can teach a preference for it. The app says so on the chorus module's spec card, and this is why.
The vetting gate
No candidate is ever played live unvetted. This is what makes randomly composed DSP graphs safe to put in front of a person.
Evolution will generate pathological patches: screaming resonance, silent duds, NaN-poisoned state, astronomically high pitches. The gate is what makes that acceptable rather than dangerous.
What it measures
vet(samples, cfg) inspects the raw, pre-normalization render and returns
either a report or a quarantine reason.
pub struct VetReport {
pub peak: f64, // max |sample|
pub rms: f64, // whole-phrase RMS
pub dc_ratio: f64, // |mean| / rms
pub pinned_fraction: f64, // fraction within 2% of peak
}
Failures, checked in this order:
| Order | Failure | Condition |
|---|---|---|
| 1 | Silent | Empty buffer |
| 2 | NonFinite | Any sample is not finite |
| 3 | Silent | |
| 4 | Overlevel | |
| 5 | DcDominated |
pinned_fraction, the share of samples within 2% of the peak, is
informational only. It indicates heavy limiting, which is a character
rather than a fault; promoting it to a failure would quarantine an entire
timbre.
The thresholds
impl Default for VetConfig {
fn default() -> Self {
Self { rms_floor: 1e-4, peak_ceiling: 2.0, max_dc_ratio: 0.6 }
}
}
Deliberately lenient. The gate exists to catch pathology, not to encode taste; that is the model's job, and a gate that quietly enforces a preference corrupts the data it protects.
The polyphony-scaled ceiling
VetConfig::for_spec scales the peak ceiling with the phrase's polyphony:
where is max_voices(). The default 2.0 is one limiter-bounded voice (~1.5
peak in the ±1.0 float domain) plus overshoot headroom; gate-synced voices
legitimately sum toward × one voice.
Not scaling it would quarantine honest polyphony as runaway, and specifically the dyad segment, which exists to measure that summing. The measurement and the gate have to agree about what stacking is.
The thresholds were re-checked, and did not move
Worth recording, because a gate tuned before a whole family of modules existed is exactly the kind that starts quarantining a timbre.
When the drive modules arrived, the three thresholds were measured over the
full cross of {soft, hard, tube} × drive {0.3, 0.6, 0.85, 1.0} × {saw, square, supersaw}, plus a stacked fold → tube drive → resonant ladder chain:
| Measured | Against | Result |
|---|---|---|
| peak never exceeded 2.00 | ceiling 3.5 (at ) | Fine |
| never exceeded 0.0016 | limit 0.6 | Fine |
| rms stayed far above the floor | Fine — distortion raises level |
Peak is bounded by construction: quiver's shapers all normalize into the ±1 domain and rescale, so a drive module is bounded at ±5 V however hard it is pushed. Drive buys harmonics, not level.
The DC result is 0.0016 only because
compile::makes_dc puts a
blocker in front of every tube-mode patch. Without it the same renders measure
1–8%, still nowhere near 0.6. So this gate was never what protected the
feature extractor from that offset. A threshold a defect passes comfortably
is not a defence against it.
The one threshold that would have had to move, had the shaper not been bounded,
is peak_ceiling.
The order is the design
pipeline::featurize composes the stages, and the order matters:
// 1. Domain check — BEFORE the render
if let Some((site, value)) = tree.domain_violations().into_iter().next() {
return Err(FeaturizeError::OutOfDomain { site, value });
}
// 2. Render
let mut render = render_phrase(tree, spec)?;
// 3. Vet the RAW render
let report = vet(&render.samples, &VetConfig::for_spec(spec))?;
// 4. Normalize
let norm = normalize_to(&mut render.samples, render.sample_rate, TARGET_LUFS)…;
// 5. Extract φ
let audio = audio_features(&render);
let structural = struct_features(tree);
// 6. Non-finite check on the VECTOR
for (name, value) in Features::phi_names().iter().zip(features.phi()) {
if !value.is_finite() { return Err(FeaturizeError::NonFiniteFeature { … }); }
}
Four things about that order:
The domain check is first, before the render. A term with a knob outside
its range is not a candidate that happens to sound bad: it is a term whose
would be a lie, and the ~600 ms render is wasted on it either way.
This is the gate that keeps the observation log clean: every row in the log
came through here. It is also the gate that was missing when the 1e30
sentinel got in, because vetting is a gate on the sound and amp.sustain = 1e30 renders perfectly well.
Vetting is on the raw render. Its thresholds are about the patch's real output level; measuring peak after normalization would make the ceiling meaningless.
Normalization is before extraction. Otherwise loudness leaks into every amplitude-sensitive coordinate.
There is a second finiteness check, on the vector. It costs one pass over
forty-four doubles against a render that took most of a second, and it is the only
thing standing between a NaN out of a spectral descriptor and a posterior fit
that returns all-NaN . It is a different error from OutOfDomain and
names the coordinate rather than a genome site, because at that point the term
was legal and the measurement went wrong.
Quarantine is not just hiding
A failed candidate is never played and never shown, and it also scores
QUARANTINE_FITNESS = -50.0 in the search target.
That is safety layer 2: evolution learns to avoid the pathological region rather than repeatedly sampling it. Hiding alone would leave the search wasting its budget in a place it cannot see is bad.
The five layers, in one place
| Layer | Where | What |
|---|---|---|
| 0 | quiver | Denormals flushed at graph scatter; NaN-latch protection on stateful modules; soft-clipped filter state; cycle detection; non-finite module outputs zeroed at scatter so one module's NaN cannot poison another's state |
| 1 | auracle-features | This gate. Audition plays pre-rendered, vetted, normalized buffers — never a live unvetted patch |
| 2 | auracle-session | Quarantine → large negative fitness, so the search avoids the region |
| 3 | auracle-grammar | Mandatory … → Limiter → StereoOutput, and parameter ranges bounded away from pathology |
| 4 | tests | ValidationMode::Strict as a property-test oracle over grammar output |
Two upstream bugs
Both in quiver, both fixed there, and both worth knowing as the class of thing that lurks under randomly composed DSP:
- Q198. Oscillator phase accumulators latched NaN permanently on
non-finite pitch (
NaN − floor(NaN)), and thewhile phase >= 1.0wrap style used by Wavetable and FormantOsc spun the audio thread forever on an infinite increment (voct_to_hzoverflows at extreme V/Oct). An infinite loop on the audio thread is not a glitch; it is a dead tab. Fixed with a sharedwrap_phasethat recovers non-finite values. - Q199. Graph scatter now zeroes non-finite module outputs, so one module's NaN/Inf can never poison another module's recursive state through the routing buffers. Containment at the graph boundary; per-module input sanitization remains defence in depth.
Still open upstream, and non-blocking: voct_to_hz is unclamped. Q198
recovers from the overflow rather than preventing it, and a pitch clamp would
additionally tame the aliasing garbage that absurd-but-finite pitches produce.
φ_audio — perceptual descriptors
Eighteen dimensions, kept compact and put on axes a linear model can express a preference along.
Computed on Hann-windowed frames of the normalized mono render (2048 samples, 50% hop), plus a few time-domain and segment-local measurements. Every field is finite by construction, because vetting ran first.
The coordinates
| # | Name | Is |
|---|---|---|
| 0 | centroid_mean:p2 | Mean spectral centroid on the log axis — brightness |
| 1 | centroid_std:p2 | SD of that centroid over frames — timbral movement, in octaves |
| 2 | rolloff_mean:p2 | Mean 85% spectral rolloff, log axis |
| 3 | flatness_mean:p2 | Mean spectral flatness — 0 tonal … 1 noisy |
| 4 | flux_mean:p2 | Mean spectral flux — how fast the spectrum changes |
| 5 | zcr_mean:p2 | Zero-crossing rate as an equivalent frequency, log axis |
| 6 | rms_mean:p2 | Mean frame RMS |
| 7 | rms_std:p2 | SD of frame RMS — dynamics |
| 8 | crest:p2 | crest factor |
| 9 | attack_s:p2 | of the first note |
| 10 | tail_ratio:p2 | tail level relative to whole-phrase RMS |
| 11 | bass_fraction:p2 | Energy fraction below ~250 Hz |
| 12 | held_centroid_std:p2 | Centroid SD over the held note's gate-on span only |
| 13 | high_ratio:p2 | RMS of the highest note's span, relative to the held note's |
| 14 | chord_flatness_delta:p2 | Flatness over the chord note's span, minus the held note's |
| 15 | motion_slow:p2 | Held-note motion energy, 0.5–2 Hz — sweeps and breathing |
| 16 | motion_mid:p2 | Held-note motion energy, 2–8 Hz — pulsing and tremolo |
| 17 | motion_fast:p2 | Held-note motion energy, 8–30 Hz — flutter |
The :p2 suffix is the stimulus generation
tag, and it is the migration
mechanism rather than a comment.
Why these axes and not the obvious ones
The model downstream is linear in , so the axis a feature lives on decides what preferences are expressible at all.
Frequency features are logarithmic, not linear in Hz
Brightness and pitch perception are octave-based. On a linear-Hz axis normalized by Nyquist, moving a patch from 200 Hz to 400 Hz (a full octave, an enormous audible change) shifts the coordinate by 0.009, while 8 kHz → 16 kHz shifts it by 0.36.
A linear model in that coordinate cannot represent "I like my basses a shade brighter": the entire usable range is swallowed by the bright tail of the pool. The preference is not hard to learn, it is inexpressible.
So log_axis puts centroid, rolloff and ZCR on a shared octaves-above-20
Hz scale, normalized to at Nyquist:
20 Hz because below it frequency is not audible as pitch and the ratio scale stops meaning anything. Normalizing at Nyquist keeps the vector sample-rate agnostic.
Note that a zero-crossing rate is a frequency (two crossings per cycle), so it goes on the same axis:
where is the crossing fraction. Leaving it as a raw fraction would put a frequency-like quantity on a non-frequency axis beside three that are on one.
Heavy tails are logged
crest spans 1 to 40+; tail_ratio spans three orders of magnitude.
Standardizing either raw hands the model a coordinate whose z-score is
near-constant for most of the pool and for a handful of outliers: a
coordinate that separates nothing except the outliers.
The floor inside the tail log matters: a pluck fully decayed by the last 300 ms would otherwise send the log to , and "silent tail" and "very quiet tail" are the same judgement to a listener anyway.
The attack crossing is interpolated, not floored
Quantizing the 90%-of-peak crossing to the analysis-window index makes
attack_s exactly zero for every patch whose first window is already at
peak (most percussive patches), turning a continuous axis into a zero-inflated
spike.
So the envelope uses a fine grid (4 ms window, 1 ms hop) and interpolates linearly between the last sub-threshold hop and the first one over it:
The measurement window is onset → the second note's onset (2.0 s under the v2 phrase), and the ms inside the log keeps the fast end resolved instead of compressing every percussive patch into the same value.
Spectral definitions
Per frame, with magnitudes over and :
Centroid. The magnitude-weighted mean frequency, then log-axised:
Rolloff. The lowest bin at which cumulative power reaches 85% of the total.
Flatness. Geometric over arithmetic mean of the power spectrum, clamped to 1:
Flux. Normalized by the combined magnitude sum of both frames:
Dividing by the current frame alone is the obvious choice and it explodes: a loud frame decaying into near-silence gives an enormous flux for a change that is barely audible. The combined denominator keeps it in roughly .
Frames whose power is below contribute to none of the spectral means: a silent frame has no centroid, and averaging in a zero would drag brightness down in proportion to how much silence the phrase happens to contain.
Segment-local coordinates
The last three are measured over one note's gate-on span, and they exist because whole-phrase statistics conflate things a listener does not.
Roles are found by property, not position, which is what keeps them meaningful if the phrase changes:
- held: the first note.
- high: the highest note at least half an octave above the held one.
- chord: the first note with chord voices.
A phrase missing a role yields 0.0 for its features, which reads as "no evidence" rather than as a measurement.
held_centroid_std is the important one. centroid_std over the whole
phrase conflates note-to-note register jumps with genuine timbral motion: a
static patch played across two octaves has a large centroid_std. Restricted
to the held note's span the coordinate is register-constant by
construction, so it is the axis on which "a filter sweeping at 0.4 Hz" and "a
static patch" are different patches at all. It needs at least 3 frames in the
span, or it reports 0.0.
high_ratio = of the high note's span RMS over the held note's.
Does the patch speak in the upper register, or does its filter choke it?
chord_flatness_delta = mean flatness over the chord span minus the held
span. Intermodulation and mud when voices stack.
Motion bands
held_centroid_std says how much a held note moves. It cannot say how
fast. Measured on one saw-into-ladder patch under a ladder of cutoff
modulations (cargo run -p auracle-features --example motion_probe --release),
a 0.55 Hz sweep and a 13 Hz flutter score 0.098 and 0.094, and stepped random
motion scores like a 6 Hz LFO. A linear model on those coordinates cannot hold
"slow breathing, not fast wobble" — which is the first thing anyone says about
a texture.
Hearing sorts fluctuation by modulation rate: a filterbank over the envelope, not just its variance (Dau, Kollmeier & Kohlrausch 1997), and the band-wise modulation power of a sound is much of what makes it recognisable as a texture at all (McDermott & Simoncelli 2011). The three coordinates are that filterbank, cut to three bands.
Over the held span, starting once the note has arrived so the attack is not
read as motion — 250 ms after onset, or later if the level (smoothed over
≈ 46 ms) has not yet reached 97% of its peak — two trajectories are taken at a
256-sample hop (≈ 172 frames/s — the spectral features' own 43 frames/s would
fold the fast band): brightness
in octaves, and level
, where one unit
is 6 dB — one doubling, the same currency as an octave of brightness — and a
dip reads at most 60 dB deep, so one frame of digital silence in a chopped
sound cannot outweigh every audible wobble. The arrival rule matters for pads:
a 0.9 s swell measured from the fixed 250 ms alone read 4.3 octaves over the
floor in the slow band, because a ramp is curved in log level and detrending
leaves most of it. Each is linearly detrended (a ramp across
the span is drift, which held_centroid_std already carries), Hann-windowed
and transformed. With the detrended residual, its variance and
its modulation power spectrum, band gets the variance share
The result is a log standard deviation in octaves. The floor, , is a hundredth of an octave: a static tone reads it exactly in all three bands, so "still" is one value and not numerical noise. A phrase whose held span is shorter than 0.75 s reads the floor too.
Measured on the probe ladder, the band that reads highest follows the rate: 0.55 Hz lands in slow, 2.7 Hz in mid, 13 Hz in fast, and stepped random motion spreads across slow and mid as its spectrum says it should.
What it cannot say
It does not say whether motion is regular. Separating a periodic sweep from a random walk needs several cycles in the window, and the held span holds fewer than three cycles of anything in the slow band. Both candidate measures tried — the normalized autocorrelation peak and the harmonic share of the modulation spectrum — separate periodic from random cleanly at 2.7 Hz and above, and not at all below 1.5 Hz, which is exactly where evolving textures live. A coordinate that guesses there would be taught to the model as a measurement, so regularity waits for a stimulus with a longer held span.
Deliberately compact
Eighteen dimensions is a choice. The model is a mixture of linear experts, and interpretable axes are the point: "bright", "noisy", "slow attack", "long tail" are things the DIRECTIONS tab can name and a person can recognise in their own preferences.
A 128-dimensional MFCC bank would carry more information and would be unreadable, and would make the cold start dramatically worse: every dimension is posterior variance to pay down before the model says anything at all.
Known collinearity
Measured over 1200 prior draws (cargo run -p auracle-features --example pipeline_stats --release -- 1200), the variance inflation factors are mostly
comfortable, with one cluster that is not:
| Coordinate | VIF |
|---|---|
rolloff_mean | ≈ 18.4 |
zcr_mean | ≈ 10.4 |
centroid_mean | ≈ 5.9 |
That is the brightness cluster — three genuine measurements of one perceptual thing. It is left standing deliberately: dropping any of them discards real signal rather than redundancy, since they disagree in informative ways (a bright noisy patch and a bright tonal patch differ in ZCR-versus-centroid). The right fix is a shared or fused prior over the cluster, which is a modelling change rather than a feature change, and is not done.
For contrast, φ_struct had two exact linear
dependencies, which is a different and worse problem and was fixed by dropping
columns.
φ_struct — structural descriptors
Twenty-six dimensions, free to compute.
These cost nothing: no compile, no render, just a walk of the term. That is what makes the screening cascade possible: a structure-only surrogate prunes candidates before the expensive render path. They also capture taste axes audio features cannot fully separate ("likes supersaws", "likes deep modulated chains").
The coordinates
Nineteen family counts:
n_vco n_supersaw n_noise n_wavetable n_pluck n_formant n_silence
n_filter n_drive n_time n_mod_fx n_reverb n_dynamics
n_lfo n_env n_rand n_follow n_mod_shape n_mod_logic
Seven term-level numbers:
mod_density mod_depth_mean amp_attack amp_sustain amp_release
chain_balance frac_sidechained
Twenty-six in total, appended after the eighteen audio coordinates to give .
Families, not one column per module
StructFeatures keeps a raw counter per module kind internally (the Styles tab
and the auto-namer both want "two filters", not "two subtractive stages"), but
NAMES and to_vec collapse forty-three productions into nineteen family
counts.
Two reasons.
Nothing meaningful distinguishes them. n_fold, n_distortion and
n_bitcrush all answer "how much nonlinear colour". n_chorus, n_phaser,
n_flanger, n_tremolo and n_vibrato all answer "how much periodic
movement". A user who likes drive does not first decide which drive.
Per-kind columns arrive as near-indicator variables. The prior draws bitcrush at 2.5%, ring mod at 2% and granular at 1.5%, so those columns are zero in ~19 of every 20 pool members. A coefficient fitted on a column that is almost always zero is estimated from a handful of rows, and the Styles tab would render it beside coefficients fitted on hundreds, at the same visual weight.
Measured over 1200 draws, the extreme case: each of the four CV processors appears in under 4% of patches and each of the six combiners in under 1%. A column that is zero in 99 rows of every 100 is not a coefficient, it is a rounding error with a name in the UI.
Sixteen sparse columns would also cost sixteen dimensions of posterior variance for the cold start to pay down before the model says anything at all.
The step sequencer shows the rule applied to a newcomer. The prior draws it
into about 3% of slots, so a column of its own would be exactly the
near-indicator above. It joins n_rand, which has become the stepped CV
family (StructFeatures::n_stepped): to a listener an S&H and a step pattern
are the same gesture, a value that holds and jumps (or glides) on a clock, and
they differ only in whether the values were drawn once into the genome or
anew at every tick. It does not join the euclid in n_mod_logic, because a
euclid emits a gate and a step sequence emits a value. The column keeps the
name n_rand because stored observations carry φ names, and every row already
on disk predates the step sequencer, so its n_rand already is its
stepped-CV count. The panel labels the column "stepped mods".
What is deliberately not in φ
size, an exact identity
Every audio node increments exactly one raw counter, so
Including it makes the design matrix rank-deficient. The Gaussian prior
keeps the posterior proper, so nothing crashes and no test fails, but there is
an unidentified ridge along which the MH chain random-walks forever. That
wrecks mixing, splits each coefficient arbitrarily between size and the
counts (so the per-feature weights the Styles tab renders mean nothing
individually), and poisons the
taste→grammar proposal tilt, which reads exactly those
coefficients.
size − depth would be no better: still an exact linear combination of
coordinates already present.
The field is kept for display and naming. It just never reaches the model.
A second, subtler identity
Dropping size alone was not enough, and a VIF sweep caught it: VIF
on every column involved, which is what an exact dependency
looks like numerically.
A tree is a forest of source leaves joined by productions that each take some number of audio children, so the leaf count exceeds the total branch count by exactly one:
Silence joins that sum as a source leaf, because that is what it is: it has
no children, so it ends a branch exactly as a Vco does. Joining keeps this
one equation with one dropped column, and stays the
column dropped. Leaving it outside instead would make the identity exact for a
tree with no holes and slack for one with them — near-exact almost always,
which is a worse thing to carry than an exact dependency: an exact one is
unmistakable in a VIF sweep, and a near-exact one is a large number that looks
like a judgment call.
exactly, for every tree. This only became a general statement when the four dynamics productions arrived, each taking two audio subterms exactly as mix and ring mod do.
That is one equation, so exactly one column has to go, and dropping more would remove real dimensions rather than redundant ones. With both binary counts gone, could not tell a crossfade from a ring modulator at all, which are about as different as two nodes in this grammar get.
So n_mix leaves: it is the one determined by the others, and its proposal
tilt is recovered from the source coefficients in the engine's biased_prior.
The other five stay, but never as columns of their own: ring mod lives
inside n_drive, the vocoder inside n_filter, and comp/duck/gate inside
n_dynamics.
Why n_dynamics is still safe
Worth checking rather than assuming, because n_dynamics is exactly
and it is a retained
column, the only family whose members are all on the wrong side of the
identity.
It is safe because the identity needs each binary count separately.
n_ringmod is only ever visible summed with folds, distortions and
bitcrushers; n_vocoder only summed with filters and EQs. No linear
combination of the retained columns isolates either, so the equation cannot be
reconstructed. n_dynamics supplies three of the six binary terms and nothing
supplies the other three.
Confirmed empirically: on the 1200-draw sweep every structural coordinate came
back well under 10, with n_dynamics at 1.9.
depth, a weaker but real argument
VIF . Not exact, so the posterior stays proper, but a coefficient that unstable is not individually meaningful, and the Styles tab renders these per-feature weights as though they were. Dropped.
Health of the retained set
Every family coordinate came back under 4 on the 1200-draw sweep, the
highest being mod_depth_mean at 3.8. That is the reason the families exist;
forty separate module columns would not have managed it.
The three most recent additions: n_mod_shape 1.6, n_mod_logic 1.3,
mod_depth_mean 3.8, with mod_density rising from 2.7 to 4.1 as the one
visible cost of adding a second modulation-shape coordinate beside it.
Reproduce with:
cargo run -p auracle-features --example pipeline_stats --release -- 1200
The unit coordinates
Seven of the twenty-six are UNIT_NAMES, a subset of NAMES rather than
a reordering. Each is either a normalized genome site read straight through
(amp_attack, amp_sustain, amp_release, mod_depth_mean) or a ratio that
is already in (mod_density, chain_balance, frac_sidechained).
The distinction matters for display: these can be rendered as percentages honestly, whereas a family count cannot.
amp_sustain is also the coordinate the 1e30 sentinel killed; see
the sentinel.
Standardization
A Gaussian prior over only makes sense on a common scale. The fitting of that scale is a view of the data, not the data.
Raw scales vary wildly: counts 0–5, log-octave axes ~0–1, log crest 0–4. The standardizer is a per-dimension affine map
re-fit at every posterior fit, over the union of the observation log and the live pool.
It persists with the profile, always
is only meaningful relative to the standardization that produced it, so a taste profile carries both or neither. A log without its standardizer is a set of numbers whose units have been lost.
The log stores raw , which is what makes re-fitting safe: a re-fit standardizer simply re-expresses the same evidence on a scale that still matches where the pool actually is. Had the log stored z-scores, the scale would be frozen at whatever the pool looked like on the day each vote was cast.
The inverse map exists for exactly one reason: migrating logs written before raw-φ logging. A legacy log plus the standardizer it was written under is the raw data, just encoded.
Robustness: a fault detector, not a policy
Standardizer::fit is the plain moments unless a column is provably
runaway. On clean data it is bit-identical to the naive fit.
Per column:
- Drop non-finite cells (a column that is entirely non-finite falls back to , the reading of "no usable evidence on this axis").
- Compute the plain moments .
- Compute winsorized moments with the extreme 2% of each tail pulled in.
- Use the winsorized pair only if .
const WINSOR_TAIL: f64 = 0.02;
const WINSOR_MIN_ROWS: usize = 10;
const RUNAWAY_RATIO: f64 = 1e6;
Finally if , so a degenerate column standardizes everything to itself rather than dividing by nothing.
Why not just winsorize always
Because it was tried first and thrown out, and the measurement is why.
Clipping 2% of each tail unconditionally took a 16-seed search_health --climb
run from +1.877 ± 0.362 mean gain, climbing on 15 of 16 seeds, to +0.204
± 1.347 on 11 of 16 — with one seed at −18.2.
Trimming a real tail is not free. A data-hygiene fix that costs the search a standard deviation is not a fix. So the clip became a fault detector: plain moments unless the column is provably broken.
Why the threshold is
The first guess was 8×, on the reasoning that clean columns differ "by a factor of order one". The paired run said otherwise: 15 of 16 seeds came back bit-identical and the sixteenth went from +0.12 to −40.5.
So the threshold was measured.
cargo run -p auracle-features --example winsor_ratio --release -- 150
fits 150 clean 48-patch pools and reports the largest plain/winsorized
ratio per column. Over 6 000 column-fits the maximum is 14.6
(rms_std:p2), with chord_flatness_delta:p2 at 13.9 — and still climbing
with the sample, because a log-scale audio descriptor over a pool that happens
to contain one near-silent patch genuinely has a tail.
Meanwhile a single in a column whose real values live in gives a ratio near .
sits five orders above anything clean has been observed to produce and twenty-three below the fault, which is about as far from both edges as this quantity allows.
The tail size
ceil, not floor. The first version used floor and was inert exactly
where it was needed. The reference population is a 48-patch pool, and , so nothing was clipped at the size the app
actually fits at. The pre/post measurement came back bit-identical and said so.
Below 10 rows nothing is winsorized at all: with a handful of values the min and max are the spread, and pulling them in throws away the only information about it.
There is also a hi > lo guard, which keeps a legitimately rare column intact:
when 96% of rows are the same value (a module that appears in two patches out
of forty-eight) the tail is the column's only information, and clipping it
would flatten a real coordinate to nothing in the name of robustness.
Winsorizing rather than trimming
When it does fire, the extreme rows are pulled in, not dropped. The rows are not independent draws from a nuisance distribution: they are the patches the player actually met, and a real extreme patch is evidence about where the pool is. Winsorizing keeps its vote and takes away only its leverage on the units.
Two properties, both tested
A single escaped row cannot kill a column. Fifty values spread over plus one : unwinsorized, the outlier owns the mean and the scale, every real patch standardizes to the same place, and the column is dead. The model can never learn from an axis whose fifty honest values are separated by of a standard deviation. The test asserts moves by less than 0.05 and that the coordinate still separates two real patches by more than 3.
Clean columns come out bit-identical to the plain moments. The load-bearing
property, asserted with assert_eq! on floats, because "close enough" would
let the regression back in, over a heavy right tail, a near-constant column, a
bipolar one, a count with a legitimately extreme member, and a five-row column
below the floor entirely.
One implementation detail exists to protect that property: the column stays in row order and the quantiles come off a copy. Floating-point addition is not associative, so summing the sorted column would move the mean by a ULP on clean data, and the whole claim is that clean data comes out bit-identical.
Where this fits in the defence
The fault this detector exists for is fixed upstream of here: clamp_domains
on load, FeaturizeError::OutOfDomain before the render, the load-time repair.
This is the line that means the next escape costs a coordinate's precision
rather than the coordinate.
Layers above should make this unnecessary. It exists anyway, because in the sentinel incident the value got through everything that was supposed to stop it.
Utility as a max of experts
A candidate is as good as its best lens thinks it is. Two more obvious designs cannot represent a cross-island comparison at all.
The form
style lenses, each a linear functional on the standardized feature vector. Utility is the maximum, not a weighted mixture.
At this reduces exactly to Bayesian linear regression on , which is a useful property: the mixture is a strict generalization with no special-casing at the boundary.
pub fn utility_mix(&self, phi: &[f64]) -> f64 {
self.theta.iter()
.map(|t| dot(t, phi))
.fold(f64::NEG_INFINITY, f64::max)
}
Why a maximum
Taste is multi-modal. One person can love dark drones and bright plucks ("ambient-me" and "acid-me"), and those are not points on one axis. A single linear utility would average them into a preference for neither, and would then be confidently wrong about both.
The max form gives each island its own lens, and every judgement, including a duel across two islands, compares candidates on the shared scale . A dark drone and a bright pluck are both scored, each by whichever lens likes it most, and the comparison is well-formed.
Two rejected designs, and why
A per-session style latent
"One mood per session — sample which lens is active, then use it."
Fails because it cannot represent several islands inside a session. A user who auditions a pad, then a bass, then a pad in one sitting is not switching moods; they have two preferences at once. Whenever the session's latent is wrong for the current candidate, every observation in that session is scored by the wrong lens.
A per-observation marginalized lens
"Marginalize over which lens judges each observation."
Fails on a sharper point: it forces both duel items through the same lens, so a cross-island comparison is unrepresentable. There is no lens under which "the drone beats the pluck" is a sensible statement if the drone lives in lens 1 and the pluck in lens 2, and a duel between them is exactly the question the acquisition rule will ask.
This is not a theoretical objection. A synthetic bimodal user exposed it: the marginalized mixture failed to beat . Adding capacity made the model no better, which is the signature of capacity the likelihood cannot use.
What max-utility buys structurally
There are no discrete latent sites at all. No lens assignment to sample, no
categorical variables, no label-switching during inference to fight. Every
site in the model is an f64, which means fugue's generic adaptive single-site
MH applies unchanged — no custom kernel, no Rao-Blackwellization.
Label permutation is resolved post hoc instead, by
TastePosterior::aligned.
is an upper bound, not a claim
by default (SessionConfig::k_styles), and the fitted number of live
lenses grows with evidence.
Nothing enforces that; it falls out. A lens with no evidence to explain stays
near its prior, and style_share reports what fraction of the pool each lens
actually claims as its best. A lens claiming ≈0% is idle: the user's taste
has fewer islands than , and the app dims it rather than inventing a name
for it.
So is capacity, and the data decides how much gets used.
The prior, and the correction forces
The factor is standard: with for a standardized vector, it makes the prior utility of a candidate roughly unit-variance, so likelihood scales stay sane at any feature count.
The factor is the correction the max form forces, and it is easy to miss.
Under the prior each is marginally , so is the maximum of iid standard normals — whose standard deviation falls with :
| 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|
| 1.000 | 0.826 | 0.748 | 0.701 | 0.669 |
The mean shift cancels in duels (both sides shift equally) and is absorbed by and the cutpoints elsewhere. The variance shrinkage does not cancel. Left uncorrected, drops from 2.0 at to 0.90 at — so growing mid-session would quietly make the model less able to express a strong preference.
That is the opposite of what adding capacity should do, and it would present as "the model gets vaguer the longer I use it".
Dividing by restores invariance: is the same at every .
What the interface reads off this
| Quantity | Is |
|---|---|
utility_mix(z) | of over posterior draws — the glow and size on the taste map |
utility(z, k) | Lens 's opinion specifically |
best_style(z) | Which lens claims this candidate — the hue on the map |
responsibilities(z) | Posterior probability that each lens is the best one for this candidate |
style_share(pool) | Per-lens share of the pool, averaged over candidates |
prob_prefers(a, b) | — the bank row's percentage |
responsibilities is a posterior distribution over which lens applies, which
is strictly more informative than an argmax and is what lets a candidate sit
visibly between two islands.
One utility, three likelihoods
Every feedback mode conditions the same latent . They differ only in how an answer connects to it.
All three enter as a single factor carrying the total weighted
log-likelihood, so from fugue's point of view the model has one observation
node regardless of how many kinds of feedback the log contains.
Pairwise duels — Bradley–Terry
Feedback::Duel { a, b, chose_a } => {
let d = s.utility_mix(a) - s.utility_mix(b);
log_sigmoid(if *chose_a { d } else { -d })
}
The primary signal: best statistical properties, lowest cognitive load. It identifies up to an additive constant, which is exactly the right amount of information: a preference relation does not have an origin, and pretending otherwise is what makes absolute ratings drift.
Note that here is the mixture utility, so a duel across two islands is a comparison of "the drone's best lens's opinion" against "the pluck's best lens's opinion". That this is well-formed is the whole reason for the max form.
log_sigmoid is computed stably as with
. The naive
underflows for moderately confident predictions, which is
exactly where a fitted model spends its time.
Keep / kill — a thresholded Bernoulli
is a per-session latent, one per session in the log.
The app emits one side of it: a bank row's cut calls
record_keep(id, false) once its 7 s undo window closes (main.js, cutRow).
Nothing emits a keep; the triage surfaces that would are unbuilt.
"Feeling picky today" is therefore modelled rather than treated as noise. A session where you kill almost everything is read as a strict session (a high ) rather than a transformation of your taste. Without the per-session threshold, a strict day and a generous day would average into a meaningless global bar, and both days' data would be degraded by the other's.
One implementation subtlety: reweighting an old observation against a posterior fitted before that session existed finds no site, and contributes zero rather than guessing. No threshold site means no threshold evidence.
Star ratings — a cumulative logit
with 0-based cutpoints, (so the first term is 0) and (so the last is 1). Six categories by default, hence five cutpoints.
let upper = if k == n_cats - 1 { 1.0 } else { sigmoid(s.cuts[k] - u) };
let lower = if k == 0 { 0.0 } else { sigmoid(s.cuts[k - 1] - u) };
(upper - lower).max(1e-12).ln()
This treats ★★★ as "between two cutpoints" rather than as the number 3, which is the point. A rating is an ordinal judgement, and modelling it as a real number asserts that the gap between 1 and 2 stars equals the gap between 4 and 5, which no rater believes.
Because the cutpoints are fitted, the model absorbs scale drift: a user who becomes harsher moves the cutpoints, not . Without that, a change in rating habit would be indistinguishable from a change in taste.
Enforcing the ordering
Cutpoints must be increasing. Rather than constrain the sampler, the model samples unconstrained normals and transforms:
The exponential increments are positive by construction, so ordering holds for every draw. No rejection, no constrained kernel, and the generic single-site MH applies unchanged.
The constants place the prior sensibly: near (so a 0-star rating means "well below average"), and increments with a median of so the five cutpoints span a few units of utility.
Edit-beats-original
Not a fourth likelihood, but a duel with a provenance tag. Committing a
hand edit with my edit is better records Duel { a: edited, b: original, chose_a: true }.
The tag is what makes the claim auditable. Provenance distinguishes:
Duel | A dealt duel you listened to |
HeardEdit | An edit committed through a heard comparison |
SelfReport | An edit committed by ticking the box |
PerformOffer | A PERFORM offer heard against the sound being played, then taken (offer wins) or passed by asking for another (the played sound wins) |
These make the same claim in the log, and there is no reason to believe they are equally reliable. Calibration scores them separately, which is the only way to find out rather than assume.
Recency weighting
Every observation's log-likelihood is scaled before summing:
so the newest observation has weight 1 and one back has weight .
Default recency_half_life = Some(150.0); None disables forgetting entirely.
Taste is allowed to change, and a model weighting a vote from three sessions ago equally with one from a minute ago would fight the user when it did. The cost is stated plainly: this is not a proper Bayesian posterior over a stationary parameter, it is a tempered/discounted likelihood, chosen because stationarity is the wrong assumption about a person.
Implicit signals are out of scope
Listen time, replays, exports, hover duration: not recorded.
They are cheap to collect and easy to misread: a long listen can mean fascination or confusion, and the two have opposite signs. This version prefers less data that means what it says.
Site count, and what it costs
The model has
sample sites. With and 6 star categories, that is : **49
- ** at and 225 + at .
Single-site MH re-executes the whole program on every step, so every site
is reconstructed once per step. Two consequences, both measured by
auracle-taste/examples/fit_bench.rs:
- The fit is several times slower at the cap than at the first fit.
- The step budget is fixed, so a mature fit gets proportionally fewer sweeps per site than an early one. Growing makes the fit both slower and statistically thinner.
That is a real tension in the design, and it is why the
address table is hoisted out of the step loop.
Building addresses inline cost a format!, a re-allocation and a SipHash per
site per step, which measured as the bulk of a mature fit's wall time.
The posterior
MCMC when it can afford to, importance sampling when it cannot, and an honest signal for when the cheap path has run out.
The full fit
TasteModel::fit runs fugue's adaptive single-site Metropolis–Hastings:
| Default | |
|---|---|
| Post-warmup steps | 10 000 (mcmc_samples) |
| Warmup steps | 3 000 (mcmc_warmup) |
| Retained draws | ≤ 500, by thinning |
Every site is an f64 — there are
no discrete latents — so the generic
chain applies with no custom kernel. Adaptation tunes per-site proposal scales
during warmup.
Each MH step moves one site, so a useful way to budget is . At , sites over 10 000 steps is roughly 48 sweeps per site — which is thin, and is the tension noted in site count.
The result is uniformly weighted:
TastePosterior { cfg, samples, weights: vec![1.0 / n; n] }
The address table
SiteAddrs::new builds every site address once per fit, and the model
clones Address (an Arc refcount bump plus a cached hash) into each node.
Building addresses inline (addr!(format!("theta{k}"), i)) cost a format!
into a String, a re-allocation into Arc<str> and a SipHash of that string,
per site per step: roughly 3.7 M allocations per mature fit, and measurably
the bulk of the fit's wall time (examples/fit_bench.rs; the fit is steps × sites-shaped and the likelihood is only ~20% of it even at 100 observations).
The addresses are a pure function of , none of
which move during a fit. And they are produced by the same addr!
invocations as before, so traces, serialized posteriors and warm-start paths
see byte-identical addresses.
Thinning happens at the driver, not after it
97% of the chain is discarded, and it is discarded as it is produced.
That used to happen one line after the whole chain was built. adaptive_mcmc_chain
materialized every step — pushing (TasteSample, Trace) per iteration into a
Vec it returned by value — and only then did step_by(stride) keep every 20th.
At that is ~10 000 Trace clones of 225 + S BTreeMap entries each,
held live at once to retain 500: 303.1 MB peak RSS at the shipped budget,
scaling with n_samples, and a plausible mobile-Safari OOM on a 32-bit heap
rather than mere waste.
It could not be fixed here. The retention was inside fugue's chain driver, and
the pieces needed to reimplement that driver with identical RNG consumption
(single_site_mh_step, propose_and_score, SingleSiteProposalHandler) are
private or pub(crate); forking fugue's inference core into this crate would
have traded a memory spike for a correctness hazard on every upgrade.
So it was fixed upstream instead, as
fugue-ppl 0.2.2:
adaptive_mcmc_chain_thinned takes a stride and pushes only when
i % thin == 0.
| peak RSS | mature-fit checksum | |
|---|---|---|
| before | 303.1 MB | 07d204764b58c88b |
| after | 18.2 MB | 07d204764b58c88b |
16.7× less peak memory for bit-identical draws — the unchanged checksum is
the point of that table rather than a footnote to it. thin gates the push and
nothing else: every transition still runs, so the RNG is consumed in the same
order and quantity, and is exactly
what step_by kept. fit_bench's per-fit checksum is the Auracle-side witness;
fugue's thinning_retains_exactly_the_draws_step_by_would is the upstream one.
What stays resident is the 500 draws the posterior actually keeps, so the peak
no longer scales with mcmc_samples at all — the budget is now free to be
chosen on the recovery tables rather than against a memory ceiling.
Between fits: sequential importance sampling
A full fit costs seconds and cannot run after every vote. So each new observation is folded into the existing draws by reweighting:
let m = ll.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mut w: Vec<f64> = (0..n).map(|i| self.weight(i) * (ll[i] - m).exp()).collect();
This is exact (the weighted draws target the updated posterior) and it costs . It is what makes each duel respond to the one before it; without it the acquisition rule reads a frozen posterior and re-asks the same question until the next full fit.
The max-shift before exponentiating is the usual guard. Log-likelihoods here are bounded above by 0, so it is not strictly needed for duels, but it keeps mixed modalities safe.
Effective sample size
Equals the draw count for uniform weights, and collapses toward 1 as weights concentrate.
Importance weights degenerate, and ESS says so rather than letting the posterior quietly become one point wearing 500 hats. It is the trigger for paying for a real refit.
Systematic resampling
When weights have concentrated far enough, resampled() draws the weighted set
back to a uniformly weighted one of the same size.
The trade: resampling produces duplicate draws, so the sample is impoverished but still spans the posterior's support, and ESS on the fresh uniform weights no longer claims more information than is there. Left unresampled, almost all the mass sits on one draw, and a "posterior" of one point tells the acquisition function it is certain when it is merely exhausted.
It is a stopgap between full refits, not a substitute for one.
Deterministic (systematic, offset ) rather than multinomial, because every other stochastic step in the engine is seeded and reproducible and this one has no reason not to be.
The refit trigger
pub fn needs_refit(&self) -> bool {
match &self.posterior {
Some(_) => self.resamples_since_fit > 0,
None => !self.log.is_empty(),
}
}
So the condition is not "every duels": it is "we have had to resample at least once since the last real fit", i.e. the cheap path has provably run out of road. The app surfaces this as the teaching meter and the wordmark's listening lamp; a refit happens at most every six duels, and only when this says so.
Label alignment
Mixture posteriors are permutation-symmetric in the style labels (label switching), so per-style summaries are meaningless on a raw posterior.
aligned() resolves it post hoc, in two passes:
- Relabel every sample to best match a reference (the last sample), maximizing total cosine similarity across lenses.
- Recompute the mean of the pass-1 result and relabel against that.
Alignment is exhaustive over permutations, which is fine because and . No-op at .
Call it before theta_mean, theta_std, style_share or anything else
per-style. Aggregate quantities (utility_mix, prob_prefers) are
permutation-invariant and do not need it.
What the summaries are
All weighted by the importance weights:
theta_mean(k) | |
theta_std(k) | Per-dimension posterior SD — the whiskers in DIRECTIONS |
utility_mix(z) | of — glow and size on the map |
responsibilities(z) | |
style_share(Z) | responsibilities averaged over candidates |
prob_prefers(a,b) |
prob_prefers marginalizes and the weights and the
per-candidate lens choice, which is why it is the right thing to show on a bank
row: it is a predictive probability, not a point estimate's opinion.
Serialization
TastePosterior serializes to JSON, and weights carries #[serde(default)]
so older persisted posteriors, written before reweighting existed, deserialize
to empty and are read as uniform.
The observation log remains the source of truth. A posterior snapshot is a cache: it can always be recomputed from the log plus its standardizer, and that is exactly what a profile stores.
Calibration
Every duel is forecast before it is answered. This page is how those forecasts are scored, and why the obvious metric would have lied.
Prequential by construction
record_duel scores the posterior's and only then
appends the observation. So every forecast is an out-of-sample, one-step-ahead
prediction: the model has never seen the answer it is being scored on.
Why not accuracy
A running count of outcomes is accuracy, and accuracy is not a proper scoring rule. Two failures, and the second is fatal here:
It cannot see sharpness. A model that says 0.51 every time and is right 51% of the time scores identically to one that says 0.99 and is right 51% of the time. The second is wildly overconfident and accuracy cannot tell you.
It is pinned near 50% by the acquisition rule. An information-seeking rule deliberately picks pairs near , because those are the questions worth asking. So the hit rate sits near chance by construction: a perfectly calibrated model looks like a coin flip, and the user concludes it is not learning.
The second point is what makes accuracy harmful rather than merely crude: it penalizes the search for doing its job.
hit_rate is still computed and shown, only so the interface can display
how misleading it is next to the real number.
Brier score and skill
where is the probability the model gave to the option the user actually picked. Lower is better; is what always saying 0.5 scores.
Reported as skill against that baseline:
| skill | Means |
|---|---|
| No better than a coin flip | |
| Perfect and certain | |
| Worse than a coin |
Brier is proper and bounded, and it moves as sharpness improves rather than only as accuracy does, which is the property accuracy lacked.
Log-loss, and what it may not be compared across
Baseline .
Comparable across time for one acquisition rule. Not comparable across acquisition rules: an information-seeking rule serves duels near , which carry the highest log-loss by construction. Comparing two rules on their own self-chosen question sets would score the willingness to ask hard questions as a failure.
check_log_loss is the version for that comparison. See below.
The selection-bias fix
The acquisition function chooses which duels get scored, which means overall skill is measured on a question set the model helped select. That is circular.
So a fraction of duels are drawn uniformly at random and flagged
Forecast::random_check. The app marks them ◇ unbiased probe, and
calibration restricted to those is unbiased:
| Field | Is |
|---|---|
check_n | Number of random-probe forecasts |
check_skill | Brier skill on them — the number without an asterisk |
check_log_loss | Log-loss on them — the only log-loss comparable across rules |
It costs a small share of the query budget and it is the only number here that means what it says unqualified.
The shipped default acquisition is uniform random pairing, so every duel is already
an unbiased sample and check_skill equals overall skill. The probe machinery exists
for the BALD rule, where the distinction is real, and it is one of the reasons
uniform pairing was chosen. See Acquisition.
The reliability diagram
Five buckets over (N_BINS = 5, the most a small session
can fill without every bucket being noise). Each bucket reports:
predicted | Mean forecast in the bucket — the model's claim |
observed | Observed frequency of "A won" — the evidence |
n | How many forecasts landed here |
Plotted, the diagonal is the claim and the dots are the reality. This is the display that makes calibration legible: a single number cannot distinguish "overconfident at the top end" from "underconfident in the middle", and the shape of the failure is what tells you what to do about it.
The app draws a whisker per bucket for how much a bucket that size could wobble by chance, so a dot off the diagonal with a whisker crossing it is not yet evidence of anything.
By provenance
The same scores, split by how the answer was collected:
pub struct ProvenanceScore {
pub provenance: String, // "duel" | "heard_edit" | "self_report" | "perform_offer"
pub n: usize,
pub brier: f64,
pub log_loss: f64,
pub skill: f64,
}
The comparison this exists for: a hand edit committed through a heard duel and one committed by ticking my edit is better make the same claim in the log, and there is no reason to believe they are equally reliable. Scoring them against forecasts the model made before either answer arrived is the only way to find out which, and it costs one tag.
Empty streams are omitted, so a session that has never committed a hand edit carries exactly one row.
Interpreting it
| Shape | Reading |
|---|---|
| Skill ≈ 0, small | Too early. Correct and expected |
| Skill < 0 with real | Worse than chance — either overfitting a coincidental coordinate, or genuinely inconsistent answers |
| Dots below the diagonal on the right | Overconfident: when it says 80% it is right less often |
| Dots above on the left | Underconfident |
| Skill stuck near 0 with large | The preference is probably not in the feature space |
The user-facing version of this table is in Reading what it learned.
Why the number can look bad
Committing to a forecast before each answer and then reporting the error against a proper scoring rule means the model can publicly fail, and early on it does.
That is what makes the number worth reading later, and it is why the app shows "not beating a coin flip yet" rather than hiding the metric until it flatters.
The Boltzmann target
One distribution, two factors: the grammar supplies parsimony, the learned taste supplies direction, and is the single dial between them.
The target
This is fugue-evo's EvolutionModel with the learned utility plugged in as
fitness, so the whole thing becomes an ordinary probabilistic program and
typed-MH / SMC drivers apply unchanged.
What each factor does
is the parsimony pressure. It is the prior probability of the term under the typed PCFG, not a penalty term. Deeper terms pay more prior mass by construction, because each extra level multiplies in another Bernoulli that came out "processor" plus that node's own parameter draws.
Ad-hoc size penalties in genetic programming need tuning, interact badly with fitness scaling, and leave the target distribution unwritten. Here the target is written down, and the parsimony term is a probability rather than a hyperparameter.
is the direction. The expectation is over the posterior, so the search climbs the model's mean belief and is not seduced by a single confident-looking draw.
SessionConfig::beta, default 2.0.
| Behaviour | |
|---|---|
| Browse the prior. The taste model is ignored | |
| Shipped default | |
| large | Optimizer mode — "give me your best guess at my perfect patch" |
One dial for conservatism, which is the practical payoff of writing the target down: there is no explore/exploit schedule to tune, no diversity term, no niching parameter. Tempering the same target is also how tempered SMC would work if it were wired up.
Fitness through the surrogate
SurrogateFitness is the bridge:
impl Fitness for SurrogateFitness {
fn evaluate(&self, genome: &PatchTree) -> f64 {
match featurize_memo(genome, &self.phrase, &self.memo, false) {
Ok((cf, _)) => {
let phi = self.standardizer.transform(&cf.features.phi());
self.posterior.utility_mix(&phi).0
}
Err(_) => QUARANTINE_FITNESS,
}
}
}
Three things in nine lines:
Quarantine is a fitness, not just a filter. QUARANTINE_FITNESS = -50.0,
so a pathological candidate contributes a large negative factor to the target
and the search learns to avoid the region rather than repeatedly sampling
it. That is
safety layer 2; hiding alone would
leave the search wasting budget somewhere it cannot see is bad.
The standardizer must be the one the observations were made under. is meaningless against any other scaling; see Standardization.
want_audio: false. The surrogate only ever wants ; nothing in a
refinement generation is played. Asking for samples would undo the memo: a miss
would convert 141k f64s it then drops, and a hit would copy a ~565 KB buffer
out of the audio tier. Twice per MH step, ~96 times per seed, that is tens of
megabytes of churn for a value discarded on the next line.
Why the render memo matters
It is what makes the walk affordable at all.
adaptive_single_site_mh executes the model twice per step: once to
re-score the current trace (bit-identically the tree the previous step
accepted, and therefore already featurized) and once for the proposal. Without
a memo, one render in two is a recomputation of a number the walk already
has.
At ~600 ms per render, a 40-step walk from each of 10 seeds is 800 renders without the memo and 400 with it. That is the difference between a generation taking half a minute and taking a minute, per generation, forever.
What is not sampled from this
What ships is not a sample from . Refinement runs a short adaptive single-site MH walk warm-started from each of the best pool members and keeps the final state: local hill-climbing on that target, which is what a candidate pool needs, rather than a draw from it.
Tempered SMC with the crossover population kernel remains the design. The distinction is in Refinement.
Proposals, and the taste tilt
The loop closes here: what the model learns reshapes the grammar the search walks — what it proposes, and, because the tilted grammar is installed as the prior, what it scores against too.
Moves
Refinement uses fugue's adaptive single-site MH over the trace, so the move set is whatever the trace machinery provides:
- Parameter moves perturb one continuous or discrete site.
- Structural moves regenerate a subtree, which changes the set of sites and is therefore a reversible-jump move. fugue handles the Jacobian bookkeeping; Auracle does not implement it.
The structural moves are the same lattice as hand edits. One vocabulary, two callers.
The tilt
Once a posterior exists, the grammar's categorical weights are reshaped by what it has learned:
then renormalized. SessionConfig::proposal_tilt is , default 0.6.
pub fn tilt_weights(base: &[f64], tilts: &[f64], eta: f64) -> Vec<f64> {
let mut out: Vec<f64> = base.iter().zip(tilts)
.map(|(w, t)| w * (eta * t).exp().clamp(0.25, 4.0))
.collect();
let sum: f64 = out.iter().sum();
if sum > 0.0 { for w in &mut out { *w /= sum; } }
out
}
The function is pure, which is why the taste→grammar mapping is testable without an MCMC fit. That matters for a mapping this easy to get subtly wrong.
The clamp
bounds every multiplier, so no module kind is ever starved or monopolized.
Without it a confidently-fitted coefficient could drive a kind's proposal weight to effectively zero, and the search would stop being able to discover that it was wrong about that kind. A prior that has been argued out of considering an option cannot be argued back in by evidence it can no longer generate.
Where comes from
biased_prior builds the tilt vector from the posterior, in three steps.
1. Blend the lenses by their pool share.
Share-weighted rather than uniform, so an idle lens (one claiming ≈0% of the pool) contributes ≈nothing to how the search proposes. Uniform weighting would let a lens with no evidence steer the search as hard as one with plenty.
2. Shrink each coefficient by its own uncertainty.
| Regime | Factor |
|---|---|
| \theta | |
| \theta | |
| \theta |
Same shape as a signal-to-noise weighting, and chosen over a hard significance cut for a specifically musical reason: a cut makes the proposal distribution jump discontinuously as evidence accumulates, and users hear that as the instrument changing its mind. A smooth ramp is a model getting more opinionated; a threshold crossing is a different instrument arriving mid-session.
3. Map coordinates to categorical slots. The source-kind tilts read
n_vco, n_supersaw, n_noise, n_wavetable, n_pluck, n_formant
directly; processor and modulation tilts read their family coordinates.
The n_mix reconstruction
n_mix is not a column of :
it was dropped to break an exact linear dependency.
But the search still needs some tilt for the mix production, and
biased_prior recovers it from the source coefficients. That is legitimate
precisely because of the identity that forced the drop: n_mix is determined
by the other counts, so information about it is present in what remains. The
dependency that made the column unusable as a regressor is what makes it
recoverable as a tilt.
Why tilt rather than only score
A scored-only search is limited by what it happens to generate. If the prior draws bitcrush into 2.5% of terms, then no matter how much the model likes bitcrush, only 2.5% of proposals will contain one and the search has to wait for luck.
Tilting the grammar means the search looks where the model expects to find things. Combined with the clamp, it is a change of emphasis rather than a change of support: every kind stays reachable, and the ones the model believes in get proposed more often.
This page used to say that tilting changes the kernel, not the target, and that the
stationary distribution is unchanged. That was false, and the September 2026 audit
(AU-G3) caught it. biased_prior builds the tilted grammar and installs it as the
prior of the EvolutionModel; fugue-evo's target is prior.model() + factor(β·f),
and fugue's categorical proposal is a resample from that same prior, so the Hastings terms
cancel and the chain is a correct MH sampler for
which is a different target from . The seed is scored under the same tilted
prior, RefineKeep::Best ranks under it, and the parsimony mass the walk climbs is the
tilted one. Nothing about that is unsound — MH is exact for — but "what the search
is climbing" includes the tilt.
A true proposal tilt, one that leaves alone, would need a custom site proposal
carrying its own Hastings correction; fugue 0.2.2 offers only PriorResample for usize
sites, so it is not available without an upstream hook. Because refinement
hill-climbs rather than samples, the practical effect is the one
intended — the climb finds the kinds the listener likes sooner — and the field keeps its
name (SessionConfig::proposal_tilt) since the app and the harness both set it. What
changed is the claim, not the code.
Structural taste, specifically
Note that the tilt reads the structural coefficients. That is a deliberate
asymmetry: coordinates map onto grammar productions
more or less directly (n_filter ↔ the filter production), whereas an audio
coefficient like centroid_mean has no single production to point at.
Brightness is a property of the composition, not of a module.
So the audio half of influences the search only through the fitness
factor, and the structural half through both the fitness factor and the tilted
prior. Turning
centroid_mean into a proposal tilt would require a model of which productions
raise brightness, which is a model nobody has fitted.
Locks as conditional refinement
Locking is exact rather than heuristic: Metropolis-within-Gibbs on the conditional posterior. The argument depends on one detail that is easy to omit.
The claim
Let be a set of trace addresses. Refinement with locked samples from
That is the target distribution conditioned on the locked sites holding their current values. Not "mostly avoids changing them"; conditioned on them.
That is exactly Metropolis-within-Gibbs: a valid MCMC scheme in which a subset of coordinates is held fixed and the remainder is updated by MH steps that respect the constraint.
The implementation
pub fn violates_locks(prev: &Trace, next: &Trace, locked: &HashSet<String>) -> bool
A proposal is rejected if it changes, deletes, or creates any address in . Rejection happens outside the kernel: the move is simply not taken.
Why all three, and why both directions
The third, creates, is the one that gets omitted, and omitting it breaks the proof.
Scanning only prev catches changes and deletions. But a birth at a locked
address would be allowed through, while the death that would undo it is
rejected. The constraint region is then asymmetric:
which violates detailed balance. Concretely, the chain drifts into locked structure it can never leave, so a user who locked a module would watch the search grow new sites inside it and then be unable to remove them.
Checking both traces makes the constraint region symmetric, and symmetry is what the Metropolis-within-Gibbs argument needs.
The honest limit
A lock is a set of exact address strings, typically snapshotted from the UI. Every address in it is frozen, in both directions, and that is exact.
It is not the same as freezing a module. A structural move can grow a brand-new address inside a locked module — one that was in neither trace when the set was taken, so it cannot be in the set. That case is not caught.
It costs nothing in correctness: the case is symmetric by construction (unmatched in both directions), so detailed balance holds. It just means "locked" is a guarantee about addresses, not about subtrees.
In practice the UI's lock module (▢) control snapshots every address currently inside the module, which covers everything that exists at lock time. A subsequent structural move that adds a genuinely new site inside it is the uncovered case.
The granularity available
| Control | Locks |
|---|---|
| A knob's lock dot | One parameter address |
| A module's ▢ | Every address currently in that module |
| lock knobs | Every parameter address in the patch |
| lock wiring | Every structural address |
| clear locks | Nothing |
refine_from(seed_id, locked) takes the set explicitly, so a frontend can
construct any subset.
What this enables
The workflow the rack exists for:
Find a patch whose character you like but whose envelope is wrong. Lock every knob except the envelope. Evolve. You get variations that differ only where you allowed them to.
Because the guarantee is exact rather than best-effort, that is a statement about what the search will do rather than what it will probably do. A heuristic version (penalize changes to locked sites, or revert them afterwards) would be a search that mostly respects your intent, and "mostly" is not a useful promise about the one thing you explicitly protected.
Everything locked
If covers every address, the search has nothing to do and every proposal is rejected. The engine reports this the same way it reports any unsuccessful generation, as "no proposal beat its parent", which is honest but not very informative. It is listed in Troubleshooting as a thing to check.
Relation to the design's exactness claim
The decisions log states this as:
Locks / partial evolution — Freeze any set of trace addresses; MH proposals touching them are rejected outside the kernel, in both directions. Exactly Metropolis-within-Gibbs on the conditional posterior, so locking is exact rather than heuristic.
This page is that claim spelled out: why both directions are needed, and where the address-level guarantee stops.
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 with a crossover population kernel. That is an intention, not a description of the code.
What runs is a dozen-to-forty-step adaptive single-site MH walk warm-started from each of the best pool members, keeping the final state: local hill-climbing on that target, rather than a draw from it.
What runs
Engine::refine is three lines over two primitives:
pub fn refine<R: Rng>(&mut self, rng: &mut R) {
for parent_id in self.refine_begin() {
self.refine_seed(rng, parent_id);
}
}
refine_begin advances the generation counter and returns the top
refine_seeds candidates by posterior utility, best first. It returns
empty (and does not advance the counter) when there is no posterior or no
standardizer, because there is no direction to climb in.
refine_seed clones the seed's tree, walks refine_steps MH steps with no
locks, and injects one state of that walk as a child. It returns None if the
walk was rejected or landed on a tree the pool already holds.
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 user's preferences, 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.
Injection displaces the pool's lowest-utility member; pinned candidates are exempt.
The split is measured
Defaults, both scaled from the palette's operator count N_OPS = 20:
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 palette change.
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:
| 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 you did not want 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. Twenty steps is 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.
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 user'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 user 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 genuinely 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 can prune candidates before the expensive path. Survivors get rendered and scored in full.
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 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 · Δtaste +0.65.
Utilities are recorded at event time: a later refit changes the model, and re-deriving these numbers afterwards 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.
Performance: named controls and the drift walk
A control called Bright is a fixed direction in standardized φ. Which knobs it turns is measured per patch, checked on real renders, and refused when the knobs cannot honestly produce it.
PERFORM, the instrument's first view, needs two things from the machinery. It
needs controls whose names mean the same thing on every patch, which a knob
address cannot give: node/0#cut is a brightness control in one patch and a
wavefolder's drive in another. And it needs motion that stays inside the
sound: a patch that changes on its own without changing what it is made of.
The first is a least-squares problem on the patch's own Jacobian. The second is
the locked walk with every structural site locked. Both live in
auracle_session::perform, and reach the browser as perform_wire,
perform_apply, perform_drift and perform_offer on WasmEngine.
Symbols on this page
The notation fixes as the feature dimension and as a
term, so this page uses different letters for the two things the code calls
d and x.
| Symbol | Is | In the code |
|---|---|---|
| The patch's continuous knob values, normalized | Jacobian::values | |
| The audio block of standardized at those values | audio_z | |
| A named control's unit direction | direction(...), d | |
| at the current values | Jacobian::cols | |
| The knob move the wiring solves for | x | |
| The knobs a control is allowed to move, | support | |
| A control's setting; is the sound as it is | c |
Distances along are in units: one unit is one standard deviation of the session's own spread, from the standardizer. The instrument's tooltips and the code's comments write this unit as σ.
A named control is a direction
Six controls, each a fixed weighting of named φ_audio coordinates, normalized to unit length:
| Control | Low · high | Weights before normalizing |
|---|---|---|
| Bright | dark · bright | centroid_mean , rolloff_mean |
| Snap | bloom · snap | attack_s , crest |
| Motion | still · restless | held_centroid_std, motion_slow, motion_mid, motion_fast, each |
| Body | thin · full | bass_fraction |
| Grit | smooth · rough | flatness_mean |
| Space | close · far | tail_ratio |
So , and
Motion spreads over its four coordinates with weight each, the
motion bands plus the older
held_centroid_std. A unit test requires each direction to have unit norm over
φ's real coordinate names, so a control whose every coordinate was renamed away
fails the build. A control that lost one of several coordinates would still
pass, and would quietly narrow to the rest.
The direction is defined in , not raw , because only a standardized coordinate has a scale that means the same thing across axes. The patch's current position on a control is ; the instrument draws it as a dot on the control's ring, at of the way to the stop.
The standardizer is the session's, refit at every posterior fit. A control's
wiring and position are therefore relative to the patches this session has
seen, and can change after a refit. Before the pool has a standardizer at all,
perform_wire returns null and the view says there is nothing to measure
against yet.
The other two controls, Blend and Wander, are not directions in φ and live in the instrument, not in this module.
The Jacobian, by one-sided differences
For each continuous knob , one render nudged by :
JACOBIAN_STEP of the knob's normalized range: large enough to move φ
past the numerical floor of a five-second render, small enough to stay local.
Stepping toward the interior keeps every nudge inside the knob's domain,
and one-sided differences cost renders instead of the that central
differences would, all through the render memo. A knob whose nudge fails to vet
gets a zero column, and the wiring simply cannot use it.
The estimate is first order: its error is in the curvature of the response. That is not bounded here. It is what the verification below exists to catch.
Wiring: ridge, support, re-solve
The knob move that best produces is the ridge solution
Ridge rather than plain least squares because is badly conditioned by construction. Two knobs can do nearly the same thing to the sound, which makes columns nearly collinear, and many knobs are nearly inaudible, which makes columns nearly zero. Unregularized, the solve answers both with large, cancelling moves.
The support is the four knobs with the largest effect
(MAX_KNOBS), and
the move is re-solved on it:
Truncating would throw away the other knobs' contribution without letting the four that remain compensate; the re-solve is the best move on those four. Choosing the four by effect is a heuristic, not a best-subset search. It is effect and not because a knob that barely moves the sound needs a large coefficient to contribute anything. Ranked by coefficient, that knob headed the support, and the scale below then gave the knob doing the work a sliver of a turn. On Iron Bass, Bright wired to the drive () at , and the cutoff (, of centroid per unit) barely moved: of reach.
The control's own knobs first. The solve runs twice at most. First it is
restricted to the knobs a musician would name for the control
(NamedControl::sites: cutoff, tone, … for Bright; mod depth and rate for
Motion). If that wiring clears the gate below, it is used. Only if it does not
does the solve range over every live knob, with those sites as a soft prior
(SEMANTIC_RIDGE). A player who turns Bright and watches the cutoff move has
learned something true about the patch. One who watches the amp release move,
which is what the unrestricted solve chose on Acid Line, has learned nothing.
Four, because a control is heard as one gesture, and because four is where most of a patch's audible leverage already is: over the preset library, a patch's four most audible knobs carry a median 68% of its φ movement (below).
The movement this predicts is , and three numbers follow from it.
Purity is the cosine between the predicted movement and the direction asked for:
is a pure move along the label. At the off-axis part of the movement is times the on-axis part.
Scale. A full turn may move no knob by more than MAX_TRAVEL of its
range. The knob with the largest effect gets all of it, and the rest follow in
proportion, clamped at the same limit:
Purity and reach below are computed from the clamped travel , so they describe the move the control actually makes.
The upper clip is KNOB_MAX. Every continuous site is
on the half-open interval, so a knob at exactly has log-prior and
would make the patch un-evolvable. A performance gesture must never do that.
Reach is the predicted movement along at a full turn, in units:
A control is a search control on this patch when the knobs cannot honestly produce it:
(PURITY_FLOOR, REACH_FLOOR), or when there is no support or the predicted
movement points the wrong way. The reach floor was first set at against
the preset library's spread. The session pool is spread wider than a curated
library, which shrinks every distance, and hid controls that are
plainly audible on a live patch.
Several controls turned at once add: each contributes to the knobs it wires, and the sum is clipped. The composition is linear by assumption and is not verified.
Purity measures cross-talk, not correlates
Purity asks whether a move is this control. Measured against the whole of φ, it cannot tell a brightening that also raises the zero-crossing rate and the high band — which every real brightening does — from one that also slows the attack. Over a fresh session pool, Bright's median cosine with its own axis was 0.26 for that reason. What a player hears as "this control does something else" is movement along another control's axis, so purity is the cosine with inside the subspace the six named axes span:
with the other controls' axes. Reach and position stay on .
And two controls whose predicted movements are within
of each other are one gesture with two names: the later one in the fixed order
(Bright, Snap, Motion, Body, Grit, Space) becomes a search control
(separate, COLLINEAR).
Measured over the first 24 patches of a fresh session pool (reach_census,
seed 7), with verification on real renders:
| Reachable | Bright | Snap | Motion | Body | Grit | Space |
|---|---|---|---|---|---|---|
| Purity against the whole of φ | 25% | 58% | 58% | 17% | 4% | 38% |
| Purity against the named axes | 29% | 71% | 62% | 21% | 4% | 38% |
Patches on which no control reaches fell from 4 of 24 to 2. The gain is modest because purity was not Bright's real limit: its median predicted reach is about 0.1σ, since many pool patches have no filter for a named control to turn. That is the honest reading, and a search control's offer is the answer to it.
The same 24 patches, after the effect ranking, the control's-own-knobs pass
and a half-travel retry in verify (all three above and below). Both rows
are measured on the same build, since the pink-noise fix and the re-voiced
presets moved the pool standardizer:
| Reachable | Bright | Snap | Motion | Body | Grit | Space | mean per patch |
|---|---|---|---|---|---|---|---|
| Ranked by coefficient | 29% | 67% | 62% | 21% | 4% | 38% | 2.21 |
| Ranked by effect | 50% | 79% | 46% | 29% | 4% | 46% | 2.54 |
The median verified reach of a reachable control grew three- to four-fold (Bright 0.09σ → 0.33σ, Snap 0.54σ → 0.95σ). Motion lost: 62% → 46%. Motion leans on several small knobs together, and giving the strongest one its whole turn is where a Motion wiring most often turns back on itself. The half-travel retry did not win those patches back. Dropping the weak knobs was tried first and cost Motion more.
Tried and not shipped. Aiming the solve at each control's population pattern (the correlation-weighted direction, Haufe et al. 2014) first looked like a large improvement, but only because purity was then measured against the pattern instead of the axis. Measured against the axis, with the pool correlation shrunk toward the identity (Schäfer & Strimmer 2005) and collinear controls separated, it reached fewer patches than the bare axis (Bright 21%, Motion 42%), and on 2 of 12 patches it put Bright and Body onto the same knobs with opposite signs. It was removed.
Verification on real renders
The Jacobian is a local, linear claim, and it fails exactly where a player would notice: at a boundary. On First Bass, which already sits at the floor of motion, the linear prediction said turning Motion down would make it stiller. Rendered, it made it very slightly more restless.
So verify renders every non-search control alone at four settings and
measures the movement along its own axis:
For each half, with sign , the measured reach is
The half must move the asked way at half travel, and further at full travel. A half that moves at the end but reverses on the way is closed. A half is open when , half the reach floor. A control with neither half open becomes a search control. The instrument draws a half-closed control with half its ring and names the end it is stuck at: already as still as it gets, for Motion on First Bass.
A control that would close at full travel gets one retry at half: the same two-point test over (the renders are already in the memo). If a half opens, the wiring keeps half its travel rather than closing.
Total cost for a patch with knobs and reachable controls is renders, plus two for each retry, all through the memo. In the browser that is around 20–30 renders, and the page caches each measurement by tree and by vote count, so returning to a patch costs none. The view re-wires after every glide and every patch change, so the claims are always about the neighbourhood the sound is in.
The gate, and why it samples somewhere else
named_controls_move_the_sound_they_name wires and verifies four presets
(First Bass, Ceiling, Detune Dream, Long Way Down) and then checks every
open half at , a point verification never rendered:
It requires at least eight open halves, so a wiring change that closed most of them would fail the gate rather than pass it vacuously.
It samples somewhere else because no finite set of samples proves a response monotone. A smooth function can agree with any finite set of points and reverse between them. Checking an unsampled point is the measurable form of the promise, and a check at the points verification already used would be the same test twice.
The tolerance is stated rather than zero because a five-second render has numerical noise. units is two orders of magnitude below an audible difference and above that noise. A check with no tolerance failed on Detune Dream's Snap, which reversed by : real, measurable, and far below anything a listener would hear. That is the case the tolerance exists for. Verification itself has no tolerance: a half that reverses at a sampled point is closed however small the reversal.
The measurements, and what reproduces them
Per patch, not per knob kind. The obvious alternative to measuring a Jacobian per patch is a table: what a cutoff knob, or an attack knob, usually does. Both were measured over the 61 presets. The table was built leave-one-out, from per-site-kind averages over the other 60, so no preset was wired by a table that had seen it.
| Median purity | Bright | Snap | Motion |
|---|---|---|---|
| Wired from the patch's own Jacobian | 0.61 | 0.77 | 0.75 |
| Wired from the best leave-one-out per-site table | 0.23 | 0.16 | 0.09 |
The same knob does different things in different patches. That is the reason the grammar exists, and it is the reason the table is not used.
Grit and Space measure a median purity of about zero by knobs alone on most presets, because most patches contain no drive or reverb to turn. They are the controls most often drawn as search controls.
Grafts. Turning a search control first tries to give it something to turn
(graft_for). Bright and Body get a flat EQ, placed below any stereo module
that ends the chain, because above one it folds the patch to mono (Ghost Bell
moved 0.27σ that way). Space, turned up, gets its amp release raised to 0.6
(≈250 ms). A reverb was tried first, and every effect here sits before the amp
envelope: on First Bass with a reverb grafted, ∂Space/∂mix measured −0.04 and
∂Space/∂release +3.65. Grit gets nothing (see the
open question). perform_inserts measures each
graft. Over the preset bank, the EQ is transparent (median |Δz| 0.000; the
unit test bounds it at 0.05σ). It opens Bright on 7 of the 10 presets where
Bright was a search control, and Body on 15 of 48. The release graft opens
Space on 41 of 46, at 1–2σ of reach. It is not transparent, and is not meant
to be: it is what "farther" asked for.
Leverage. Nudging every knob of every preset by and measuring how
far moves, in units of the library's own
per-coordinate spread: over 61 presets and 821 knobs, a patch's four most
audible knobs carry a median 68% of its total movement and its top eight
94%. 172 of the 821 knobs barely move it at all. That is what makes
MAX_KNOBS a real operation rather than a random pick with a story
attached.
| Example | Produces |
|---|---|
cargo run -p auracle-features --example jacobian_probe --release > jac.csv | for every preset: the raw material for both purity rows |
cargo run -p auracle-features --example leverage_probe --release > leverage.csv | Per-knob leverage for every preset |
cargo run -p auracle-session --example perform_wiring --release -- "First Bass" | The shipped wiring on named presets: knobs, purity, reach, position, search |
cargo run -p auracle-session --example perform_inserts --release | For each preset's search controls, whether PERFORM's graft is transparent and whether it makes the control reachable |
cargo run -p auracle-session --example reach_census --release -- 24 7 | How many controls reach the patches of a fresh session pool, with verification, and how the gate would read with purity against the whole of φ |
The two probes print CSV and the medians are computed from it. jacobian_probe
uses central differences on raw φ at the same , so it measures the
same response as the shipped code without being bit-identical to it.
perform_wiring runs the shipped jacobian and the bare-axis wire under a
preset-library standardizer, and does not run verification; reach_census
runs the shipped path (Engine::wire_controls) with verification.
Drift: a local walk on the live knobs
Engine::drift samples the same target
restricted to this patch's shape: every structural and categorical address,
every continuous site without a live handle, and the player's own locks are
held fixed — the walk moves only live_knobs. Holding sites fixed in a Metropolis–Hastings walk is
exact conditioning (Locks as conditional refinement), so the walk
targets over the knobs the voices can take
live. Structure cannot change under the player's hands, and nothing the walk
moves needs a recompile to be heard.
The kernel is not refinement's. Each step picks one free knob uniformly and proposes
accepted with probability . The
reflected Gaussian is symmetric, so there is no Hastings correction
(Engine::local_walk). Refinement's kernel, fugue's adaptive single-site MH,
starts every fresh chain with a wide proposal on a unit-interval knob: measured
over 12 presets, an 8-step "drift" moved some knob by 0.3–0.85 of its range. A
drift should wander, and how far is the Wander dial's to say.
| Wander | Steps | Farthest knob moved (12 presets) | |
|---|---|---|---|
| gentle drift | 8 | 0.05 | 0.06–0.14 |
| mid drift | 18 | 0.08 | 0.15–0.33 |
| roam | 40 | 0.15 | 0.25–0.61 |
(cargo run -p auracle-session --example drift_distance --release.) The walk
returns its end state, or nothing if it ends where it started. Like
refinement, a short walk's end state is local movement on , not a
draw from it (What is not sampled).
Nothing enters the pool: a performance gesture is not a candidate until the
player keeps it.
The wiring survives a small drift. The named controls are a linear model measured with 0.08 knob steps, so the instrument re-measures them only when some knob has left a 0.12 neighbourhood of where they were measured. A gentle drift usually stays inside and costs nothing; before, every glide was followed by a full re-measure of about 46 renders, which in drift kept the worker busy much of the time and queued offers behind it.
The instrument then glides the knobs from where they are to the returned values along a smoothstep, , over 2 to 6 seconds. The intermediate states are interpolations, not states of the walk. They are all valid patches, since the structure is fixed and the knob box is convex. A touch stops the glide where it is.
Offers
Engine::offer is the same walk with only the player's locks, so structural
moves are allowed: it may add, remove or replace a module. It is also
non-inserting. The instrument asks for 20 steps (40 in roam) and plays the
result in the B slot, never as a jump. A search
control released more than from its
centre asks for one, and springs back without moving a knob.
Before any evidence: VetOnlyFitness
With no posterior there is no utility to climb, and the first version of PERFORM returned nothing for drift and offers until the player had made picks. But the posterior before any evidence is not undefined. It is the prior.
With a zero-mean prior on and a single lens, the prior expectation of
utility is for every patch, so the target is
itself. VetOnlyFitness writes that down, with the vetting
gate kept:
At an unvetted term is down-weighted by , so is restricted to listenable patches, to any precision that matters. The proposal distribution is the plain grammar prior too, since the taste tilt needs a posterior.
Every drift and offer reply carries whether it was taste-directed
(Engine::has_taste), and the instrument says which: drifting through the
grammar — no taste yet, or drifting toward your taste. Wiring the named
controls needs no taste at all, only a standardizer.
The B slot
An offer is heard through a second LivePoly in the same AudioWorklet. It
receives every note-on, note-off, bend, glide, unison and arpeggiator message
the first one does, and notes held when it loads are replayed into it, so it
joins a chord already sounding. It renders continuously while loaded, so its
envelopes and tails are in step with A when the mix moves.
The mix is equal-power, with the mix position smoothed per sample:
The one-pole has a time constant of 500 samples, about 10 ms at 48 kHz, so a Peek is a gesture rather than a click. The gains satisfy , which holds the summed power constant when A and B are uncorrelated. When they are nearly identical the sum rises by up to 3 dB at , since . An equal-gain law would have the opposite error, flat for identical sources and 3 dB down for unrelated ones. An offer is a different patch, so the equal-power error is the one taken.
B is loudness-matched. Its makeup gain is the offer's own loudness normalization to −18 LUFS on the standard phrase, with clamped to dB, the same makeup every live patch gets. Without it a crossfade would mostly compare levels, and the louder side reliably wins. The match is made on the standard phrase at each patch's own settings; a named control turned on A afterwards changes A's level without re-normalizing it.
What is not done
- Named controls are not directed search. A search control asks for an untargeted offer: the walk still targets , not tilted toward . Adding to the log-target would aim it, and is the same substitution as target-directed search. It is not built, so the offer may not move the way the control was turned.
- The directions are fixed, not personal. The six are the same for every player. A control along a fitted style lens is a different and more interesting object, and not this one.
- What the player does here is logged, not fitted. Keep, Back, Take, each
offer and each control turn are recorded as
ImplicitEvents (perform_keep,perform_back,perform_take,perform_offer,perform_turn). None enters the likelihood. A Keep can mean I love this or stop drifting for a moment, and implicit signals stay out of the model until a fit using them can be validated against the explicit ones. - Combinations are not verified. Each control is verified alone, at .
- Motion hears one note. Its axis is built from the held note's span, with the limits that implies.
Acquisition
Which duel to ask next. The answer turned out to be "it does not matter much".
The rules
Acquisition is selectable, because the choice is an empirical claim and
both alternatives are kept so the comparison stays runnable.
| Rule | Picks |
|---|---|
Random (default) | A pair uniformly at random from the pool |
Bald | The pair maximizing expected information gain about |
Thompson | Dueling Thompson sampling — a best-arm rule |
The measurement
cargo run -p auracle-session --example learn_synthetic --release -- --compare 20
20 seeds, 72 duels, refit every 12, against the synthetic user.
The methodology matters more than usual here, because the effect sizes are small:
- Common random numbers. Pool fill, the user's coin flip at duel , the MCMC seed at round , and refinement seeds are all shared across arms, so only the acquisition draw differs. Without this the between-seed variance would swamp everything.
- One fixed held-out exam under a single reference scale, so arms that built different pools are still answering the same questions.
- is two standard errors of the paired difference.
Three metrics: cosine similarity to the true (↑), rank correlation on the exam (↑), and excess nats against the true model (↓).
Static pool — i.i.d. prior draws, refine_steps: 0
| cos θ* ↑ | rank r ↑ | excess nats ↓ | |
|---|---|---|---|
| random | 0.460 | 0.731 | 0.211 |
| thompson | 0.416 | 0.628 | 0.254 |
| bald | 0.484 | 0.762 | 0.199 |
| bald − thompson | +0.068 ± 0.062 | +0.134 ± 0.044 | −0.055 ± 0.014 |
| bald − random | +0.025 ± 0.058 | +0.031 ± 0.046 | −0.012 ± 0.013 |
Thompson is the one clear loser (). It is a best-arm rule: it converges on identifying the top patch, which is not what a duel is for here. Finding the single best patch in a pool and learning the shape of a taste are different objectives, and optimizing the first does not deliver the second.
BALD and uniform pairing are within two standard errors on every metric.
Evolving pool — refine_steps: 12, refinement between rounds
A static i.i.d. pool is a weak regime to conclude from on its own: prior draws are spread over feature space by construction, which is exactly where uniform pairs already achieve near-optimal coverage and an information-seeking rule has no redundancy to prune. The shipped pool is not that pool (refinement injects children near the current best, and insertion evicts the worst) so the comparison runs an evolving regime too.
| cos θ* ↑ | rank r ↑ | excess nats ↓ | |
|---|---|---|---|
| random | 0.479 | 0.694 | 0.232 |
| thompson | 0.459 | 0.583 | 0.276 |
| bald | 0.465 | 0.707 | 0.232 |
| bald − thompson | +0.006 ± 0.068 | +0.124 ± 0.066 | −0.044 ± 0.017 |
| bald − random | −0.015 ± 0.055 | +0.013 ± 0.048 | −0.000 ± 0.014 |
Same answer. Thompson loses; BALD and uniform pairing tie on every metric.
Why Random is the default
Measured in both the regime the product starts in and the regime it evolves into, uniform pairing is indistinguishable from BALD. A rule with four tuning constants that ties a rule with none should not ship on a tie.
Two supporting reasons survived checking, one did not:
- The
info_gainBALD reports had zero consumers in the frontend. - BALD's repeat avoidance is real but barely needed over a pool this size that
uniform pairing already samples without repeating (gated by
duels_spread_over_candidates_not_just_pairs). Randommakes every duel an unbiased calibration sample rather than one in ten, a virtue that holds regardless of which rule learns faster.
A retraction worth recording
One earlier justification was withdrawn for a bad reason, and the record should say so.
The "pool grows and concentrates" argument was dismissed on the grounds that insertion caps the pool, but a capped size is not an unchanging spread, and evicting the worst member could in principle concentrate a pool. Dismissing the concentration argument because it was unmeasured, while treating a measurement from the other regime as decisive, had the burden of proof backwards.
The evolving run above is that measurement. It happens to show the concentration never materializes, but the default rests on the measured tie, not on the dismissal.
What Bald is still for
It decisively beats the best-arm rule, so it is the right thing to reach for if acquisition ever needs to do something uniform pairing cannot:
| Lever | Config | Default |
|---|---|---|
| Bias duels toward patches the user will enjoy auditioning | duel_utility_weight | 0.1 |
| Bound how often one patch reappears | duel_exposure_penalty | 0.25 |
| Avoid re-asking a pair | duel_repeat_penalty | 0.5 |
| Soften the selection | duel_temperature | 0.6 |
| Reserve unbiased probes | duel_check_every | 10 |
All measured, none currently worth the tie.
A correction worth recording
An earlier version of the BALD rule scored its enjoyment term on unnormalized utility and used an absolute softmax temperature of 0.05 nats.
Both are scale bets, and both lost. The enjoyment term grew without bound as the posterior sharpened, and ran to — so the "softmax" was an argmax. That version was measurably worse than random, and it is the version an independent replication measured.
It also produced the duel repetition observed in the running app: the same defect, seen from two directions. Fixed, BALD ties random, and the tables above are the fixed rule.
The general lesson: a temperature with units of nats is a bet about the scale of the quantity it divides, and a quantity that grows as a model sharpens will eventually break that bet.
Where uncertainty would earn its keep
The design's argument for acquisition is that 's posterior uncertainty lets early sessions ask informative questions (duels the model cannot rank) while a confident model mostly serves things you will like. That remains the right frame, and it is also the frame in which the measurement says the informative-question machinery is not currently paying for itself.
At session horizon — tens of duels, a 48-patch pool kept spread by its own dynamics — there is not enough redundancy in the question set for an information-seeking rule to exploit. A much larger pool, or a much longer session, is where the tie would be expected to break.
Safety
Evolution will generate pathological patches. Five layers make that acceptable rather than dangerous.
Randomly composed DSP graphs produce screaming resonance, silent duds, NaN-poisoned recursive state and astronomically high pitches. None of that is hypothetical and none of it is rare. Safety is layered because no single check covers it.
The layers
| Layer | Where | What |
|---|---|---|
| 0 | quiver | Denormals flushed at graph scatter; NaN-latch protection on stateful modules; soft-clipped filter state; cycle detection with named paths; non-finite module outputs zeroed at scatter |
| 1 | auracle-features | The vetting gate. Audition plays pre-rendered, vetted, normalized buffers — never a live unvetted patch |
| 2 | auracle-session | Quarantine → QUARANTINE_FITNESS = -50.0, so the search learns to avoid the region |
| 3 | auracle-grammar | Mandatory … → DC blocker → VCA → Limiter → StereoOutput; parameter ranges bounded away from pathology |
| 4 | tests | ValidationMode::Strict as a property-test oracle over grammar output |
Layer 0 is a dependency's, and the one Auracle has least control over, which is why it was audited and why two bugs found there are recorded below.
Layer 0 — quiver
Verified 2026-07-28 and hardened where needed. quiver was already substantially prepared for this use:
- Denormals flushed at graph scatter.
- NaN-latch protection on stateful modules — filters, limiter and EQ sanitize inputs so non-finite samples cannot poison recursive state.
- Soft-clipped SVF state.
- Cycle detection with named-path errors.
- Actionable
PatchErrors (InvalidPortlists the available ports). ValidationMode::Strictfor typed connections.
Two gaps were found and fixed upstream:
Q198: permanently latched NaN, and an infinite loop on the audio thread.
Oscillator phase accumulators latched NaN forever on non-finite pitch, because
NaN − floor(NaN) is NaN. Worse, the while phase >= 1.0 wrap style used by
Wavetable and FormantOsc spun the audio thread forever on an infinite
increment, and voct_to_hz overflows at extreme V/Oct, which the grammar can
reach. An infinite loop on the audio thread is not a glitch, it is a dead tab
with no error message. Fixed with a shared wrap_phase that recovers
non-finite values.
Q199: cross-module poisoning. Graph scatter now zeroes non-finite module outputs, so one module's NaN or Inf cannot poison another module's recursive state through the routing buffers. Containment at the graph boundary; per-module input sanitization remains defence in depth.
voct_to_hz is clamped to ±32 octaves as of quiver-dsp 0.3.0 (auracle pins
0.3.3), so the overflow Q198 recovers from can no longer be produced by pitch
CV at all; what remains at the clamp is finite aliasing garbage, which the vet
gate quarantines like any other.
Layer 1 — the vetting gate
No candidate is ever played live unvetted. Audition plays pre-rendered, LUFS-normalized buffers, and the standard-phrase render doubles as a health check.
Thresholds, the measurements that confirmed them, and the ordering that makes the whole thing work are in The vetting gate.
The structural point: one render serves the health check, the features and the playback. That is what makes "you never hear an unvetted patch" true by construction rather than by discipline: there is no second path that could skip the check, because there is no second render.
Layer 2 — fitness shaping
Quarantined patches do not just get hidden; they score in the search target.
Hiding alone would leave the search spending its budget in a region it cannot observe is bad, repeatedly rediscovering the same pathology. Shaping the fitness makes avoidance something the search learns.
Layer 3 — the live path
Only vetted patches are free-playable, and the compiled output chain is mandatory:
The limiter is compiled in by auracle-grammar, not optional and not a
setting. On top of quiver's scatter sanitization.
And parameter priors are bounded (resonance max 0.85, delay feedback max 0.7, V/Oct into an audible band) so the grammar cannot express the most degenerate settings. That is categorically better than generating and rejecting them: there is no pathological region for the search to keep sampling.
Layer 4 — Strict as an oracle
Grammar output is compiled with ValidationMode::Strict in the test suite.
Because the grammar is typed, a SignalMismatch is by construction a bug in
our grammar, so Strict is a property-test oracle: sample terms, compile
all, any error fails the test with quiver's actionable message.
Patches are wired in Warn mode, with an allowlist test pinning the two
warning classes the compiler deliberately uses. See
Validation mode: two different modes for
two different questions.
Non-audio safety
The gates above are about sound. Two others are worth listing here because they are the same kind of thinking applied elsewhere.
Escape everything a user or a file can name. renderBank once built rows
by interpolating r.name straight into innerHTML. Renaming a patch to <img src=x onerror=…> executed, persisted into the saved bank, and re-fired on
every reload. The same sink is fed by imported patch JSON, so opening a
shared patch was script execution in the recipient's session. Every
interpolation of a name is now escaped, including the two that land in
attributes, and textContent is preferred wherever the node allows it.
Refuse to measure a term you cannot interpret.
FeaturizeError::OutOfDomain rejects a term with a knob outside its range
before the render, because its would be a lie and a row the model
cannot interpret must not enter the log. This is the gate the
1e30 sentinel got past
when it did not exist.
What is not defended against
- A malicious patch file can name things and set parameters. Names are escaped and parameters are domain-checked and repaired, so the blast radius is intended to be zero. It is still a parser handling untrusted input, and that is always a claim rather than a guarantee.
- Hearing damage is mitigated (limiter, LUFS normalization to a peak ceiling, no unvetted playback) but the output level is ultimately yours. Nothing stops you turning a limiter-bounded signal up.
- Denial of service via a huge patch is bounded by the module and depth ceilings, not by a time limit. A 24-module patch with granular and reverb is legitimately expensive.
- The audio thread can still be starved by the rest of the machine. That is a browser scheduling matter and outside what the engine can fix.
Persistence and migration
The observation log is the source of truth. Everything else is a cache, and saying so is what makes migration tractable.
What is stored
| Object | Contains |
|---|---|
SessionState | The whole session: pool, bank, names, log, posterior, generation, forecasts |
BankEntry | A patch's tree, id, origin, name, pinned flag. Renders and features are re-derived on import |
ObservationLog | Every Feedback with its session index and raw by name |
Profile | The log plus the standardizer — the portable unit |
TastePosterior | A snapshot. Recomputable from the log |
Two of these choices carry the design.
BankEntry stores the tree, not the features. Trees are the source of
truth; renders and are re-derived on import. That is what lets the
feature extractor change without invalidating a saved bank, and it is why
restoring a large session costs real work rather than being instant.
The log stores raw by name. Not standardized, and not by index. Both halves of that matter, below.
A profile is the log plus its standardizer
pub struct Profile {
pub log: ObservationLog,
pub standardizer: Option<Standardizer>,
}
is only meaningful relative to the standardization that produced it, so the two persist together or not at all. A log without its standardizer is a set of numbers whose units have been lost.
The posterior itself is not in a profile. It does not need to be: it is recomputable from these two, and shipping a fitted model would mean shipping something that could disagree with the evidence it was fitted from.
Names, not indices
FitSet::build projects a stored log onto the current feature names,
matching on the name. The rule is same name ⇒ same coordinate, and anything
unmatched is left at the new standardizer's mean, which standardizes to zero
and means "this vote says nothing about that axis".
That is the honest imputation, and it is why by-name storage is worth the
bytes. By index, a feature-set change would silently re-interpret every
historical vote: coordinate 12 was held_centroid_std yesterday and is
mod_density today, and every vote ever cast would now be a claim about a
different thing.
Three kinds of change, and the one that fails silently
Dropped coordinate. size and n_mix were removed to break exact linear
dependencies. Drop them from the migration too; nothing is lost that was ever
usable.
Changed units. These have to be converted, because a value silently carried across a unit change is worse than a dropped one: it is evidence pointing the wrong way. The conversions applied when the audio features moved to the log axis:
| Coordinate | Conversion |
|---|---|
centroid_mean, rolloff_mean, zcr_mean | Recover the frequency from the linear-Hz fraction, re-map onto the octave axis. Exact |
centroid_std | The spread of a linear quantity becoming the spread of a log one. No exact inverse for a spread, so the delta method — the local derivative of the axis map at that observation's own centroid. First-order, and honest about it |
crest, tail_ratio, attack_s | Now logged. Exact |
Renamed coordinate, the silent failure. When n_delay became n_time,
by-name matching would have found no n_time in any historical row and imputed
it at the mean for every vote ever cast. That reads as "this user has no
opinion about delays" rather than as a rename, and nothing anywhere would have
reported a problem.
RENAMES carries the value across, and in this case it is exact rather than
a convenience: n_time counts delays and granulators, and no observation
predating that wave can contain a granulator — so the old n_delay count
is the new coordinate's value for every row being migrated.
That reasoning is worth copying for the next rename. A rename table entry is only exact if the new coordinate's extra contributors could not have been present in the old data.
Schema 1 → raw φ
The oldest logs stored standardized over a 30-coordinate feature set with no names.
Recoverable, because the profile persisted the standardizer alongside it:
inverts the transform exactly, and the schema-1 coordinate order is known
and fixed (SCHEMA1_NAMES). Then the unit conversions above apply.
This is the concrete payoff of persisting the standardizer with the log: a legacy log plus the standardizer it was written under is the raw data, just encoded. Without the standardizer those votes would be unrecoverable.
Forward compatibility in the small
Individual fields use #[serde(default)] where a default is honest:
| Field | Default | Reads as |
|---|---|---|
BankEntry::pinned | false | Sessions saved before pinning existed had no pins |
TastePosterior::weights | empty | Uniform — posteriors written before reweighting existed were uniform |
Forecast::provenance | Duel | Every forecast already on disk was a dealt duel, which is what Duel means |
TasteConfig::recency_half_life | None | No forgetting |
Each of those is a case where the default is correct history, not merely a value that parses. That is the bar for adding one: if the default would misrepresent what an old file meant, it needs a migration instead.
Where the browser keeps it
IndexedDB, under the page's origin. No account, no server, nothing transmitted.
Consequences worth stating in a reference: the hosted build and a locally-served copy are different origins and do not share storage; clearing site data destroys the session; and there is no server-side copy to recover from. The only backup is an exported profile.
Restore is farmed
Restoring re-renders the saved bank, which is the single most expensive thing
the app does on load. It runs through the same parallel path as the initial
fill — import_session_deferred → bank_absorb → restore_finish — rather
than serially. See The web runtime.
The persistent render cache
is a pure function of — that is the determinism contract — so a featurization this browser has already performed can be replayed instead of re-rendered. Without that, every reload re-renders the whole bank from nothing: ~48 candidates at ~0.5 s each, for numbers the machine computed yesterday.
Farm workers consult an IndexedDB store (auracle-renders) before rendering and
write back on a miss. The engine reports the hit rate per wave into the app's own
log.
The key is not enough
render_key addresses , which is everything
depends on given a fixed featurizer. It hashes the inputs, and a
change to the normalizer or to a descriptor's formula is a change to the
function — the same key would then name a different measurement.
RENDER_EPOCH is that missing coordinate and cache_namespace combines the two.
A namespace mismatch orphans every stored row at once, which is the only
correct granularity: a cache whose invalidation is anything less than total will
one day serve a number from a featurizer that no longer exists. Bump the epoch on
any change to a coordinate, to loudness normalization (including
PEAK_CEILING and TARGET_LUFS), to the vetting thresholds, or to the compiler's
term → module mapping. When in doubt, bump: the cost is one cold boot.
A hit is checked rather than trusted — pre_featurized re-derives the key
from the tree the engine holds at that index and drops the row if it disagrees.
Two deliberate limits
Cached rows carry without samples, so a job that asked for audio
still renders. Serving it a row would move the saving onto the first patches the
player actually auditions, which is exactly where wantAudio exists to avoid it.
Eviction is "clear everything" past a row cap, which is crude on purpose: an LRU needs an access-time write on every hit, turning the cheap path into a write, and what is being protected is a disk quota rather than a working set.
It lives in the farm worker rather than in the engine's runFarm loop, whose
absorb cursor, re-issue watchdog and speculative-work handling must not acquire
asynchrony. A cache hit is simply a job that returns fast.
Pins live engine-side
Candidate::pinned and BankEntry::pinned, not a UI-side set.
The engine is what evicts, so the engine must be what knows about exemptions. Holding pins in the UI beside the stars would rebuild exactly the split that made the stars-are-saves bug possible.
Capped at pool_size / 4 so the pool can never be pinned solid. That state has
no honest report, because it surfaces as insert_candidate returning None,
which callers already render as "no proposal beat its parent".
The web runtime
Three thread kinds, one wasm binary, and a set of constraints that shaped the architecture more than any design preference did.
The threads
| Thread | Holds | Runs |
|---|---|---|
| Main | UI, Web Audio graph | main.js — never in the audio or render data path |
| Engine worker | WasmEngine (all of auracle-session) | worker.js — pool fill, fits, refinement, workbench |
| Render workers ×N | A wasm instance, nothing else | farm.js — stateless (term, phrase) → φ |
| AudioWorklet | LivePoly | The instrument. Real-time |
Main compiles the wasm binary once, spawns the render workers, and
transfers one MessagePort per worker into the engine worker. After that
main is out of the data path, and no audition buffer ever touches the UI
thread.
No nested workers (Safari shipped those only in 16.4), no SharedArrayBuffer,
no COOP/COEP headers, no build step and no server change. Those constraints are
why the topology is a star around the engine worker rather than a tree.
The AudioWorklet's hostile environment
An AudioWorklet has no fetch, no TextDecoder, no TextEncoder.
wasm-bindgen's glue needs all three.
So the worklet is assembled as a blob with the glue inlined behind a polyfill, and raw wasm bytes are transferred into it for a synchronous in-worklet compile.
The bytes specifically, not the module: a transferred WebAssembly.Module
arrives as a silent messageerror in some engines. That is the kind of failure
that costs a day: no exception, no log, just a worklet that never initializes.
Also, no wall clock on the audio thread. LivePoly uses a deterministic
xorshift for the random arpeggiator pattern; anything Date.now()-shaped
belongs on the main thread.
LivePoly holds compiled copies of the patch, via
the same compile() path evolution uses
and with the limiter included, plus oldest-note stealing and silent-tail voice
parking. Every workbench edit re-patches the live instrument.
The stack size
wasm32's default stack is 1 MB, and the patch compiler is recursive: every
level of Compiler::build constructs quiver modules by value before moving
them into the patch, and some carry large inline buffers. A PitchShifter
holds [f64; 4800] (38 KB), a Granular more.
A dozen-module patch overflows it, and it does so as memory access out of bounds, nowhere near the flag that caused it. It then poisons the engine:
the panic unwinds out of a &mut self binding, and every later call fails with
wasm-bindgen's "recursive use of an object" instead of the real fault.
The fix is 8 MB, the same order as the native main-thread stack the test suite
runs on, which is why make check never saw this:
WASM_STACK := 8388608
WASM_RUSTFLAGS := RUSTFLAGS="-C link-arg=-zstack-size=$(WASM_STACK)"
It lives in the Makefile, and every build path goes through it: CI,
releases, the site build. Invoking wasm-pack directly ships a 1 MB stack and
reintroduces the bug, which is why the CI workflows build wasm via make wasm
rather than calling the tool.
Progressive boot
Boot costs ~40 renders. The bank is standardized and posted as playable at
8 patches, which is when the first duel is dealt. The remaining ~32 fill in
chunks that yield to the message queue between batches, so playing during
the fill is real rather than cosmetic.
filled still fires, and everything downstream of it still runs.
fill_progress carries stage/stages, so a restore and a top-up fill each
own a labelled share of one bar.
The render farm
capped at 2 when deviceMemory ≤ 4. Override with ?farm=k or
localStorage["auracle-renderers"]; 0 is the serial path exactly.
The pool is identical at every width, including 0
This is a structural guarantee, and two properties carry it:
Draws are indexed. Draw is the prior sampled under
StdRng::seed_from_u64(splitmix64(fill_seed, i)), so a term is a pure
function of . Not of arrival order, not of which
worker got it.
Results are absorbed in index order. The pool at index depends only on indices .
Together those mean a lost or timed-out job is re-issued by index with no retained state, and speculative work past the stop point is simply discarded.
Gated natively by farm_width_does_not_change_the_pool and
farm_absorption_reproduces_the_serial_pool, on (id, tree, raw φ).
Every degradation path falls back to the serial fill of the same draw
stream: a worker that never initializes, one killed mid-boot, a build-stamp
mismatch, a browser that cannot structured-clone a WebAssembly.Module. So
parallelism costs time and never content.
The one loud exception: a job retired after two attempts logs a console warning. That degradation is meant to be visible.
Worker replies are load-bearing
Every workbench edit message must get a reply — bench or edit_rejected
— or the main thread's in-flight queue deadlocks.
bench_missing is the sharpest case. The worker has always sent it when
edit_begin fails, and because nothing handled it, the optimistic "it's on the
workbench" toast stayed on screen while the bench showed the previous patch. A
protocol whose failure message has no listener is a protocol with a silent
failure mode.
The general rule: a control that cannot act says so. The recurring bug is
silence: a ▶ with no handler, an if (x == null) return, a worker failure
nothing listened for. Prefer a disabled control with a reason in its title, or
a note; never a handler that returns.
Caching, in development
The dev server sends Cache-Control: no-store and the app version-stamps
its worker and wasm URLs. Both are needed: a browser's heuristic cache ignores
late no-store on an already-cached module worker.
Get this wrong and you get a rebuild that appears to change nothing, or an engine and a UI from two different commits.
Verification beyond make check
UI changes are verified live in a browser (Playwright) with numeric audio
assertions (an AnalyserNode RMS, boundary-sample checks around patch swaps)
plus a zero-console-error requirement.
Debug hooks: window.__aur and window.__aurLog. (window.__ric is kept as
an alias for notes written before the rename.)
That combination is the only thing that can catch a class of bug make check
cannot see: the engine is correct, the UI is correct, and the message between
them is wrong.
Lineage
Auracle is the third attempt at the same idea. The first two are why this one is shaped the way it is.
| Iteration | Year | What it proved | What it lacked |
|---|---|---|---|
| neuralCompressor (C++/Arduino pedal) | 2020 | The interaction model: human-based GA, fit/unfit foot-switch, mutate/crossover knobs | The engine — EA and DSP were never implemented |
| evosynth v1 (Next.js/Tone.js + FastAPI/DEAP) | 2025 | A working interactive GA over a fixed ~30-parameter subtractive synth; parameter locking; lineage tracking | Preference persistence (ratings died each generation), topology evolution, principled inference |
| Auracle (this project) | 2026– | — | — |
v0 had the interaction but no engine. v1 had an engine, but a naive one with no memory of the user. Both of those gaps are load-bearing in the present design:
- The engine is real, and it is inference rather than a genetic algorithm. Search is Metropolis–Hastings in trace space against a Boltzmann target whose fitness is a fitted posterior, not a hand-written scoring function.
- Preferences persist. Every judgement enters an observation log that outlives the generation it was made in, the session it was made in, and — via profile export — the browser it was made in.
Platform
Web and WebAssembly first: both foundations ship first-class WASM, and the interaction design was the unsettled part, so the fastest iteration loop won. That is what exists.
A desktop plugin via nih-plug (VST3/CLAP) and then AUv3 via a Swift shell are
the intended next shells; neither is started. The constraint they inherit is
the one the web build already keeps: inference and rendering stay off the
audio thread, which only ever plays the current patch.
Decisions log
What was chosen, and what it was chosen over. A decision that only records the choice is not a record of anything.
Each row links, where there is one, to the page that works the choice out in full. Rejected alternatives are named in the rationale rather than kept in a separate list, because the reason a design was rejected is only legible next to the one that replaced it.
| Decision | Choice | Rationale |
|---|---|---|
| Genome representation | Typed combinator-term PCFG (not raw graph, not NEAT) | Types make every sample valid; reuses fugue-evo grammar machinery; all 3 evolution levels in one rep |
| Feedback signals | Pairwise duels + stars (ordinal) + keep/kill; no implicit signals | One latent utility, three likelihoods; duels primary |
| Taste features | φ_audio (18) + φ_struct (26) = φ ∈ ℝ⁴⁴ | Transfer across topologies + free structural screening |
| Feature axes | Log-frequency, logged heavy tails, families not per-module columns | The model is linear in φ, so the axis decides what is expressible; sparse columns are coefficients fitted on a handful of rows |
| Utility form | Max of linear experts u = max_k θ_k·z, K = 5 | Multi-modal taste; handles cross-island duels (per-observation and per-session latent-z designs both fail there); no discrete sites; K=1 ≡ BLR |
| Preference sets | Discovered style lenses, aligned post-hoc; nameable and persisted | A lens claiming ≈0% of the pool is idle — K is an upper bound |
| Locks / partial evolution | Freeze any set of trace addresses; MH proposals touching them are rejected outside the kernel, in both directions | Exactly Metropolis-within-Gibbs on the conditional posterior, so locking is exact rather than heuristic |
| Hand edits | Knob turn = write at a trace address; commit inserts as new candidate; optional "edit beats original" duel, provenance-tagged | Panel and genome share one encoding, so edits, locks, and evolution cannot drift |
| Profile portability | Export = observation log + standardizer; log stores raw φ by name | θ is only meaningful relative to its standardizer; by-name raw storage is what lets the feature set change without re-interpreting history |
| Palette | 43 productions: 7 sources, 20 processors, 16 modulators; categorical orders are append-only wire format | Enough texture axes to learn on; the codec writes indices into the trace |
| Feedback loops in grammar | Not yet (tree terms only — see open questions) | Stability; internal-feedback modules still allowed. Note the ceiling is tighter than "acyclic": a tree also forbids sharing, so one output cannot feed two places |
| Audition | Standard 5.05 s phrase + free-play; per-style phrases later | Feature comparability requires fixed stimulus |
| Loudness | LUFS-normalize all renders to −18 | Loudness bias would poison the preference data |
| Acquisition | Uniform random pairing by default; BALD selectable, Thompson kept for contrast | Measured tie with BALD over 20 paired seeds in two pool regimes; uniform has no tuning constants and makes every duel an unbiased calibration sample |
| Calibration metric | Brier skill against the 0.5 baseline, plus random check duels | Accuracy is not proper and is pinned near chance by an information-seeking pairing rule |
| Recency | Discounted likelihood, half-life 150 observations | Taste is allowed to change; stationarity is the wrong assumption about a person |
| First frontend | Web / WASM | Both deps ship WASM; fastest UX iteration; shareable |
| Session UX | All three modes, built duels → grid → radio | Same observation stream; sequenced by signal quality |
| Safety | Vetting gate: audition = pre-rendered vetted buffers, never live unvetted patches | One render serves health-check, features, and playback; quarantine + fitness shaping teach evolution to avoid pathology |
What is not in this table
Two kinds of thing deliberately stay out of it, and three live on their own pages: open questions, which are decisions that have not been made rather than decisions that have; unraised directions, which are possibilities nobody has yet argued about either way — the distinction matters, because a reader cannot otherwise tell rejected from never considered; and what the audition cannot hear, which is the one premise underneath several rows of this table rather than a row of it.
Reversible details. Buffer sizes, the exact number of MCMC steps, which easing curve a knob uses. These live in the code and in the pages that quote them by name; a decisions log that tracks them stops being readable.
The pass-by-pass history. What changed when is
CHANGELOG.md.
This table is evergreen: it says what is true now and why, not what was true in
March.
Milestones and the gates that closed them
Each milestone had a demo that either worked or did not. None of them closed on "the code is written".
| # | Deliverable | Demo / gate | Status |
|---|---|---|---|
| M0 | Workspace scaffold, CI | cargo check green | ✅ |
| M1 | auracle-grammar: PCFG, palette, term→Patch compiler | Play random grammar samples (already fun) | ✅ |
| M2 | auracle-features: phrase renderer, LUFS, feature vector | Feature vectors stable & reproducible for fixed seeds | ✅ |
| M3 | auracle-taste: mixture-BLR (K=1), 3 likelihoods, synthetic user | Posterior recovers ground-truth θ*; regret shrinks | ✅ |
| M4 | auracle-session: two-loop engine + acquisition | Headless closed loop vs synthetic user | ✅ |
| M5 | WASM + web app: duel mode + bench | A human can teach it their taste | ✅ |
| M6 | Grid & radio modes; K>1 style discovery; named profiles | Styles discovered & pinnable | ◑ |
Status (2026-08-05). M0–M5 complete. Of M6, K>1 style discovery has shipped (dynamic K, max-of-experts, post-hoc label alignment), styles are nameable and persist, and profiles export and import as a portable observation log plus its standardizer. Grid and radio modes remain open, and with them keep/kill's triage surface: today it is emitted only by the bank's cut, as a kill.
The pass-by-pass record is
CHANGELOG.md.
The synthetic user, and why M3 was a gate
Before any UI existed, the taste crate was validated against a simulated user: ground-truth θ* (and ground-truth styles for K>1), synthetic duels/stars/keep-kills with realistic noise, asserting
- posterior concentration on θ*, and
- shrinking regret of the acquisition loop.
That makes the core falsifiable headlessly, and it later doubled as a demo mode — watch it learn a fake user in fast-forward.
It has since become the harness the engine's own tuning is settled on.
search_health --budget-ab chose the 40 × 10 refinement
split, and learn_synthetic --compare produced the
acquisition measurement. The closed-loop test runs
the real grammar → render → vet → feature pipeline, and it is the only test in
the workspace that can fail when the loop is broken while every component is
individually correct.
Session UX: three modes, one observation stream
All modes are emitters into the same observation log. The build order was duels → grid → radio, sequenced by signal quality rather than by effort.
- Duel stream + workbench — shipped. The core loop is A/B duels in EVOLVE; candidates land on the PLAY workbench (since renamed PATCH) where stars, free-play, hand edits, locks and export happen. TASTE is the model reporting on itself: the map, the style lenses, their coefficients with credible intervals, and the calibration diagram.
- Population grid — open. See a generation at once, rate/cull/breed; keeps evosynth v1's "generations" mental model for users who want to steer. This is where keep/kill triage would get its surface.
- Radio mode — open. Lean-back continuous stream with keep/kill/skip; the payoff once generation quality is high.
The app grew a long way beyond the original duel-mode scope on the way there: four-voice AudioWorklet polyphony, MIDI, an arpeggiator, an interactive lockable rack with typed rewiring and a node bank, three separate banks, session persistence in IndexedDB, and a 62-patch preset library across seven families.
Open questions
Things this design has not settled. They are written down here rather than left out, because a reference that only describes what works is not a description of the system.
Every entry below is a question this design raised. Possibilities it never raised are a separate register — unraised directions — and an entry there graduates onto this page as soon as someone can state the measurement that would settle it.
-
Tempered SMC for generation. The Boltzmann target is written down but not sampled from. Whether the crossover population kernel is worth the complexity over local climbing is untested; the measured non-concentration of the pool — it widens slightly over a session — weakens the diversity argument for it.
-
Cross-island discovery.Closed by measurement, and it was wrong. This entry read: "local refinement from island A will not find island B; a tempering schedule would cross the valley." Measured against a bimodal synthetic user (make islands), 20.9 % of refinement events cross islands — 13.5 % of all events decisively, with both ends more than 1.0 onto their island rather than hovering at the boundary — and 0 of 8 seeds ended with a pool on one island only.The reasoning was wrong about the geometry. The walk is not local in feature space: it is a reversible-jump walk over a tree grammar, and a single accepted structural move swaps a subtree, which is a large jump in φ. There is no valley to cross, because the search does not have to travel through the space between the islands. See Refinement.
-
Fan-out and feedback in the grammar. Two separate ceilings, deferred together because they are the two things the term algebra cannot say.
This entry used to read "the grammar is DAG-only today", which understated it in the direction that matters. The genome is a tree —
term.rssays so in its first line — and a tree forbids more than cycles. It forbids sharing: one output cannot feed two places, so there is no shared sub-patch. A DAG would already allow that. The distinction is the whole of the first half of this entry, and the docs were describing the looser of the two ceilings.Fan-out is the more valuable of the two and the more invasive. One oscillator into both a filter and a delay line, summed — an idiom so ordinary that the app already has to explain its absence. The connect offer does that well, volunteering the constraint as a fact ("A copy: one output cannot feed two places"), and the panel called it the best copy in the product. But it is still a ceiling being narrated rather than lifted.
The cost is not the grammar rule; it is everything keyed to the tree. Child indices (
node/0,node/1) are the trace addressing, so a shared node has no single path and the address scheme stops being a naming of the term. That scheme is load-bearing for panel knobs, locks, live parameter handles, MH proposals and the persisted genome —CONTRIBUTING.mdlists it as a sharp edge for exactly this reason.children(),size(),depth()andsite_count()all assume each node is visited once, andsizeis φ's parsimony term. Every structural op inmutateassumes a unique parent. It is a genome-format change with a migration, not a production.Feedback needs a mandatory attenuator and limiter in the loop path, and a delay of at least one sample to be computable at all. quiver's graph is evaluated per sample in dependency order, so a cycle needs an explicit unit-delay node to break it — which is a real design, not a relaxation of the acyclicity check.
Deferred, deliberately, and this is the record of it. Neither is blocked on evidence — no measurement would change the answer — so neither belongs in the "measure it" pile with the rest of this page. They are blocked on being worth a genome migration, and nothing in the loop currently says they are: the search is not starved for expressiveness (refinement crosses islands, and the pool widens rather than concentrates). Re-open this when a listener wants something the tree cannot say, rather than when someone notices it cannot say it.
-
Per-style audition phrases — a discovered bass style picks a bassline, a pad style a chord swell. Still open, and worth stating precisely what stands in the way, because the migration mechanism is not it.
The
:p2stimulus tag does solve the history problem: a phrase change renames the audio coordinates, old votes keep their stimulus-independent structural coordinates, and their old-stimulus audio coordinates are imputed as "no evidence" — which the likelihood now handles honestly rather than as a measurement. Two prerequisites are therefore already met, and one is not:- Comparability is fine, contrary to the obvious worry. The phrase is a
property of the session (
SessionConfig::phrase), not of a candidate, so everything in a pool is auditioned under one stimulus and duels stay apples-to-apples. Per-style phrases only make sense as per-context phrases for the same reason. - The tag would have to be derived rather than declared.
:p2is a hard-coded literal inAudioFeatures::NAMES, andFeatures::phi_namesis global. A phrase that varies needs the tag to be a function of thePhraseSpecactually rendered, or the names silently stop describing the numbers. - The real obstacle is circular, and it is a design problem rather than an engineering one. A style is discovered — it is an inference from φ. φ is measured under a phrase. If the phrase is chosen by the style, then the stimulus depends on an inference that depends on the stimulus. That loop can be broken (bootstrap from the standard phrase, switch only once a style's share is confidently high, never re-audition history), but every way of breaking it is a decision about how much the instrument is allowed to change what it is measuring while it measures it.
Deferred until that loop has an answer worth defending, rather than until someone has time — the mechanism is ready and the question is not.
- Comparability is fine, contrary to the obvious worry. The phrase is a
property of the session (
-
Where acquisition would earn its keep.Measured, and the tie does not break. BALD ties uniform pairing at session horizon, and this entry named two regimes where that should stop being true: a much larger pool, or a much longer session. Both were run at 20 CRN-paired seeds,bald − random:regime cos θ* rank r excess nats baseline (pool 48, 6 rounds) +0.059 ± 0.046 (static) +0.045 ± 0.068 −0.013 ± 0.012 (static) pool 192 −0.002 ± 0.044 +0.015 ± 0.061 −0.001 ± 0.012 24 rounds (288 duels) +0.031 ± 0.042 +0.013 ± 0.013 −0.003 ± 0.006 At the baseline BALD has two marginal wins in the static regime (t = 2.6 and −2.2). Widening the pool fourfold removes them rather than growing them, and lengthening the session fourfold leaves everything inside noise with several signs flipped. The reasoning behind the entry — that a bigger pair space gives an information-seeking rule more redundancy to prune — does not survive being tried.
What is stable across all three regimes is that BALD beats dueling Thompson (t = 2.9 to 6.9), which was already known and is unchanged.
So uniform random pairing stands as the default on the same grounds it always had: it ties the information-seeking rule everywhere anyone has looked, has no tuning constants, and makes every duel an unbiased calibration sample. The session-length knob this needed (
--rounds) is now inlearn_syntheticbeside--pool, so the next person can ask a third regime without patching a constant. -
Fit cost at the K cap. Single-site MH re-executes the whole program per step, so a mature fit is both slower and statistically thinner than an early one (225 + S sites over a fixed 10 000 steps ≈ 44 sweeps per site). The address table is hoisted out of the step loop and the chain no longer holds itself in memory, so what is left is purely the statistical shape of the problem — the budget can now be chosen on the recovery tables rather than against a memory ceiling. The written-down option (cap at 3) is gated on
style_shareevidence from real sessions.That evidence is now collected. Every posterior fit records what fraction of the pool each lens claimed, and the register persists across reloads (
Engine::style_shares). The question is still open — it wants sessions, which take time to accumulate, and synthetic runs cannot answer it — but it is now open for want of data rather than for want of an instrument. Rows wherek == k_stylesare the ones that bear on it:kgrows with the log, so an early row with two lenses is not evidence that lenses 3–5 are idle. -
Which state of a refinement walk to inject.Run, and it tied. A walk renders ~40 candidates and keeps one;RefineKeep::Besttakes the highest-log π_βstate the walk occupied, seed included, and ships switched off. Sixteen paired seeds:LastBestmean gain +1.927 ± 0.452 +1.774 ± 0.302 median gain +2.058 +1.819 10% trimmed +1.840 ± 0.383 +1.925 ± 0.190 climbed on 14/16 15/16 Paired difference (
Best−Last): mean −0.153 ± 0.384, median −0.185, trimmed −0.113 ± 0.318, sign test 8 better / 8 worse (p = 1.000). As exact a tie as sixteen seeds can produce, and it does not clear zero at 2 se on any statistic — so the default staysLast, kept re-checkable rather than deleted, asAcquisition::Thompsonis.Neither the feared failure nor the hoped-for win appeared. The worry was that argmax over a surrogate would deepen the catastrophic tail; across the pair the tails are a wash. What did show is that
Bestis the lower-variance rule rather than the better one — half the trimmed standard error (0.190 against 0.383). Injecting the walk's argmax is more consistent than injecting where it stopped; it just does not aim anywhere better on average. That is the argument to re-run this on if the surrogate ever gets sharper. -
Interior signal taps in quiver.Closed, and it was wrong. This entry read: "a compiled patch exposes exactly one output … a quiver-side probe API would turn both into measurements. Not filed — it needs scoping first." There was nothing to scope and nothing to file. quiver'sStateObserverhas takenLevel,ScopeandSpectrumsubscriptions on any node port for some time, in the release the lockfile already pinned. The gap was here, not upstream.The compiler now records where each term node's audio leaves it, and the rack's flow animation multiplies a measured RMS into its reach factor while notes sound. Two expectations about the work turned out not to hold either:
sync_output_keepaliveis unnecessary, because the genome is a tree and every module's output already feeds a parent, so quiver is already computing every metered value; and the open design question — which of N voices to meter — has an answer, the most recently pressed one, since a sum across the bank averages notes at different envelope phases and is not the level on any wire. The port trace stays an offline render, now by choice: it wants the same phrase every time so that two looks at it are comparable. -
fugue-evo'sClosed, and it was wrong three times over. The entry read: "it does not compile there, so the workspace takes fugue-evo with default features off and refinement is single-threaded natively too — in the one place the engine is embarrassingly parallel."parallelfeature on wasm32.Wrong about the blocker: fugue-evo#22 established that
checkpoint, notparallel, was the only thing that did not build on wasm32. Wrong about the remedy: enablingparallelwould change nothing here, because everyrayonuse in fugue-evo sits under#[cfg(feature = "classic")], and Auracle takes["std", "ppl"]and drives refinement itself throughinference::mh::EvolutionChain. And wrong about the prize: the harness is not waiting on single-threaded refinement.search_healthand therefinement_improves_poolfloor already spawn one thread per seed and saturate the machine, so parallelising inside a refinement cannot make a 16-seed measurement faster — the cores are already busy.What is left is real but smaller than the entry implies, and it is a UX number rather than a harness one: latency on a single refinement, which is the app's ⚡ button. Filed as that, not as a build-configuration change.
-
Bright and Body are entangled on a filtered bass (open). On Acid Line the cutoff is the obvious brightness knob, +3.2σ of centroid per unit, and the named-control wiring does not use it: the same move drops
bass_fractionby 4.6σ, which is Body's axis, so the cross-talk gate is right to call it impure, and Bright reads search on the patch where a player most expects it. Solving in the six-dimensional named subspace instead of across φ was tried and reverted: with more knobs than named axes it can always cancel the cross-talk, and it did so musically — Ceiling's "Bright" became a shorter release. Candidates: let Bright own a little of Body's axis (a brightening is expected to thin a bass), or report a coupled control honestly ("brighter, and thinner") instead of hiding it. Neither is decided. Update: the wiring now tries a control's own sites first (Bright: cutoff, tone, …) and ranks knobs by effect rather than coefficient, and on the re-voiced Acid Line the cutoff alone clears the gate for Bright: purity 0.87, reach 2.85σ. The entanglement is still there, and now shows up on the other side. Body's best move is the same cutoff turned the other way, soseparatemakes Body the search control. -
Grit hears noise, not saturation (open). Grit's axis is
flatness_mean, and a drive on a tonal sound adds harmonics, which φ reads as Bright, not as flatness. On Iron Bass, a saw through a tube drive, drive moves flatness by 0.000σ per unit. Across 24 fresh-pool patches Grit reaches 0 to 1. A bitcrusher does move flatness. It is transparent only at 16 bits, though, where its slope is zero and the local measurement cannot see it, and it clips anything hotter than its ±5 V window. So PERFORM grafts nothing for Grit. What would fix it is a roughness descriptor in φ (inharmonic or beating partials, or Sethares-style sensory dissonance) with Grit's axis on it. That is a φ change, and it needs aRENDER_EPOCHbump and a taste-model revalidation. -
Remaining quiver hardening — closed.
voct_to_hzgained a ±32-octave clamp in quiver-dsp 0.3.0, and auracle pins 0.3.3 as of the September 2026 audit. Renders inside ±32 octaves are unchanged, so noRENDER_EPOCHwas bumped for it; the render-cache namespace now carries the quiver version as its own coordinate, which orphans the stored rows from 0.2.0 anyway — the right outcome, because for pathological CV (chainedOffsets past ±32 octaves) the two versions render differently: 0.2.0 recovered an infinite increment by phase reset, 0.3.x aliases at a finite ~THz pitch. Both are garbage the vet gate quarantines; they are not the same garbage. -
Frame silence is recognised only at exactly zero power (AU-F2, open).
audio.rssplits the phrase into chains at frames whose unnormalised FFT power is below1e-12, which is an amplitude of ≈2e-9 (−173 dBFS). Rests read as silent only because quiver'sAdsrsnaps exactly to 0 at the end of its release and the VCA is multiplicative; a release or chord tail that outlasts a 0.15–0.2 s rest never gets a chain break, and the flux fix (#51) and the segment features depend on one. The likely fix is a threshold relative to the phrase (−60 dB of global RMS, say). It moves φ for every patch with a tail, so it is aRENDER_EPOCHbump and owes the measurement — φ over prior draws before and after, andmake revalidate— that has not been made. Documented at the line rather than changed blind. -
The brightness cluster in φ_audio.
rolloff_mean,zcr_meanandcentroid_meanare three genuine measurements of one perceptual thing. A fused prior over the cluster is now implemented and switched off, which is a more useful state than either "not done" or "done".The VIFs quoted when this was written were 18.4 / 10.4 / 5.9; after the ZCR DC removal they measure 16.9 / 9.7 / 5.9 and
zcr_meanno longer trips the collinearity flag at all. A third of the original argument was a coordinate bug rather than a modelling problem.Two gates were run at ρ = 0.25 and they disagreed. The closed-loop gate, which scores θ recovery, improved (0.657 → 0.702). The 48-seed paired climb, which scores what the pool is worth to the listener, regressed — −0.579 ± 0.188 trimmed (−3.09 se), sign test 16 better / 32 worse (p = 0.029). So it ships at ρ = 0.
Both results are real because they measure different things: pooling an ill-conditioned ridge regularizes estimating θ, and biases the search that consumes θ. The general point outlives the feature — a VIF says these coordinates move together across patches, which is a fact about φ; fusing their coefficients asserts a listener's preferences move together, which is a fact about people and does not follow. Re-open if the listener model ever gains a reason to believe it does; the sweep and both gates are there to re-run.
Directions the design has not raised
The counterpart to the open questions. An open question is a decision this design put on the table and has not settled; what follows was never on the table. Written down so that not considered and considered and rejected stop looking alike from the outside.
Everything here came out of a review pass in August 2026 that read the books and the crates and asked what the architecture already supports that nobody has argued about. Nothing on this page has been decided, and several entries name the reason they might be wrong.
Why this is a fourth register
| Page | Holds | The state it records |
|---|---|---|
| Decisions log | A choice, and what it beat | Settled |
| Milestones | A deliverable and the gate that closed it | Done, or not |
| Open questions | Questions this design raised | Unsettled, and named |
| This page | Possibilities this design never raised | Unexamined |
| What the audition cannot hear | One premise, and where it costs | Assumed |
The last row is the odd one and the sharpest. An entry here is an opportunity; an entry there is a limit on what any amount of modelling can buy, because it names something the measurement does not contain. Two entries on this page — §2 and §3 — have their strongest arguments over there rather than here.
The rule for leaving this page is what keeps it from becoming a wishlist. An entry graduates into open questions as soon as someone can state the measurement that would settle it, and into the decisions log once it is made. An entry that can be stated as neither is speculation and should be deleted rather than kept.
Each entry therefore names three things: the machinery that already exists, what stands in the way, and what would settle it. Most of these are one shell or one substitution away from working, which is the reason they are worth a page rather than an issue.
| # | Direction | Leans on |
|---|---|---|
| 1 | An evidence path for the questions that need real sessions | Profile = log + standardizer |
| 2 | Search toward a reference sound rather than toward a fitted taste | The target consumes as a black box |
| 3 | Declared context, which dissolves the per-style-phrase loop | presets::CATEGORIES, SessionConfig::phrase |
| 4 | Radio as the fix for observation volume, not as the third mode | The keep/kill likelihood, already fitted |
| 5 | A multisample export, as the cheap exit into a DAW | Deterministic headless rendering |
| 6 | Refinement that runs while nobody is watching | The native harnesses, the render farm |
| 7 | Counterfactual explanation — the edit with the largest predicted gain | Trace addresses, the structural edit ops |
| 8 | A map whose axes are style lenses rather than principal components | TasteMap, and its own converged flag |
| 9 | The taste crate as a general preference-learning library | φ enters by name, and nothing else is audio |
| 10 | The measurements as published results | The changelog already holds them |
| 11 | The production→brightness map, so audio taste can tilt proposals too | Every (term, spec) → φ ever computed |
| 12 | Building the cascade the φ split is justified by | φ_struct is render-free |
| 13 | Refinement seeds across the farm, for ⚡ latency | The seeds are independent; the farm is idle |
| 14 | A per-session cutpoint offset, by the argument already made for | The design, one likelihood over |
| 15 | A sweep-denominated fit budget, as the alternative to capping K | fit_bench already measures the axis |
| 16 | A per-provenance likelihood temperature | ProvenanceScore, already computed |
The pattern entries 11–16 came out of
Six of these are one observation wearing six hats: Auracle is a better instrument than it is a consumer of its own instruments. It pays to generate labelled data about itself and then reads that data once, literally.
| Recorded | Consumed as |
|---|---|
Engine::style_shares, per fit | nothing yet (§1) |
Calibration split by Provenance | a display (§16) |
| Quarantine reasons | one scalar, QUARANTINE_FITNESS (§12) |
| The persistent render cache | an exact-match lookup (§11) |
LineageEvent, with utilities at event time | a caption (§7) |
A cache is a lookup table; a model is a lookup table that generalizes. Every row above is the first of those and could be the second.
1. The register is gated on evidence that cannot arrive
The fit-cost entry in open questions says this in as many
words: it is "open for want of data rather than for want of an instrument",
and Engine::style_shares was built to collect exactly the rows that bear on it
— the ones where k == k_styles. Behind it sits the question the K cap is a
proxy for, which is whether a listener ever uses five lenses at all.
Those rows are on other people's machines, and there is no path off them. Persistence is unambiguous: IndexedDB under the page's origin, no account, no server, nothing transmitted, and the only backup that exists is a profile the user exported by hand.
So a project whose method is close the question by measuring it has a class of questions it cannot close. That is a structural fact about the architecture, not a backlog item, and it is the single most consequential thing on this page.
The mechanism already exists. A Profile is the log
plus its standardizer — self-contained, portable, raw stored by name,
already exposed as ⋯ → Save taste profile. What is missing is a
destination, not a format.
What this must not become. The decisions log rejects implicit signals, and the guide is explicit that listen time, replays and hovers are not recorded. That decision is about what the model is allowed to learn from, and it should survive this entry untouched. The property worth keeping is nothing leaves without a deliberate gesture at a file the user can read — which an opt-in donation of an exported profile satisfies and telemetry does not. A donation path that is a button plus a place to send the file (an issue attachment is sufficient; no server is required) keeps every word of persistence true.
What it would close, and what it opens. Directly: the K-cap question, whose
evidence is the rows where k == k_styles. Indirectly, and worth more, a
population prior on . Cold start today is 44 coordinates starting
from a prior mean of zero, against which the three-pick warm
start buys 18 observations in thirty
seconds. A hierarchical prior fitted across donated profiles is the only
available lever that moves the starting point rather than the rate — and
because the stack underneath is a probabilistic programming language, a
hierarchical prior is a model that can be written rather than an inference
engine that has to be built.
Two reasons it might be wrong, both worth stating before it is built. A donated corpus is self-selected — it answers how many lenses does an enthusiast use, which is not the question. And a population prior asserts that other listeners' preferences are evidence about yours, which is close to the assumption the max-of-experts design already refused at the level of one listener's islands; refusing it within a person and accepting it across people needs an argument, not an analogy.
What would settle it. Cross-validation on donated profiles: does a population-prior warm start beat the three-pick warm start on held-out duels over a session's first hundred observations? That is an offline measurement with no live user in it, and it can be run the day a corpus exists.
2. Target-directed search: "make it sound like this"
Every path through this system learns a utility. But the Boltzmann target consumes as a black box:
Nothing in the search requires to be fitted. Substituting a distance to a reference vector,
turns the whole apparatus — MH in trace space, reversible jump, vetting, the pool, and locks as exact conditional refinement — into a matcher, with no new algorithm. Locks are what make it more than a novelty: match this, and leave my filter section alone is a conditional match, which is a thing the machinery already does exactly rather than heuristically.
This is the most common real sound-design task, and it is the one shape of it the instrument cannot express today.
The distance can only be over the audio half, and that is a feature. A recording a user brings has no term, so it has no φ_struct at all — 26 of the 44 coordinates are simply not defined for it, and has to zero them. That is the right behaviour rather than a limitation: sound like this, by whatever means, with the grammar prior left to supply the parsimony that keeps the means sane. A match that also scored structure would be asking the search to reproduce a topology the reference never had.
What actually stands in the way is φ_audio's stimulus dependence.
is measured under the standard phrase; the
:p2 tag exists precisely because
a coordinate's meaning is relative to the stimulus that produced it, and a
reference clip is not that phrase. Twelve of the eighteen
φ_audio coordinates are whole-phrase statistics and
survive the mismatch with a caveat about pitch content; the six
segment-local ones (held_centroid_std and the three motion bands, on
the first held note; high_ratio; chord_flatness_delta) find their roles
by property — first note, highest
note, first chord — and arbitrary audio may have none of them. The imputation
is already honest, since an absent coordinate reads as no evidence, but here
the absence lands on the axes describing timbral motion and register, which is
where a listener's "sounds like" often lives.
The honest form is therefore a partial match with the matched subset named on screen, rather than a match that quietly scores twelve coordinates and calls it a likeness.
There is also a reason to want this beyond convenience, and it is the workflow argument: duels ask which of two a listener prefers, and sound design is more often the pursuit of something already imagined. Target-directed search is the missing half of that loop rather than an extra mode of it.
What would settle it. No human is needed for the first pass: hold out a
patch from the prior, treat its as the reference, and measure how far
refinement closes the distance against a random-restart baseline over paired
seeds — the same shape as the existing search_health measurements. If that
fails, the idea is dead before any UI is drawn.
3. Declared context, and the circularity it dissolves
The per-style audition phrase entry is blocked on a loop, and the entry states it precisely: a style is discovered — an inference from — and is measured under a phrase, so a phrase chosen by style makes the stimulus depend on an inference that depends on the stimulus.
That loop is a property of discovery, not of per-context stimulus. It
disappears entirely if the context is declared: I am hunting a bass
tonight is a statement, not an inference, and a phrase chosen by it depends on
nothing that was measured. Comparability holds by the argument the entry
already makes — the phrase is a property of the SessionConfig, so everything
in a pool is auditioned under one stimulus and duels stay apples-to-apples.
The declared context also already exists, half-built and discarded at the model
boundary: presets::CATEGORIES is a seven-way family label (bass, lead,
keys, pad, texture, perc, weird) carried by all 62 presets, used for
copy and for spanning the warm start,
and read by nothing downstream.
What it costs. The utility becomes , and there are two shapes for that. Appending a context block to keeps the model linear and lets a single coefficient mean brightness matters more to me in a lead; a per-context set of lens weights is the heavier design, multiplying the coefficients that must be paid for by evidence, which is the resource the cold start is already short of. The first is the one that fits the model that exists.
The cost that will actually bite is historical: an observation becomes a judgement about a (patch, context) pair, and every vote already in every log has no context. Under the by-name imputation rule that reads as the standardizer's mean, which is honest and weak — the correct behaviour, and still a real dilution of a user's history the first time it ships.
This does not close the open question; it reroutes it. Per-style phrases stay exactly as open as they are. What changes is that the per-context half of the value — a bassline for basses, a chord swell for pads — becomes reachable without answering the circularity at all.
The musical argument for declaring context is stronger than this modelling one, and it is on the other page: a preference fitted over a whole history converges on the average of a taste, and a player needs the corner they are working in tonight.
4. Radio is a throughput fix, not the third mode
Grid and radio remain open, and keep/kill is a fitted likelihood whose only surface, the bank's cut, records kills alone. The usual reading is two modes left to build. There is a sharper one.
The two unbuilt modes are the ones that produce observations in bulk — grid judges a generation at a time, radio never stops — and radio in particular produces them at the rate of listening rather than at the rate of deciding. A duel costs concentrated attention and a session yields tens of them. That difference is not a matter of degree for this model, because several of its mechanisms are denominated in observation counts:
- Recency weights a vote places back by . A user whose entire history is a few hundred observations lives inside the first half-life, and forgetting — built, documented and figured — never does anything.
- K grows with evidence, so the multi-modal taste the max-of-experts design exists to represent needs enough log to discover a second lens at all.
- Calibration is a Brier skill score against 0.5, plus random check duels. Its error bars are a sample-size problem.
So radio is not the third mode. It is what makes the model's own dynamics observable, and it should be ranked on that rather than on effort.
The counter-argument, which is real. Keep/kill is the weakest signal per observation, and radio would flood the log with it. Worse, recency is denominated in log positions, so a flood of cheap observations pushes a considered duel past the half-life faster in wall-clock terms — the unit silently changes meaning when the observation rate changes. If radio ships, the recency weight probably has to become per-signal-kind, or be denominated in time rather than in positions. That is a modelling decision hiding inside a UI mode, and finding it before building is most of the value of writing this entry down.
5. The bank does not leave the browser
What comes out today is a WAV of your own playing, a patch file, and a profile.
Everything a musician does next happens in a DAW. Lineage names
the intended shells — nih-plug VST3/CLAP, then AUv3 — and neither is started.
There is a cheaper exit that does not wait on a plugin, and the
determinism contract is what makes it nearly free: —
and the render it comes from — is a pure function of , which is the property render_key and RENDER_EPOCH already
assert. A multisample export — a grid of offline renders across pitch and
velocity, plus an SFZ mapping, which is a plain-text format — puts any patch in
the bank into every DAW that loads a sampler, today, with no audio-thread work
and no shell.
A second, smaller one: the genome is a term with a compiler, so export this patch as quiver source costs a printer and makes a patch inspectable, diffable and portable to a native host.
What it does not solve, and the entry should not pretend otherwise. A multisample is a snapshot of an instrument, not the instrument. Per-note modulation, the arpeggiator, anything that responds to how it is played, and the live parameter handles that make the rack worth touching do not survive sampling. This is a bridge that gets patches used while the real answer is built, and its value is entirely in being available years earlier.
6. The machine loop stops when the tab closes
The two-loop architecture describes a machine-paced loop that evaluates thousands of candidates against what it has learned. It does that only while a page is open and a person is sitting there.
Every piece of an offline mode exists. The harnesses (search_health,
learn_synthetic, closed_loop_sweep) run the real grammar → render → vet →
featurize pipeline natively; the render farm
already parallelizes; a Profile already round-trips. Leave it running and
come back to a pool that has been refined for an hour needs a shell, not an
algorithm.
Two things are worth saying before anyone builds it. The pool would be refined against a that is stale by exactly as long as you were away — fine, because moves slower than the pool does, but it should be stated rather than discovered. And a browser tab cannot do this: background timers are throttled and the tab may simply be discarded. So this is a native direction, and it is the first argument for a non-web shell that is not a plugin.
7. Explanation stops at the coefficients
The TASTE view reports the map, the style lenses, with credible intervals and the calibration diagram. That is already more than the genre offers, and it is descriptive: it says what the model believes, not what to do about this patch.
The next rung is counterfactual, and trace
addresses make it close to free. Enumerate the
structural edit ops at every node plus a knob write at
every parameter site, score each result with the fitted , and report the
argmax: this patch sits 0.8 below your best; the single largest predicted gain
is node/0#cut +0.3. Every ingredient — the address scheme, the ops, the
utility, the compiler that guarantees the result is playable — is built and
tested.
Two caveats that decide whether it ships honestly. is a fitted
quantity with uncertainty, so an argmax over it is an argmax over a surrogate —
the same move the RefineKeep::Best measurement found to
be lower-variance rather than better. A suggestion without its interval
overclaims in exactly the way this project's TRUST surfaces exist to prevent.
And a confidently-signed suggestion that is wrong is the failure a user
notices and does not forgive, which argues for reporting the top few with
intervals rather than one imperative.
This is also the concrete cash-out of the README's claim against star-a-generation synths — that they cannot tell you why. Coefficients are an answer to that. A named edit is the answer a person asked for.
8. The map's axes could be learned rather than principal
The taste map projects with PCA over standardized
— top two principal axes by power iteration — and TasteMap carries
a converged: [bool; 2] flag because the top two eigenvalues can near-tie.
The doc comment names the reason: the brightness cluster is three genuine
measurements of one perceptual thing. The map is already honest about being
potentially unstable.
An alternative is to project onto learned directions instead: the top two style lens vectors (orthogonalized), or utility against posterior standard deviation. Those axes are stable against the eigenvalue tie by construction, and — the part that matters for a surface inviting someone to recognise territory — they are nameable: "more like your style 1" is a thing a person can hold, and "the first principal axis of a standardized feature matrix" is not.
Not obviously better, and the reason is worth keeping. A learned axis moves when the model does, so the territory changes meaning between fits. That is a different instability, moved from the solver to the model — arguably worse for a map, arguably better because it is explicable and can be announced.
What would settle it. Measure the rotation of each projection's axes
between consecutive fits over a session and compare. That is a small
instrumentation on top of TasteMap and a number, not an opinion.
9. auracle-taste is domain-independent and welded to a synth
Strip the words. The crate learns a scalar utility over from three observation kinds — pairwise comparisons, ordinal ratings against fitted cutpoints, and a threshold decision — with a max-of-experts form for multi-modal preference, recency weighting, a calibration report, and a synthetic-user harness that falsifies the whole thing headlessly.
None of that is about audio. The only audio-shaped thing is , and it
enters by name through FitSet::build — the same property that makes
profiles survive a feature-set change makes the crate indifferent to what the
features measure. Fonts, colour ramps, shader parameters, recipes,
hyperparameters: the model does not know the difference.
The reason to wait is real and should be the stated one. Extraction is an API commitment, and the project is 0.x with a save format that still moves; publishing a crate whose serialization is not settled buys a migration obligation to strangers. That is a timing argument, which is a much better reason than "no time", and it means the entry has a trigger: revisit at 1.0, or when a second consumer actually exists.
10. The measurements are a result, and results travel
The acquisition entry is a well-powered null. BALD ties uniform random pairing on cosine similarity to , on rank correlation and on excess nats across three regimes at 20 CRN-paired seeds, while beating dueling Thompson (t = 2.9 to 6.9) throughout. The literature on preference elicitation is full of acquisition functions and nearly empty of properly paired nulls, and a null is the result a practitioner most needs and least often finds.
The fused brightness prior is the same kind of thing: two gates that disagreed, with a general reason attached — a VIF is a fact about patches, and fusing coefficients asserts something about people, which does not follow.
Neither needs any work. They are written, sourced, and reproducible from a checkout. What is missing is a destination, and the gap between "recorded in a changelog" and "somewhere a person searching for the answer will find it" is the entire distance.
11. The model nobody has fitted is in the render cache
Proposals closes by explaining why only the structural half of tilts the grammar:
Turning
centroid_meaninto a proposal tilt would require a model of which productions raise brightness, which is a model nobody has fitted.
That model's training set already exists, in quantity, and the project is
already paying to store it. Every the
system has ever computed is a labelled row, and the
persistent render cache is
exactly a table of them that survives reloads. pipeline_stats already draws
1200 prior samples to compute the VIFs; the same draws are a design matrix. A
ridge fit of the eighteen audio coordinates on the twenty-six structural ones is
, which is the missing map.
Why it is worth more than it sounds. The coefficients a listener can actually recognise in themselves — bright, slow attack, long tail — are exactly the ones that today only score. The structural half does double duty (scores and proposes); the audio half is half-employed. The asymmetry is documented and argued for honestly, but it has never been costed, and it is costed in the currency the tilt exists to buy: a search that looks where the model expects to find things.
The objection, and why the existing design already answers it. Brightness is a property of the composition, not of a module — a filter's effect depends on what precedes it — so a linear map is crude. But a tilt needs only a sign and a rough magnitude, and it is already clamped to . The clamp exists to stop a confident coefficient starving a kind; it doubles as the rail that makes a crude map safe to consult.
What would settle it. The search_health --budget-ab shape: does
audio-tilted proposal weighting beat structural-only tilting on a synthetic
user's true utility, over paired seeds? The harness that answered the 40 × 10
split answers this unchanged.
12. The screening cascade is cited, and unbuilt
auracle-features/src/structural.rs says, in the present tense:
These cost nothing (no compile, no render), which is what makes the screening cascade work: a struct-only surrogate prunes candidates before the expensive render path.
φ_struct and Refinement both repeat it, and it is part of the stated justification for being two-part at all.
Nothing in auracle-session prunes anything. SurrogateFitness::evaluate
calls featurize_memo for every candidate it is handed — a full
compile → render → vet → featurize — and there is no screen, cascade or
pre-filter in the crate. This entry is therefore half a direction and half a
correction: either the cascade gets built, or three places should say makes
possible rather than works.
The risk profile is unusually good, and it is worth naming. A screen can only ever waste opportunity; it can never corrupt evidence. Anything actually injected is still really rendered, really vetted and really featurized, so the observation log is untouched no matter how wrong the screen is. Very little else in this system has that property.
Where not to build it. Refinement already says its affordability comes from the render memo rather than from screening, because the walk re-scores its own current state every step. So the payoff is in pool fill, which is the path nobody has looked at.
And the cheapest version needs no model at all. Every quarantined candidate
is a labelled row, and today the label is
consumed as a single scalar, QUARANTINE_FITNESS. A screen fitted on those
labels prunes exactly the candidates whose render was guaranteed to be thrown
away.
13. Ten independent walks, run one at a time
pub fn refine<R: Rng>(&mut self, rng: &mut R) {
for parent_id in self.refine_begin() {
self.refine_seed(rng, parent_id);
}
}
An MH walk is inherently sequential. The ten seeds are not — they are
independent by construction, which is why search_health already spawns one
thread per seed natively. In the browser they run one after another, while the
render farm — a pool of wasm workers — sits
idle for the duration.
This is not the closed parallel-feature entry in
open questions. That one was about rayon inside
fugue-evo and it was correctly closed. This is about distributing whole
seed-walks across workers that already exist.
The blocker is real and worth stating: farm workers are deliberately stateless
(term, phrase) → φ, and a walk needs the kernel and the posterior, so they
would have to become walk workers with a much heavier contract and a
versioning problem the render protocol does not have. The prize is the number
that same entry already identified as the one that matters — ⚡ latency — and it
is a factor of the seed count rather than a few percent.
14. Stars have a global scale; keep/kill has a per-session one
Likelihoods argues for the per-session threshold exactly right:
Without the per-session threshold, a strict day and a generous day would average into a meaningless global bar, and both days' data would be degraded by the other's.
That argument is fully general over absolute-scale signals, and the star cutpoints are fitted globally. Worse, the asymmetry runs the wrong way round from ship state: keep/kill reaches the log only as kills, from the bank's cut, while stars are a signal users actually emit — so the mechanism exists on the one-sided signal and is missing from the full scale.
The cutpoints do already absorb drift, which the page says and which is true: a user who becomes harsher over months moves them rather than . What they cannot absorb is swing — Tuesday strict, Thursday generous — because a single global set of cutpoints has to average the two. Drift and swing are different phenomena and the model currently sees them as one.
The change is the smallest on this page: a per-session offset added to the cutpoints, the same shape as , one extra latent per session. It also interacts with §15 — under a fixed step budget, adding sites silently thins every other coefficient, which is precisely the problem that entry is about.
15. The fit budget is denominated in steps, not sweeps
10 000 fixed steps over sites at is ~44 sweeps per site, against ~200 at . The posterior page states the consequence plainly: growing makes the fit both slower and statistically thinner. So the model that has learned the most about a user is fitted worse than a fresh one, and nothing on screen says so.
Open questions offers one remedy — cap at 3, gated
on style_share evidence. There is a second that has never been written down:
make the budget proportional to the site count, so that sweeps per site is
the constant and the step count is derived. Then the cost of a rich model shows
up honestly as wall time rather than dishonestly as degraded inference.
The two are different trades. Capping spends capability to buy cost; rescaling spends time to buy cost. Which is right depends on whether a mature fit's wall time is actually a problem for a person, and that is a UX question nobody has asked, not a statistics question.
auracle-taste/examples/fit_bench.rs already measures the exact axis, so the
comparison is a harness run rather than a project.
16. Provenance is scored, and then ignored
Calibration splits Brier skill by
duel / heard_edit / self_report, for a reason it states outright:
...there is no reason to believe they are equally reliable. Scoring them against forecasts the model made before either answer arrived is the only way to find out.
The likelihood then sums all three with identical weight. The measurement was built, the answer is knowable, and nothing consumes it — the same shape as §1, one loop shorter.
A per-provenance likelihood temperature closes it. The design detail that decides whether it is honest: the temperature should be a fitted parameter, not the measured skill plugged in, because tempering a likelihood by a point estimate derived from the same data is circular. Fitted, it costs three sites and buys a sentence worth having — the model learns how much to trust each way you talk to it.
Uses this was not designed for
Not directions so much as observations about what the engine already is.
Sound sets, not sounds. A style lens plus a diversity constraint generates a coherent family: twelve UI sounds in one voice, a game's one-shot set, an earcon family that a person can learn. The engine is closer to this than it looks — the pool already holds diverse candidates, the map already measures spread, and what is missing is a set-level objective (maximize total utility subject to a pairwise distance floor), which is a submodular selection over the pool rather than a new search. It is also a different product with a different buyer.
A teaching artifact. This reference is most of a course on typed MH, reversible jump, Boltzmann targets, ordinal likelihoods and calibration, with a runnable instrument attached and — the part no course has — negative results with their measurements.
Sound design without a modular interface. Every gesture the instrument requires reduces to which of these two. That is an accessibility property rather than a beginner property, and it is not claimed anywhere.
An instrument for a real empirical question. Whether one listener's timbre
preference is multi-modal at all is testable, style_shares is already the
recording of it, and the answer is interesting whichever way it falls. Blocked
on §1, like
everything else here that needs people.
One risk, which is not a direction
This reference is ~5,100 lines of Markdown against 38,518 lines of Rust
(measured the day this page was written, and this page is 429 of the former),
beside a user guide, a brand spec and a changelog that carries measurement
tables. That ratio is why this project is good, and it is also a wall.
CONTRIBUTING.md
invites contributions and enforces make check and make site-check. What it
does not say is which parts of the unwritten bar are negotiable — whether a
contributor changing a default is expected to arrive with a paired-seed
measurement, whether a new φ coordinate needs a VIF table, whether an open
question may be closed by argument.
Either answer is fine. A project may reasonably be a single-author artifact with the door decoratively open. The ambiguity is the thing that deters, and it costs one paragraph to remove.
What the audition cannot hear
One assumption runs through every page of this book: that preference measured under a fixed gesture is the same thing as musical taste. This page is where that assumption is examined and found to be doing more work than it can carry.
The argument is not new here. It is already in this book, made once, in the page that replaced the v1 phrase with the v2 one:
So the grammar could express patches the audition could never reveal, and the taste model was being asked to learn preferences over evidence that was not in . No amount of model improvement fixes that; it is a measurement problem.
That reasoning was applied to four holes — slow pads, sub-Hz modulation, anything above Eb4, polyphonic stacking — and each was closed with the cheapest segment that revealed it. Then it stopped. Everything below is the same sentence, still true, about the things the v2 phrase did not reach.
Why this is a separate register
Unraised directions holds possibilities the design never considered. This page holds something different and less comfortable: one premise the design commits to everywhere, and the eight places that commitment costs something. An entry there is an opportunity; an entry here is a limit on what any amount of modelling can currently buy.
| # | What cannot be heard | Costs |
|---|---|---|
| 1 | How a patch responds to playing — velocity above all | The object being modelled |
| 2 | Modulation rate and shape | The instrument's best feature, unrewardable |
| 3 | How loud a patch natively is | A whole axis of ordinary preference |
| 4 | How a patch sits against other material | Why isolated judgements mislead |
| 5 | Anything tempo-relative | The difference between a sound and a part |
| 6 | What the player is looking for | Converges on the average of a taste |
| 7 | — a conflation rather than a gap | A free elicitation improvement, unclaimed |
| 8 | — an asset already fitted, unexposed | A control only this project could offer |
Seven and eight are not limits of the audition; they are places where the same premise has quietly shaped the product rather than the measurement. They are here because they come from the same root.
1. A patch is a function from performance to sound, and it is sampled once
NoteSpan — the whole of what the renderer knows about a note — is:
pub struct NoteSpan {
pub voct: f64, // pitch, V/Oct from C4
pub chord: usize, // additional gate-synced voices
pub on_start: usize,
pub on_end: usize,
}
There is no velocity field, anywhere in the render path. Every audition note is struck identically.
Three feet away in the same repository, the live instrument takes MIDI velocity, pitch bend and sustain, and the guide documents playing it that way. So the instrument responds to a dimension the measurement holds constant.
The consequence is not a missing coordinate; it is a category error about what a candidate is. A patch is a function from performance to sound. evaluates that function at a single point and hands the result to a model that then speaks about patches. Everything downstream — , the style lenses, the map, the calibration diagram — is a faithful model of preference over point samples of instruments, presented as a model of preference over instruments.
What is invisible as a result, in rough order of how much a player would care:
- Velocity response. The single largest determinant of whether a patch is playable at all.
- Note-length behaviour. Whether it blooms, or just holds.
- Retrigger under fast playing. Whether the envelope survives a run.
- Key tracking, beyond the one octave-up check
high_ratioperforms. - The arpeggiator. It ships in the instrument and appears in no audition.
This is the one thing on this page that cannot be fixed inside . Every other entry is a coordinate or a surface; this is a change to what is being measured. The in-idiom version is exactly the move the v2 phrase already made: a second render at a different velocity and articulation, contributing difference coordinates — brightness per velocity, level per octave — rather than more absolute ones. Two renders instead of one is the same cost-for-coverage trade the v2 phrase paid knowingly, and the segment-local coordinates are the precedent for measuring a contrast rather than a level.
2. Modulation is the distinctive claim, and its rate is not measured
The instrument's distinguishing feature is that modulation is a whole chain: an
s&h rand → quantize → slew can reach a cutoff, the node bank exists to
explain where a modulator may legally go, and nearly every module carries a mod
slot.
Here is everything records about that:
| Coordinate | Says |
|---|---|
mod_density (struct) | how much of the term is modulated |
mod_depth_mean (struct) | how deep the modulation is |
centroid_std (audio) | how much brightness moved over the phrase |
held_centroid_std (audio) | how much it moved within one held note |
Rate appears nowhere. Shape appears nowhere. A 0.3 Hz filter sweep and a 7 Hz tremolo at equal depth land on near-identical coordinates. A random sample-and-hold and a sine LFO are indistinguishable to every one of them.
So "I like slow evolving movement and dislike fast wobble" is not a preference that is hard to learn. It is inexpressible — in exactly the sense the log-axis argument uses about brightness on a linear-Hz axis, and for the same reason: the model is linear in , so a distinction absent from the coordinates is absent from the hypothesis space.
The second-order consequence is worse than the first. Proposal tilts read the
structural coefficients,
and mod_density is one of them — so the search can learn more
modulation and can never learn slower modulation. The instrument's most
distinctive capability is the one the search cannot be rewarded for using well.
The measurement already has its window open. The held note exists, in the words of its own page, to reveal "sub-Hz modulation over a register-constant sustain". The segment was built for this and then not measured for it. An autocorrelation of the centroid or RMS envelope across that span yields two coordinates:
- dominant modulation rate, on the shared octave axis every other frequency in already uses, and
- periodicity strength — how much of the movement is periodic at all, which is what separates an LFO from a random walk.
If two numbers could be added to , these are the two.
Status — rate is measured now; periodicity was tried and cannot be, in this window. Rate arrived as three motion bands rather than one dominant-rate number: a single "rate" coordinate is undefined for a static patch and meaningless for a random walk, while band energies are defined for everything and let a linear model hold slow yes, fast no as two coefficients of opposite sign. On the probe ladder the band that reads highest follows the LFO rate from 0.55 Hz to 13 Hz. Periodicity failed its own test: both an autocorrelation peak and the harmonic share of the modulation spectrum separate an LFO from a random walk at 2.7 Hz and above and not at all below 1.5 Hz, because the held span holds fewer than three slow cycles. That is the range evolving textures live in, so the coordinate was not shipped; it is now a stimulus-length question, and belongs with §1.
3. Loudness is normalized away, and the raw level is already computed
Normalizing every render to −18 LUFS is correct, and the reason given is the right one: loudness bias would poison the preference data.
The other half of that trade is not stated anywhere. Loudness is part of a
sound's identity. Hits hard, sits back, has weight are ordinary,
strongly-held preferences, and they have been normalized out of existence by
design. crest and rms_std recover dynamics within a patch; absolute level
is gone.
What makes this an easy win rather than a lament is that the number already
exists. VetReport measures peak
and rms on the raw, pre-normalization render — deliberately, because the
gate's thresholds are about real output level — and both are then discarded.
The quantity how loud is this patch natively is computed on every candidate
this project has ever rendered.
Adding as a coordinate reintroduces no playback bias, because playback stays normalized; the confound the loudness decision guards against is between what the listener hears and what they are comparing, and a feature the model reads is neither. It simply lets the model see an axis that is currently invisible.
The honest caveat, which decides it: raw level is partly an artifact of gain staging inside the grammar rather than a property anyone hears. That makes it possibly noise — and it makes the question a single sweep on existing data rather than an argument.
4. Everything is judged in a silent room
No mix context, no drums, no key, no other material. Every duel asks which of these two do you prefer, in silence.
Musicians almost never choose sounds absolutely. The bass that wins in
isolation is routinely the one that disappears under a kick; the pad that wins
alone is the one that eats the vocal. carries bass_fraction and
three brightness measures, but nothing about spectral occupancy relative to
other material, because there is no other material for it to be relative to.
This is probably the deepest reason isolated-audition tools feel wrong to practitioners in a way that is hard to articulate, and it bites harder here than elsewhere: the premise of this project is that the instrument is learning what you like, and what it is learning is what you like in an empty room.
The scope that would test it is not "build a DAW". One user-supplied backing loop, played underneath the audition, changes what every vote means — and it interacts directly with §3, because level against a bed is exactly the judgement normalization removes.
5. There is no tempo, and the presets are working around it by hand
An LFO's rate is sampled as u01() and mapped to Hz. There is no global tempo,
no host sync and no note divisions. A Clock tempo exists for the euclidean
sequencer's own bpm port, and it is not a session-level musical clock.
The evidence that this costs something is in this repository's own preset source, in comments:
rate: 0.55, // ≈83 bpm
rate: 0.62, // ≈107 bpm
p0: 0.55, // ≈83 bpm — about one jump per bar
Preset authors are hand-computing, in comments, the thing a sync control would compute. An unsynced modulator is among the most common reasons a good patch is unusable in a track: it can be set close by ear and it drifts over sixteen bars.
For an instrument whose output is meant to end up in music, tempo-relative rates are not a convenience. They are the difference between a sound and a part — and they would also give §2 a natural axis to express rate on, since a listener's preference about movement is far more plausibly "a cycle per bar" than "0.34 Hz".
6. The loop is selection; sound design is pursuit
A duel asks which do you prefer. A player with a sound in their head wants get closer to this.
Locks plus ⚡ evolve from this is a genuine pursuit affordance and a good one.
But the primary loop is judging what you are handed, and the
target-directed
entry in the other register is better understood from here: it is not a feature,
it is the missing half of a workflow.
There is a sharper form of this, and it is about what the model converges to. A preference fitted over everything a person has ever voted on approaches the average of their taste. Max-of-experts answers part of it — islands rather than a centroid, which is the whole reason for the max — but the islands are discovered from , not selected by intent. So on any given evening the instrument proposes toward the union of everything the listener has ever liked, when what they need is the one corner they are working in tonight.
That is the musical argument for declared context, and it is a stronger one than the modelling argument on that page. It is the difference between a tool that knows you and a tool that is useful today.
7. Comparability constrains the measurement, not the listener
The decisions log records the audition as "Standard 5.05 s phrase + free-play", with the rationale "feature comparability requires fixed stimulus".
The rationale is true of . It is not true of the person.
comes from a deterministic offline render. What a human hears while deciding is an independent choice, and could be anything at all — including their own playing, on both patches, with the same lick. The coordinates would remain exactly as comparable, because nothing about them depends on what came out of the speakers during the vote.
So duel in your own hands costs nothing. It is a far more musical elicitation than voting on four notes chosen by the instrument, and it is the direct remedy for §1: a player testing velocity response themselves is measuring the dimension the phrase holds constant, even if the coordinates still cannot see it.
The cost, stated rather than hidden. The vote becomes a judgement about an
experience the coordinates only partly describe. That adds noise, and adds bias
if a player's own gestures systematically emphasize something the phrase does
not. But it is noise in the measurement of something a listener cares about,
against precision about something they do not — and the project already owns the
instrument that would detect it, since
calibration by provenance exists
precisely to score differently-collected answers against each other rather than
assume they are equivalent. A PlayedDuel provenance would make this
measurable on arrival.
8. Forty addresses and no macros, when the macro axis is already fitted
The rack exposes every trace address, and the word macro does not appear in the web app. A player performing wants two to four hands-on controls, not one per address in the term.
The interesting part is that the fitted model already contains the right axes. A style lens is a direction in feature space whose meaning is what this listener cares about. A macro that moves a patch along the model's top learned direction is a control that is personal by construction — bite for one user, movement for another — and nothing else can offer it, because nothing else has fitted the model.
It is also the continuous form of the counterfactual explainer: same posterior, same trace addresses, same predicted-gain arithmetic. One reports a discrete edit; the other puts the direction under a finger.
If these were ordered by value per unit of work
- Two coordinates for modulation rate and periodicity (§2). Rate done, as three motion bands; periodicity blocked on stimulus length. Cheapest and highest value: it lets the search be rewarded for the instrument's best feature, and the segment it needs already exists for this exact purpose.
log(raw rms)as a coordinate (§3). Already measured on every candidate, currently discarded; recovers an axis of ordinary preference at zero audition cost.- Velocity in the phrase, as difference coordinates (§1). The expensive one, and the only one that moves the modelled object from a sound toward an instrument.
- Tempo-relative modulation rates (§5). Turns output into something usable in a track, and gives §2 its natural axis.
- Declared context (§6), promoted from the other register on musical grounds.
- Duels in the player's own hands (§7). Nearly free, conceptually the largest unlock, and measurable via provenance from the day it ships.
The summary worth keeping
This is an unusually rigorous instrument for measuring timbre preference under a fixed gesture, and much of the design reads as though that were the same thing as musical taste. It is not, and the gap between them is where every entry above lives.
The tool for closing it is already in the book. The grammar can express what the audition cannot reveal, and no amount of model improvement fixes a measurement problem — applied once, to four holes, and it deserves to be applied to the rest.
API documentation
Generated rustdoc for every crate in the workspace.
- auracle_grammar — the genome: typed PCFG, trace codec, compiler, structural edits, presets
- auracle_features — render, vet, LUFS-normalize, extract
- auracle_taste — the utility model, three likelihoods, MCMC posterior, standardization
- auracle_session — the two-loop engine, acquisition, calibration, persistence
- auracle_wasm —
WasmEngineandLivePoly
Built with cargo doc --workspace --no-deps, so the dependency crates are not
included. quiver, fugue-ppl
and fugue-evo have their own docs on docs.rs.
Where to start
The doc comments in this codebase carry a lot of the reasoning, and a few are worth reading directly rather than through this book's summary of them:
| For | Read |
|---|---|
| The grammar's site table | auracle_grammar::prior module docs |
| Why has families rather than per-module columns | auracle_features::structural module docs |
| The max-of-experts argument, and the correction | auracle_taste::model module docs |
| Why the standardizer's threshold is | auracle_taste::standardize::RUNAWAY_RATIO |
| Why refinement is 40 steps × 10 seeds | auracle_session::SessionConfig::refine_steps |
| The acquisition measurement, in full | auracle_session::Acquisition |
| Why accuracy was replaced by Brier skill | auracle_session::calib module docs |
Building it locally
cargo doc --workspace --no-deps --open
# or, the target this site uses:
make site-api
What rustdoc covers
The generated docs are authoritative about signatures and invariants, and they are where the numbers live. They are deliberately quiet about the pipeline: no rustdoc page explains why vetting has to run before normalization, because that fact belongs to no single item.
That is the division of labour between this book and the API docs: the book owns the reasoning that spans crates, and rustdoc owns the reasoning that fits beside a definition.
Bibliography
The literature Auracle's methods come from, grouped by where they appear. These are the specific results the implementation relies on, not a survey.
Preference learning
Bradley, R. A. and Terry, M. E. (1952). Rank Analysis of Incomplete Block Designs: I. The Method of Paired Comparisons. Biometrika 39(3–4), 324–345. → The duel likelihood, . Used in
Chu, W. and Ghahramani, Z. (2005). Preference Learning with Gaussian Processes. ICML. → The framing of preference data as observations of a latent utility. Auracle's utility is linear in a fixed feature map rather than a GP, which is a deliberate trade of flexibility for interpretability and a tractable cold start.
McCullagh, P. (1980). Regression Models for Ordinal Data. JRSS B 42(2), 109–142. → The cumulative-logit model with learned cutpoints, which is how star ratings are treated as ordinal rather than as numbers. Used in
Brochu, E., de Freitas, N. and Ghosh, A. (2007). Active Preference Learning with Discrete Choice Data. NIPS. → Preferential Bayesian optimization: the loop of latent utility + expensive human oracle + cheap surrogate that Auracle's two loops implement.
Active learning and acquisition
Houlsby, N., Huszár, F., Ghahramani, Z. and Lengyel, M. (2011). Bayesian Active Learning for Classification and Preference Learning. arXiv:1112.5745. → BALD: expected information gain about the parameters. Implemented and selectable; it ties uniform pairing on this problem at session horizon.
Yue, Y., Broder, J., Kleinberg, R. and Joachims, T. (2012). The K-armed Dueling Bandits Problem. JCSS 78(5), 1538–1556. → The dueling-bandit framing, and by extension the Thompson rule that measurably loses here because it optimizes best-arm identification rather than parameter recovery.
Calibration and scoring
Brier, G. W. (1950). Verification of Forecasts Expressed in Terms of Probability. Monthly Weather Review 78(1), 1–3. → The proper scoring rule that replaced accuracy. Why that mattered
Gneiting, T. and Raftery, A. E. (2007). Strictly Proper Scoring Rules, Prediction, and Estimation. JASA 102(477), 359–378. → What "proper" means, and why a rule that is not proper can be gamed by a model that hedges.
Dawid, A. P. (1984). Present Position and Potential Developments: Some
Personal Views. Statistical Theory: The Prequential Approach. JRSS A 147(2),
278–292. → Prequential evaluation: score each forecast before seeing its
outcome. This is exactly what record_duel does, and it is what makes the
reliability diagram out-of-sample.
DeGroot, M. H. and Fienberg, S. E. (1983). The Comparison and Evaluation of Forecasters. The Statistician 32, 12–22. → Reliability diagrams, and the calibration/refinement decomposition that explains why the shape of the failure is more informative than the scalar.
Monte Carlo
Metropolis, N. et al. (1953). Equation of State Calculations by Fast Computing Machines. J. Chem. Phys. 21(6), 1087–1092. Hastings, W. K. (1970). Monte Carlo Sampling Methods Using Markov Chains and Their Applications. Biometrika 57(1), 97–109. → The sampler.
Green, P. J. (1995). Reversible Jump Markov Chain Monte Carlo Computation and Bayesian Model Determination. Biometrika 82(4), 711–732. → Trans-dimensional moves: what a structural proposal is, since it changes the set of sites. Handled by fugue rather than by Auracle.
Del Moral, P., Doucet, A. and Jasra, A. (2006). Sequential Monte Carlo Samplers. JRSS B 68(3), 411–436. → Tempered SMC: the designed generation mechanism, and not what currently ships.
Kong, A., Liu, J. S. and Wong, W. H. (1994). Sequential Imputations and Bayesian Missing Data Problems. JASA 89(425), 278–288. → Effective sample size , the degeneracy diagnostic that triggers a refit.
Douc, R. and Cappé, O. (2005). Comparison of Resampling Schemes for Particle Filtering. ISPA. → Systematic resampling, chosen over multinomial for determinism.
Stephens, M. (2000). Dealing with Label Switching in Mixture Models. JRSS B 62(4), 795–809. → Why per-component summaries of a mixture posterior need post-hoc alignment.
Probabilistic programming
Goodman, N. D. and Stuhlmüller, A. (2014). The Design and Implementation of Probabilistic Programming Languages. dippl.org. → The model-as-program framing that fugue implements and that makes the grammar a prior rather than a generator function.
Ritchie, D., Horsfall, P. and Goodman, N. D. (2016). Deep Amortized Inference for Probabilistic Generative Models. arXiv:1610.05735. → Context for what trace-based inference over structured programs makes possible.
Grammar-based genetic programming
Whigham, P. A. (1995). Grammatically-based Genetic Programming. Workshop on Genetic Programming. → Using a grammar to constrain the search space so every individual is valid: Auracle's representation decision, with types in place of production rules.
Koza, J. R. (1992). Genetic Programming: On the Programming of Computers by Means of Natural Selection. MIT Press. → Tree-based GP, subtree crossover, and the bloat problem that a prior rather than a penalty addresses.
Takagi, H. (2001). Interactive Evolutionary Computation: Fusion of the Capabilities of EC Optimization and Human Evaluation. Proc. IEEE 89(9), 1275–1296. → The canonical statement of interactive evolution's user-fatigue bottleneck, which is the problem the two-loop architecture and the learned surrogate exist to solve.
Audio features and loudness
ITU-R BS.1770-4 (2015). Algorithms to measure audio programme loudness and true-peak audio level. → K-weighting, 400 ms gated blocks, the two gates. Implemented here
EBU R 128 (2020). Loudness normalisation and permitted maximum level of audio signals. → The practice around BS.1770 that makes −18 LUFS a sensible target.
Peeters, G. (2004). A large set of audio features for sound description. CUIDADO project report, IRCAM. → Spectral centroid, spread, flatness, rolloff and flux, in the definitions φ_audio uses.
Bregman, A. S. (1990). Auditory Scene Analysis. MIT Press. → Background for why octave-based frequency axes and segment-local measurements are the right coordinates for a perceptual feature vector.
Dau, T., Kollmeier, B. & Kohlrausch, A. (1997). Modeling auditory processing of amplitude modulation. I. Detection and masking with narrow-band carriers. JASA 102(5), 2892–2905. → The modulation filterbank: hearing sorts envelope fluctuation by rate. Why motion is measured in bands and not as one variance.
McDermott, J. H. & Simoncelli, E. P. (2011). Sound texture perception via statistics of the auditory periphery. Neuron 71(5), 926–940. → Band-wise modulation power is much of what makes a texture recognisable; the grounding for treating motion rate as a first-class axis of taste.
Statistics of the feature space
Belsley, D. A., Kuh, E. and Welsch, R. E. (1980). Regression Diagnostics: Identifying Influential Data and Sources of Collinearity. Wiley. → Variance inflation factors, the diagnostic that found two exact dependencies in φ_struct.
Haufe, S., Meinecke, F., Görgen, K., Dähne, S., Haynes, J.-D., Blankertz, B. and Bießmann, F. (2014). On the Interpretation of Weight Vectors of Linear Models in Multivariate Neuroimaging. NeuroImage 87, 96–110. → A direction's pattern versus its filter , and why a wiring tried and not shipped.
Schäfer, J. and Strimmer, K. (2005). A Shrinkage Approach to Large-Scale Covariance Matrix Estimation and Implications for Functional Genomics. Statistical Applications in Genetics and Molecular Biology 4(1), Article 32. → The shrinkage that made that measurement fair.
Huber, P. J. (1981). Robust Statistics. Wiley. → Winsorizing, and the reasoning behind using it as a fault detector rather than routinely.
The libraries
- quiver — github.com/alexnodeland/quiver · docs.rs
- fugue-evo — github.com/alexnodeland/fugue-evo · docs.rs
- fugue-ppl — docs.rs
Lineage
Auracle is the third iteration of one idea, and the two before it are worth knowing about because what each lacked is what this one is for:
| Iteration | Year | Proved | Lacked |
|---|---|---|---|
| neuralCompressor (C++/Arduino pedal) | 2020 | The interaction model: human-driven GA, fit/unfit footswitch, mutate/crossover knobs | The engine — neither the EA nor the DSP was ever implemented |
| evosynth v1 (Next.js/Tone.js + FastAPI/DEAP) | 2025 | A working interactive GA over a fixed ~30-parameter subtractive synth; parameter locking; lineage tracking | Preference persistence (ratings died each generation), topology evolution, principled inference |
| Auracle | 2026– | — | — |
v0 had the interaction but no engine. v1 had an engine, but a naive one with no memory of the user.