Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

term  compile  patch  render  audio  vet  audio  φ  R40    uθ    R \text{term} ;\xrightarrow{\text{compile}}; \text{patch} ;\xrightarrow{\text{render}}; \text{audio} ;\xrightarrow{\text{vet}}; \text{audio} ;\xrightarrow{\varphi}; \R^{40} ;\xrightarrow{;u_\theta;}; \R

and a loop that closes over it: your answers condition θ\theta, and θ\theta 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

  1. 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.
  2. 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.
  3. Utility as a max of experts explains why taste is a maximum over lenses rather than a mixture, and what that buys.
  4. 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. xx is a patch term, φ(x)\varphi(x) its feature vector, θ\theta the taste parameters, uu 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

SymbolIsIn the code
xxA patch term — a tree in the typed grammarPatchTree
ppA tree path, e.g. node/0/1path keys
ttA trace — the execution record of the grammar programfugue::Trace
φ(x)R40\varphi(x) \in \R^{40}The feature vector of xxFeatures::phi()
φaudioR15\varphi_{\text{audio}} \in \R^{15}Perceptual descriptors of the renderAudioFeatures
φstructR25\varphi_{\text{struct}} \in \R^{25}Structural descriptors of the termStructFeatures
zzA standardized feature vector, z=(φμ)/sz = (\varphi - \mu)/sphi_std

φ\varphi is always the concatenation [φaudio;φstruct][\varphi_{\text{audio}} ; \varphi_{\text{struct}}], in that order. It is written φ\varphi rather than ϕ\phi throughout; the code's phi is this vector.

The taste model

SymbolIsIn the code
KKNumber of style lenses (5\le 5)TasteConfig::k_styles
ddFeature dimension (4040)TasteConfig::n_features
θkRd\theta_k \in \R^{d}Lens kk's weight vectorTasteSample::theta[k]
θ\thetaAll of them, K×dK \times dTasteSample::theta
u(x)u(x)Latent utility of xxutility_mix
uk(x)u_k(x)Lens kk's utility, θkz\theta_k^\top zutility(phi, k)
τs\tau_sSession ss's keep/kill thresholdTasteSample::tau[s]
cjc_jStar cutpoint jjTasteSample::cuts[j]
σθ\sigma_\thetaPrior SD of one θ\theta coordinateTasteConfig::sigma_theta()
sKs_KMax-of-KK-normals SD correctionMAX_NORMAL_SD
SSNumber of sessions in the logFitSet::n_sessions()
SymbolIsIn the code
pgrammar(x)p_{\text{grammar}}(x)Prior probability of term xxPatchGrammarPrior
β\betaBoltzmann sharpnessSessionConfig::beta
πβ\pi_\betaThe target, pgrammar(x)eβE[uθ(x)]\propto p_{\text{grammar}}(x),e^{\beta,\E[u_\theta(x)]}
η\etaProposal-tilt strengthSessionConfig::proposal_tilt
L\mathcal{L}The set of locked addresseslocked: HashSet<String>

Conventions

  • σ()\sigma(\cdot) is the logistic function σ(v)=1/(1+ev)\sigma(v) = 1/(1+e^{-v}), never a standard deviation. Standard deviations are always subscripted (σθ\sigma_\theta) or written as ss.
  • log\log 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 ww are always normalized unless stated: importance weights sum to one, recency weights are relative to the newest observation being 11.
  • "Standardized" always means after the affine transform in Standardization. The taste model never sees raw φ\varphi; 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:

MacroRenders
\RR\R
\EE\E
\phivecφ\phivec
\thetakθk\thetak
\sigσ\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.

ModuleOwns
termPatchTree, AudioNode, ModNode — the genome type. The Audio/Mod sort split is enforced by Rust's type system, so ill-sorted terms are unrepresentable
priorPatchGrammarPrior — the PCFG as a fugue program. Implements fugue-evo's GenomePrior
genomeThe canonical trace codec. This is the addressing scheme, and a round-trip property test keeps it from drifting
compileTerm → quiver Patch, with live parameter handles. 4 500 lines; the largest single thing in the workspace
mutateStructural edit operations, and their validity gate
editSingle-site parameter writes by address
diffHuman-readable diffs between two terms — what the lineage log prints
describeThe rack description the panel draws from
presetsThe 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.

ModuleOwns
phrasePhraseSpec — the standard stimulus
renderDeterministic headless rendering through quiver
vetThe quarantine gate
loudnessITU-R BS.1770 K-weighting, gated integrated loudness, and the peak ceiling
audioφaudio\varphi_{\text{audio}} — 15 perceptual descriptors
structuralφstruct\varphi_{\text{struct}} — 25 structural descriptors
pipelineThe composition, in the one order that is safe
cacheRender 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.

ModuleOwns
modelTasteModel as a fugue program; TastePosterior and its summaries
observeFeedback, ObservationLog, FitSet — and the by-name projection that migrates old logs
standardizeThe affine transform, with runaway-column detection
syntheticSyntheticUser — 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 θ\theta^*, and that acquisition regret shrinks. That makes the core falsifiable headlessly.

auracle-session

The engine every frontend drives.

ModuleOwns
engineEngine — pool, log, posterior, refinement, workbench, lineage. 2 800 lines
surrogateThe learned taste as a fugue-evo Fitness
calibPrequential forecast scoring and reliability diagrams
mapThe 2D projection behind the taste map
namingGenerated patch and style names
farmIndexed draw seeding — what makes parallel filling reproducible
migrateLoading sessions written by older versions

auracle-wasm

Two objects, on two threads, and the split matters:

  • WasmEngine is the whole auracle-session engine, in a Web Worker. Pool filling, posterior fits, refinement, workbench edits. Nothing real-time.
  • LivePoly is the instrument, in an AudioWorklet. It holds NN compiled copies of the current patch, via the same compile() 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.0Modular DSP. Library name is quiver
fugue-evo 0.3.1Evolution as inference. default-features = falsecheckpoint/parallel do not compile on wasm32
fugue-ppl 0.2.1The 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_json with float_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 same diagram, with the traffic moving. What the ASCII version above cannot show is that the two loops run at different speeds. Green flows continuously with no human in it; amber moves at the pace you answer questions.

The patch loop

