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 and the open questions
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, | — | |
| Proposal-tilt strength | 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 61-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 | — 15 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 proposal tilt carries back into the grammar.
The tilt is the part that makes this more than a scored search. The fitted structural coefficients reshape the categorical proposal weights the search draws new modules from:
with each multiplier clamped to so no module kind is ever starved or monopolized. Details and the shrinkage applied to are in Proposals.
So the loop is genuinely closed: your answers change what gets proposed, not only what scores well once proposed.
Why this is preferential Bayesian optimization
There is a latent objective (your utility), an expensive oracle (you), a cheap surrogate (the posterior), and a generator of candidates (the grammar prior plus MH). The acquisition step is where 's posterior uncertainty earns its keep: early sessions can ask informative questions (duels the model cannot rank), and a confident model can mostly serve things you will like.
Whether it is worth asking informative questions rather than random ones is an empirical question. See Acquisition.
The gate on all of it
auracle-session's closed-loop test runs the engine against a SyntheticUser
with known ground-truth , end to end through the real grammar →
render → vet → feature pipeline, and asserts that the learned taste ranks
genuinely preferred patches on top.
It is slow, and it is the only test that can fail when the loop is broken while every component is individually correct.
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 | , 8 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, 8 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:
- Five leaves: LFO, envelope, random (sample-and-hold), envelope follower, Euclidean.
Opwraps one modulation term: quantize, slew, rectify, hold.Paircombines two.
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-two modules: 7 sources, 20 processors, 15 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 …
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::RangeInclusive<f64> = 0.0..=1.0;
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. Three things fall out of that:
- 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.
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.
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 |
|---|---|
| Modules | 24 |
| Term depth | 9 |
| Modulation depth | 4 |
Shown in the app as 8/24 modules · 6/9 depth · 1/4 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".
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 = 9; // audio tree depth
pub const MAX_MOD_DEPTH: usize = 4; // modulation term nesting
These protect the realtime voice and the feature pipeline rather than shaping
the search. The prior's own ceilings are lower (max_mod_depth of 2), on the
reasoning that a person stacking shapers by hand knows what they are building.
MAX_MOD_DEPTH stops well short of the audio ceiling for a concrete reason: a
Pair branches, so depth 4 is up to sixteen 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.
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 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
Fifteen 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 |
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.
Deliberately compact
Fifteen 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-five 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
Fourteen family counts:
n_vco n_supersaw n_noise n_wavetable n_pluck n_formant
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-five in total, appended after the fifteen 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-one module kinds into fourteen 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.
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-five 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.
No frontend emits this today: Engine::record_keep and its wasm binding exist,
but the triage surfaces that would call them are unbuilt, so every log written
by the app so far contains duels and stars only.
"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 |
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 : **45
- ** at and 205 + 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 205 + 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"
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 what the search proposes, not only what it scores.
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 proposal 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 proposals 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 proposal distribution 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.
Tilting the proposal changes the kernel, not the target. The MH accept/reject step still scores against , so the stationary distribution is unchanged: a tilted proposal is a better-informed way of exploring the same target, not a different one.
That would matter more if refinement were sampling from . It is not; it is hill-climbing on it, so in practice the tilt's effect is to make the climb find good regions sooner rather than to change what "correct" means.
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 scoring, and
the structural half influences both scoring and proposing. 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.
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.
Still open upstream, 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.
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 (15) + φ_struct (25) = φ ∈ ℝ⁴⁰ | 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 | 42 productions: 7 sources, 20 processors, 15 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 one lives on its own page: open questions, which are decisions that have not been made rather than decisions that have.
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 only intended UI surface.
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 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 61-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.
-
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 (205 + S sites over a fixed 10 000 steps ≈ 48 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.
-
Remaining quiver hardening (non-blocking, tracked upstream):
voct_to_hzis unclamped — overflow is now recovered by Q198 rather than prevented, and a pitch clamp would also tame aliasing garbage at absurd-but-finite pitches. -
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.
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.
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.
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.