Machine-paced. No human in it.

  1. Fill. Sample terms from the grammar prior, compile, render, vet, featurize. Pool target is SessionConfig::pool_size vetted candidates (48 by default, though the web app passes 40 in apps/web/main.js), with at most 400 draws attempted per fill, since vet failures burn attempts.
  2. Refine. Once a posterior exists, take the top refine_seeds candidates and run refine_steps Metropolis–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.
  3. 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.

  1. Observe. Every duel, star, keep/kill and edit claim appends to the observation log, as raw φ\varphi, never standardized. That is what lets the standardizer be re-fit later without invalidating history.
  2. Reweight, immediately. Each new observation folds into the existing posterior by importance sampling. Exact, O(S)O(S), and it is what makes the next question respond to the last answer.
  3. 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 nn 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 θ\theta 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:

wi    wiexp(ηti)w'_i ;\propto; w_i \exp(\eta, t_i)

with each multiplier clamped to [14,4][\tfrac14, 4] so no module kind is ever starved or monopolized. Details and the shrinkage applied to tit_i 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 θ\theta'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 θ\theta^*, 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:

AddressNames
nodeThe root audio node
node/0, node/0/1Children, by index
node/0/mThe modulation slot hanging off node/0
node/0/m/0A subterm of that modulation term
node/0#cutThe cut parameter of the node at node/0
node/0/m#rateThe rate parameter of that modulation term
amp#attackThe 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

SubsystemUses an address to
Panel knobsIdentify what a knob writes
Hand editsWrite one site: set_param(addr, value)
LocksName the frozen set L\mathcal{L}
Live parameter handlesMap a knob to an atomic in the running voices
MH proposalsName the site a move touches
The lineage diffPrint 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:

  1. Writes the atomic, so the running voices change without a recompile.
  2. 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

L\mathcal{L} is a set of exact address strings, typically snapshotted from the UI. A proposal xxx \to x' is rejected if it changes, deletes or creates any address in L\mathcal{L}.

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 L\mathcal{L}. 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 evolutionIn the term grammar
Node settingsLeaf parameter sites — an f64 or usize draw at each module node
ConnectivityInterior structure — chains, parallel branches, modulation attachments
Node setWhich 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.

Sample a few. Green is the Audio sort, amber is Modulation, and they are different Rust types. The dashed edges are modulation slots, and no amount of mutation can put one sort where the other belongs, because such a term cannot be constructed. Press it repeatedly and watch small terms dominate: that is the prior's parsimony, with nothing penalising size. (A faithful miniature of the production weights, not the shipped grammar.)

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 pp emits real probabilistic choices at path-keyed addresses:

SiteAddressDistribution
source-vs-processor<p>#leafBernoulli(source_prob)\mathrm{Bernoulli}(\text{source_prob}), forced at max depth
source kind<p>#srcCategorical(wsrc)\mathrm{Categorical}(w_{\text{src}}), 6 kinds
processor kind<p>#opCategorical(wop)\mathrm{Categorical}(w_{\text{op}}), 20 kinds
modulation kind<p>/m#modCategorical(wmod)\mathrm{Categorical}(w_{\text{mod}}), 8 kinds
CV-processor kind<p>/m#modopUniform over ModOp::ALL
CV-combiner kind<p>/m#pairopUniform over PairOp::ALL
discrete params<p>#wave, #oct, #color, #fkind, #table, #dmodeUniform categoricals
continuous params<p>#cut, #res, #det, …Uniform(0,1)\mathrm{Uniform}(0,1)

The amplitude envelope is fixed at amp#attackamp#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: πβpgrammareβu\pi_\beta \propto p_{\text{grammar}} \cdot e^{\beta u}, and pgrammarp_{\text{grammar}} 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.
  • Op wraps one modulation term: quantize, slew, rectify, hold.
  • Pair combines 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:

ProductionSecond input
Mix, RingModAudioMerges two chains into one
Comp, Duck, Gate, VocoderControlReal 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 NN 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 Uniform(0,1)\mathrm{Uniform}(0,1). 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 [0,1][0,1] and the mapping to Hz, seconds, dB or cents happens in the compiler. Three things fall out of that:

  • The prior is trivially correct. Uniform(0,1)\mathrm{Uniform}(0,1) 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 [0,1][0,1] 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:

SiteDomain
#waveWaveform: saw, square, triangle, sine
#octOctave offset
#colorNoise colour
#fkindFilter kind
#tableWavetable shape
#dmodeDrive 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:

ViolationResponseBecause
A knob outside [0,1][0,1]Repaired, exactly and locallyThere is one right answer
A term over the module/depth ceilingsRefusedFixing 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:

  1. 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.
  2. Its φ\varphi entered the observation log, with amp_sustain =1030= 10^{30}.
  3. The standardizer fit on that column produced a mean of 1.2×1029\approx 1.2 \times 10^{29} and an SD of 5.5×1029\approx 5.5 \times 10^{29}, which standardized every real patch in the pool to 0.2±1030-0.2 \pm 10^{-30}.
  4. The coordinate was dead. The model could never learn from it again, and the belief line still printed a contribution for it.
  5. The panel read SUSTAIN 1200.0 dB, and the HELD tray printed 1e+30.

The fixes are at three layers:

  • clamp_domains on 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:

CeilingDefault
Modules24
Term depth9
Modulation depth4

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.

OpDoes
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:

  • Replace on 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.
  • SetMod with a shaper kind wraps the existing modulation term rather than evicting it, which is what makes s&h rand → quantize → slew a 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 this on 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:

audioDC blockerVCA (amp ADSR)LimiterStereoOutput \langle\text{audio}\rangle \to \text{DC blocker} \to \text{VCA (amp ADSR)} \to \text{Limiter} \to \text{StereoOutput}

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 [0,1][0,1] site, and the ranges are deliberately bounded away from pathology:

Bound
Filter resonancemax 0.85
Delay feedbackmax 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.response and Limiter.soft are read at a 2.5 V threshold, so 5 V and 10 V do the same thing. The compiler uses named constants GATE_TRUE = 5.0 / GATE_FALSE = 0.0 rather 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 2va2^{v \cdot a}, 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 Offset driving 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.
  • LivePoly compiles the same term, through the same function, to play it: NN copies for NN 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:

#NoteGate onGate offChordReveals
1C41.80 s0.20 sSlow attacks; sub-Hz modulation over a register-constant sustain
2C50.30 s0.15 sWhether the patch speaks at all an octave up
3C40.50 s0.20 s+E4Intermodulation and mud when voices stack
4C30.80 s1.10 sBass 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.

Click a segment. Solid is gate-on, dashed is the release window after it, and the second lane is the dyad's own compiled voice. The amber band is the final 300 ms, the window 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 φ\varphi. 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:

  1. 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_std is measured here specifically, which is what makes it register-constant by construction.
  2. 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).
  3. 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.
  4. 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 φ\varphi 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 φ\varphi 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 θ\theta, 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:

G=3.999844 dB,Q=0.707175,fc=1681.974 HzG = 3.999844\ \text{dB}, \quad Q = 0.707175, \quad f_c = 1681.974\ \text{Hz}

Stage 2, the highpass:

Q=0.500327,fc=38.1355 HzQ = 0.500327, \quad f_c = 38.1355\ \text{Hz}

With k=tan(πfc/fs)k = \tan(\pi f_c / f_s), VH=10G/20V_H = 10^{G/20} and VB=VH0.499667V_B = V_H^{0.499667}, the shelf's coefficients are

a0=1+kQ+k2,b0=VH+VBkQ+k2a0,b1=2(k2VH)a0,b2=VHVBkQ+k2a0 a_0 = 1 + \tfrac{k}{Q} + k^2, \qquad b_0 = \frac{V_H + V_B \tfrac{k}{Q} + k^2}{a_0}, \qquad b_1 = \frac{2(k^2 - V_H)}{a_0}, \qquad b_2 = \frac{V_H - V_B \tfrac{k}{Q} + k^2}{a_0}

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.

The filter, evaluated rather than drawn. This curve is computed in the page from the same constants listed above, through the same bilinear transform, so it is the response the pipeline applies. The two dashed curves are the shelf and the highpass; the solid one is their sum. Change the sample rate and the corners stay put, which is what deriving the coefficients buys.

Block loudness and the two gates

Blocks are 400 ms with 75% overlap. Each block's loudness is

Lj=0.691+10log10 ⁣(1Nnw[n]2)L_j = -0.691 + 10 \log_{10}!\left(\frac{1}{N}\sum_{n} w[n]^2\right)

where ww is the K-weighted signal. The 0.691-0.691 dB offset is the spec's calibration constant.

Then two gates, in order:

  1. Absolute gate. Discard blocks with Lj70L_j \le -70 LUFS. If none survive, the signal is silent and the function returns None.
  2. Relative gate. Compute the mean energy of the surviving blocks, and discard blocks more than 10 LU below it: Γ=0.691+10log10 ⁣(Eˉabs)10\Gamma = -0.691 + 10\log_{10}!\big(\bar{E}_{\text{abs}}\big) - 10

The integrated loudness is the same expression over the twice-gated set:

L=0.691+10log10 ⁣(1GjG10(Lj+0.691)/10)L = -0.691 + 10 \log_{10}!\left(\frac{1}{|\mathcal{G}|}\sum_{j \in \mathcal{G}} 10^{(L_j + 0.691)/10}\right)

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:

beforeafter
peak p500.6230.623
peak p90 / p99 / max1.061 / 2.098 / 4.0631.000 / 1.000 / 1.000
over full scale22 (15%)0
over 1.25 — where the app's master.gain = 0.8 clips11 (8%)0
gave up gain22 (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.

This moved φ

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 φ\varphi, 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:

rendervetnormalizeφaudio\text{render} \to \text{vet} \to \text{normalize} \to \varphi_{\text{audio}}

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 φaudio\varphi_{\text{audio}} 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:

OrderFailureCondition
1SilentEmpty buffer
2NonFiniteAny sample is not finite
3Silentrms<104\text{rms} < 10^{-4}
4Overlevelpeak>ceiling\text{peak} > \text{ceiling}
5DcDominatedmean/rms>0.6\lvert\text{mean}\rvert / \text{rms} > 0.6

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:

ceiling=2.0+1.5(V1)\text{ceiling} = 2.0 + 1.5,(V - 1)

where VV is max_voices(). The default 2.0 is one limiter-bounded voice (~1.5 peak in the ±1.0 float domain) plus overshoot headroom; NN gate-synced voices legitimately sum toward NN× 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:

MeasuredAgainstResult
peak never exceeded 2.00ceiling 3.5 (at V=2V=2)Fine
mean/rms\lvert\text{mean}\rvert/\text{rms} never exceeded 0.0016limit 0.6Fine
rms stayed far above the floor10410^{-4}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 φ\varphi 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 θ\theta. 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

LayerWhereWhat
0quiverDenormals 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
1auracle-featuresThis gate. Audition plays pre-rendered, vetted, normalized buffers — never a live unvetted patch
2auracle-sessionQuarantine → large negative fitness, so the search avoids the region
3auracle-grammarMandatory … → Limiter → StereoOutput, and parameter ranges bounded away from pathology
4testsValidationMode::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 the while phase >= 1.0 wrap style used by Wavetable and FormantOsc spun the audio thread forever on an infinite increment (voct_to_hz overflows at extreme V/Oct). An infinite loop on the audio thread is not a glitch; it is a dead tab. Fixed with a shared O(1)O(1) wrap_phase that 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

#NameIs
0centroid_mean:p2Mean spectral centroid on the log axis — brightness
1centroid_std:p2SD of that centroid over frames — timbral movement, in octaves
2rolloff_mean:p2Mean 85% spectral rolloff, log axis
3flatness_mean:p2Mean spectral flatness — 0 tonal … 1 noisy
4flux_mean:p2Mean spectral flux — how fast the spectrum changes
5zcr_mean:p2Zero-crossing rate as an equivalent frequency, log axis
6rms_mean:p2Mean frame RMS
7rms_std:p2SD of frame RMS — dynamics
8crest:p2log\log crest factor
9attack_s:p2log(attack+5ms)\log(\text{attack} + 5,\text{ms}) of the first note
10tail_ratio:p2log\log tail level relative to whole-phrase RMS
11bass_fraction:p2Energy fraction below ~250 Hz
12held_centroid_std:p2Centroid SD over the held note's gate-on span only
13high_ratio:p2log\log RMS of the highest note's span, relative to the held note's
14chord_flatness_delta:p2Flatness 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 φ\varphi, 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.

Drag the low note, or use the two presets. Both rows place a frequency by ear, with the same distance meaning the same interval, but they report different coordinates. On the linear axis one octave is worth forty times more at the top of the spectrum than at the bottom, so a single weight cannot mean "brighter" in both places. On the log axis an octave is an octave, which is what makes the coordinate weightable at all.

So log_axis puts centroid, rolloff and ZCR on a shared octaves-above-20 Hz scale, normalized to [0,1][0,1] at Nyquist:

log_axis(f)=log2 ⁣(max(f,f0)/f0)log2 ⁣(max(fNyq,2f0)/f0),f0=20 Hz\text{log_axis}(f) = \frac{\log_2!\big(\max(f, f_0)/f_0\big)}{\log_2!\big(\max(f_{\text{Nyq}}, 2f_0)/f_0\big)}, \qquad f_0 = 20\ \text{Hz}

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:

zcr_mean=log_axis ⁣(rfs2)\text{zcr_mean} = \text{log_axis}!\left(\frac{r \cdot f_s}{2}\right)

where rr 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 +4+4 for a handful of outliers: a coordinate that separates nothing except the outliers.

crest=log ⁣peakRMS+ϵ,tail_ratio=log ⁣(RMSlast 300 msRMS+ϵ+103)\text{crest} = \log!\frac{\text{peak}}{\text{RMS} + \epsilon}, \qquad \text{tail_ratio} = \log!\left(\frac{\text{RMS}_{\text{last 300 ms}}}{\text{RMS} + \epsilon} + 10^{-3}\right)

The 10310^{-3} floor inside the tail log matters: a pluck fully decayed by the last 300 ms would otherwise send the log to -\infty, 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:

h=(i1)+0.9max(e)ei1eiei1,attack_s=log ⁣(hhopfs+0.005) h^\star = (i-1) + \frac{0.9,\max(e) - e_{i-1}}{e_i - e_{i-1}}, \qquad \text{attack_s} = \log!\left(\frac{h^\star \cdot \text{hop}}{f_s} + 0.005\right)

The measurement window is onset → the second note's onset (2.0 s under the v2 phrase), and the +5+5 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 mim_i over bins=1024\text{bins} = 1024 and power=mi2\text{power} = \sum m_i^2:

Centroid. The magnitude-weighted mean frequency, then log-axised:

fc=iiΔfmiimif_c = \frac{\sum_i i \cdot \Delta f \cdot m_i}{\sum_i m_i}

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:

flatness=min ⁣(1, exp ⁣(1Nilog(mi2+ϵ))1Nimi2+ϵ)\text{flatness} = \min!\left(1,\ \frac{\exp!\big(\tfrac{1}{N}\sum_i \log(m_i^2 + \epsilon)\big)}{\tfrac{1}{N}\sum_i m_i^2 + \epsilon}\right)

Flux. Normalized by the combined magnitude sum of both frames:

flux=m(t)m(t1)2imi(t)+imi(t1)+ϵ\text{flux} = \frac{\lVert m^{(t)} - m^{(t-1)} \rVert_2}{\sum_i m^{(t)}_i + \sum_i m^{(t-1)}_i + \epsilon}

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 [0,1][0,1].

Frames whose power is below 101210^{-12} 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 = log\log 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:

CoordinateVIF
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 φR40\varphi \in \R^{40}.

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

sizeiniexactly, for every tree\text{size} \equiv \sum_i n_i \qquad\text{exactly, for every tree}

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 109\approx 10^9 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:

nvco+nsupersaw+nnoise+nwavetable+npluck+nformant+nsilence n_{\text{vco}} + n_{\text{supersaw}} + n_{\text{noise}} + n_{\text{wavetable}} + n_{\text{pluck}} + n_{\text{formant}} + n_{\text{silence}} nmixnringmodncompnduckngatenvocoder  =  1 {} - n_{\text{mix}} - n_{\text{ringmod}} - n_{\text{comp}} - n_{\text{duck}} - n_{\text{gate}} - n_{\text{vocoder}} ;=; 1

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 nmixn_{\text{mix}} 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, φ\varphi 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 ncomp+nduck+ngaten_{\text{comp}} + n_{\text{duck}} + n_{\text{gate}} 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 21.7\approx 21.7. 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 [0,1][0,1] (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 θ\theta only makes sense on a common scale. The fitting of that scale is a view of the data, not the data.

Raw φ\varphi scales vary wildly: counts 0–5, log-octave axes ~0–1, log crest 0–4. The standardizer is a per-dimension affine map

zj=φjμjsjz_j = \frac{\varphi_j - \mu_j}{s_j}

re-fit at every posterior fit, over the union of the observation log and the live pool.

It persists with the profile, always

θ\theta 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 φ\varphi, 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:

  1. Drop non-finite cells (a column that is entirely non-finite falls back to μ=0,s=1\mu = 0, s = 1, the reading of "no usable evidence on this axis").
  2. Compute the plain moments (μ,s)(\mu, s).
  3. Compute winsorized moments (μw,sw)(\mu_w, s_w) with the extreme 2% of each tail pulled in.
  4. Use the winsorized pair only if s>106sws > 10^6 , s_w.
const WINSOR_TAIL: f64 = 0.02;
const WINSOR_MIN_ROWS: usize = 10;
const RUNAWAY_RATIO: f64 = 1e6;

Finally sj1.0s_j \gets 1.0 if sj<109s_j < 10^{-9}, 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 10610^6

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 σ\sigma 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 103010^{30} in a column whose real values live in [0,1][0,1] gives a ratio near 2×10292 \times 10^{29}.

10610^6 sits five orders above anything clean φ\varphi 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

k(n)={0n<10clamp(0.02n, 1, (n1)/2)n10k(n) = \begin{cases} 0 & n < 10 \ \mathrm{clamp}\big(\lceil 0.02n \rceil,\ 1,\ \lfloor (n-1)/2 \rfloor\big) & n \ge 10 \end{cases}

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 48×0.02=0\lfloor 48 \times 0.02 \rfloor = 0, 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 [0,1][0,1] plus one 103010^{30}: 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 103010^{-30} of a standard deviation. The test asserts σ\sigma 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

u(x)=maxk1..K  θkz(x),z(x)=φ(x)μsu(x) = \max_{k \in 1..K} ; \theta_k^\top z(x), \qquad z(x) = \frac{\varphi(x) - \mu}{s}

KK style lenses, each a linear functional on the standardized feature vector. Utility is the maximum, not a weighted mixture.

At K=1K = 1 this reduces exactly to Bayesian linear regression on zz, 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 u=maxkuku = \max_k u_k. A dark drone and a bright pluck are both scored, each by whichever lens likes it most, and the comparison is well-formed.

Drag either lens. Each candidate is coloured by the lens that scores it highest and lit by how highly, the same encoding the instrument's taste map uses. Pull the lenses apart and two islands appear, each with its own idea of what "good" points at. Then press compare K = 1: one direction has to explain both islands at once, and the only direction that does lies between them, describing a taste nobody has.

Two rejected designs, and why

A per-session style latent zsz_s

"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 K=1K = 1. 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.

KK is an upper bound, not a claim

K=5K = 5 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 KK, and the app dims it rather than inventing a name for it.

So KK is capacity, and the data decides how much gets used.

The prior, and the correction KK forces

θk,jN(0,σθ2),σθ=1d  sK\theta_{k,j} \sim \mathcal{N}(0, \sigma_\theta^2), \qquad \sigma_\theta = \frac{1}{\sqrt{d}; s_K}

The 1/d1/\sqrt{d} factor is standard: with z2d\lVert z \rVert^2 \approx d 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 sKs_K factor is the correction the max form forces, and it is easy to miss.

Under the prior each uku_k is marginally N(0,1)\mathcal{N}(0,1), so u=maxkuku = \max_k u_k is the maximum of KK iid standard normals — whose standard deviation falls with KK:

KK12345
sKs_K1.0000.8260.7480.7010.669

The mean shift cancels in duels (both sides shift equally) and is absorbed by τ\tau and the cutpoints elsewhere. The variance shrinkage does not cancel. Left uncorrected, Var(uaub)\mathrm{Var}(u_a - u_b) drops from 2.0 at K=1K=1 to 0.90 at K=5K=5 — so growing KK 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 sKs_K restores invariance: Var(uaub)\mathrm{Var}(u_a - u_b) is the same at every KK.

What the interface reads off this

QuantityIs
utility_mix(z)(mean,sd)(\text{mean}, \text{sd}) of uu over posterior draws — the glow and size on the taste map
utility(z, k)Lens kk'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)Eθ[σ(u(a)u(b))]\E_\theta,[\sigma(u(a) - u(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 uu. 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.

One latent quantity, three ways of asking about it. Move candidate A's utility and watch all three panels respond together. Drag τ to see a strict session and a generous one; drag the star cutpoints to see why ★★★ means between two cutpoints rather than the number three. Squeeze two cutpoints together and that rating nearly stops being reachable, which is exactly what a rater who never uses the middle of the scale looks like to the model.

Pairwise duels — Bradley–Terry

P(AB)=σ(u(A)u(B))P(A \succ B) = \sigma\big(u(A) - u(B)\big)

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 uu 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 uu 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 softplus(v)-\mathrm{softplus}(-v) with softplus(t)=max(t,0)+log(1+et)\mathrm{softplus}(t) = \max(t,0) + \log(1 + e^{-|t|}). The naive log(1/(1+ev))\log(1/(1+e^{-v})) underflows for moderately confident predictions, which is exactly where a fitted model spends its time.

Keep / kill — a thresholded Bernoulli

P(keep)=σ(u(x)τs),τsN(0,1)P(\text{keep}) = \sigma\big(u(x) - \tau_s\big), \qquad \tau_s \sim \mathcal{N}(0,1)

τs\tau_s 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 τs\tau_s) 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 τs\tau_s site, and contributes zero rather than guessing. No threshold site means no threshold evidence.

Star ratings — a cumulative logit

P(y=k)=σ(cku)σ(ck1u)P(y = k) = \sigma(c_k - u) - \sigma(c_{k-1} - u)

with 0-based cutpoints, c1=c_{-1} = -\infty (so the first term is 0) and cKcat1=+c_{K_{\text{cat}}-1} = +\infty (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 θ\theta. 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:

c0=2+1.5r0,cj=cj1+exp(0.5+0.7rj),rjN(0,1)c_0 = -2 + 1.5,r_0, \qquad c_j = c_{j-1} + \exp(-0.5 + 0.7,r_j), \quad r_j \sim \mathcal{N}(0,1)

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: c0c_0 near 2-2 (so a 0-star rating means "well below average"), and increments with a median of e0.50.61e^{-0.5} \approx 0.61 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:

DuelA dealt duel you listened to
HeardEditAn edit committed through a heard comparison
SelfReportAn 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:

wi=0.5(n1i)/h,h=150w_i = 0.5^{,(n - 1 - i)/h}, \qquad h = 150

so the newest observation has weight 1 and one hh back has weight 12\tfrac12. Default recency_half_life = Some(150.0); None disables forgetting entirely.

logp(Dθ)=iwilogp(yiθ)\log p(\mathcal{D} \mid \theta) = \sum_i w_i , \log p(y_i \mid \theta)

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

Kd+S+(nstars1)K \cdot d + S + (n_{\text{stars}} - 1)

sample sites. With d=40d = 40 and 6 star categories, that is 40K+S+540K + S + 5: **45

  • SS** at K=1K=1 and 205 + SS at K=5K=5.

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 KK 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 KK 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 steps10 000 (mcmc_samples)
Warmup steps3 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 stepssites×desired effective sweeps\text{steps} \approx \text{sites} \times \text{desired effective sweeps}. At K=5K=5, 205+S205 + S 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 (K,d,nstars,S)(K, d, n_{\text{stars}}, S), 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 K=5K = 5 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 RSSmature-fit checksum
before303.1 MB07d204764b58c88b
after18.2 MB07d204764b58c88b

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 0,stride,2stride,0, \text{stride}, 2\cdot\text{stride}, \dots 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:

ws    wsp(yθs)swsp(yθs)w_s ;\leftarrow; \frac{w_s , p(y \mid \theta_s)}{\sum_{s'} w_{s'} , p(y \mid \theta_{s'})}

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 O(S)O(S). 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

ESS=1sws2\mathrm{ESS} = \frac{1}{\sum_s w_s^2}

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.

Fold observations in and watch the weights concentrate. Each bar is one posterior draw. Reweighting is exact and costs almost nothing, but the mass keeps collecting on fewer draws until a "posterior" of a handful of points would tell the acquisition rule it is certain when it is merely exhausted. Then press resample: ESS goes back to full and most of the draws are now duplicates of each other: the sample is impoverished rather than informative, which is why this is a stopgap and not a substitute for a fit.

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 12N\tfrac{1}{2N}) 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 nn 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:

  1. Relabel every sample to best match a reference (the last sample), maximizing total θ\theta cosine similarity across lenses.
  2. Recompute the mean of the pass-1 result and relabel against that.

Alignment is exhaustive over permutations, which is fine because K5K \le 5 and 5!=1205! = 120. No-op at K=1K = 1.

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)swsθk,s\sum_s w_s \theta_{k,s}
theta_std(k)Per-dimension posterior SD — the whiskers in DIRECTIONS
utility_mix(z)(mean,sd)(\text{mean}, \text{sd}) of uu — glow and size on the map
responsibilities(z)sws1[best lens of z under θs=k]\sum_s w_s \mathbb{1}[\text{best lens of } z \text{ under } \theta_s = k]
style_share(Z)responsibilities averaged over candidates
prob_prefers(a,b)swsσ(us(a)us(b))\sum_s w_s, \sigma(u_s(a) - u_s(b))

prob_prefers marginalizes θ\theta 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 P(A wins)P(A \text{ wins}) 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 p>0.5p > 0.5 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 p=0.5p = 0.5, 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.

Four hundred real forecasts, scored. Move the confidence dial to make the forecaster over- or under-claim and watch the dots leave the diagonal. Then press ask only near-ties: the hit rate drops to around 50% and stays there however good the model is, because that is what asking hard questions does to a coin-flip tally. The skill score keeps moving, which is why it is the one on display.

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

B=1ni(pichosen1)2B = \frac{1}{n}\sum_{i} \big(p_i^{\text{chosen}} - 1\big)^2

where pchosenp^{\text{chosen}} is the probability the model gave to the option the user actually picked. Lower is better; B=0.25B = 0.25 is what always saying 0.5 scores.

Reported as skill against that baseline:

skill=1B0.25\text{skill} = 1 - \frac{B}{0.25}

skillMeans
00No better than a coin flip
11Perfect and certain
<0< 0Worse 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

LL=1nilogpichosen(nats)\text{LL} = \frac{1}{n}\sum_i -\log p_i^{\text{chosen}} \quad \text{(nats)}

Baseline log20.693\log 2 \approx 0.693.

Comparable across time for one acquisition rule. Not comparable across acquisition rules: an information-seeking rule serves duels near p=0.5p = 0.5, 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:

FieldIs
check_nNumber of random-probe forecasts
check_skillBrier skill on them — the number without an asterisk
check_log_lossLog-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.

With the default rule, every duel is a probe

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 P(A wins)P(A \text{ wins}) (N_BINS = 5, the most a small session can fill without every bucket being noise). Each bucket reports:

predictedMean forecast in the bucket — the model's claim
observedObserved frequency of "A won" — the evidence
nHow 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

ShapeReading
Skill ≈ 0, small nnToo early. Correct and expected
Skill < 0 with real nnWorse than chance — either overfitting a coincidental coordinate, or genuinely inconsistent answers
Dots below the diagonal on the rightOverconfident: when it says 80% it is right less often
Dots above on the leftUnderconfident
Skill stuck near 0 with large nnThe 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 β\beta is the single dial between them.

The target

πβ(x)    pgrammar(x)  exp ⁣(βE[uθ(φ(x))])\pi_\beta(x) ;\propto; p_{\text{grammar}}(x);\exp!\big(\beta \cdot \E[u_\theta(\varphi(x))]\big)

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

pgrammar(x)p_{\text{grammar}}(x) 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.

exp(βE[uθ])\exp(\beta \E[u_\theta]) 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.

Turn β and watch the target move. At β = 0 the target is the prior: the search browses, and simple terms dominate because that is what the prior says, not because anything penalises size. Turn it up and the mass walks out toward the region the taste model likes, paying prior probability to get there. One dial, and the trade it makes is visible rather than tuned.

β\beta

SessionConfig::beta, default 2.0.

β\betaBehaviour
0\to 0Browse the prior. The taste model is ignored
2.02.0Shipped default
largeOptimizer 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. θ\theta is meaningless against any other scaling; see Standardization.

want_audio: false. The surrogate only ever wants φ\varphi; 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 πβ\pi_\beta. 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:

wi    wiclamp ⁣(eηti, 14, 4)w'_i ;\propto; w_i \cdot \mathrm{clamp}!\big(e^{\eta t_i},\ \tfrac14,\ 4\big)

then renormalized. SessionConfig::proposal_tilt is η\eta, 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

[14,4][\tfrac14, 4] bounds every multiplier, so no module kind is ever starved or monopolized.

Hollow bars are the prior's own weights; filled bars are the tilted ones. Raise η and the model's opinions start pushing kinds around. Then turn the clamp off: the strongest coefficients drive their kinds toward never being proposed at all. A prior that cannot generate an option can never be argued back into it, because the evidence would have to come from proposing it. Red is a multiplier sitting at a bound.

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 tit_i comes from

biased_prior builds the tilt vector from the posterior, in three steps.

1. Blend the lenses by their pool share.

θˉ=ksharekθkmean,σˉ=ksharekθksd\bar\theta = \sum_k \text{share}_k , \theta_k^{\text{mean}}, \qquad \bar\sigma = \sum_k \text{share}_k , \theta_k^{\text{sd}}

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.

shrink(θ,σ)=θθθ+σ\mathrm{shrink}(\theta, \sigma) = \theta \cdot \frac{|\theta|}{|\theta| + \sigma}

RegimeFactor
σθ\sigma \ll\theta
σ=θ\sigma =\theta
σθ\sigma \gg\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 φ\varphi: 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.

On detailed balance

Tilting the proposal changes the kernel, not the target. The MH accept/reject step still scores against πβ\pi_\beta, 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 πβ\pi_\beta. 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: φstruct\varphi_{\text{struct}} 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 θ\theta 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 L\mathcal{L} be a set of trace addresses. Refinement with L\mathcal{L} locked samples from

πβ(x¬L    xL)\pi_\beta\big(x_{\neg\mathcal{L}} ;\big|; x_{\mathcal{L}}\big)

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 xxx \to x' is rejected if it changes, deletes, or creates any address in L\mathcal{L}. 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:

xx allowed,xx rejectedx \to x' \text{ allowed}, \qquad x' \to x \text{ rejected}

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

ControlLocks
A knob's lock dotOne parameter address
A module's Every address currently in that module
lock knobsEvery parameter address in the patch
lock wiringEvery structural address
clear locksNothing

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 L\mathcal{L} 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.

Design versus implementation

The design describes generation as sampling from πβpgrammareβE[uθ]\pi_\beta \propto p_{\text{grammar}} \cdot e^{\beta \E[u_\theta]} 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::Lastthe state the walk ended on — the default
RefineKeep::Bestthe highest-logπβ\log \pi_\beta state it occupied, seed included

The archive is free. Every trace the kernel returns already carries its own logπβ\log \pi_\beta, 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 E[u]\E[u] 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.

Why the default has not moved

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:

refine_steps=2NOPS=40,refine_seeds=NOPS/2=10\text{refine_steps} = 2 \cdot N_{\text{OPS}} = 40, \qquad \text{refine_seeds} = \lceil N_{\text{OPS}} / 2 \rceil = 10

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:

stepsseedsproposalsmean uumax uu
40104001.7148.154shipped
4031201.2416.178same depth, fewer seeds
6631980.7746.281same total, fewer seeds
20204000.5686.790half 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 πβ\pi_\beta 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 islands99 / 473 (20.9 %)
of those, decisive — both ends > 1.0 onto their island64 (13.5 % of all events)
seeds whose pool ended on one island only0 / 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 φ\varphi. 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 Δφ\lVert \Delta \varphi \rVert 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:

  1. The diversity argument for SMC is weaker than expected at session horizon.
  2. 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

φstruct\varphi_{\text{struct}} 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 φ\varphi 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.

RulePicks
Random (default)A pair uniformly at random from the pool
BaldThe pair maximizing expected information gain about θ\theta
ThompsonDueling 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 tt, the MCMC seed at round rr, 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.
  • ±\pm is two standard errors of the paired difference.

Three metrics: cosine similarity to the true θ\theta^* (↑), 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 ↓
random0.4600.7310.211
thompson0.4160.6280.254
bald0.4840.7620.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 (t=2.2/6.1/8.0t = 2.2 / 6.1 / -8.0). 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 φaφb\lVert \varphi_a - \varphi_b \rVert 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 ↓
random0.4790.6940.232
thompson0.4590.5830.276
bald0.4650.7070.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_gain BALD 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).
  • Random makes every duel an unbiased calibration sample rather than one in ten, a virtue that holds regardless of which rule learns θ\theta 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:

LeverConfigDefault
Bias duels toward patches the user will enjoy auditioningduel_utility_weight0.1
Bound how often one patch reappearsduel_exposure_penalty0.25
Avoid re-asking a pairduel_repeat_penalty0.5
Soften the selectionduel_temperature0.6
Reserve unbiased probesduel_check_every10

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 exp(ΔJ/T)\exp(\Delta J / T) ran to e10e^{10} — 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 θ\theta'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

LayerWhereWhat
0quiverDenormals 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
1auracle-featuresThe vetting gate. Audition plays pre-rendered, vetted, normalized buffers — never a live unvetted patch
2auracle-sessionQuarantine → QUARANTINE_FITNESS = -50.0, so the search learns to avoid the region
3auracle-grammarMandatory … → DC blocker → VCA → Limiter → StereoOutput; parameter ranges bounded away from pathology
4testsValidationMode::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 (InvalidPort lists the available ports).
  • ValidationMode::Strict for 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 O(1)O(1) 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 50-50 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:

audioDC blockerVCALimiterStereoOutput\langle\text{audio}\rangle \to \text{DC blocker} \to \text{VCA} \to \text{Limiter} \to \text{StereoOutput}

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 NN 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 φ\varphi 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

ObjectContains
SessionStateThe whole session: pool, bank, names, log, posterior, generation, forecasts
BankEntryA patch's tree, id, origin, name, pinned flag. Renders and features are re-derived on import
ObservationLogEvery Feedback with its session index and raw φ\varphi by name
ProfileThe log plus the standardizer — the portable unit
TastePosteriorA 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 φ\varphi 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 φ\varphi 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>,
}

θ\theta 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:

CoordinateConversion
centroid_mean, rolloff_mean, zcr_meanRecover the frequency from the linear-Hz fraction, re-map onto the octave axis. Exact
centroid_stdThe 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_sNow 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 φ\varphi over a 30-coordinate feature set with no names.

Recoverable, because the profile persisted the standardizer alongside it:

φraw=zs+μ\varphi_{\text{raw}} = z \cdot s + \mu

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:

FieldDefaultReads as
BankEntry::pinnedfalseSessions saved before pinning existed had no pins
TastePosterior::weightsemptyUniform — posteriors written before reweighting existed were uniform
Forecast::provenanceDuelEvery forecast already on disk was a dealt duel, which is what Duel means
TasteConfig::recency_half_lifeNoneNo 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_deferredbank_absorbrestore_finish — rather than serially. See The web runtime.

The persistent render cache

φ\varphi is a pure function of (term,spec)(\text{term}, \text{spec}) — 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 (term,spec)(\text{term}, \text{spec}), which is everything φ\varphi 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 φ\varphi 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 trustedpre_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 φ\varphi 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

ThreadHoldsRuns
MainUI, Web Audio graphmain.js — never in the audio or render data path
Engine workerWasmEngine (all of auracle-session)worker.js — pool fill, fits, refinement, workbench
Render workers ×NA wasm instance, nothing elsefarm.js — stateless (term, phrase) → φ
AudioWorkletLivePolyThe 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 NN 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

N=clamp(hardwareConcurrency2,  0,  6)N = \mathrm{clamp}(\text{hardwareConcurrency} - 2,; 0,; 6)

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 ii is the prior sampled under StdRng::seed_from_u64(splitmix64(fill_seed, i)), so a term is a pure function of (fill_seed,i)(\text{fill_seed}, i). Not of arrival order, not of which worker got it.

Results are absorbed in index order. The pool at index ii depends only on indices <i< i.

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.

IterationYearWhat it provedWhat it lacked
neuralCompressor (C++/Arduino pedal)2020The interaction model: human-based GA, fit/unfit foot-switch, mutate/crossover knobsThe engine — EA and DSP were never implemented
evosynth v1 (Next.js/Tone.js + FastAPI/DEAP)2025A working interactive GA over a fixed ~30-parameter subtractive synth; parameter locking; lineage trackingPreference 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.

DecisionChoiceRationale
Genome representationTyped 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 signalsPairwise duels + stars (ordinal) + keep/kill; no implicit signalsOne latent utility, three likelihoods; duels primary
Taste featuresφ_audio (15) + φ_struct (25) = φ ∈ ℝ⁴⁰Transfer across topologies + free structural screening
Feature axesLog-frequency, logged heavy tails, families not per-module columnsThe model is linear in φ, so the axis decides what is expressible; sparse columns are coefficients fitted on a handful of rows
Utility formMax of linear experts u = max_k θ_k·z, K = 5Multi-modal taste; handles cross-island duels (per-observation and per-session latent-z designs both fail there); no discrete sites; K=1 ≡ BLR
Preference setsDiscovered style lenses, aligned post-hoc; nameable and persistedA lens claiming ≈0% of the pool is idle — K is an upper bound
Locks / partial evolutionFreeze any set of trace addresses; MH proposals touching them are rejected outside the kernel, in both directionsExactly Metropolis-within-Gibbs on the conditional posterior, so locking is exact rather than heuristic
Hand editsKnob turn = write at a trace address; commit inserts as new candidate; optional "edit beats original" duel, provenance-taggedPanel and genome share one encoding, so edits, locks, and evolution cannot drift
Profile portabilityExport = 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
Palette42 productions: 7 sources, 20 processors, 15 modulators; categorical orders are append-only wire formatEnough texture axes to learn on; the codec writes indices into the trace
Feedback loops in grammarNot 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
AuditionStandard 5.05 s phrase + free-play; per-style phrases laterFeature comparability requires fixed stimulus
LoudnessLUFS-normalize all renders to −18Loudness bias would poison the preference data
AcquisitionUniform random pairing by default; BALD selectable, Thompson kept for contrastMeasured 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 metricBrier skill against the 0.5 baseline, plus random check duelsAccuracy is not proper and is pinned near chance by an information-seeking pairing rule
RecencyDiscounted likelihood, half-life 150 observationsTaste is allowed to change; stationarity is the wrong assumption about a person
First frontendWeb / WASMBoth deps ship WASM; fastest UX iteration; shareable
Session UXAll three modes, built duels → grid → radioSame observation stream; sequenced by signal quality
SafetyVetting gate: audition = pre-rendered vetted buffers, never live unvetted patchesOne 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".

#DeliverableDemo / gateStatus
M0Workspace scaffold, CIcargo check green
M1auracle-grammar: PCFG, palette, term→Patch compilerPlay random grammar samples (already fun)
M2auracle-features: phrase renderer, LUFS, feature vectorFeature vectors stable & reproducible for fixed seeds
M3auracle-taste: mixture-BLR (K=1), 3 likelihoods, synthetic userPosterior recovers ground-truth θ*; regret shrinks
M4auracle-session: two-loop engine + acquisitionHeadless closed loop vs synthetic user
M5WASM + web app: duel mode + benchA human can teach it their taste
M6Grid & radio modes; K>1 style discovery; named profilesStyles 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

  1. posterior concentration on θ*, and
  2. 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.

  1. 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.
  2. 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.
  3. 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 treeterm.rs says 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.md lists it as a sharp edge for exactly this reason. children(), size(), depth() and site_count() all assume each node is visited once, and size is φ's parsimony term. Every structural op in mutate assumes 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 :p2 stimulus 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. :p2 is a hard-coded literal in AudioFeatures::NAMES, and Features::phi_names is global. A phrase that varies needs the tag to be a function of the PhraseSpec actually 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.

  • 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:

    regimecos θ*rank rexcess 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 in learn_synthetic beside --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 KK at 3) is gated on style_share evidence 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 where k == k_styles are the ones that bear on it: k grows 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::Best takes the highest-log π_β state the walk occupied, seed included, and ships switched off. Sixteen paired seeds:

    LastBest
    mean 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 on14/1615/16

    Paired difference (BestLast): 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 stays Last, kept re-checkable rather than deleted, as Acquisition::Thompson is.

    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 Best is 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's StateObserver has taken Level, Scope and Spectrum subscriptions 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_keepalive is 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's parallel feature on wasm32. Closed, 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."

    Wrong about the blocker: fugue-evo#22 established that checkpoint, not parallel, was the only thing that did not build on wasm32. Wrong about the remedy: enabling parallel would change nothing here, because every rayon use in fugue-evo sits under #[cfg(feature = "classic")], and Auracle takes ["std", "ppl"] and drives refinement itself through inference::mh::EvolutionChain. And wrong about the prize: the harness is not waiting on single-threaded refinement. search_health and the refinement_improves_pool floor 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_hz is 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_mean and centroid_mean are 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_mean no 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 φ\varphi
  • auracle_taste — the utility model, three likelihoods, MCMC posterior, standardization
  • auracle_session — the two-loop engine, acquisition, calibration, persistence
  • auracle_wasmWasmEngine and LivePoly

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:

ForRead
The grammar's site tableauracle_grammar::prior module docs
Why φstruct\varphi_{\text{struct}} has families rather than per-module columnsauracle_features::structural module docs
The max-of-experts argument, and the sKs_K correctionauracle_taste::model module docs
Why the standardizer's threshold is 10610^6auracle_taste::standardize::RUNAWAY_RATIO
Why refinement is 40 steps × 10 seedsauracle_session::SessionConfig::refine_steps
The acquisition measurement, in fullauracle_session::Acquisition
Why accuracy was replaced by Brier skillauracle_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, P(AB)=σ(uAuB)P(A \succ B) = \sigma(u_A - u_B). 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 1/ws21/\sum w_s^2, 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

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:

IterationYearProvedLacked
neuralCompressor (C++/Arduino pedal)2020The interaction model: human-driven GA, fit/unfit footswitch, mutate/crossover knobsThe engine — neither the EA nor the DSP was ever implemented
evosynth v1 (Next.js/Tone.js + FastAPI/DEAP)2025A working interactive GA over a fixed ~30-parameter subtractive synth; parameter locking; lineage trackingPreference persistence (ratings died each generation), topology evolution, principled inference
Auracle2026–

v0 had the interaction but no engine. v1 had an engine, but a naive one with no memory of the user.