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.

For engineers: the genome, audition, features, the taste model, search, PERFORM's wiring, and the web runtime. 2:17 · chapters and transcript

This book is the technical companion to the guide. The guide says what the instrument does; this book says how, with the math written out and pointers into the code that implements it.

It is organized as a pipeline, because that is what Auracle is. A term (the genome) compiles to a patch, the patch renders audio, the vetting gate passes or quarantines it, φ\varphi measures it as 44 numbers, and the utility uθu_\theta scores it:

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

A loop closes over it: the player’s picks condition the taste parameters θ\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 a reader 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.

Four pages to start with

  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 styles 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.
  • Playing the instrument is the guide.
  • Working on Auracle (layout, the quality bar, the sharp edges, cutting a release) is CONTRIBUTING.md.
  • What changed when is CHANGELOG.md.

Design decisions, rejected alternatives, the milestones, the open questions, and the directions nobody has raised yet are in this book, under Design. They used to be a DESIGN.md at the repo root, which made the reasoning and the math 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, and its search is fugue-evo’s typed MH with a learned fitness plugged in. Auracle uses neither the tempered SMC nor any crossover.
  • 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)∈R44\varphi(x) \in \R^{44}The feature vector of xxFeatures::phi()
φaudio∈R18\varphi_{\text{audio}} \in \R^{18}Perceptual descriptors of the renderAudioFeatures
φstruct∈R26\varphi_{\text{struct}} \in \R^{26}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 styles, the experts of the taste model (≤5\le 5)TasteConfig::k_styles
ddFeature dimension (4444)TasteConfig::n_features
θk∈Rd\theta_k \in \R^{d}Style 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)Style kk’s utility, θk⊤z\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)]}(none)
η\etaTaste-tilt strength (a tilt of the prior; see Proposals)SessionConfig::proposal_tilt
L\mathcal{L}The set of locked addresseslocked: HashSet<String>

Conventions

  • σ(⋅)\sigma(\cdot) is the logistic function σ(v)=1/(1+e−v)\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/katex-macros.txt, which book.toml points to, 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. Nearly 5,000 lines, the largest single file 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 62-patch hand-made library, in seven families

genome’s codec is the grammar’s addressing. It is one scheme rather than two kept in sync, which is what makes a knob turn, a lock, and an MH proposal refer to the same thing.

auracle-features

The measurement crate. One render serves three purposes: the vet report, the feature vector, and the audition buffer the player 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}}: 18 perceptual descriptors
structuralφstruct\varphi_{\text{struct}}: 26 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. About 4,100 lines
walkA generation’s walks as data: the jobs, the context they share, and the walk as a pure function
performPERFORM’s named controls, drift, and offers
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 the instrument plays 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. Among its scripts: main.js (UI and Web Audio), worker.js (the engine), farm.js (a stateless render and walk worker), live-audio.js (worklet assembly), perform.js (PERFORM), taste.js (TASTE and LEARNING), midi.js (MIDI controllers) and booth.js (booth mode). Beside them are smaller modules, most of them pure rules with unit tests of their own, such as words.js (sentences built from engine facts), taste-geom.js (TASTE’s and LEARNING’s geometry), deal.js (EVOLVE’s dealing) and toasts.js (the toast lane). apps/web/AGENTS.md lists every one and what it holds.

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.5.1Modular DSP. Library name is quiver
fugue-evo 0.4.0Evolution as inference. default-features = false, because checkpoint and parallel do not compile on wasm32
fugue-ppl 0.2.4The probabilistic programming layer. From 0.2.3 a seeded run draws the same in wasm32 as natively

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 player 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 of the player’s picks.

The patch loop

Machine-paced. No human in it.

  1. Fill. Sample terms from the grammar prior, compile, render, vet, featurize. The pool target is SessionConfig::pool_size vetted candidates (48 by default in engine.rs; the web app passes 40 in apps/web/main.js), with at most max_draws (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. The defaults in engine.rs are 10 seeds × 40 steps, both scaled from the grammar’s processor count (N_OPS, 20, in prior.rs), so a change to the processor set does not silently change the search’s character.
  3. Inject. Each child is admitted only if it beats the member it would displace. When the generation ends, the lowest-utility members are replaced to bring the pool back to size. Pinned (saved) candidates are exempt, including ones pinned while the generation runs, and so is a sound kept as new until its first pick (at most pool_size / 4 of them, persistence).

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 (an EVOLVE pair, a heard PERFORM offer, or an edit claim), star, and cut 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. It is exact, costs time linear in the number of posterior draws, and is what makes the next pair respond to the last pick.
  3. Refit, every sixth pick. Full MCMC over the log: 10,000 post-warmup steps (mcmc_samples) after 3,000 warmup steps (mcmc_warmup), thinned to at most 500 retained draws (KEEP, in auracle-taste’s model.rs).

The app paces refits by count: every sixth pick refits (FIT_EVERY, 6, in apps/web/main.js), and so do the end of the warm start and opening a taste profile that holds picks. The engine also reports when the cheap path has run out of road, that is, when the reweighted posterior’s effective sample size has collapsed far enough to need resampling since the last fit (Engine::needs_refit). The app does not wait for that signal. See The posterior.

Where they meet

Acquisition picks the pair the player hears next. The taste 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 weights of the grammar the search draws new modules from. Because the tilted grammar is installed as the prior, they also reshape the target the search climbs (see Proposals). Kind ii’s weight wiw_i is multiplied by eηtie^{\eta t_i}, where tit_i is the fitted coefficient for that kind and η\eta is the tilt strength (proposal_tilt, 0.6, in engine.rs):

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 closed: the player’s picks change what gets proposed and what the search counts as parsimonious, not only what scores well once proposed.

Why this is preferential Bayesian optimization

There is a latent objective (the player’s utility), an expensive oracle (the player), a cheap surrogate (the posterior), and a generator of candidates (the grammar prior plus MH). The acquisition step is where θ\theta’s posterior uncertainty would earn its keep: early sessions could ask informative questions (duels the model cannot rank), and a confident model could mostly serve sounds it predicts the player will pick.

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 and reversible-jump Metropolis–Hastings operate on traces without knowing anything about synthesizers. Auracle contributes a grammar, not an inference algorithm. It uses no crossover: every child grows from one seed (walk.rs).

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 a knob whose change disappears on the next patch swap; writing only the genome gives a knob that must be recompiled to be heard.

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 x→x′x \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 penalizing 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}}), 8 kinds
processor kind<p>#opCategorical(wop)\mathrm{Categorical}(w_{\text{op}}), 20 kinds
modulation kind<p>/m#modCategorical(wmod)\mathrm{Categorical}(w_{\text{mod}}), 9 kinds
CV-processor kind<p>/m#modopUniform over ModOp::ALL
CV-combiner kind<p>/m#pairopUniform over PairOp::ALL
oscillator octave<p>#octCategorical(woct)\mathrm{Categorical}(w_{\text{oct}}) over −2…+2 (see Discrete sites)
discrete params<p>#wave, #color, #fkind, #table, #dmode, #channelUniform categoricals
input slot<p>#inputPlayerInput: draws slot 0, scores every slot alike (see AUDIO IN)
continuous params<p>#cut, #res, #det, …Uniform(0,1)\mathrm{Uniform}(0,1)

The amplitude envelope is fixed at amp#attack … amp#release.

The three categorical orders (8 sources, 20 processors, 9 modulation kinds) are the persisted wire format, because the codec writes the chosen index into the trace. They are append-only.

Parsimony is the prior, not a penalty

Deeper terms pay more prior mass by construction: each additional level of recursion multiplies in another #leaf Bernoulli that came out “processor”, and each processor node draws its own parameters. There is no size penalty term anywhere.

Ad-hoc parsimony penalties are the norm in genetic programming and a persistent source of trouble: they need tuning, they interact badly with fitness scaling, and they leave the target distribution unwritten. Here the target is written down: πβ∝pgrammar⋅eβ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:

  • Six leaves: LFO, envelope, random (sample-and-hold), envelope follower, Euclidean, step sequencer.
  • Op wraps one modulation term: quantize, slew, rectify, hold.
  • Pair combines two.

The #mod order is None, Lfo, Env, Rand, Follow, Euclid, Op, Pair, Steps. The step sequencer is a leaf that sits after the two branches, because the order is append-only wire format and it arrived last. So “is this kind a leaf” is a predicate (mod_kind_is_leaf), not an index range: the range it replaced (kind < 6) would have switched the new leaf off at the depth bound along with the branches, and a term forced to bottom out could never have drawn it.

The step sequencer’s values are latent

Steps carries eleven continuous sites: #srate, #slen, #sslew, and one per step, #s0 … #s7. Each step value uiu_i is its own Uniform(0,1)\mathrm{Uniform}(0,1) draw and plays as (2ui−1)⋅5 V(2u_i - 1)\cdot 5\text{ V}; #slen decides how many of the eight play (2 to 8, seven equal bins of the knob). This is the Mutable Instruments Marbles design, turned into a genome:

  • One site, one step. An MH proposal that moves #s3 re-voices step four and nothing else, so evolution edits a pattern the way a hand does.
  • Hidden, not deleted. The steps past #slen stay in the trace. A proposal that shortens the pattern and a later one that lengthens it give back the steps that were hidden instead of inventing new ones.

The module behind it is Auracle’s own (auracle_grammar::steps::StepsCv) rather than quiver’s StepSequencer, whose values are internal state with no ports: every one of the eleven sites is a port driven by a live knob, so a bar drag in the rack is an atomic write, not a recompile. Its clock is free-running (#srate is 0.5⋅25x0.5\cdot 2^{5x} steps per second), and every audition hears it that way; tempo sync is a live-instrument concern and is not in the genome. With the dock’s sync on, the live engine snaps each sequencer’s rate to the nearest division of the tempo in octaves (straight, triplet, or dotted; a quarter-step per beat up to eight) and drives every voice’s clock from one transport through a sync port the term never sees (<key>#~sync). The transport restarts on the first key down or on MIDI start, and it counts steps, not bars, so a five-step pattern keeps its polymeter against a four-beat arp.

So s&h rand → quantize → slew is a legal modulation term, and the rack draws the whole chain. Subterms live at <p>/m/0 and <p>/m/1, the same child convention the audio tree uses; it is unambiguous because every modulation key sits below a /m.

Its parsimony pressure is max_mod_depth, and the renormalizations that enforce it live in mod_weights_at: at maximum depth only leaves remain available, and below a processor the “no modulation” option is removed so a slot that must be filled is filled.

A modulation slot hangs off every module with somewhere to send it. The exceptions are the ones without: Noise, whose only site is a color switch, AudioIn, whose only continuous knob is a level, Mix / RingMod, whose two inputs are both audio and whose single knob is the blend, and the two player kinds, Track and Capture, whose knobs are how they listen and play. Having two audio children is not itself an exception: the four sidechained productions (comp, duck, gate, and vocoder) take two subterms and carry a slot as well.

The modules

Forty-six modules: 8 sources, 22 processors (20 the prior draws and 2 player kinds), 16 modulators.

sources     Vco  Supersaw  NoiseGenerator  Wavetable  KarplusStrong  FormantOsc
            Silence  AudioInput
processors  Mix  Filter  Fold  Delay  Chorus  Reverb  Distortion  Bitcrush
            Phaser  RingMod  Flanger  Tremolo  Vibrato  Eq  Granular  Shift
            Comp  Duck  Gate  Vocoder
            PitchTracker (Track)  Capture        (player kinds)
modulators  Lfo  Adsr  SampleAndHold  SlewLimiter  EnvelopeFollower  …
            StepsCv (Auracle's own: a step sequencer whose values are ports)

Seven processors are binary:

ProductionSecond input
Mix, RingModAudioMerges two chains into one
Comp, Duck, Gate, VocoderControlReal sidechaining, in a typed tree
TrackControlThe signal that plays the first branch

A compressor’s sidechain is not an audio input, and the type system makes wiring it as one impossible.

AUDIO IN: the player’s input

AudioIn is a source whose signal comes from outside the patch: one of the host’s inputs, read through quiver’s AudioInput (ADR-015). It is source index 7, with three knobs:

KnobSiteRange
input#inputA slot, 0..INPUT_SLOTS (8), shown as 1 to 8. Which device a slot means is the host’s to say
gain#gainUniform(0,1)\mathrm{Uniform}(0,1), mapped to −24 to +12 dB (INPUT_GAIN_DB_MIN, INPUT_GAIN_DB_MAX), unity at 2/3
channel#channelleft, right, or both summed

What it is for comes from where it is patched: into a filter or a delay it is processed, into a ducker’s key or a vocoder’s modulator it shapes another sound. It is measured with the session’s audition clip, never with the live input.

The input belongs to the player. #input is a real trace site, so the rack, a knob edit, a lock and the lineage diff all address it, but the grammar never chooses it. Its distribution, PlayerInput, samples slot 0 and gives every slot the same (unnormalized) mass, so a node the prior draws reads the first input, and a player’s choice of slot 3 is exactly as probable, and as evolvable, as slot 0. A refinement walk locks every #input its seed holds (locks): it may change the gain, the channel and everything the signal goes through, and never the input or the node itself.

AUDIO IN is a player kind, like TRACK and CAPTURE below: the prior never draws one, and scores a player’s. Its source weight is 0 (AUDIO_IN_WEIGHT), with no taste tilt, so no fill, walk or offer deals an input and the source table draws exactly what it drew before the term: whether a patch listens is a choice a player makes by patching one in. The #src site’s distribution, SourceKind, samples that table unchanged and scores AUDIO IN at PLAYER_SOURCE_MASS (0.005) where the table gives it none, so a listening patch is inside the support and ⚡ and generations walk it, holding the node by its #input.

TRACK: an input plays the patch

Track lets a voice or an instrument play the patch the way the keys do (RFC-008’s play the patch). It compiles to quiver’s PitchTracker, which reads pitch by YIN, a gate and a level from its /1 branch (usually an AUDIO IN), and it is #op 20.

KnobSiteRange
band#bandlow (40–500 Hz), mid (70–1000 Hz) or high (140–2000 Hz): the band the tracker searches
sensitivity#tsensUniform(0,1)\mathrm{Uniform}(0,1), the gate threshold on the tracker’s level scale, geometric from TRACK_GATE_LOUD (2.5 V) down to TRACK_GATE_QUIET (0.025 V); quiver’s own 0.25 V at the knob’s center. Up opens on a quieter input
dynamics#tdynUniform(0,1)\mathrm{Uniform}(0,1): how far the input’s level shapes the played branch, from not at all to fully (full level at TRACK_FULL_LEVEL, 5 V, an input 6 dB under a full-scale sine)

The input is the keyboard for /0. While the compiler builds the played branch, the tracker’s pitch and gate stand where the keys’ stand everywhere else: a VCO there sings the tracked note (its octave knob still transposes it), a pluck is plucked and a mod envelope fires on every tracked onset. The tracker’s gate is also summed into the voice’s amp envelope with the keys’ (either opens it), so a note sung into an AUDIO IN sounds the way a key does, and a key let go while the input still sounds holds the voice open until the input stops. Outside /0 the keys play the patch as before, so a mix can hold a tracked side and a keyed side.

Three consequences worth knowing:

  • A chord collapses. Every voice of a chord plays the one tracked note inside /0: there is one input, and one pitch in it. In the audition phrase the dyad’s second voice is a follower: it plays the note the main voice’s tracker hears from its first frame, and its amp keeps its own key’s gate, so it stops with the dyad.
  • Live, one voice tracks. The instrument (LivePoly) builds a patch with a TRACK with one tracked voice, its open voice, and every key’s voice is a follower of it, as the phrase’s chord voices are: so a key let go stops with its key instead of staying open while the input sounds. The tracked voice is not held like a key; its tracker’s gate opens it, so while the input is monitored it sounds when the input does. It renders its quantum first and copies its tracked signals frame by frame (CompiledVoice::read_tracks) for the followers to read before each of their frames.
  • Before the first tracked note, /0 plays C4 (the tracker’s 0 V), and between notes it holds the last note it tracked.
  • The tracker hears what is there. Where one note still rings under the next, YIN can read the pair (see below).

Rendered on the audition clip, a TRACK plays the reference figure’s notes: thirteen of its fourteen within 1.4 cents, and the D4 that sounds over a still-ringing E4 17 cents sharp, because YIN hears the pair.

CAPTURE: a recorded take as a source

Capture records what is patched into it and plays the take back (RFC-008’s resample). It compiles to quiver’s Capture and is #op 21.

KnobSiteRange
play#playonce (each note plays the take to its end), hold (while the note is held) or loop (round and round while held)

Its output is its take, not its input. /0 is what it records; it is built and running, and never heard. So inserting a CAPTURE turns the chain below it into what is recorded, and the patch plays the take instead. Each note plays the take at the keys’ pitch, C4 at the speed it was recorded; an empty take plays silence, as an unplugged socket does.

The take is the sound’s content, saved in the term:

"take": {"format": "f32le-base64", "sample_rate": 48000.0, "length": 96000, "data": "…"}

It is quiver’s own Capture state, lossless, so a reloaded take plays back bit for bit. A take is bounded the way an audition clip is: the format tag, a rate no higher than MAX_TAKE_RATE (192 kHz), a length no longer than TAKE_SECONDS (4 s, quiver’s default Capture buffer) at that rate, and the data’s length checked before it is decoded. A take that breaks a rule never costs the sound: the term loads with the take empty, and a restore counts the sound as repaired, so the app says so. When that take was the sound’s only source it no longer renders, and the restore keeps it aside (the engine’s held list) instead of dropping it: out of the pool, so nothing deals, fits or breeds it, reported apart from the repairs (“One sound’s take couldn’t be read. It’s kept safe until you record it again.”, once for each set of such sounds), and written back by every save with its take JSON-equal to what was loaded, until a readable take brings it back (readmit_held), measured as a new sound. A fragment sent with a take that couldn’t be read (SetTake, ReplaceTree, InsertTree) is refused. A take is not a trace site, so no walk can propose a new one, and an edit that changes only a take logs a lineage event whose diff is empty.

A recording stops at TAKE_SECONDS at the voice’s rate (the record gate goes through a window that closes then), even into a buffer a longer, higher-rate take has enlarged, so what a capture records always reads back as a take.

Recording is the host’s. A compiled voice has a record gate per capture and reads the take back from quiver’s state, and StructOp::SetTake puts it in the term. Nothing in the engine raises the gate, so a measurement render never records and a capture measures the same every time. In the app, RECORD on the CAPTURE’s plate has the worklet copy the input the CAPTURE listens to while it is lit, and on STOP (or at TAKE_SECONDS) the engine worker plays that copy through one voice of the patch, its key held at C4 and the gate raised (render_take, with LivePoly::set_record and LivePoly::take_json): the take a voice recording live would have made, without compiling, rendering or encoding on the audio thread. The take goes out as set_take, an edit like any other. A sound kept safe is recorded the same way from its saved term, and the take goes to readmit_held.

Player kinds: scored and never drawn

Track and Capture are player kinds: a sound has one because a player put it there. The prior must never draw them (a drawn tree that tracks or captures would move every random-tree statistic, and a capture with nothing recorded is silence), but it must not score them at zero either: a tree the prior gives log⁡p=−∞\log p = -\infty cannot start a walk, so a player’s tracked sound could never be evolved.

The #op site’s distribution, OpKind, does both, as PlayerInput does for #input. Its sample is the categorical over the twenty drawn kinds, exactly as before, so it returns the same index from the same stream and every draw is unchanged. Its log_prob scores a drawn kind as before and a player kind at PLAYER_OP_MASS (0.005, the weight of the grammar’s other two kinds a player puts in place). The extra mass leaves the scorer unnormalized, which is harmless: a walk holds every player kind’s #op, so the term is the same constant in every ratio it takes. N_OPS, which the refinement budget scales with, still counts the twenty drawn kinds.

A TRACK or CAPTURE whose input chain holds an AUDIO IN scores finite too: SourceKind does for that AUDIO IN what OpKind does for the TRACK.

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 the 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 warning-class pairings the compiler uses on purpose, the clearest being a constant bipolar Offset feeding a unipolar knob. The allowlist test (every_prior_sample_compiles) names each class with its reason, which keeps a known exception from quietly becoming a habit of ignoring warnings.

Where this comes from

The design mirrors fugue-evo’s ArithmeticGrammarPrior, with quiver signal sorts in place of arithmetic types. That is deliberate: fugue-evo’s subtree mutation and reversible-jump MH apply to this genome unchanged, because they operate on traces and this genome’s trace encoding is faithful. Auracle uses that MH. It does not use crossover, since every child grows from one seed (walk.rs), and it does not use fugue-evo’s tempered SMC.

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::Range<f64> = 0.0..1.0;
pub const PARAM_MAX: f64 = 1.0 - f64::EPSILON;

pub fn in_domain(v: f64) -> bool {
    v.is_finite() && PARAM_DOMAIN.contains(&v)
}

Every continuous site is normalized to [0,1)[0,1) and the mapping to Hz, seconds, dB, or cents happens in the compiler. Half-open, because that is what Uniform(0,1)\mathrm{Uniform}(0,1) is: fugue’s log_prob is −∞-\infty at x≥1x \ge 1. The domain used to be 0.0..=1.0, which made exactly 1.0 legal here and impossible under the prior: a knob dragged to its stop, two shipped presets, and the default vibrato insert all had log⁡p=−∞\log p = -\infty, so init_from refused them and ⚡ evolve silently did nothing. Every clamp in the crate now lands on PARAM_MAX, never on 1.0, and it is one f64::EPSILON below rather than the next float down so that a JSON round trip cannot put it back on the boundary. No mapping in the compiler can hear the difference. Three things fall out of the shared domain:

  • The prior is trivially correct. 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 penalized for.

It is not a substitute for vetting, which catches pathology that arises from composition: a bounded resonant filter fed by a bounded distortion fed by a bounded fold can still scream.

Latent sites

Not every continuous site is heard. A step sequencer (ModNode::Steps, see the grammar) always carries eight step values #s0 … #s7, and #slen decides how many of them play. The rest are latent: in the trace, in the domain, drawn from the prior, and moved by MH like any other site, but inert until a longer #slen reveals them.

That is deliberate, and it costs nothing the search has to pay for. A proposal on a latent step changes neither the sound nor φ\varphi, so the tempered target accepts it as a neutral move (drift, not selection: nothing about taste has acted on it yet), and the value it leaves behind is what a later #slen proposal reveals. Shrinking a pattern never throws a step away, and growing it again brings back the step that was there.

Discrete sites

Categoricals, each with a named domain, and all uniform but the octave:

SiteDomainDrawn
#waveWaveform: saw, square, triangle, sineuniformly
#octOctave offset, −2 … +25%, 15%, 40%, 25%, 15% (OCTAVE_WEIGHTS, in prior.rs)
#colorNoise coloruniformly
#fkindFilter kinduniformly
#tableWavetable shapeuniformly
#dmodeDrive mode: soft, hard, tubeuniformly

The octave is weighted for the speakers a patch is heard on. A patch’s register comes almost entirely from its lowest oscillator. Drawn uniformly, 30.5% of a fresh bank put most of its energy at C4 below 200 Hz, and 17.5% put under a fifth of it in 200 Hz–5 kHz, the band a laptop’s speakers reproduce: sounds a laptop barely plays. With the weights, the same five fresh loads of the app’s pool (200 patches, measured by auracle-wasm’s pool_loudness example) hold 21.5% and 8.5%. Most of what is still below 200 Hz has its lowest oscillator at −1: a patch with two oscillators has one at −1 or lower more than a third of the time.

Octave 0 is the common draw and the lowest the rarest. The edges keep a weight above zero: at zero, a preset or a saved patch with an oscillator at −2 or +2 would score log⁡p=−∞\log p = -\infty, and refinement would refuse to start from it.

Plus the structural categoricals (#src, #op, #mod, #modop, #pairop), whose orders are the persisted wire format and therefore append-only.

Enumerating the sites

domain_violations() returns every out-of-domain continuous site as (address, value), in address order. It reads the trace, not the term:

self.to_trace().choices.iter().filter_map(|(a, c)| match c.value {
    ChoiceValue::F64(v) if !in_domain(v) => Some((a.to_string(), v)),
    _ => None,
})

The trace enumerates exactly the continuous sites, by construction, from the same walk the prior samples. A hand-written match over the productions would be a second table of “which fields are knobs”, and the first module somebody forgot to add to it would be the one the next bad value escaped through.

This is the address scheme paying for itself: there is one enumeration of the genome’s sites, and it is the one inference uses.

Repair, not refusal

clamp_domains() pulls every out-of-domain site back in and returns how many it fixed. NaN goes to the domain’s midpoint; anything else is clamped, with 1.0 and above landing on PARAM_MAX. It runs on every session load, which is what mends a save written by a build that still let a knob rest on the stop.

It is half of normalize_tree(), the normal form every tree is put in once, on its way in: a session's bank on load, a held sound brought back, an opened patch file, and a whole tree the panel posts. The other half folds a modulation term the grammar would fold (ModNode::normalized): a shaper over nothing is nothing, a pair with an empty side is its other side, and a one-parameter shaper's unused second parameter is 0. Every structural edit ends in both, though an edit that grafted a subtree once brought its modulation in unfolded, which is how a saved session or a patch file came to hold such a term. A term left unfolded was rewritten by the next edit instead, and on such a patch every module the guess could add also took the folded one away, so the guess said the patch was full. The compiler builds each such term as its folded form, so the fold changes no sound: it takes off the rack a module that did nothing. That is why a restore says so when it clamps a knob and not when it folds a term.

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 modulation term the grammar foldsFoldedIt already plays as its folded form
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±10−30-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:

Ceiling (in mutate.rs)DefaultBecause
Modules (MAX_SIZE)24the realtime voice
Term depth (MAX_DEPTH)6the prior’s max_depth (5) + 1, the deepest term it can score
Modulation depth (MAX_MOD_DEPTH)3the prior’s max_mod_depth (2) + 1

The app shows a ceiling once a patch is one step from it, in the form 23/24 modules, and all three (8/24 modules · 4/6 depth · 1/3 mod depth) with Show measurements on. 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 that every walk came back unchanged. See the validity gate for why the two depth ceilings are derived from the prior rather than set above it.

Structural edits

Hand edits and search proposals walk the same lattice, which is what makes the workbench trustworthy.

The vocabulary

Because the genome is a typed tree, rewiring is a small closed set of operations that are type-safe by construction: an LFO can never end up in an audio slot, and a filter always has exactly one audio input.

OpDoes
Replace { key, kind }Swap the node’s kind. A processor keeps the old node’s primary input, so replacing a source with a processor wraps the source; a source takes the whole subtree’s place
Insert { key, kind }Insert a processor into the wire between this node and its parent; the old subtree becomes its primary input
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 SET ASIDE tray is.

What a splice keeps

Insert and a processor Replace place the kind’s default module, and they seat the chain with the same splice InsertTree uses. The chain becomes the new module’s primary input, /0: the old subtree for Insert, the old node’s own primary input for Replace (the old node itself, when it was a source). On the two-input modules that is a mix’s or ring mod’s a, the in of a compressor, ducker or gate, and a vocoder’s carrier, the one branch whose waveform reaches the output. A Replace drops the old node itself and its /1, and nothing else. The default module brings its own second branch:

KindIts own /1
mixa triangle VCO
ring moda sine VCO an octave up
comp, duck, gatea pluck as the key
vocodera formant oscillator as the voice

So a vocoder placed on a chain makes that chain speak. The Vox Machina preset is built the same way: a supersaw stack as the carrier, a formant voice as the modulator. Until October 2026 Insert and Replace built the vocoder with a supersaw carrier of its own and dropped the chain they landed on. The app was never affected: it never sends Insert or Replace. Its structural edits are insert_tree, replace_tree, set_mod_tree, set_mod, swap_mix and delete, and its other wiring gestures post the whole rewired tree.

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 share one lattice

The search does not call these operations. A walk’s structural move is fugue’s single-site MH on the trace: it redraws one structural site (#leaf, #src, #op or #mod) and draws from the prior whatever the new choice needs, keeping every site whose address survives. What hand edits and the search share is the state space and the address scheme: both produce terms of one grammar, under ceilings that end where the prior’s support does.

Consequences:

  • Anything a player can build by hand, the search can score and walk from, because it is inside the prior’s support. Anything the search produces, a player 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 = PRIOR_MAX_DEPTH + 1;          // audio tree depth: 6
pub const MAX_MOD_DEPTH: usize = PRIOR_MAX_MOD_DEPTH + 1;  // modulation nesting: 3

MAX_SIZE protects the realtime voice and the feature pipeline. The two depth ceilings are derived from the prior’s support, and that is a correction: they used to be 9 and 4 against a prior whose max_depth is 5 and max_mod_depth is 2, on the reasoning that a person stacking modules by hand knows what they are building and the ceiling only protects the voice. What that reasoning missed is that the prior forces #leaf at max_depth and zeroes Op/Pair at max_mod_depth, so the deepest term it can score has depth max_depth + 1. A hand edit past that had log⁡p=−∞\log p = -\infty, EvolutionChain::init_from returned None, and ⚡ evolve on the patch did nothing and said nothing: the very failure the grammar gives Silence non-zero weight to prevent. Now the ceiling is the support, stated once in prior.rs and read from there.

A session saved under the old ceilings may hold a deeper tree. It still loads and plays (no load path re-checks the ceilings, because corruption must not be load-bearing), but refinement reports it as outside_support rather than pretending to walk, and a structural edit that leaves it over the ceiling is refused until one brings it under.

MAX_MOD_DEPTH stops well short of the audio ceiling for a concrete reason: a Pair branches, so depth 3 is up to eight leaves on one cable, and each is another level of the compiler’s by-value recursion stacked on top of the audio tree’s. That is a stack-depth argument rather than an aesthetic one; see the wasm stack note.

The gate test

The structural-edit suite is a gate rather than a set of unit assertions:

Apply every operation at every node of randomly generated trees, and require the result to stay compilable.

This catches the class of bug that unit tests miss: an operation that is individually correct but produces an invalid term in combination with a particular tree shape. The codebase leans on gates like this generally; the preference is stated in CONTRIBUTING.md: prefer extending a gate over asserting implementation details.

Naming stability

NodeKind serializes as snake_case, and that string is also what describe::RackModule::kind reports and what the frontend keys its module list 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.

Silence is silence on both sides, and it is the one kind in the vocabulary nobody shops for: Replace { kind: silence } is how an edit unplugs a socket. The app’s unplug, extract, and cable-move gestures leave the same leaf (as {"Silence":{}} in the tree they post), the rack describes it as kind silence titled empty, and it renders nothing. It used to be unreachable by hand, so the app stood a saw VCO in an unplugged socket and drew the EMPTY plate over it; the plate said “nothing here” while the saw played, and φ measured the saw. As a source kind it can be neither inserted into a wire nor wrap anything.

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:

⟨audio⟩→DC blocker→VCA (amp ADSR)→Limiter→StereoOutput \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 2v⋅a2^{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 dialed 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. An AudioIn always pays for the blocker: a host input can carry an offset of its own, and so does a Capture, whose take was recorded from one. A Track pays for it only if the branch it plays does.

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 this compiler uses several of them on purpose, for example:

  • a unipolar modulation envelope driving a bipolar FM input;
  • a constant bipolar Offset feeding a unipolar knob.

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 (every_prior_sample_compiles) asserts they stay within an allowlist that names each class with its reason. That test keeps a known exception from drifting into a habit of ignoring 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.

Taps, and the level on each cable

Compilation also records each term node's tap: the quiver port its audio leaves by (CompiledVoice::taps, keyed by the node's trace key). A term is a tree, so each module's output feeds exactly one parent, and its tap carries exactly what the cable from that module carries. Two readers use them, and both read in the same scale, dB re 1 V of the raw port value (quiver's calculate_rms_db), so PATCH maps a level to light one way:

  • The live meter. While notes sound, LivePoly::set_meter subscribes to every tap, read on one voice, the newest one sounding, and each tap's RMS reaches the page as quiver's level windows fill. It needs no recompile and costs nothing when off.
  • The cable probe, for a patch at rest (auracle_features::probe_cables, WasmEngine::edit_cable_levels in wasm, the worker's cable_levels message). It renders the audition phrase once and reads every audio cable after every tick of the main voice, through the routing slot its tap resolves to, keeping each cable's RMS and peak over the whole phrase, gaps included. A cable that carries nothing reads PROBE_FLOOR_DB (−120 dB).

The probe's cables are the rack's: one per audio wire of describe, in its order, with the from and to module keys PATCH draws a cable by and both modules' uids, which PATCH's cable identity is made of. Modulation cables are not measured, because the compiler taps audio nodes only, and PATCH draws a modulation cable by its rate. Chord voices are not measured either: the levels are the voice that plays every note of the phrase, as the live meter's are one voice.

The probe writes nothing: it reads each port's last value and leaves the patch alone, so a render it watches is the plain render, bit for bit. All 62 presets render identically with and without it (crates/auracle-features/examples/cable_probe.rs), and the test suite holds every fourth preset to that. It is not φ, and nothing it reads reaches the featurizer.

Its cost is one render of the phrase. Natively, a probed render took a median 0.99 times a plain one over the 62 presets (162 ms against 165 ms of CPU, on a shared machine, so the reads are lost in the noise); in wasm under node it took a median 160 to 206 ms over two runs, against 183 to 238 ms for a render with φ (crates/auracle-wasm/examples/cable_cost.mjs). That is not a per-block cost, so the probe runs once an edit has settled, in the worker's later lane, and the live meter covers the patch while notes sound.

How PATCH uses it

PATCH (apps/web/patch.js) asks for the probe once the bench has settled after an open or an edit (nothing in the edit lane, nothing opening, no knob held, about half a second after an edit's reply and a little over a second after an open or PATCH coming into view, so the next click is not kept waiting behind a render), and only while PATCH is in sight; a knob turned during a drag asks once, after the drag. A reply measured on a tree the bench has since left is dropped and asked again. With the levels in hand, each audio cable at rest:

  • carries light by its level, mapped as the live meter maps one: −54 dB re 1 V and below is unlit, 0 dB full, linear between (paintWireLevel's stroke opacity, eased over --d-state);
  • wears a level mark on its middle that lights one bar per third of that range, with the level as its tooltip.

Until a structure has been measured, its cables are unlit and its marks hollow: the rack used to draw an estimate here (each cable's share of the output through the mixers above it, every source at unity), which is not a measurement and is not drawn on the workbench any more. After a knob turn the marks stay hollow until the change has been measured. Modulation cables carry no light and no mark. The pictures of other patches (EVOLVE's pair) still draw the estimate; they have no probe.

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 the player hears under their 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.

For audio engineers: the patch graph, the modules and their types, compilation, the audition phrase, loudness, vetting, the features, and the live voices. 2:49 · chapters and transcript

The spec

PhraseSpec::default() is four notes, 5.05 s in all, at 44,100 Hz, with the random seed 0xE05_F00D:

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

That reasoning closed four holes and then stopped. It is still true of everything the v2 phrase did not reach, velocity above all: NoteSpan has no velocity field, and the live instrument responds to velocity. What the audition cannot hear is the register of what is still outside the stimulus, and this paragraph is the argument it is built on.

The v2 default covers each hole with the cheapest segment that reveals it:

  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 behaviors 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 behavior. 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.

Audition clips

A live input is neither fixed nor repeatable, so it cannot be measured as it is. A patch with an AUDIO IN is measured with an audition clip instead, and the clip is part of the stimulus.

A patch that reads the player’s input (an AudioIn anywhere in its term, PatchTree::listens) would otherwise be rated on whatever the room sounded like when it was rendered. Taste would chase the room. So every AUDIO IN in a measurement render reads an audition clip: its input slot’s own, a capture of what the player plays into that input, if it has one, else the clip every slot hears, which is a built-in reference signal unless a session saved before slots had clips of their own brought back its one capture (RFC-008, part 2). The features, the vet, the walks and the render farm all render with it, so a patch that listens is measured, rated and bred like any other.

What a clip is

AuditionClip (auracle-features, clip.rs):

  • one or two channels, at the phrase’s sample rate;
  • at most MAX_CLIP_SECONDS (8 s) long;
  • quantized to 16 bits when it is made, so the engine measures exactly the samples it saves and a reload measures the same φ\varphi;
  • named by a content id: FNV-1a 128 over its rate, channel count and samples.

A capture becomes a clip through AuditionClip::from_interleaved. It is resampled linearly to the phrase’s rate (a browser captures at 48 kHz, the phrase renders at 44.1 kHz) and cut to the phrase’s length, since nothing past the phrase is ever read. Two captures are refused rather than repaired: one holding a sample that is not a number, and one that is silent (below the vet’s silence floor, an RMS of 10−410^{-4}), because every patch that listens would fail the vet over it.

The clip rides inside the stimulus, as PhraseSpec::clip. None means the reference. An input slot can have a clip of its own, in PhraseSpec::clips, which an AUDIO IN on that slot reads in its place (PhraseSpec::slot_clip). A spec without a clip serializes exactly as it did before clips existed, which is what keeps every existing cache key and fingerprint where it was.

The reference signal

auracle_features::reference builds it from the phrase’s rate and length. It is a plucked figure: fourteen notes over four and a half seconds, in A minor pentatonic from A2 to E4, then a rest.

NotesHarmonic stacks of up to 32 partials, none above 45% of the sample rate, at 1/k1/k (bright) or 1/k1.61/k^{1.6} (dark), alternating
EnvelopeA 2 ms attack, then an exponential decay: 0.07 to 0.08 s for a pluck, 0.35 s and 0.2 s for one longer note in each bar
Picks12 ms of white noise at every onset
DynamicsAn accent pattern across each bar
LevelPeaks at −6 dBFS (REFERENCE_PEAK), so an AUDIO IN at unity meets the patch at half of quiver’s 5 V full scale
ChannelsMono

It has to exercise what a patch does to an input, and each property is there for a reason:

  • Pitched. Every note is a harmonic stack, so a filter, a wavefolder, a pitch shifter or a vocoder has partials to work on.
  • Transients. A pluck has fallen at least 27 dB by the next note, and a longer note 19 dB, so every onset is a rise a follower, a compressor, a gate or a ducker can react to, and a delay has onsets to repeat.
  • Broadband. The picks are white noise across the whole band and the stacks reach up to 10 kHz, so a lowpass on the input moves the spectral centroid.
  • Space around it. Every note has died away before the phrase’s last 300 ms, the window tail_ratio is measured in.

It is deterministic: integer sample indices, libm’s sine and exponential (pure Rust, so the native build and the wasm build compute the same samples), a fixed xorshift for the picks, and the same 16-bit quantization a capture gets. It is built once per rate and length and shared.

Being mono, it makes an AUDIO IN’s channel knob inaudible to the model. A stereo capture is where that knob is heard.

examples/audio_in.rs measures eight patches built around an input through it: a lowpass on the input lowers the centroid (0.63 to 0.38) and a highpass raises it (to 0.74); hard drive lowers the crest factor (2.69 to 1.56); a reverb smears the plucks (crest 2.04, centroid movement halved); the input keying a ducker sets a supersaw pumping (motion_mid −2.54 to −0.90); and the input as a vocoder’s modulator moves the supersaw’s spectrum. A reverb does not lengthen the tail: every effect sits before the voice’s amp envelope, so the tail ends with the amp’s release, for this patch as for any.

Rendering on the host’s clock

render_phrase gives a patch that listens one quiver AudioInputStream for each input slot it reads, for the whole render, built with with_host_clock; slots that hear the same clip share one stream. The whole clip is written once, as one block, and the render calls advance() after each frame’s tick of every voice, never tick_block. That is quiver’s rule for a frame-by-frame host, and it is what keeps the chord voices in step: a chord voice is compiled at its note’s onset, mid-render, and on the host’s clock its AUDIO IN reads the frame every other voice is reading from its first tick. (On a cursor-mode stream a voice built mid-block would be silent until the next block, and a render has only one.) The test renders a dyad over a clip that is silent until the dyad and a tone after it, and the dyad’s render is the mono render doubled, bit for bit.

A patch that does not listen has no stream to keep in step, and its render ticks each voice in blocks of 64 frames (tick_block): the same samples, bit for bit, at about two thirds of the CPU. Its chord voice still ticks frame by frame beside the main voice while it rings if it follows a TRACK, or if its sources draw quiver’s shared random stream (noise, a plucked string), whose draws come in tick order across the voices.

Frame nn of the render reads frame nn of the clip: a clip shorter than the phrase falls silent at its end.

A patch that does not listen gets no stream at all, so its render, and every row cached from it, is the same whatever clip the session holds.

The compiler binds the streams: compile_with_input (and compile_for_render, the same voice with its knobs folded, which a measurement render uses) builds each AUDIO IN in the patch on the stream it is given for that AUDIO IN’s slot (InputStreams), a slot it is given none for on a stream nothing writes (silent), and compile builds them all so, which is the same voice for any patch that does not listen. The live instrument, LivePoly, binds its own stream to every slot (InputStreams::every): quiver’s cursor mode, built once in LivePoly::new, which the worklet calls in its port handler and never in process(), and which every voice it rebuilds shares. The worklet writes each quantum’s input through input_ptr and write_input, with no allocation, and a quantum with no write is silent. It writes only while the player monitors (MONITOR on the AUDIO IN module); otherwise the live input is silent and only the module’s meter reads it.

Every patch ends in its amp envelope, gated by the keys, so a live patch that listens would be silent with no key down. LivePoly builds such a patch one voice longer (LivePoly::set_open): with monitoring on, that open voice is held at C4 (0 V, OPEN_NOTE) at full velocity, outside the keys’ allocation, so the input sounds through the whole patch, amp envelope included. Every voice reads the one stream, so a key held over the open voice adds a second copy of the input while it sounds, as a chord voice in the phrase does.

Cache keys

Two clips never share a cached row, and a new clip costs no row it does not have to.

  • render_key is unchanged for a patch that does not listen. For one that does, when every slot it reads hears one clip, a 0xfe separator and the clip’s content id (the reference’s when the spec has none) follow the spec. When its slots hear different clips, each slot it reads follows instead, in order, as a 0xfd separator, the slot and its clip’s id. A clip on a slot the patch does not read moves nothing. The clip’s samples are never hashed per key: the id already is their hash.
  • cache_namespace never sees the clip. The farm’s store is stamped with its namespace and cleared when the stamp changes, so a clip there would throw away every cached render each time a player captured one.
  • The farm’s stored key, "<namespace>/<render_key>", therefore carries the clip for exactly the patches that listen.
  • PERFORM’s wiring key (wireKey in perform.js) appends the clips to the key of a sound with an AUDIO IN, and to no other, named as render_key names them (clipKey in clips.js): the one clip’s id when every input the sound reads hears it, which is the key such a sound had before an input could have a clip of its own, and otherwise each input it reads with its clip’s id, in order (0:<id>+1:<id>).

Walks and the farm

A walk renders with the clip because WalkContext.phrase carries it, and the context goes with every walk job (farm_walk reuses its parsed copy while the text matches). A farm worker’s fill and restore renders use the clip because the phrase handshake carries it, parsed once per worker rather than once per render: a phrase with a clip is about 600 KB of JSON.

The handshake is sent when the farm starts, which is before a staged restore installs the session’s saved clip. So after a capture, or a restore with a clip, the farm can be a phrase behind, and its render of a patch that listens carries the old clip’s key:

  • A fill draw that listens is admitted only on the session’s own key. pre_featurized refuses the stale result, and Engine::absorb_prior measures the draw itself, on the session’s clips, as the serial fill would. It does the same when the farm sent no result for a listener, since the old clip may be why it failed the vet. A draw that does not listen keys no clip and is taken as sent.
  • A restore’s entry is refused by bank_absorb, and the worker measures it with bank_render.

The pool is right either way, but each such patch costs a serial render. So the engine worker sends the handshake again (farmResendPhrase in worker.js): to the crew standing whenever a capture is taken, and in a staged restore right after the import installs a captured clip on any input, before the bank’s first render goes out. The phrase carries every input’s clip, so one handshake brings the farm every one of them. A crew raised later is handed the current phrase when it starts.

Capturing a clip

The web app captures a clip of each input the player plays into (audio-in.js), and each becomes that input slot’s own:

  • On an input’s first listen. When an input a sound on the bench reads, or one lent to a recording (a CAPTURE’s RECORD, or RECORD AGAIN on a sound kept safe, lend), first carries a signal (over −50 dBFS RMS, CLIP_SIGNAL_DB) while no clip has been captured from that input (the engine hears it through the reference, or through the one clip of a session saved before inputs had their own, below), it records CLIP_SECONDS (6 s) of it, before the patch, at the browser’s rate. Each input waits for its own signal, so a patch reading two inputs has each captured when it is first played. The engine cuts a capture to the phrase (about 5.05 s), so the capture leaves room over it; a mono input is sent as one channel.
  • On NEW CLIP, whatever the input holds: a module’s NEW CLIP captures the input that module reads, whether the keys read it or its meter only.
  • It is sent to the engine worker as set_input_clip with the input’s slot, which calls WasmEngine::set_input_clip. The reply carries the engine’s sentence, which the app shows, what it measured again (only the pool’s members that read that input), and which clip every input now hears (clips, from input_clip), as the worker’s ready and a restore carry it.

The app never sets the clip every input hears (set_audition_clip): a capture of one input is that input’s own, not another’s. So in a new session an input that has not been listened to is heard through the reference, and the clip every input hears is the reference, unless a session saved before inputs had clips of their own brought its one clip back. Such a session’s inputs all hear that clip until each has its own. Since the app never sets it, a captured clip that every input hears is always such a session’s, and an input heard through it has no clip captured from it: its first listen captures it (clips.js’s firstListen compares the input’s clip with the one every input hears, clip on ready and a restore’s audition_clip). Nothing tells which input the old clip came from, so that input is captured again too, at the cost of one capture and of measuring again the sounds that read it, in the background. An input with a clip of its own, captured in this session or restored as its own, is left alone.

A silent capture is refused (see above) and changes nothing; the app shows the engine’s sentence and does not capture that input again by itself.

Stored with the session

The clip is saved in SessionState.audition_clip, in its saved form:

{"format":"s16le-base64","sample_rate":44100.0,"channels":1,
 "frames":222703,"data":"…","source":"captured"}

Interleaved 16-bit little-endian samples in base64: about 600 KB of text for five seconds of mono. The reference is never saved; no clip means the reference. The slots’ own clips are saved beside it in SessionState.audition_clips, an object from the slot ("0" to "7") to the same form: one per slot at most, and a slot whose clip is the one every slot hears is not written, so the file holds at most nine clips.

A saved clip is untrusted input, and AuditionClip::from_saved holds it to the bound quiver’s Capture uses for its saved take:

  • the format tag must be s16le-base64;
  • the rate must be finite, positive and at most 192 kHz (MAX_CLIP_RATE);
  • one or two channels;
  • at most MAX_CLIP_SECONDS at its own rate;
  • a frame count that agrees with the data, and the data’s length is checked before it is decoded, so nothing allocates beyond what the file’s own bytes occupy;
  • and not silent.

The session holds the clip as raw JSON and checks it on the way in, so a clip that cannot be read costs the session its clip and nothing else. It is restored before the bank is measured. An unreadable one restores as the reference, and the clip’s status (Engine::audition_clip_status, the wasm binding audition_clip) says why, in unreadable. A slot’s own clip that cannot be read costs that slot alone, which hears the one every slot hears, and its status (Engine::slot_clip_status, the binding input_clip) says why. A session saved before slots had clips of their own restores its one clip on every slot.

Setting a clip (Engine::set_audition_clip) measures the pool’s members that listen again, and only them. A member that no longer vets under the new clip keeps the measurement it had and is reported as unmeasured: a clip change never deletes a patch. Observations already in the log keep the φ\varphi they were logged with. Setting it also takes each slot’s own clip away. Setting one slot’s (Engine::set_slot_clip, the binding set_input_clip) measures again only the members that read that slot.

Breeding a patch that listens

AUDIO IN is a player kind (grammar): the prior never draws one, so no fill or walk deals an input, and it scores a player’s, so a listening patch is walked like any other. A walk renders every proposal with the session’s clips, because WalkContext.phrase carries them, and the farm’s walk (farm_walk) is handed the same context: a ⚡ or a generation from a listening seed lands the same child on the farm as on the engine, with the seed’s inputs held and any CAPTURE’s take carried through (crates/auracle-wasm’s a_listening_seed_evolves_on_the_farm_with_its_clip_and_take).

What is open

  • One live input. LivePoly binds one input stream, so in the live voices every AUDIO IN reads the input of the patch’s first one; the app opens each other input for its module’s meter only, and says so. The model measures a patch that reads two inputs through each input’s own clip, so it rates the patch as it sounds with each input playing its own signal, which the live voices do not play yet.

Loudness normalization

Louder reliably wins A/B tests. Without normalization the model would learn a taste for loudness and present it as a preference about timbre.

Every render is normalized to −18 LUFS (TARGET_LUFS, in pipeline.rs) 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(k2−VH)a0,b2=VH−VBkQ+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+10log⁡10 ⁣(1N∑nw[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 Lj≤−70L_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+10log⁡10 ⁣(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+10log⁡10 ⁣(1∣G∣∑j∈G10(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 clips)11 (8%)0
gave up gainnone22 (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-leveling 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 pick about clipping rather than about the patch. That is 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 a sound’s level before normalization is exactly the information normalization exists to discard.

Where it sits in the pipeline

After vetting and before feature extraction:

render→vet→normalize→φ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 what ▶ plays (raised to the target where a ceiling stopped it short, in auracle-wasm’s level.rs). One buffer serves the health check, the measurement, and the playback, which is what makes “no audition is unvetted” true by construction rather than by discipline. (The live keys play an edit before its check: see Safety.)

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 number of picks 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 auditioned unvetted. This is what makes randomly composed DSP graphs safe to put in front of a person. The live keys play an edit before its check, and a failed check mutes them (Safety).

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<10−4\text{rms} < 10^{-4}
4Overlevelpeak>ceiling\text{peak} > \text{ceiling}
5DcDominated∣mean∣/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 (V−1)\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 floor10−410^{-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 defense 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-four doubles against a render that took most of a second, and it is the only thing standing between a NaN out of a spectral descriptor and a posterior fit that returns all-NaN θ\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 auracle-session’s surrogate.rs) 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. Every ▶ plays a pre-rendered, vetted, normalized buffer. The live keys play an edit before its check, and a failed check mutes them
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 defense in depth.

Since quiver-dsp 0.3.0, voct_to_hz clamps pitch to ±32 octaves (Auracle pins 0.4.0), so the overflow Q198 recovers from can no longer be produced by pitch CV. What remains at the clamp is finite aliasing, which meets this gate like any other render.

φ_audio: perceptual descriptors

Eighteen dimensions, kept compact and put on axes a linear model can express a preference along.

Computed on Hann-windowed frames of the normalized mono render (2048 samples, 50% hop), plus a few time-domain and segment-local measurements. Every field is finite by construction, because vetting ran first.

The coordinates

#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+5 ms)\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
15motion_slow:p2Held-note motion energy, 0.5–2 Hz: sweeps and breathing
16motion_mid:p2Held-note motion energy, 2–8 Hz: pulsing and tremolo
17motion_fast:p2Held-note motion energy, 8–30 Hz: flutter

The :p2 suffix is the stimulus generation tag, and it is the migration mechanism rather than a comment.

Why these axes and not the obvious ones

The model downstream is linear in φ\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 a taste for 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)=log⁡2 ⁣(max⁡(f,f0)/f0)log⁡2 ⁣(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 ⁣(r⋅fs2)\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+ϵ+10−3)\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 10−310^{-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 judgment 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⋆=(i−1)+0.9 max⁡(e)−ei−1ei−ei−1,attack_s=log⁡ ⁣(h⋆⋅hopfs+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 mapped by log_axis:

fc=∑ii⋅Δf⋅mi∑imif_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⁡ ⁣(1N∑ilog⁡(mi2+ϵ))1N∑imi2+ϵ)\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(t−1)∥2∑imi(t)+∑imi(t−1)+ϵ\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 10−1210^{-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.

Motion bands

held_centroid_std says how much a held note moves. It cannot say how fast. Measured on one saw-into-ladder patch under a ladder of cutoff modulations (cargo run -p auracle-features --example motion_probe --release), a 0.55 Hz sweep and a 13 Hz flutter score 0.098 and 0.094, and stepped random motion scores like a 6 Hz LFO. A linear model on those coordinates cannot hold “slow breathing, not fast wobble”, which is the first thing anyone says about a texture.

Hearing sorts fluctuation by modulation rate: a filterbank over the envelope, not just its variance (Dau, Kollmeier & Kohlrausch 1997), and the band-wise modulation power of a sound is much of what makes it recognizable as a texture at all (McDermott & Simoncelli 2011). The three coordinates are that filterbank, cut to three bands.

Motion is measured over the held span, starting once the note has arrived, so the attack is not read as motion. That is 250 ms after onset (MOTION_SKIP_S, in audio.rs), or later if the level, smoothed over ≈ 46 ms, has not yet reached 97% of its peak (MOTION_ARRIVED). Two trajectories are taken at a 256-sample hop (MOTION_HOP, ≈ 172 frames/s); the spectral features’ own 43 frames/s would fold the fast band. One is brightness, ct=log⁡2(centroidt/20 Hz)c_t = \log_2(\text{centroid}_t / 20,\text{Hz}), in octaves. The other is level, ℓt=max⁡(log⁡2RMSt, log⁡2peak−10)\ell_t = \max(\log_2 \text{RMS}_t,\ \log_2 \text{peak} - 10), where one unit is 6 dB (one doubling, the same currency as an octave of brightness). A dip reads at most 60 dB deep, so one frame of digital silence in a chopped sound cannot outweigh every audible wobble.

The arrival rule matters for pads. A 0.9 s swell measured from the fixed 250 ms alone read 4.3 octaves over the floor in the slow band, because a ramp is curved in log level and detrending leaves most of it.

Each trajectory is linearly detrended (a ramp across the span is drift, which held_centroid_std already carries), Hann-windowed, and transformed. With rr the detrended residual, σr2\sigma^2_r its variance, and P(f)P(f) its modulation power spectrum, band BB gets the variance share

vB(r)=σr2⋅∑f∈BP(f)∑f>0P(f),motionB=12log⁡2 ⁣(vB(c)+vB(ℓ)+10−4). v_B(r) = \sigma^2_r \cdot \frac{\sum_{f \in B} P(f)}{\sum_{f > 0} P(f)}, \qquad \texttt{motion}_B = \tfrac12 \log_2!\big(v_B(c) + v_B(\ell) + 10^{-4}\big).

The result is a log standard deviation in octaves. The floor, 12log⁡210−4≈−6.64\tfrac12\log_2 10^{-4} \approx -6.64, is a hundredth of an octave: a static tone reads it exactly in all three bands, so “still” is one value and not numerical noise. A phrase whose held span is shorter than 0.75 s reads the floor too.

Measured on the probe ladder, the band that reads highest follows the rate: 0.55 Hz lands in slow, 2.7 Hz in mid, 13 Hz in fast, and stepped random motion spreads across slow and mid as its spectrum says it should.

What it cannot say

It does not say whether motion is regular. Separating a periodic sweep from a random walk needs several cycles in the window, and the held span holds fewer than three cycles of anything in the slow band. Both candidate measures tried (the normalized autocorrelation peak and the harmonic share of the modulation spectrum) separate periodic from random cleanly at 2.7 Hz and above, and not at all below 1.5 Hz, which is exactly where evolving textures live. A coordinate that guesses there would be taught to the model as a measurement, so regularity waits for a stimulus with a longer held span.

Deliberately compact

Eighteen dimensions is a choice. The model is a mixture of linear experts, and interpretable axes are the point: “bright”, “noisy”, “slow attack”, and “long tail” are things LEARNING’s weights can name and a person can recognize in their own taste.

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 1,200 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.7
zcr_mean≈ 12.0
centroid_mean≈ 6.8

These are the values with the grammar’s octave weights (Discrete sites); drawing the octave uniformly, as it did before them, gave 19.7, 12.9, and 6.1. The zero-crossing rate’s DC removal, when it shipped, took them from 18.4, 10.4, and 5.9 to 16.9, 9.7, and 5.9 (Open questions has the history).

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). A shared (fused) prior over the cluster would be a modeling change rather than a feature change. It is built and switched off (TasteConfig::fused_rho, 0 by default): it improved θ recovery on the closed-loop gate and made the climb worse, as Open questions records.

For contrast, φ_struct had two exact linear dependencies, which is a different and worse problem and was fixed by dropping columns.

φ_struct: structural descriptors

Twenty-six dimensions, free to compute.

These cost nothing: no compile, no render, only a walk of the term. That is what makes a screening cascade possible: a structure-only surrogate could prune candidates before the expensive render path. No such cascade is built yet (Directions §12). They also capture taste axes audio features cannot fully separate (“likes supersaws”, “likes deep modulated chains”).

The coordinates

Nineteen family counts:

n_vco  n_supersaw  n_noise  n_wavetable  n_pluck  n_formant  n_silence
n_filter  n_drive  n_time  n_mod_fx  n_reverb  n_dynamics
n_lfo  n_env  n_rand  n_follow  n_mod_shape  n_mod_logic

Seven term-level numbers:

mod_density  mod_depth_mean  amp_attack  amp_sustain  amp_release
chain_balance  frac_sidechained

Twenty-six in total, appended after the eighteen audio coordinates to give φ∈R44\varphi \in \R^{44}.

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-six productions into nineteen family counts, and three of them, AudioIn, Track and Capture, into none (see below).

Two reasons.

Nothing meaningful distinguishes them. n_fold, n_distortion, and n_bitcrush all answer “how much nonlinear color”. 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 1,200 draws, the extreme case: each of the four CV processors appears in under 4% of patches and each of the six combiners in under 1%. A column that is zero in 99 rows of every 100 is not a coefficient, it is a rounding error with a name in the UI.

Sixteen sparse columns would also cost sixteen dimensions of posterior variance for the cold start to pay down before the model says anything at all.

The step sequencer shows the rule applied to a newcomer. The prior draws it into about 3% of slots, so a column of its own would be exactly the near-indicator above. It joins n_rand, which has become the stepped CV family (StructFeatures::n_stepped): to a listener an S&H and a step pattern are the same gesture, a value that holds and jumps (or glides) on a clock, and they differ only in whether the values were drawn once into the genome or anew at every tick. It does not join the euclid in n_mod_logic, because a euclid emits a gate and a step sequence emits a value. The column keeps the name n_rand because stored observations carry φ names, and every row already on disk predates the step sequencer, so its n_rand already is its stepped-CV count. The panel labels the column “stepped mods”.

What is deliberately not in φ

size, an exact identity

Every audio node increments exactly one raw counter, so

size≡∑iniexactly, 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}} −nmix−nringmod−ncomp−nduck−ngate−nvocoder  =  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.

Audio in: counted, and not a column

AudioIn is a source leaf too, and it has its raw counter, n_audio_in, so size is still the sum of the counts and the identity above, written over the raw counts, still holds. But it is not a column of φ\varphi: RFC-008 keeps φ\varphi’s shape, and at its prior weight (0 until live capture works, 0.5% after) the column would be zero in nearly every row. Leaving it out brings no dependency back, because the identity was never reachable from the retained columns: it needs n_mix, which is dropped, and each binary count separately, which the families hide. The shape coordinates count it with no help (chain_balance and frac_sidechained read arity from the tree, where it is a leaf), and what an input patch does to a signal reaches the model through φaudio\varphi_{\text{audio}}, measured on the audition clip.

Track and Capture are counted the same way, with n_track and n_capture and no column, and one more reason: the prior never draws either (they are player kinds), so a column would be zero in every drawn row. The shape coordinates read them from the tree: a TRACK is a binary node, a CAPTURE a unary one over what it records.

The identity holds exactly, for every tree. It became a general statement only when the four sidechained productions (comp, duck, gate, and vocoder) 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 1,200-draw sweep every structural coordinate comes back well under 10, with n_dynamics at 1.7 (1.9 when this was first measured).

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 count comes back at 3.0 or under on the 1,200-draw sweep, the highest being n_noise. That is the reason the families exist; forty separate module columns would not have managed it. The term-level numbers run higher, and still well under 10: amp_attack is the highest of all twenty-six at 6.3, then mod_density 4.6, amp_release 4.3 and mod_depth_mean 4.1.

When the three most recent additions arrived they measured n_mod_shape 1.6, n_mod_logic 1.3 and 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. The same sweep today reads 1.8, 1.2, 4.1 and 4.6.

Reproduce with:

cargo run -p auracle-features --example pipeline_stats --release -- 1200

The unit coordinates

Six of the twenty-six are UNIT_NAMES, a subset of NAMES rather than a reordering. Each is either a normalized genome site read straight through (amp_attack, amp_sustain, and amp_release) or a ratio of two counts, already in [0,1][0,1] (mod_density, chain_balance, and frac_sidechained).

mod_depth_mean is not one of them. It is a mean of modulation depths (1 for a bare modulator, 2 for one shaped once), so it is 2 or more for exactly the shaped chains it exists to measure.

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>106 sws > 10^6 , s_w.

The constants, in auracle-taste’s standardize.rs:

const WINSOR_TAIL: f64 = 0.02;
const WINSOR_MIN_ROWS: usize = 10;
const RUNAWAY_RATIO: f64 = 1e6;

Finally sj←1.0s_j \gets 1.0 if sj<10−9s_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, ⌊(n−1)/2⌋)n≥10k(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 10−3010^{-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 defense

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.

Faces

A face is a picture of a render: its spectrum in bands and slices of time, compared with the bank. It is not a feature. Nothing in φ\varphi, the taste model or the vetting gate reads it.

The app draws every sound as its face (the guide’s a sound’s face). The measurement is auracle_features::face; the comparison with the bank is apps/web/faces.js, and one renderer draws a face at every size, from a bank row’s icon to a full screen, apps/web/vessel.js.

Where it is taken

Inside every featurization (featurize_memo), from the same normalized render φ\varphi is measured on, and carried beside φ\varphi on the memo row (CachedFeatures::face). Every render already passes through there (the serial fill, the farm’s farm_render, a generation’s walks, an offer, an edit), so a face costs no render of its own: 1.66 ms natively against a 146 ms featurization (1.1%, examples/face_cost.rs over the 62 presets), and 2.5 ms in wasm against 201 ms (auracle-wasm/examples/face_cost.mjs).

Samples are read as the f32 the audition buffer holds, so the face of the featurization’s render and the face of the stored audition are the same bytes. A row stored before faces existed has none and is still current φ\varphi (the RENDER_EPOCH does not move for it); its face is completed by a render when the app asks for it.

The measurement

Hann-windowed frames of 2048 samples, hop 1024, of the mono render xx.

Bands. FACE_BANDS = 40 bands with edges evenly spaced in log frequency from FACE_LO_HZ = 35 Hz to FACE_HI_HZ = 14 kHz (about a fifth of an octave each):

ek=35⋅400k/40,k=0,…,40.e_k = 35 \cdot 400^{k/40}, \quad k = 0, \dots, 40.

A band’s value is the mean power density over its edges. With PjP_j the power in FFT bin jj (covering [j−12,j+12)[j - \tfrac12, j + \tfrac12) in bins) and objo_{bj} the length of band bb’s overlap with bin jj,

Bb=∑jobj Pj∑jobj.B_b = \frac{\sum_j o_{bj}, P_j}{\sum_j o_{bj}}.

A band narrower than one bin (every band below about 150 Hz at 44.1 kHz) reads the bin it sits in rather than nothing, and white noise reads the same in every band. (Prototype v2’s data summed whole bins, and its two narrowest bands read empty.)

Slices. The render is cut into FACE_SLICES = 12 equal spans of time, and a frame belongs to the slice its center falls in. On the standard phrase (5.05 s) a slice is 0.42 s: five slices of the held C4, one of the C5 stab, two of the dyad, four of the low C3 and its release. Each slice’s spectrum is the mean of its frames’ power; the long-term spectrum is the mean over every frame.

Levels. Each spectrum is in dB relative to its own loudest band, so a face shows shape and not level (the render is loudness-normalized already), floored at FACE_FLOOR_DB = −60 dB. Each slice’s loudness is its mean power in dB relative to the loudest slice, with the same floor. A slice under about −170 dBFS is silence: all floor.

Encoding. FACE_LEN = 532 bytes, each a level in FACE_STEP_DB = 0.5 dB steps above the floor: the long-term spectrum (40), the slices (12 × 40), their loudness (12). It serializes as one base64 string, about 700 bytes beside a memo row of about 1 KB.

Against the bank

The app knows which sounds the bank shows, so the comparison is made there, over the long-term spectra LsL_s of the bank’s sounds s∈Bs \in \mathcal B (the pool, less what was cut):

μb=1∣B∣∑sLs,b,σ=max⁡ ⁣(3, 140∣B∣∑s,b(Ls,b−μb)2).\mu_b = \frac{1}{|\mathcal B|}\sum_{s} L_{s,b}, \qquad \sigma = \max!\Big(3,\ \sqrt{\tfrac{1}{40|\mathcal B|}\textstyle\sum_{s,b}(L_{s,b} - \mu_b)^2}\Big).

The spread σ\sigma is one number pooled over every band of every sound, as prototype v2 took it, floored at 3 dB (FACE_SPREAD_FLOOR_DB) so a bank of near copies does not blow a fraction of a dB up to full width. A spectrum SS is drawn as

vb=Sb−μbσ.v_b = \frac{S_b - \mu_b}{\sigma}.

Below FACE_MIN_BANK = 4 faces in the bank there is nothing to compare with, and no face is drawn.

When it is taken again. The bank’s μ\mu and σ\sigma are recomputed when the set of the bank’s faces changes (a sound added, replaced or cut; at most once a frame), and the faces are drawn against them only when they have moved since the faces were last drawn by more than FACE_RESTAT_DB = 0.25 dB in a band or FACE_RESTAT_SPREAD = 1% of σ\sigma. A smaller change moves a row's 24 px face by under a tenth of a pixel at the bank's usual spread (about 13 dB), and by about a third of one at the 3 dB floor; the 150 px card's by under half a pixel, and about two at the floor (a band’s half-width moves by R⋅0.35 ΔvR \cdot 0.35,\Delta v at the mean). The recomputation is O(40∣B∣)O(40|\mathcal B|); redrawing is the cost, so the bank’s rows in view are redrawn the next frame and the rest when the page is idle (2–3 ms in the next frame for a pool of 40, against 17–22 ms when all forty were redrawn at once).

The drawing

As prototype v2 draws it (A.face): the vessel is mirrored about its center, low bands at the base, with half-width

wb=R σ(1.4 vb)⋅kw_b = R,\sigma(1.4, v_b)\cdot k

for a box of half-width RR (σ\sigma here the logistic function), so a band at the bank’s mean is half the box wide. The 40 values are smoothed over 2 bands either side (1 on a face 36 px tall or more) and joined with quadratic curves through their midpoints. The outline is the long-term spectrum (k=1k = 1); under it each slice draws a faint layer with k=0.35+0.65 ℓk = 0.35 + 0.65,\ell and opacity 0.05+0.11 ℓ0.05 + 0.11,\ell, where ℓ=clamp⁡(1+loudness/30 dB,0,1)\ell = \operatorname{clamp}(1 + \text{loudness}/30\ \text{dB}, 0, 1), and a slice with ℓ<0.25\ell < 0.25 draws none (FACE_LAYER_RANGE_DB).

Large faces (the share card, a full screen) add the specimen’s glow, a blur of 14 to 30 px on the outline, and the floor’s reflection: a line of light under the vessel and its outline mirrored in it, faint and fading over a third of its height. A slot shows its face drawn once per bank, at the screen’s density, as an image.

Keys and caching

A face is filed under its render namespace and render key, "<cache_namespace>/<render_key>", as a farm row is (persistence, the web runtime). The engine worker copies a face out of the memo (a least-recently-used map a generation’s walks churn) the first time it is asked for, and keeps it in memory and in an IndexedDB store stamped with the namespace: a build whose renders differ cannot read another’s faces. What the worker holds in memory, what the memo holds and what a resident audition gives are answered at once; the store is looked up in its background lane, and a face none of them has is rendered in a lane of its own below that, after the bank has finished arriving, behind a refit, a guess or a cable probe, and dropped if the row asking for it has left the view. Such a render is not kept, so it pushes no audition out of the cache. A face’s render already running (about half a second) is the most it can hold anything up by.

The presets’ faces ship with the app. A preset’s render is fixed, so every preset’s face is rendered ahead of time, natively, through the same engine surface the worker uses, into apps/web/preset-faces.json (make preset-faces), with the render namespace and the reference clip it was rendered under and each preset’s render key. The page draws a preset’s face from it, filed under the same key, without asking the engine, where the namespace is the session’s and, for a preset with an AUDIO IN, while the session hears the reference clip; anywhere else the engine renders it as above. A test renders every preset again natively and fails while the file differs by a byte (crates/auracle-wasm/tests/shipped_faces.rs), and another renders every preset in the built wasm, as the worker renders a preset’s face, and fails while one of them differs from the file’s, or is filed under another key (tests/web/boot_agrees.spec.js): a face is quantized to half a decibel, so a last-digit difference between the two builds could move one preset’s band by a step.

The stage’s live outline (PERFORM’s stage mode) is measured as a face is: the output’s latest 2048 samples, through the same Hann frame and FFT, read as each band’s mean power density over the same fractional bins (faces.js createLiveMeter, bandWeights), into buffers made once. Not the analyzer node’s own spectrum, which is Blackman-windowed: on a C3 sawtooth it read a low band 16.5 dB away from the face. A test feeds the meter the engine’s own fixture (one frame of that sawtooth and the face the engine takes of it) and holds every band to the face within 1 dB (it agrees to 0.24 dB); the same frame through Blackman fails it.

A sound of your own

A recording you bring is measured the way a patch is, on the part of φ a recording can measure. It takes a place among the sounds, its nearest are named, and a generation can be bred toward it.

This is the engine and the worker's side of Plan-005 task 11. The app does not offer it yet: the card that takes a dropped file is still to be built.

A recording is not the phrase

Every patch is measured on the standard phrase: a held C4, a C5 stab, a dyad and a low C3 with a release window, with rests between them. Each coordinate of φ is a statistic of that render. A recording is one sound of something else entirely, with no notes the engine knows of. So a coordinate means the same thing on a file only if it does not depend on what the phrase plays.

That was measured, not argued. examples/file_phi.rs (in auracle-features) renders every preset on three stimuli that are not the phrase (a legato melody with no rests, a drone, short stabs over two octaves), measures each render as a file, and compares the result with the same preset's φ on the phrase. The preset is the truth: a file of that patch should land where the patch does. A coordinate counts as measured by a file when, on every stimulus, its correlation with the phrase value across the presets is at least SURVIVES_R =0.9= 0.9 and its root-mean-square difference is at most SURVIVES_RMSE =0.5= 0.5σ of the presets' spread on the phrase (two presets drawn at random differ by about 1.4σ). Every σ in this table is that one: the presets' spread, not a session's. Run it with cargo run -p auracle-features --example file_phi --release; it also prints each coordinate's bias.

Coordinatemelody r / rmsedrone r / rmsestabs r / rmseMeasured
centroid_mean1.00 / 0.170.98 / 0.230.98 / 0.22yes
rolloff_mean0.99 / 0.180.98 / 0.250.99 / 0.19yes
flatness_mean1.00 / 0.031.00 / 0.041.00 / 0.05yes
zcr_mean0.98 / 0.410.99 / 0.330.95 / 0.33yes
bass_fraction0.98 / 0.200.96 / 0.430.93 / 0.39yes
centroid_std0.94 / 0.430.84 / 0.930.90 / 0.49no
flux_mean0.93 / 0.420.60 / 0.730.72 / 1.61no
rms_mean0.88 / 0.930.79 / 0.930.81 / 0.58no
rms_std0.13 / 1.820.31 / 2.080.67 / 1.78no
crest0.90 / 0.920.83 / 0.960.84 / 0.64no
attack_s0.64 / 0.950.99 / 0.140.47 / 1.21no
tail_ratio0.11 / 2.990.19 / 2.980.12 / 3.34no

62 presets on the phrase; 62, 56 and 62 measured as files (six presets fade below the trim before half a second of drone has passed).

The measured coordinates are not unbiased. A file reads a little brighter than its preset does on the phrase: zcr_mean by +0.35σ on the melody and +0.30σ on the drone, centroid_mean and rolloff_mean by +0.09 to +0.17σ. Within the bar, so they are measured; but a recording sits a shade brighter among the sounds than the patch that made it would, and its nearest sounds lean that way.

A one-shot shorter than the half second a file must hold does not measure them all: with a single G4 of 0.25 s added to the stimuli, zcr_mean (0.55σ) and bass_fraction (0.62σ) miss the bar, and they still miss at 0.5 s (0.54σ, 0.61σ). One short note at one pitch says too little about weight and brightness, which is why FILE_MIN_SECONDS stays at 0.5 s.

What a file measures is its spectral balance: how bright it is (centroid_mean, rolloff_mean, zcr_mean), how noisy (flatness_mean) and how much weight it has below 250 Hz (bass_fraction). Those depend on what the sound is. The rest depend as well on what was played:

  • Level and dynamics (rms_mean, rms_std, crest) carry the phrase's four rests and its release window, and a recording has neither.
  • Movement (centroid_std, flux_mean) over the whole phrase is mostly its jumps between registers, and a file's is whatever it plays.
  • The attack is the phrase's held C4 arriving, and a file's first event is something else.
  • The tail is the phrase's release window; a trimmed file's last 300 ms are wherever the sound stopped.
  • The six role coordinates (held_centroid_std, high_ratio, chord_flatness_delta and the three motion bands) each read one note of the phrase by its role, and a file has no such note.
  • Every structural coordinate describes a patch, and a recording has none.

So a file is placed by 5 of φ's 44 coordinates. The other 39 are masked (FILE_MASKED and the structural half; file_observed() is the whole mask). A gate test re-measures half the presets on every build and fails when the mask and the measurement disagree.

How a file is measured

featurize_file takes what the page decoded, mixed to mono, at its own rate, and measures it as featurize measures a render:

  1. Bounds. A rate from 8 to 192 kHz, at most FILE_INPUT_MAX_SECONDS =120= 120 s in, every sample finite. A refusal is a flag the app words (bad_rate, too_long, too_short, non_finite, silent), never text.
  2. Trim. The sound runs from the first 10 ms window within TRIM_DB =60= 60 dB of the loudest to the end of the last one. Silence inside the recording is kept; it is part of the sound.
  3. Cut. At most FILE_MAX_SECONDS =30= 30 s of sound, from where it starts, and at least FILE_MIN_SECONDS =0.5= 0.5 s.
  4. Resample to the phrase's 44.1 kHz with a windowed sinc (16 zero crossings, Blackman window, cut at 0.97 of the lower Nyquist). Every frequency coordinate is normalized at Nyquist, so this is what puts a 48 kHz file's brightness on the same axis as a render's. A file already at 44.1 kHz is not touched.
  5. Normalize to TARGET_LUFS =−18= -18 under the same peak ceiling, as loudness normalization does for every render.
  6. Measure with the same audio_features a render goes through. Nothing in it changes for a file. Its first-note window is the phrase's own (onset to the second onset, 2.0 s), and its role coordinates find no note and read their "no evidence" values, which the mask then ignores.

Measuring a render's φ is unchanged by any of this: no coordinate, constant or step of featurize moved.

What the page sends

The page decodes the file, mixes it to mono, cuts it to 120 s and resamples anything above 48 kHz down to 48 kHz before it posts it. The engine measures at most the first 30 s of sound, and the samples are copied into the engine's memory, which wasm never gives back: two minutes grow it by about 42 MB at 48 kHz and 108 MB at 192 kHz. The analysis is one call the worker cannot interrupt; for 30 s of sound it takes 0.16 s at 44.1 kHz, 0.31 s at 48, 0.52 s at 96 and 0.94 s at 192 (auracle-wasm's examples/own_cost.mjs, wasm under node on an M3 Max), most of it resampling.

What each refusal asks of the card:

  • too_long: more than 120 s reached the engine. Cut it and send it again.
  • too_short: under half a second of sound once trimmed. Nothing to retry; the card says the sound is too short to place.
  • silent, non_finite, bad_rate: nothing in it can be measured.
  • After any refusal the sound brought before is still the session's: the card asks own_sound to redraw it.

Missing coordinates

What a masked coordinate becomes depends on what reads the sound, and none of it claims to know the coordinate's value:

  • Distances leave them out. The nearest sounds are ranked by Euclidean distance in standardized φ over the five measured coordinates. Filling a masked coordinate with the pool's mean would pull every distance toward the center of the pool and call the most ordinary patch the nearest.
  • The tilt has no factor for them, which is the same as integrating them out (below).
  • Anything linear reads them at the mean, where they add exactly nothing. The map's projection is one such reading (below). A utility θ⊤z\theta^\top z would be another, and the observation log already puts a coordinate an old vote lacks at the standardizer's mean for the same reason: zero contribution, exactly "no evidence". Nothing reads the sound's utility today.

Its place among the sounds

The sound's zz is its raw φ standardized by the session's standardizer, the same scale every pool member's is on, over the five coordinates it has.

Its nearest sounds (Engine::own_nearest, Engine::own_nearest_presets) are ranked by

d(x)  =  (∑j∈O(zj(x)−zj∗)2)1/2,d(x) ;=; \Big(\sum_{j \in O} \big(z_j(x) - z^*_j\big)^2\Big)^{1/2},

where OO is the set of measured coordinates and z∗z^* the file's. TASTE's map is a projection of this same Euclidean space (PCA over standardized φ), so near means what the map draws as near, without the projection's loss. The app had no nearest sounds before this, so there was no other metric to match. The presets are ranked from the measurements the app already ships (perform-wirings.json, regenerated by make perform-wirings), back to raw φ and standardized by this session's scale: no preset is rendered.

Its place on the map (TasteMap::own) is the map's own projection of the five measured coordinates, on the map's axes a1,a2a_1, a_2 about its center μ\mu:

x  =  ∑j∈Oa1j (zj∗−μj),y  =  ∑j∈Oa2j (zj∗−μj),x ;=; \sum_{j \in O} a_{1j},(z^j - \mu_j), \qquad y ;=; \sum{j \in O} a_{2j},(z^_j - \mu_j),

which is every other point's projection with each masked coordinate at the map's mean. The sound is placed on the map; it is not one of the points the axes are fitted to, so bringing a sound does not move the map.

That was chosen by measurement, in examples/own_census.rs (in auracle-session). Each target preset is put into a taught pool, so the map draws it, and its recording is placed. Error is the distance to the preset's own point, as a share of the map's spread (the RMS distance of its points from the center), over 18 placements (3 sessions × 6 presets):

Placed byError
The projection above (Placement::Imputed, used)0.37 ± 0.05
Probabilistic PCA's posterior mean for a partly observed point (Placement::Fit)0.41 ± 0.04
The centroid of its three nearest pool members (the prototype's way)0.53 ± 0.07
The projection, from the preset's phrase φ on the same five coordinates0.36 ± 0.05

The posterior mean, (AO⊤AO+σ2Λ−1)−1AO⊤(zO∗−μO)\big(A_O^\top A_O + \sigma^2 \Lambda^{-1}\big)^{-1} A_O^\top (z^*_O - \mu_O) with A=[a1 a2]A = [a_1\ a_2], the axes' variances Λ\Lambda and the variance σ2\sigma^2 they leave unexplained, shrinks a point measured on few coordinates toward the center; here that cost more than it saved. The last row says where the error comes from: a recording costs almost nothing over the phrase on the same coordinates, and the mask is what costs. Five coordinates put a sound within about a third of the map's spread of where its whole φ would, which is what the prototype's card means by "placed by its spectrum".

Breeding toward it

Engine::refine_toward_jobs opens a generation exactly as EVOLVE POOL does, with two differences. Its parents are the refine_seeds pool members nearest the sound, not the best ranked: breeding toward a sound starts from what is already near it. And every walk samples a target tilted toward the sound (TowardFitness):

π(x)  ∝  pgrammar(x) exp⁡ ⁣(β (f(x)−min⁡(γ2 d(x)2,  F))),d(x)2=∑j∈O(zj(x)−zj∗)2, \pi(x) ;\propto; p_{\text{grammar}}(x), \exp!\Big(\beta,\big(f(x) - \min\big(\tfrac{\gamma}{2},d(x)^2,; F\big)\big)\Big), \qquad d(x)^2 = \sum_{j\in O}\big(z_j(x) - z^*_j\big)^2,

where ff is the taste surrogate E[uθ(φ(x))]\E[u_\theta(\varphi(x))], β=2\beta = 2, γ\gamma is OWN_GAMMA =4= 4 and FF is OWN_FLOOR =25= 25.

Read as Bayes, this is the taste's own target times a Gaussian likelihood N(zO∗; zO(x), τ2I)\mathcal N\big(z^*_O;, z_O(x),, \tau^2 I\big) with τ2=1/(βγ)\tau^2 = 1/(\beta\gamma): the patches you would like, weighted by how well each explains the recording as a measurement of its sound. A masked coordinate has no factor, which is that likelihood integrated over it. At γ=4\gamma = 4, τ≈0.35\tau \approx 0.35σ of the session's spread. That is the same size as the error a recording makes on the coordinates it measures (0.03 to 0.43σ in the table above), but those are σ of the presets' spread, a different scale, so it is a choice of size made with the census below, not a calibration of the file's error.

The floor is the likelihood of a contaminated measurement: the recording is the patch's sound with noise τ\tau, or a sound this grammar does not make, and then distance says nothing more. It is also what keeps the walk out of patches that do not vet. A proposal that does not vet has no φ to measure, so it keeps its quarantine score (−50-50) untilted; an unbounded pull would rank it above every patch more than 100/γ=5\sqrt{100/\gamma} = 5σ from the target, and a first census without the floor saw strongly pulled walks end on one; with it, none did. Half the quarantine's depth keeps every listenable patch above quarantine for any taste above −25-25, and still pulls out to 2F/γ≈3.5\sqrt{2F/\gamma} \approx 3.5σ.

The walk stays exact Metropolis–Hastings, as an aimed offer does: the kernel is untouched and the tilt is a deterministic function of the patch, so every acceptance is the ratio of this target, and the state a walk ends on is a draw from it. The tilt costs no render: the taste surrogate has just featurized the proposal through the same memo. A proposal that does not vet keeps its quarantine score. The target rides in the generation's context (WalkContext::toward), so the render farm walks the same tilted target the engine would. A generation without a target walks exactly as before; at γ=0\gamma = 0 the tilted walk is the untilted one, bit for bit.

Children are admitted by the bar every generation's are: a child must please the taste more than the member it would displace. So the engine breeds toward a sound only after the first fit, as EVOLVE POOL does; before it, Breed toward it adds the nearest presets (below).

Measured

examples/own_census.rs: three sessions (a pool of 48 and a taste taught by a synthetic listener over 60 duels), six target presets recorded on the melody, five walks of 40 steps a target, every arm from the same job seeds, so the arms are paired walk by walk. cargo run -p auracle-session --example own_census --release reproduces these tables (its defaults are 3 6 1,2,4,8 5), and its placement error lines the placement table above. Distances are in σ of the session's spread over the five measured coordinates, mean ± standard error over 90 walks: to the recording's z∗z^* at the walk's start and end, and at the end to the preset itself (its phrase φ on the same coordinates), which is the truth the recording stands in for. ΔE[u]\Delta\E[u] is the child's expected utility minus its parent's.

From the parents nearest the sound, which is what a breed toward it does:

ArmStartEndChangeEnds nearerΔE[u]\Delta\E[u]End to the preset
untilted0.61 ± 0.042.55 ± 0.16+1.94 ± 0.167%+0.86 ± 0.172.60 ± 0.16
γ = 10.61 ± 0.040.88 ± 0.05+0.27 ± 0.0533%+0.55 ± 0.120.94 ± 0.05
γ = 20.61 ± 0.040.67 ± 0.04+0.06 ± 0.0448%+0.32 ± 0.080.71 ± 0.03
γ = 40.61 ± 0.040.52 ± 0.03−0.09 ± 0.0359%+0.36 ± 0.070.61 ± 0.03
γ = 80.61 ± 0.040.43 ± 0.02−0.18 ± 0.0371%+0.25 ± 0.080.53 ± 0.02

From the taste's own parents (the best ranked, as EVOLVE POOL breeds from), which says whether the pull works from farther away:

ArmStartEndChangeEnds nearerΔE[u]\Delta\E[u]End to the preset
untilted2.18 ± 0.152.29 ± 0.14+0.12 ± 0.1750%+0.73 ± 0.412.38 ± 0.15
γ = 12.18 ± 0.151.06 ± 0.07−1.12 ± 0.1578%+0.03 ± 0.171.16 ± 0.06
γ = 22.18 ± 0.150.93 ± 0.06−1.25 ± 0.1581%−0.10 ± 0.171.01 ± 0.06
γ = 42.18 ± 0.150.79 ± 0.05−1.38 ± 0.1588%−0.56 ± 0.100.84 ± 0.05
γ = 82.18 ± 0.150.86 ± 0.09−1.32 ± 0.1689%−0.30 ± 0.250.94 ± 0.09

Tilted walks end nearer than untilted ones from the same jobs by 2.03 ± 0.16σ from the nearest parents and 1.50 ± 0.15σ from the taste's (paired by walk, γ = 4). Untilted, a walk from the nearest parents wanders off the sound as it climbs the taste; tilted, it stays near and still pleases the taste more than its parent. From farther away the pull costs taste (−0.56 at γ = 4), which is the trade the target states. γ = 4 is the smallest pull whose walks from the nearest parents end nearer than they began, and from the taste's parents it ends nearest both the recording and the preset: at 8 the floor leaves the farther walks unguided. Of the 90 γ = 4 children from the nearest parents, absorbed into their generations as the app would, 86 were admitted and 4 were not.

What is not done, and what the card will do

The card is the app's half, still to be built: the worker answers own_sound_set, own_sound, own_sound_clear and refine with toward: true, and nothing sends them yet. Where the prototype shows what the engine does not do, the maintainer decided the card's behavior (Plan-005 task 11):

  • In the bank, never in a pair. The prototype lists the recording among the presets and plays it from there, and so will the card: the page keeps the decoded audio in IndexedDB, so it plays after a reload too. In the engine the sound has no tree, so it is never dealt in a pair, held as a patch or bred from; the session keeps only its measured coordinates, by name, because an autosave holding half a minute of samples would be megabytes the engine never reads.
  • Before the first fit, Breed toward it adds the nearest presets, as the prototype does: own_nearest_presets names them and load_preset adds them, with no taste needed. After the first fit it breeds the tilted generation above.
  • The children grow from their parents, the pool members nearest the sound, leaning toward the recording; the prototype's buds from the dropped face would show a fact the engine does not record (ADR-012).
  • Hold it plays the recording through the named controls once audio in is live (Plan-007), the UI stream's work.
  • Nearest, and the map, are of other sounds. The prototype ranks presets by its 40-band face and places the recording on a map of the 62 presets. The engine ranks pool members and presets, separately, over the five coordinates, and places it on TASTE's map of the pool and what you have heard. The face itself is the page's to draw from the decoded file, as for any render.

Not done in the engine:

  • More of φ. Five coordinates are what a recording measures under today's φ. A recording's movement, attack and tail would need coordinates defined without the phrase's notes, which is a change to φ and owes a revalidation. A change to φ also owes a re-measure of the mask: the gate test fails until FILE_MASKED agrees with file_phi again.

Utility as a max of experts

A candidate is as good as its best style thinks it is. Two more obvious designs cannot represent a cross-island comparison at all.

For a technical audience: the taste model and the search as the code computes them, and why each piece has the form it does. 2:46 · chapters and transcript

The form

A candidate xx has the feature vector φ(x)\varphi(x), which the standardizer maps to z(x)z(x) with per-feature means μ\mu and scales ss. The taste model holds KK styles, and style kk is a weight vector θk\theta_k: a linear functional on zz. Utility is the maximum over styles, not a weighted mixture:

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

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.

One posterior draw’s utility (TasteSample::utility_mix):

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 style, and every judgment, including a duel across two islands, compares candidates on the shared scale u=max⁡kuku = \max_k u_k. A dark drone and a bright pluck are both scored, each by whichever style rates it highest, and the comparison is well-formed.

Drag either arrow. Each arrow is a lens (a style): one direction in feature space. Each candidate is colored by the style that scores it highest and lit by how highly, the same encoding the instrument’s taste map uses. Pull the arrows 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 style 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 tastes at once. Whenever the session’s latent is wrong for the current candidate, every observation in that session is scored by the wrong style.

A per-observation marginalized style

“Marginalize over which style judges each observation.”

Fails on a sharper point: it forces both duel items through the same style, so a cross-island comparison is unrepresentable. There is no style under which “the drone beats the pluck” is a sensible statement if the drone lives in style 1 and the pluck in style 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 style 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, with no custom kernel and 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, in engine.rs), and the fitted number of live styles grows with evidence.

Nothing enforces that; it falls out. A style with no evidence to explain stays near its prior, and style_share reports what fraction of the pool each style actually claims as its best. A style claiming ≈0% is idle: the player’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,j∼N(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 ∥z∥2≈d\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=max⁡kuku = \max_k u_k is the maximum of KK iid standard normals, whose standard deviation falls with KK (MAX_NORMAL_SD, in auracle-taste’s model.rs):

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(ua−ub)\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 a player would meet it as a model that grows vaguer the longer they play.

Dividing by sKs_K restores invariance: Var(ua−ub)\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)Style kk’s rating specifically
best_style(z)Which style claims this candidate: the hue on the map
responsibilities(z)Posterior probability that each style is the best one for this candidate
slope(z, ê)The slope of uu along a direction e^\hat e at zz, through the style that claims zz in each draw: PERFORM’s lean on a control
style_share(pool)Per-style share of the pool, averaged over candidates
prob_prefers(a, b)Eθ [σ(u(a)−u(b))]\E_\theta,[\sigma(u(a) - u(b))]: the forecast the app shows for a pair

responsibilities is a posterior distribution over which style 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(A≻B)=σ(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 compares the drone’s best style’s rating with the pluck’s best style’s rating. 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+e−∣t∣)\mathrm{softplus}(t) = \max(t,0) + \log(1 + e^{-|t|}). The naive log⁡(1/(1+e−v))\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),τs∼N(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.

The app emits one side of it: a bank row’s cut calls record_keep(id, false) once its 7 s undo window closes (UNDO_WINDOW_MS, defined in toasts.js and used in main.js’s cutRow). Nothing emits a keep; the triage surfaces that would are unbuilt.

“Feeling picky today” is therefore modeled rather than treated as noise. A session where the player cuts almost everything is read as a strict session (a high τs\tau_s) rather than as a change in their 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)=σ(ck−u)−σ(ck−1−u)P(y = k) = \sigma(c_k - u) - \sigma(c_{k-1} - u)

with 0-based cutpoints, c−1=−∞c_{-1} = -\infty (so the first term is 0) and cKcat−1=+∞c_{K_{\text{cat}}-1} = +\infty (so the last is 1). Six categories by default, hence five cutpoints.

The code computes it in log space:

match (k == 0, k == n_cats - 1) {
    (true, true)   => 0.0,
    (true, false)  => log_sigmoid(a * (s.cuts[k] - u)),
    (false, true)  => log_sigmoid(-a * (s.cuts[k - 1] - u)),
    (false, false) => {
        log_sigmoid(a * (s.cuts[k] - u))
            + log_sigmoid(-a * (s.cuts[k - 1] - u))
            + log1mexp(a * (s.cuts[k] - s.cuts[k - 1]))
    }
}

The factor aa is 1 unless some of the observation’s coordinates were imputed (below). A rating between the lowest and the highest rests on an identity: with b=a(ck−u)b = a(c_k - u) above l=a(ck−1−u)l = a(c_{k-1} - u), σ(b)−σ(l)=σ(b) σ(−l) (1−e−(b−l))\sigma(b) - \sigma(l) = \sigma(b),\sigma(-l),(1 - e^{-(b - l)}), so

log⁡P(y=k)=log⁡σ(a(ck−u))+log⁡σ(a(u−ck−1))+log⁡(1−e−a(ck−ck−1))\log P(y = k) = \log\sigma\big(a(c_k - u)\big) + \log\sigma\big(a(u - c_{k-1})\big) + \log\big(1 - e^{-a(c_k - c_{k-1})}\big)

Three terms of one sign, none of them a difference of near-equal numbers, so the log-probability is exact to a few rounding errors wherever uu sits. log1mexp is the standard accurate log⁡(1−e−d)\log(1 - e^{-d}) (Mächler 2012): ln_1p(-exp(-d)) above d=ln⁡2d = \ln 2 and ln(-expm1(-d)) below it, so even a category squeezed nearly shut scores its width. Far below the lower cutpoint the log-probability tends to a(u−ck−1)+log⁡(1−e−a(ck−ck−1))a(u - c_{k-1}) + \log(1 - e^{-a(c_k - c_{k-1})}), and far above the upper one to a(ck−u)a(c_k - u) plus the same constant (a_middle_rating_far_outside_its_cutpoints_scores_its_exact_tail in auracle-taste pins both tails against a series, to 10−1410^{-14}, out to a utility 10610^6 past the cutpoint).

Subtracting two near-equal sigmoids used to bottom out at a floor of ln⁡10−12≈−27.6\ln 10^{-12} \approx -27.6. The first log-space form, log⁡σ(b)+log⁡(1−σ(l)/σ(b))\log\sigma(b) + \log(1 - \sigma(l)/\sigma(b)), lifted the floor but not the tail: once a(ck−1−u)a(c_{k-1} - u) passed about 37 the ratio rounded to 1 and the result was −∞-\infty where the true value is finite, so a strong enough rating could move the weights less than a weaker one (#227; reweighting now).

This treats ★★★ as “between two cutpoints” rather than as the number 3, which is the point. A rating is an ordinal judgment, and modeling 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 player 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.5 r0,cj=cj−1+exp⁡(−0.5+0.7 rj),rj∼N(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 e−0.5≈0.61e^{-0.5} \approx 0.61 so the five cutpoints span a few units of utility.

Imputed coordinates

An observation logged under an older feature set can lack some of the current coordinates, and persistence imputes them at the mean. A duel needs no correction: both sides carry the same absence, so the imputed terms cancel in u(A)−u(B)u(A) - u(B). Keep/kill and stars compare one uu with a fixed threshold, so there the model scales the difference by

a=11+πσ2/8,a = \frac{1}{\sqrt{1 + \pi \sigma^2 / 8}},

where σ2\sigma^2 is the variance the imputed coordinates add to uu under the draw: ∑iθk,i2\sum_i \theta_{k,i}^2 over the imputed ii, for the style kk that scores the candidate. It is the logistic analogue of marginalizing a Gaussian through a probit link. The observation still teaches, and stops claiming certainty about a comparison that is partly guessed. With nothing imputed, a=1a = 1.

Edit-beats-original

Not a fourth likelihood, but a duel with a provenance tag. Keeping a hand edit as new with pick the edit ticked records Duel { a: edited, b: original, chose_a: true }.

The tag is what makes the claim auditable. Provenance distinguishes:

DuelA dealt pair the player listened to
HeardEditAn edit committed through a heard comparison
SelfReportAn edit committed by ticking the box
PerformOfferA PERFORM offer heard against the sound being played, then taken (offer wins) or passed by asking for another (the played sound wins)

These make the same claim in the log, and there is no reason to believe they are equally reliable. Calibration scores them separately, which is the only way to find out rather than assume.

Recency weighting

Every observation’s log-likelihood is scaled before summing:

wi=0.5 (n−1−i)/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. The default is recency_half_life = Some(150.0) (in engine.rs); None disables forgetting entirely.

log⁡p(D∣θ)=∑iwi log⁡p(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 pick from three sessions ago equally with one from a minute ago would fight the player 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 logged, not modeled

Play counts, opening a pair’s sound in PATCH, and the editor’s stream of edits and reverts are logged as ImplicitEvents (Engine::log_event). None of them enters the likelihood. Listen time and hover duration are not recorded at all.

They are cheap to collect and easy to misread: a long listen can mean fascination or confusion, and the two have opposite signs; a revert can be curiosity. This version prefers less data that means what it says, and keeps the log so a later fit can be validated against the explicit picks.

Site count, and what it costs

The model has

K⋅d+S+(nstars−1)K \cdot d + S + (n_{\text{stars}} - 1)

sample sites. With d=44d = 44 and 6 star categories, that is 44K+S+544K + S + 5: **49

  • SS** at K=1K=1 and 225 + SS at K=5K=5. (A fused prior over correlated coordinates, built and off by default, would add KK sites per fused group.)

Single-site MH moves one site per step, over a fixed budget of steps, and each step’s likelihood scores every observation through every lens. Two consequences, both measured by auracle-taste/examples/fit_bench.rs:

  • The fit is many 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. It used to be sharper: fugue’s chain driver re-executed the whole program on every step, which first made the address table worth hoisting 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), and then made a kernel that never rebuilds the program worth writing.

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, with these defaults (SessionConfig in engine.rs, and KEEP in auracle-taste’s model.rs):

Default
Post-warmup steps10,000 (mcmc_samples)
Warmup steps3,000 (mcmc_warmup)
Retained draws≤ 500 (KEEP), by thinning

Every site is an f64 (there are no discrete latents), so fugue’s generic chain applies, and no site is conditional on another’s value, so the program’s structure never changes. The fit runs that chain over an array of the sites’ values rather than through the program. Adaptation tunes per-site proposal scales during warmup.

Each MH step moves one site, so a useful way to budget is steps≈sites×desired effective sweeps\text{steps} \approx \text{sites} \times \text{desired effective sweeps}. At K=5K=5, 225+S225 + S sites over 10,000 steps is at most about 44 sweeps per site. That is thin, and it is the tension noted in site count.

The result is uniformly weighted:

TastePosterior { cfg, samples, weights: vec![1.0 / n; n] }

The kernel

fugue’s chain driver runs the whole program on every MH step: it rebuilds 225+S225 + S sample nodes and their closures, and fills a fresh trace of as many BTreeMap entries, to move one number. That rebuild, not the likelihood, was most of a refit’s time. Since the structure never changes, TasteModel::fit runs a kernel of its own (model/kernel.rs). The state is an array of the sites’ values in the program’s execution order, and a step proposes one value, decodes the array into a draw, and scores it with the program’s own pieces: the same priors, the same cutpoint transform and the same likelihood sum.

It is fugue’s kernel, not a new one. The start is a prior run of the program itself. The site is drawn over the trace’s address order as fugue draws it, its scale comes from fugue’s own DiminishingAdaptation, and the Gaussian step and the accept test are fugue’s, reading the random stream in the same order. The log-weight is summed in the trace’s order. So a seed deals the same draws, bit for bit: the kernel’s tests run it against fugue’s driver over seeds and configurations (fused groups, several sessions, stars, imputed coordinates, a recency half-life, a stride longer than the chain), and fit_bench’s checksums did not move.

µs per step, fit_bench 10000 3000first fit (K=1K=1, 6 rows, 50 sites)mature fit (K=5K=5, 100 rows, 226 sites)
fugue’s driver22.7207.1
the kernel1.947.8

What was left of a mature step then was the likelihood: the same 226 sites over 6 rows took 6.1 µs a step.

The engine the app runs shows the same: WasmEngine::fit in Node over the release build, on a 4-core Linux machine, took 479 ms before and 33 ms after for a first fit (6 duels), and 3.9 s before and 0.95 s after for a mature one (100 duels, K=5K=5), each the least of five, the fit’s own alignment and style shares included. Both builds ended on the same ranking, byte for byte.

A step recomputes only what its site reaches. Rescoring a proposal with the whole likelihood redid every row though one site moved: at K=5K=5 over 100 duels, a thousand dot products of 44 to move one θ coordinate. So the chain keeps, for its current state, every candidate’s utility under every lens, every row’s weighted term and every site’s prior term, and a proposal recomputes only what its site moves:

The siteWhat is recomputed
θk,iits prior term; lens kk’s utility of every candidate, then every row’s term from the kept utilities
μ (a fused group’s mean)its prior term and those of the θ drawn about it; the likelihood is unchanged
τsits prior term; the keep/kill rows of session ss
a cutits prior term; every star row (the cuts decode cumulatively, so one raw moves every later cut)

It stays bit-identical. A row’s term is one function whether its utilities were just computed or kept from an earlier step, so the two cannot drift, and the totals are the kept terms summed the way the whole computations sum them: the rows in order by the same sum, the prior from 0 in execution order. (A running total patched by each step’s difference would not be the same number.) The kernel’s tests check the kept totals against the whole ones bit for bit, after every move of every kind of site and every step of a chain, and fit_bench’s checksums did not move.

µs per step, fit_bench 10000 3000, another 4-core Linux machinefirst fitmature fit226 sites over 6 rows
every row and site rescored0.922.83.0
only what the site reaches0.67.10.7

WasmEngine::fit in Node on that machine, the least of each build’s ten or more runs: a first fit from 17.8 ms to 11.8 ms, a mature one from 436 ms to 202 ms, and both builds ended on the same ranking, byte for byte.

The address table

SiteAddrs::new builds every site address once per fit, and the program clones Address (an Arc refcount bump plus a cached hash) into each node. The kernel keys its proposal scales by the same addresses.

When fugue’s driver rebuilt the program on every step, 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 then (examples/fit_bench.rs).

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

95% of the post-warmup chain is discarded (19 draws in every 20), 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 225 + S BTreeMap entries each, held live at once to retain 500: 303.1 MB peak RSS at the shipped budget, scaling with n_samples, and a plausible mobile-Safari OOM on a 32-bit heap rather than mere waste.

It could not be fixed here then. 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. (The kernel has since done that reimplementation, for time rather than memory, and its tests against fugue’s driver are what guard that hazard: a fugue upgrade that changes the chain fails them. It keeps every stride-th draw as it runs, as the driver does.)

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,2⋅stride,…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 under half a second on a fast laptop, about two on an older one, and cannot run after every pick. So each new observation is folded into the existing draws by reweighting:

ws  ←  ws p(y∣θs)∑s′ws′ p(y∣θs′)w_s ;\leftarrow; \frac{w_s , p(y \mid \theta_s)}{\sum_{s'} w_{s'} , p(y \mid \theta_{s'})}

let m = (0..n)
    .filter(|&i| self.weight(i) > 0.0)
    .map(|i| ll[i])
    .fold(f64::NEG_INFINITY, f64::max);
let mut w: Vec<f64> = (0..n)
    .map(|i| match self.weight(i) {
        wi if wi > 0.0 => wi * (ll[i] - m).exp(),
        _ => 0.0,
    })
    .collect();

This is exact (the weighted draws target the updated posterior), and its cost is linear in the number of draws. 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 likelihoods are shifted before they are exponentiated, by the best log-likelihood among the draws that still carry weight. A draw whose weight has already fallen to zero takes no part, in the shift or in the products.

However strong the contradiction

A vote can rule out every draw that still carries weight. Likelihoods of e−3000e^{-3000} and e−2400e^{-2400} are far under the smallest double (about e−745e^{-745}), and taken as they are, every product would round to zero. Shifted by the best weighted draw, that draw's product is its own weight, so the sum cannot underflow while that draw's log-likelihood is finite. The weight moves to the draw that contradicts the vote least, by the exact update. If it concentrates the weights far enough, ESS falls, the draws are resampled, and the refit trigger is armed.

The shift used to be taken over every draw, including those with no weight left. When one of those scored best, its log-likelihood set the shift, and a vote that every weighted draw ruled out firmly enough pushed every product under the smallest double. The weighted draws still ranked the vote, but the update was skipped: a slightly weaker version of the same vote could collapse the weights and arm the trigger, while the stronger one left them where they were (the_update_is_exact_however_strong_the_contradiction in auracle-taste).

Every vote on a finite φ\varphi has a finite log-likelihood under every draw: a duel, a keep or a cut, and a star rating wherever its utility sits against the cutpoints (computed in log space, by a form exact in both tails). So a stronger contradiction never moves the weights less than a weaker one (every_vote_on_a_finite_phi_has_a_finite_likelihood in auracle-taste).

A star rating between the lowest and the highest used to be the exception. Far below its lower cutpoint its log-likelihood lost precision, and about 37 under it underflowed to −∞-\infty where the true value is finite: a draw it underflowed for lost all its weight, and once every weighted draw's did, the weights were kept. On three test draws weighted [0.7, 0.3, 0], a rating of 1 collapsed the weights to [0, 1, 0] at one strength and left them at [0.7, 0.3, 0] at a stronger one. It now moves them by the exact update at every strength, to about [0.0001, 0.9999, 0] at that stronger one (#227; a_star_rating_far_below_its_cutpoint_updates_exactly_at_every_strength).

A vote with no likelihood keeps the weights

The denominator is now not a number only when the weighted draws give the update nothing to go by: when one of their log-likelihoods is NaN, or all of them are −∞-\infty (the shift is then −∞-\infty too, and −∞−(−∞)-\infty - (-\infty) is NaN). Only a vote on a φ\varphi that is not finite does that, since every vote on a finite one has a finite log-likelihood (above). One that holds a NaN (which vetting exists to prevent) makes every log-likelihood NaN. Then the update keeps the previous weights. That is a choice: a NaN leaves no update to make. What the votes since the last fit taught the draws stays, and the observation waits in the log for the next fit. ESS is unchanged, so such a vote never calls for a resample by itself. The update used to reset the weights to uniform here, which threw that evidence away and claimed a full ESS besides (a_vote_with_no_finite_likelihood_keeps_the_previous_weights in auracle-taste).

What the app is sent after each pick

The reweighted draws are the posterior the engine holds until the next fit, so the app is told what they say after every pick, not only after a refit. Each reply to a pick (a duel, a cut, stars, an answered offer in PERFORM) carries the pool's ratings as they stand, a ratings field filled from WasmEngine::belief():

{"ranked":[{"id":12,"mean":0.84,"std":0.31,"style":1}, …],
 "seeds":[12,7,31, …],
 "may_replace":[40,3, …]}
  • ranked: every pool member's posterior mean and std of the mixture utility, best first, and the lens most responsible for it. They are the ranked list's numbers and the map's glow, size and color for each pool dot, computed under the weights the pick left, exactly as a refit's views would compute them.
  • seeds: the parents the next generation would take, and may_replace: the members its end could retire (refinement).

One pass over the draws per member serves the mean, the std and the lens (utility_mix_and_responsibilities). Measured in wasm under node on an Apple M3 Max (examples/pick_belief.mjs in auracle-wasm, beside its native twin pick_belief.rs), that is under a millisecond per pick for a session of 30 picks (two lenses) and under 2 ms at five lenses. While a generation is open with the pool over size, what its end would retire is ranked as well, under the posterior it opened with: 1.8 ms at two lenses and 3.9 ms at five. The rest of the map, its projection and the ghosts of earlier picks, costs 28 ms by 100 picks and grows with the history, so it still waits for a refit.

Effective sample size

ESS=1∑sws2\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 engine’s signal that a real refit is due.

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 (ESS below half the draws), 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(),
    }
}

The engine’s condition is not a count of picks. It is “the weights have had to be resampled at least once since the last real fit”, that is, the cheap path has provably run out of road. With no posterior yet, it is true as soon as the log holds anything. The engine reports it in its status. A vote that rules out every weighted draw arms it like any other pick that concentrates the weights, however strong it is, unless it is a vote with no likelihood, one on a φ\varphi that holds a NaN. Such a vote leaves the weights as they were and does not arm it, but the picks after it reweight from those weights as usual.

The app does not wait for it. Every sixth pick refits (FIT_EVERY, 6, in apps/web/main.js), and PERFORM’s answered offers count as picks. Two other moments refit at once: the end of the warm start, and opening a taste profile that holds picks. The app used to require needs_refit as well, to save the time of a fit whose posterior had not gone stale. Which picks those were depended on how surprising they had been, so a run of agreeable picks ended with the teaching meter’s countdown and no refit: the meter promised something it then did not do. A fit costs under half a second on a fast laptop, about two on an older one, off the audio thread, at most once every six picks outside those two moments, and the pair stays audible through it.

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 styles.
  2. Recompute the mean of the pass-1 result and relabel against that.

Alignment is exhaustive over permutations, which is fine because K≤5K \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, and slope, which names each draw's style by its rating) 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 on LEARNING’s weights
utility_mix(z)(mean,sd)(\text{mean}, \text{sd}) of uu: glow and size on the map
responsibilities(z)∑sws1[best style of z under θs=k]\sum_s w_s \mathbb{1}[\text{best style of } z \text{ under } \theta_s = k]
style_share(Z)responsibilities averaged over candidates
slope(z, ê)(mean,sd)(\text{mean}, \text{sd}) of θk⋆⊤e^\theta_{k^\star}^\top \hat e, k⋆k^\star each draw’s best style of zz: PERFORM’s lean
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 style choice, which is why it is the right forecast to show for a pair: 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 say so.

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 player 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, only so a frontend could show how misleading it is next to the skill score. The app does not show it.

Brier score and skill

B=1n∑i(pichosen−1)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 player actually picked. Lower is better; B=0.25B = 0.25 is what always saying 0.5 scores.

Reported as skill against that baseline:

skill=1−B0.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=1n∑i−log⁡pichosen(nats)\text{LL} = \frac{1}{n}\sum_i -\log p_i^{\text{chosen}} \quad \text{(nats)}

Baseline log⁡2≈0.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 one shown pair in ten (duel_check_every, 10, in engine.rs) is drawn uniformly at random and flagged Forecast::random_check, and calibration restricted to those is unbiased. Under a rule that chooses its pairs (BALD or Thompson), the app marks such a pair ◇ unbiased probe. Under the default rule, its deal line says every pair is dealt at random (◇ random pair).

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.

The share is of the pairs the player is shown, not of the pairs dealt: the app deals the next pair ahead and sometimes throws a deal away unseen, and a probe thrown away does not use up its turn.

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, in calib.rs: 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: a single number cannot distinguish “overconfident at the top end” from “underconfident in the middle”, and the shape of the failure is what says what to do about it.

The app does not plot it. LEARNING draws every forecast instead (WasmEngine::forecasts, the list these scores are taken over), each as the probability it gave the sound picked, with the hits out of all of them and the mean confidence of its guesses (expected, the mean of max⁡(p,1−p)\max(p, 1 - p)) beside the share it got right (was). Expected above was is overconfidence, the diagram’s top-end failure, in two numbers. The buckets stay in calibration() and in LEARNING’s copy as JSON.

By provenance

The same scores, split by how the answer was collected:

pub struct ProvenanceScore {
    pub provenance: String,  // "duel" | "heard_edit" | "self_report" | "perform_offer"
    pub n: usize,
    pub brier: f64,
    pub log_loss: f64,
    pub skill: f64,
}

The comparison this exists for: a hand edit kept as new through a heard duel and one kept by ticking pick the edit 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 whose every answer was a dealt pair 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 fugue-evo’s typed MH applies 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 penalizes 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 (in engine.rs).

β\betaBehavior
→0\to 0Browse the prior. The taste model is ignored
2.02.0Shipped default
largeOptimizer mode: the search climbs toward the model’s single best guess

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. Tempered SMC, which is not built, would temper this same target.

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 (in surrogate.rs), 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 about 223,000 f64s (the 5.05 s phrase at 44,100 Hz) that it then drops, and a hit would copy that buffer, about 0.9 MB as f32, out of the audio tier. Once per MH step, 40 times per seed at 40 steps, 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.

A walk step executes the model once, for the proposal; the current state is carried scored from the step before and is not run again. A walk still revisits trees (the seed, a state it returns to, the drift walks), and a render is the one expensive thing the model does, so a revisit that is recomputed is a number the walk already had.

A render of the audition phrase costs about a ninth of a second of CPU in wasm on a 4-core Linux machine (quiver-dsp 0.5.1; 2.0 s for the 18 trees of make bench-render, 3.1 s on 0.4.1 the same day) and several times that on the reference MacBook Air, so every revisit the memo answers is a whole render the generation does not wait for.

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.

The design’s tempered SMC is not built, and there is no crossover: every child grows from one seed (walk.rs). Refinement draws the distinction.

Proposals, and the taste tilt

The loop closes here: what the model learns reshapes the grammar the search walks. That changes what the search proposes and, because the tilted grammar is installed as the prior, what it scores against too.

Moves

Refinement uses fugue’s adaptive single-site MH over the trace, so the move set is whatever the trace machinery provides:

  • Parameter moves perturb one continuous or discrete site.
  • Structural moves regenerate a subtree, which changes the set of sites and is therefore a reversible-jump move. fugue handles the Jacobian bookkeeping; Auracle does not implement it.

The structural moves walk the same lattice as hand edits, by another route: they regenerate from the trace, and never call the edit operations.

The tilt

Once a posterior exists, the grammar’s categorical weights are reshaped by what it has learned:

wi′  ∝  wi⋅clamp ⁣(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 (in engine.rs).

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 styles 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 style (one claiming ≈0% of the pool) contributes ≈nothing to how the search proposes. Uniform weighting would let a style 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 players 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, and n_formant directly; processor and modulation tilts read their family coordinates. Silence takes no tilt: how often a hole appears should come from a player unplugging a socket, not from a fitted coefficient. Nor does AudioIn, for the same reason: whether a patch listens is a player’s choice. The mixer’s tilt stays the mean of the original seven, so adding the input moved no session’s mixer weight.

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 rather than only score

A scored-only search is limited by what it happens to generate. If the prior draws bitcrush into 2.5% of terms, then no matter how much the model likes bitcrush, only 2.5% of proposals will contain one and the search has to wait for luck.

Tilting the grammar means the search looks where the model expects to find things. Combined with the clamp, it is a change of emphasis rather than a change of support: every kind stays reachable, and the ones the model believes in get proposed more often.

It is a prior tilt, not a proposal tilt

This page used to say that tilting changes the kernel, not the target, and that the stationary distribution is unchanged. That was false, and the September 2026 audit (AU-G3) caught it. biased_prior builds the tilted grammar and installs it as the prior of the EvolutionModel; fugue-evo's target is prior.model() + factor(β·f), and fugue's categorical proposal is a resample from that same prior, so the Hastings terms cancel and the chain is a correct MH sampler for

π′(x)  ∝  ptilted(x) eβ E[uθ(x)],\pi'(x) ;\propto; p_{\text{tilted}}(x), e^{\beta, \E[u_\theta(x)]},

which is a different target from πβ\pi_\beta. The seed is scored under the same tilted prior, RefineKeep::Best ranks under it, and the parsimony mass the walk climbs is the tilted one. Nothing about that is unsound (MH is exact for π′\pi'), but what the search climbs includes the tilt.

A true proposal tilt, one that leaves πβ\pi_\beta alone, would need a custom site proposal carrying its own Hastings correction; fugue offers only PriorResample for usize sites, so it is not available without an upstream hook. Because refinement hill-climbs rather than samples, the practical effect is the one intended: the climb finds the kinds the player picks sooner. The field keeps its name (SessionConfig::proposal_tilt), since the app and the harness both set it. What changed is the claim, not the code.

Structural taste, specifically

Note that the tilt reads the structural coefficients. That is a deliberate asymmetry: φ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 the fitness factor, and the structural half through both the fitness factor and the tilted prior. Turning centroid_mean into a proposal tilt would require a model of which productions raise brightness, which is a model nobody has fitted.

Locks as conditional refinement

Locking is exact rather than heuristic: Metropolis-within-Gibbs on the conditional posterior. The argument depends on one detail that is easy to omit.

The claim

Let 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 x→x′x \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.

Every walk adds one set of locks of its own: the #input of each AUDIO IN in its seed. The input a node reads is the player’s, so a walk conditions on it as on a player’s lock, and the node stays where it was with the input it had. A node the walk grows reads the first input (the grammar never chooses one).

It holds the #op of each TRACK and CAPTURE the same way. The prior never draws either, so a step that regrew one away could never grow it back. Holding a node’s #op holds the node and every node above it, since regrowing any subtree that contains it would redraw that site; its knobs and everything beside it stay free. A capture’s take is not a site at all, so no walk can propose a new one, and the walk carries it onto every term it scores, and onto the term it returns.

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:

x→x′ allowed,x′→x 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 player 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 is right but whose envelope is wrong. Lock every knob except the envelope. Evolve. The variations differ only where the locks allow.

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 afterward) would be a search that mostly respects the player’s intent, and “mostly” is not a useful promise about the one thing the player explicitly protected.

Everything locked

If L\mathcal{L} covers every address, the search has nothing to do and every proposal is rejected. Each walk ends where it started, and the app reports it the way it reports any generation whose walks found nothing, as every walk came back unchanged, 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 πβ∝pgrammar⋅eβE[uθ]\pi_\beta \propto p_{\text{grammar}} \cdot e^{\beta \E[u_\theta]} by tempered SMC. That is an intention, not a description of the code. There is no crossover: every child grows from one seed (walk.rs).

What runs is a 40-step adaptive single-site MH walk (refine_steps; a walk with locked sites runs proportionally more steps, up to LOCK_SCALE_CAP (4×), in walk.rs), warm-started from each of the best pool members and keeping the final state: local hill-climbing on that target, rather than a draw from it.

What runs

A generation is jobs, then absorption. A walk reads the engine and changes nothing; only admitting its child changes the pool. So the engine hands the walks out as data and folds their results back in, in a fixed order:

pub fn refine<R: Rng>(&mut self, rng: &mut R) {
    let Some((ctx, jobs)) = self.refine_jobs(rng) else { return };
    for job in &jobs {
        let result = run_walk(&ctx, job, &self.memo);
        self.refine_absorb(result);
    }
    self.refine_finish();
}

refine_jobs advances the generation counter and makes one job for each of the top refine_seeds candidates by posterior utility, best first. Every walk shares one context: the grammar prior tilted by the posterior (computed once, as the pool stands when the generation opens), the posterior, the standardizer, the phrase, β, and the keep rule. Each job carries its seed tree, its step budget, and its own RNG seed, derived from a single draw of the caller’s refine stream. It returns nothing (and does not advance the counter) when there is no posterior or no standardizer, because there is no direction to climb in.

run_walk is the walk as a pure function of a context and a job: it walks refine_steps MH steps from the seed and returns one state of that walk, or the reason there is none. Because each walk owns its randomness and the render memo only ever saves work, a walk returns the same child in the engine or on a render-farm worker, early or late.

refine_absorb takes the results in job order, whatever order they finished in, and does what the serial loop always did after its walk: it drops a child the pool already holds, admits the rest if they earn a place, and records the lineage. A result offered out of turn changes nothing.

refine_finish closes the generation, and it is where patches leave (see who leaves, and when). Stopping a generation early is the same call: it ends the generation with the children already absorbed, which are then ranked with everyone else when the pool is trimmed.

Which state of the walk gets injected

A walk renders and featurizes ~40 candidates and injects one, and which one is a free choice that had never been measured. SessionConfig::refine_keep makes it selectable so the comparison stays runnable, the same rule the acquisition enum follows:

RefineKeep::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 player's taste, and the always-on gate has already caught this happening: over 16 seeds, two produced pools −12.0 and −5.5 worse in the synthetic user's true utility after three generations, because insert_candidate admits and evicts by the model. Turning Best on without measuring it is the move most likely to make that worse.

refine_from(seed_id, locked) is the same thing from an explicit seed with an explicit lock set, the ⚡ evolve from this path: a single job over the same walk and the same absorption, with the seed never displaced by its own child, nor by anything else while its walk is out: from refine_from_job until refine_from_absorb (or refine_from_cancel, a stop) the seed is exempt from eviction like a pinned patch.

Who leaves, and when

A child is admitted only if it beats the member it would displace: the lowest-utility member not kept, counting the displacements the generation already owes. Kept (Candidate::kept) is saved (pinned), or kept as new and not yet in a pick (persistence). Nobody leaves while the generation runs. At its end the pool is trimmed back to pool_size: its lowest-utility members not kept are replaced, so the patches that leave are exactly the ones evicting one child at a time would have removed, but a patch saved (pinned) at any point before the end is never among them. Kept candidates are always exempt.

So are the sounds on the table: the app holds the pair EVOLVE shows and the pair of a pick still in its undo window (set_on_table, at most ON_TABLE_MAX, four; read at the end as pins are, and never saved), so the vote that pick becomes when its window closes finds both its sides in the pool. One rule, Engine::spared (kept, a ⚡ seed in flight, or on the table), is what every eviction passes over: the bar a child must beat, the trim at the end, a preset's or an import's insert, a ⚡ child's trim, and retiring() below. A session that holds nothing evicts exactly as it did before the hold existed.

Both the bar a child must beat and the trim at the end are judged under the posterior the generation opened with, the one in its context and the one its walks climbed. A pick made while the generation runs reweights the engine’s posterior at once, so the next pair responds to it, but not the generation’s: which children are kept and which members leave do not depend on when picks landed between absorptions (natively, picks_during_a_generation_do_not_change_which_children_are_kept). The picks count from the next refit, which waits for the generation to end.

Before a generation: its seeds, and what it may replace

Both are stated by the engine before EVOLVE POOL is pressed.

  • next_seeds() is the parents refine_jobs would take if it ran now: the top refine_seeds of the ranked list, under the posterior as it stands, so they move with every pick between refits. A saved patch seeds like any other. Natively, refine_jobs takes them by the same rule (next_seeds_and_may_replace_are_what_a_generation_does).
  • may_replace() is the members that generation could retire, lowest first. Each walk admits at most one child, and the trim takes only as many members as the children put the pool over size, lowest first. So with the pool at size, the trim would take nothing outside the refine_seeds lowest members not kept nor on the table: those come first. But the trim reads the table as it finds it at the end, and the player keeps picking while the generation runs. A sound up at the press is put away long before the end, and up to ON_TABLE_MAX of the sounds the trim would take can be up when it ends, each sending it one member further. So the list goes on with the ON_TABLE_MAX next lowest with the table put away, and nothing outside the refine_seeds + ON_TABLE_MAX lowest members not kept can leave, whatever is on the table, unless something else changes the pool meanwhile (a save, an edit, a preset) or a sound kept as new has its first pick, after which it competes. Natively, may_replace_bounds_a_generations_end_whatever_is_on_the_table_by_then. A patch the app has cut is in the pool and ranks low, so it is on this list too: the engine does not know about cuts.

The app is sent both with every pick, beside the ranked numbers (the posterior), and marks them in the bank while EVOLVE POOL is pointed at. Once a generation is open both describe the next one, so the app marks the running generation's own instead: its parents, which the worker posts with the generation's progress (job i walks from seeds[i]), and retiring(), which each absorbed walk's message carries with the job's parent_id, as does the reply to a save, a preset, a kept edit or a change of what is on the table while the generation is open: a saved or held member drops out of eviction_order and the next lowest takes its place, and a new member can move the lowest. The app says will be replaced for retiring(), not may be: it is the lowest members not kept, as many as the children put the pool over size, under the posterior the generation opened with, so each child admitted adds one member and removes none, and the end replaces them whether it is stopped or runs out, but for a member saved meanwhile or on the table when it ends, whose place goes to the next lowest. Each child still to come can add one more.

While ⚡ walks with no generation open, its child is admitted and the pool trimmed at once (evict_to_size, the seed protected), lowest first by eviction_order, which passes over a seed in flight. The first part of may_replace() ranks the same way, the table passed over, so its first pool + 1 − pool_size members (one with the pool at size) are what the child would replace; the app marks those, and the seed.

The split is measured

Defaults (in engine.rs), both scaled from the grammar’s processor count N_OPS = 20 (in prior.rs):

refine_steps=2⋅NOPS=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 change to the processor set.

The 40 × 10 split was an argument that could have been wrong in either direction, so search_health --budget-ab was written to settle it. Over 8 seeds, 6 generations, graded against a synthetic user’s true utility, under fugue-ppl 0.2.2 (“What has changed since”, below, says what is different now):

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 nobody wanted to be, and the extra steps are what get it there.

Breadth is not free either. 20 × 20 spends the full shipped budget and is the worst row of the four. Under 0.2.2, twenty steps was not enough for a chain to leave its seed, so the generation is twenty barely-moved copies of the current top, which is also why it has the second-best max: it preserves the frontier by never straying from it.

Re-run this before changing either number.

What has changed since. The four rows were measured before 2026-10-07, under fugue-ppl 0.2.2 and with oscillator octaves drawn uniformly, and --budget-ab has not been re-run. fugue-ppl 0.2.3 reflects a knob’s proposal into its bounds (at 40 steps, knob moves are accepted 85% of the time against 53%, and are about 1.8 times larger), and octaves are now drawn by weight.

Two measurements on 2026-10-07 (the share of locked ⚡ walks whose child beats its parent, search_health --hits) found the shipped 40 steps not worse under either change, measured one at a time: 66.7% to 70.3% on 48 seeds, paired, for fugue-ppl 0.2.3, and 71.1% to 68.0% on 16 seeds, unpaired, for the weighted octaves, which ran on fugue-ppl 0.2.2, before the branch that drew them met 0.2.3. The two together, which is the walk now, were never measured by this rate at any length.

On 0.2.2, “twenty steps is not enough” read as 60.9% against 71.1% at 20 and 40 steps with octaves uniform, and did not show with them weighted (71.1% against 68.0%, inside the noise); what it reads on 0.2.3 with the weighted octaves nobody has measured. The 80-step row leans worse under each change, and the 160-step row under fugue-ppl 0.2.3, all inside the noise; longer walks are not shipped, and the weighted octaves’ 160-step row was never finished. Neither study was re-measured after the walk began to run its model once a step and the renders moved to quiver-dsp 0.5.1.

Why local climbing suits this anyway

The gap between design and implementation is real, but the implementation is not merely a shortcut.

A candidate pool is not a sample. The pool’s job is to hold a few dozen patches worth auditioning. A correct sample from πβ\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 player’s side.

Diversity comes from elsewhere. The measured result below is that the pool does not concentrate over a session anyway, so the thing SMC would primarily buy (maintained diversity via a population kernel) is being supplied by frontier-biased injection plus worst-eviction.

What is lost: any claim about the distribution of the pool. That is the whole of it. The other thing this section used to claim was lost turns out not to be.

The islands are not separated by a valley

This page previously said that a player with two distant islands “may find that refinement from island A never discovers island B, and has to reach it by hand or by the prior”. That was an argument from the shape of a local walk, and it is false.

make islands teaches a bimodal synthetic user (two islands opposed on every coordinate they share), runs real generations, and asks how often a child lands on the island its parent was not on:

refinement events that cross islands99 / 473 (20.9 %)
of those, decisive (both ends > 1.0 onto their island)64 (13.5 % of all events)
seeds whose pool ended on one island 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 could prune candidates before the expensive path, and only the survivors would be rendered and scored in full. No such cascade is built: SurrogateFitness featurizes every candidate it is handed (Directions §12).

This is designed into the feature split and is why φ\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 generation: usize,     // the generation counter at the event
    pub kind: String,          // "refine" | "edit"
    pub parent_id: u64,
    pub child_id: u64,
    pub diff: Vec<DiffEntry>,  // what changed, in trace-address terms
    pub parent_utility: f64,   // posterior mean at event time
    pub child_utility: f64,
}

tree_diff produces the address-level diff, which the app renders as attack 0.59→0.83, +noise, −distortion · liked +0.65, naming the parent and child by their bank names. The bank shows each bred child's event under its name (from Soft Pad · +delay, cutoff 1.2 kHz → 3.4 kHz). The diff is positional, so a module inserted above others shifts their addresses and one change to what the patch holds can read as several swaps, removals and additions. The app counts the modules instead: every module site nets its old name −1 and its new name +1, a swap is said only where it accounts for a lost module and a gained one, and the rest is said as gained (+) or lost (−). A module removed in one place and added in another nets to nothing. Compare shows it whole: both utilities, through the same logistic as the bank's percentages (σ(u)\sigma(u)), and the diff. A refused child (NotAdmitted) records no event; the app knows it only from the walk's reason and its job's parent_id.

Utilities are recorded at event time: a later refit changes the model, and re-deriving these numbers afterward would rewrite history to look better-informed than it was.

Hand edits appear in the same log tagged "edit", because the lineage is a record of everything that produced a patch and not only of what the machine did.

Performance: named controls and the drift walk

A control called Bright is a fixed direction in standardized φ. Which knobs it turns is measured per patch, checked on real renders, and refused when the knobs cannot honestly produce it.

PERFORM, the instrument’s first view, needs two things from the machinery. It needs controls whose names mean the same thing on every patch, which a knob address cannot give: node/0#cut is a brightness control in one patch and a wavefolder’s drive in another. And it needs motion that stays inside the sound: a patch that changes on its own without changing what it is made of.

The first is a least-squares problem on the patch’s own Jacobian. The second is the locked walk with every structural site locked. Both live in auracle_session::perform, and reach the browser as perform_wire, perform_apply, perform_drift, and perform_offer on WasmEngine. Under the model view the instrument also draws which way your taste leans along each control (below), perform_lean.

Symbols on this page

The notation fixes dd as the feature dimension and xx as a term, so this page uses different letters for the two things the code calls d and x.

SymbolIsIn the code
v∈[0,1)nv \in [0,1)^nThe patch’s nn continuous knob values, normalizedJacobian::values
z(v)∈R18z(v) \in \R^{18}The audio block of standardized φ\varphi at those valuesaudio_z
e^∈R18\hat e \in \R^{18}A named control’s unit directiondirection(...), d
J∈R18×nJ \in \R^{18 \times n}∂z/∂v\partial z / \partial v at the current values v0v_0Jacobian::cols
δ∈Rn\delta \in \R^{n}The knob move the wiring solves forx
SSThe knobs a control is allowed to move, ∣S∣≤4\lvert S \rvert \le 4support
c∈[−1,1]c \in [-1, 1]A control’s setting; 00 is the sound as it isc

Distances along e^\hat e are in zz units: one unit is one standard deviation sjs_j of the session’s own spread, from the standardizer. The instrument’s tooltips and the code’s comments write this unit as σ.

The constants on this page live in auracle-session’s perform.rs unless the text names another file.

A named control is a direction

Six controls on the panel, each a fixed weighting of named φ_audio coordinates, normalized to unit length. They are the first six of the palette's eighteen, which the engine defines and measures the same way:

ControlLow · highWeights before normalizing
Brightdark · brightcentroid_mean +1+1, rolloff_mean +1+1
Snapbloom · snapattack_s −1-1, crest +1+1
Motionstill · restlessheld_centroid_std, motion_slow, motion_mid, motion_fast, each +1+1
Bodythin · fullbass_fraction +1+1
Gritsmooth · roughflatness_mean +1+1
Spaceclose · fartail_ratio +1+1

So e^Bright=(ecentroid+erolloff)/2\hat e_{\text{Bright}} = (e_{\text{centroid}} + e_{\text{rolloff}})/\sqrt2, and Motion spreads over its four coordinates with weight 12\tfrac12 each, the motion bands plus the older held_centroid_std. A unit test requires each of the eighteen directions to have unit norm over φ’s real coordinate names, and every coordinate it weights to be one of them, so a control that lost any coordinate to a rename fails the build instead of quietly narrowing to the rest.

The direction is defined in zz, not raw φ\varphi, because only a standardized coordinate has a scale that means the same thing across axes. The patch’s current position on a control is e^⊤z(v0)\hat e^\top z(v_0); the instrument draws it as a dot on the control’s ring, at tanh⁡(e^⊤z/2)\tanh(\hat e^\top z / 2) of the way to the stop.

The standardizer is the session’s, refit at every posterior fit. A control’s wiring and position are therefore relative to the patches this session has seen, and can change after a refit. Before the pool has a standardizer at all, perform_wire returns null and the view says there is nothing to measure against yet.

The other two controls, Blend and Wander, are not directions in φ and live in the instrument, not in this module.

The Jacobian, by one-sided differences

For each continuous knob kk, one render nudged by hkh_k:

J:,k  =  z(v0+hk ek)−z(v0)hk,hk={+hv0,k<12−hotherwiseh=0.08 J_{:,k} ;=; \frac{z(v_0 + h_k, e_k) - z(v_0)}{h_k}, \qquad h_k = \begin{cases} +h & v_{0,k} < \tfrac12 \ -h & \text{otherwise} \end{cases} \qquad h = 0.08

JACOBIAN_STEP =0.08= 0.08 of the knob’s normalized range: large enough to move φ past the numerical floor of a five-second render, small enough to stay local. Stepping toward the interior keeps every nudge inside the knob’s domain, and one-sided differences cost n+1n + 1 renders instead of the 2n2n that central differences would, all through the render memo. A knob whose nudge fails to vet gets a zero column, and the wiring simply cannot use it.

The estimate is first order: its error is O(h)O(h) in the curvature of the response. That is not bounded here. It is what the verification below exists to catch.

Wiring: ridge, support, re-solve

The knob move that best produces e^\hat e is the ridge solution

δ⋆  =  (J⊤J+λI)−1J⊤e^,λ=0.05  (RIDGE). \delta^\star ;=; \big(J^\top J + \lambda I\big)^{-1} J^\top \hat e, \qquad \lambda = 0.05 ;(\texttt{RIDGE}).

Ridge rather than plain least squares because JJ is badly conditioned by construction. Two knobs can do nearly the same thing to the sound, which makes columns nearly collinear, and many knobs are nearly inaudible, which makes columns nearly zero. Unregularized, the solve answers both with large, canceling moves.

The support SS is the four knobs with the largest effect ∣δk⋆∣ ∥J⋅k∥\lvert \delta^\star_k \rvert , \lVert J_{\cdot k} \rVert (MAX_KNOBS), and the move is re-solved on it:

δS  =  (JS⊤JS+λI)−1JS⊤e^. \delta_S ;=; \big(J_S^\top J_S + \lambda I\big)^{-1} J_S^\top \hat e .

Truncating δ⋆\delta^\star would throw away the other knobs’ contribution without letting the four that remain compensate; the re-solve is the best move on those four. Choosing the four by effect is a heuristic, not a best-subset search. It is effect and not ∣δ⋆∣\lvert\delta^\star\rvert because a knob that barely moves the sound needs a large coefficient to contribute anything. Ranked by coefficient, that knob headed the support, and the scale below then gave the knob doing the work a sliver of a turn. On Iron Bass, Bright wired to the drive (∥J⋅k∥=0.15\lVert J_{\cdot k}\rVert = 0.15) at +0.5+0.5, and the cutoff (∥J⋅k∥=5.2\lVert J_{\cdot k}\rVert = 5.2, +4σ+4\sigma of centroid per unit) barely moved: 0.03σ0.03\sigma of reach.

The control’s own knobs first. The solve runs twice at most. First it is restricted to the knobs a musician would name for the control (NamedControl::sites: cutoff, tone, … for Bright; mod depth and rate for Motion). If that wiring clears the gate below, it is used. Only if it does not does the solve range over every live knob, with those sites as a soft prior (SEMANTIC_RIDGE). A player who turns Bright and watches the cutoff move has learned something true about the patch. One who watches the amp release move, which is what the unrestricted solve chose on Acid Line, has learned nothing.

Four, because a control is heard as one gesture, and because four is where most of a patch’s audible leverage already is: over the preset library, a patch’s four most audible knobs carry a median 68% of its φ movement (below).

The movement this predicts is JSδSJ_S \delta_S, and three numbers follow from it.

Purity is the cosine between the predicted movement and the direction asked for:

ρ  =  e^⊤JSδS∥JSδS∥. \rho ;=; \frac{\hat e^\top J_S \delta_S}{\lVert J_S \delta_S \rVert}.

ρ=1\rho = 1 is a pure move along the label. At ρ=0.35\rho = 0.35 the off-axis part of the movement is 1−0.352/0.35≈2.7\sqrt{1 - 0.35^2} / 0.35 \approx 2.7 times the on-axis part.

Scale. A full turn may move no knob by more than MAX_TRAVEL =0.5= 0.5 of its range. The knob with the largest effect gets all of it, and the rest follow in proportion, clamped at the same limit:

α  =  0.5∣δS,k∗∣,k∗=arg⁡max⁡k∈S∣δS,k∣ ∥J⋅k∥,tk  =  clip⁡(α δS,k, ±0.5), \alpha ;=; \frac{0.5}{\lvert \delta_{S,k^\ast} \rvert},\quad k^\ast = \arg\max_{k\in S} \lvert\delta_{S,k}\rvert,\lVert J_{\cdot k}\rVert, \qquad t_k ;=; \operatorname{clip}(\alpha,\delta_{S,k},, \pm 0.5),

v(c)  =  clip⁡ ⁣(v0+c t,  0,  1−10−6). v(c) ;=; \operatorname{clip}!\big(v_0 + c,t,; 0,; 1 - 10^{-6}\big).

Purity and reach below are computed from the clamped travel tt, so they describe the move the control actually makes.

The upper clip is KNOB_MAX. Every continuous site is Uniform(0,1)\mathrm{Uniform}(0,1) on the half-open interval, so a knob at exactly 11 has log-prior −∞-\infty and would make the patch un-evolvable. A performance gesture must never do that.

Reach is the predicted movement along e^\hat e at a full turn, in zz units:

R  =  e^⊤JS t. R ;=; \hat e^\top J_S, t .

A control is a search control on this patch when the knobs cannot honestly produce it:

search  ⟺  ρ<0.35  ∨  R<0.15 \text{search} \iff \rho < 0.35 ;\lor; R < 0.15

(PURITY_FLOOR, REACH_FLOOR), or when there is no support or the predicted movement points the wrong way. The reach floor was first set at 0.250.25 against the preset library’s spread. The session pool is spread wider than a curated library, which shrinks every zz distance, and 0.250.25 hid controls that are plainly audible on a live patch.

Several controls turned at once add: each contributes ciαiδSic_i \alpha_i \delta_{S_i} to the knobs it wires, and the sum is clipped. The composition is linear by assumption and is not verified.

Purity measures cross-talk, not correlates

Purity asks whether a move is this control. Measured against the whole of φ, it cannot tell a brightening that also raises the zero-crossing rate and the high band (which every real brightening does) from one that also slows the attack. Over a fresh session pool, Bright’s median cosine with its own axis was 0.26 for that reason. What a player hears as “this control does something else” is movement along another control’s axis, so purity is the cosine with e^\hat e inside the subspace the six named axes span:

ρ  =  e^⊤m(e^⊤m)2+∑k≠self(a^k⊤m)2,m=JSδS, \rho ;=; \frac{\hat e^\top m}{\sqrt{(\hat e^\top m)^2 + \sum_{k \ne \text{self}} (\hat a_k^\top m)^2}}, \qquad m = J_S \delta_S ,

with a^k\hat a_k the other controls’ axes. Reach and position stay on e^\hat e. And two controls whose predicted movements are within ∣cos⁡∣>0.8\lvert\cos\rvert > 0.8 of each other are one gesture with two names: the later one in the order they were wired (for the panel, Bright, Snap, Motion, Body, Grit, Space) becomes a search control (separate, COLLINEAR).

A palette control is not one of the six axes, so the same idea is written for any direction (purity_basis). Take an orthonormal basis b0,…,bqb_0, \dots, b_q of the span of e^\hat e and the six axes, with b0=e^b_0 = \hat e and the rest from Gram–Schmidt over the six in order, and, for the predicted movement mm as above,

ρ  =  e^⊤m∑i=0q(bi⊤m)2. \rho ;=; \frac{\hat e^\top m}{\sqrt{\sum_{i=0}^{q} (b_i^\top m)^2}} .

For one of the six this is the formula above, bit for bit: the other five axes share no coordinate with it, so they enter the basis unchanged (a unit test holds the two equal). A control that leans on some of the six (Thump on Body, and on Snap through crest) takes up what it shares with them, and the remainder of each, the part orthogonal to e^\hat e, counts against it in full: a move of e^\hat e plus an equal part of that remainder has ρ=1/2\rho = 1/\sqrt2 (the unit test checks this for every such pair). One with a coordinate of its own (Air’s zcr_mean) widens the span by that much. A move along the control’s own direction has ρ=1\rho = 1.

Measured over the first 24 patches of a fresh session pool (reach_census, seed 7), with verification on real renders:

ReachableBrightSnapMotionBodyGritSpace
Purity against the whole of φ25%58%58%17%4%38%
Purity against the named axes29%71%62%21%4%38%

Patches on which no control reaches fell from 4 of 24 to 2. The gain is modest because purity was not Bright’s real limit: its median predicted reach is about 0.1σ, since many pool patches have no filter for a named control to turn. That is the honest reading, and a search control’s offer is the answer to it.

The same 24 patches, after the effect ranking, the control’s-own-knobs pass, and a half-travel retry in verify (all three above and below). Both rows are measured on the same build, since the pink-noise fix and the re-voiced presets moved the pool standardizer:

ReachableBrightSnapMotionBodyGritSpacemean per patch
Ranked by coefficient29%67%62%21%4%38%2.21
Ranked by effect50%79%46%29%4%46%2.54

The median verified reach of a reachable control grew three- to four-fold (Bright 0.09σ → 0.33σ, Snap 0.54σ → 0.95σ). Motion lost: 62% → 46%. Motion leans on several small knobs together, and giving the strongest one its whole turn is where a Motion wiring most often turns back on itself. The half-travel retry did not win those patches back. Dropping the weak knobs was tried first and cost Motion more.

Tried and not shipped. Aiming the solve at each control’s population pattern Σe^\Sigma \hat e (the correlation-weighted direction, Haufe et al. 2014) first looked like a large improvement, but only because purity was then measured against the pattern instead of the axis. Measured against the axis, with the pool correlation shrunk toward the identity (Schäfer & Strimmer 2005) and collinear controls separated, it reached fewer patches than the bare axis (Bright 21%, Motion 42%), and on 2 of 12 patches it put Bright and Body onto the same knobs with opposite signs. It was removed.

Verification on real renders

The Jacobian is a local, linear claim, and it fails exactly where a player would notice: at a boundary. On First Bass, which already sits at the floor of motion, the linear prediction said turning Motion down would make it stiller. Rendered, it made it very slightly more restless.

So verify renders every non-search control alone at four settings and measures the movement along its own axis:

m(c)  =  e^⊤(z(v(c))−z(v0)),c∈{−1,−12,+12,+1}. m(c) ;=; \hat e^\top \big( z(v(c)) - z(v_0) \big), \qquad c \in {-1, -\tfrac12, +\tfrac12, +1}.

For each half, with sign s∈{+1,−1}s \in {+1, -1}, the measured reach is

rs  =  {s m(s)if 0<s m(s2)<s m(s)0otherwise. r_s ;=; \begin{cases} s,m(s) & \text{if } 0 < s,m(\tfrac{s}{2}) < s,m(s) \ 0 & \text{otherwise.} \end{cases}

The half must move the asked way at half travel, and further at full travel. A half that moves at the end but reverses on the way is closed. A half is open when rs≥0.075r_s \ge 0.075, half the reach floor. A control with neither half open becomes a search control. The instrument draws a half-closed control’s ring solid on the side it can turn toward and dotted on the other, with a stop at the center, and says which way it turns: turns toward restless only, for Motion on First Bass.

A control that would close at full travel gets one retry at half: the same two-point test over c∈{±14,±12}c \in {\pm\tfrac14, \pm\tfrac12} (the ±12\pm\tfrac12 renders are already in the memo). If a half opens, the wiring keeps half its travel rather than closing.

Total cost for a patch with nn knobs and rr reachable controls is n+1+4rn + 1 + 4r renders, plus two for each retry, all through the memo. In the browser that is around 20–30 renders, and the page caches each measurement by tree, stamped with the standardizer's revision and the render namespace (cache_namespace: the featurizer's RENDER_EPOCH and the quiver version), so returning to a patch costs none. A stamp that no longer matches is played at once and re-measured in the background. The stamp is the standardizer's revision (the status's std_rev), not the vote count: the scale moves when a fit runs over sounds the session had not met, and a pick alone left every kept wiring stale. Every preset’s measurement also ships with the app (apps/web/perform-wirings.json, made natively through the same WasmEngine calls by make perform-wirings), so a preset is playable the moment it lands; the page re-measures it in the background, because the file was measured under a standardizer fitted to a standard pool, not the session’s. The view re-wires after every patch change, and after a glide or a Keep that carries the knobs out of the neighborhood the wiring was measured in (see below), so the claims are always about the neighborhood the sound is in. A background re-measurement that wires each control to the same knobs is taken in place, without re-centering the controls.

The gate, and why it samples somewhere else

named_controls_move_the_sound_they_name wires and verifies four presets (First Bass, Ceiling, Detune Dream, Long Way Down) and then checks every open half at c=±34c = \pm\tfrac34, a point verification never rendered:

s (e^⊤z(v(3s4))−e^⊤z(v0))  >  −0.05(MONO_TOL). s,\big(\hat e^\top z(v(\tfrac{3s}{4})) - \hat e^\top z(v_0)\big) ;>; -0.05 \qquad (\texttt{MONO_TOL}).

It requires at least eight open halves, so a wiring change that closed most of them would fail the gate rather than pass it vacuously. The palette’s twelve are held to the same check on the same four presets, each wired alone, and must show at least 16 open halves over at least 8 of the twelve.

It samples somewhere else because no finite set of samples proves a response monotone. A smooth function can agree with any finite set of points and reverse between them. Checking an unsampled point is the measurable form of the promise, and a check at the points verification already used would be the same test twice.

The tolerance is stated rather than zero because a five-second render has numerical noise. 0.050.05 zz units is two orders of magnitude below an audible difference and above that noise. A check with no tolerance failed on Detune Dream’s Snap, which reversed by 0.0180.018: real, measurable, and far below anything a listener would hear. That is the case the tolerance exists for. Verification itself has no tolerance: a half that reverses at a sampled point is closed however small the reversal.

The measurements, and what reproduces them

Per patch, not per knob kind. The obvious alternative to measuring a Jacobian per patch is a table: what a cutoff knob, or an attack knob, usually does. Both were measured over 61 presets. The table was built leave-one-out, from per-site-kind averages over the other 60, so no preset was wired by a table that had seen it.

Median purityBrightSnapMotion
Wired from the patch’s own Jacobian0.610.770.75
Wired from the best leave-one-out per-site table0.230.160.09

The same knob does different things in different patches. That is the reason the grammar exists, and it is the reason the table is never the last word.

It is the first one, though (#290). A first measurement is 8 to 71 renders, a minute and more on a slow computer, and a sound nobody has measured plays its controls at once on a wiring that costs none: one kept for a sound of the same shape (the same modules and selectors, so the same knobs; predict::shape_of) if one was measured, and otherwise one predicted from a table of how each kind of module's knob moves φ (predict::KnobTable: the median measured column per module kind, site, modulation target and third of the range, backing off to coarser keys), run through the same solve. The measurement then lands on it by a rebase, so nothing heard moves. Until it does, a control playing on either says not measured yet: a borrowed wiring was measured, on another sound, and is not gated; a predicted one is gated (below). Judged against each sound's own Jacobian, held out (examples/wire_predict.rs, 240 pool sounds from six sessions):

Table learned fromWiresTurns the named wayCovers the measured reach
The presets76770%53%
The presets and one session's pool68876%56%
The presets and five sessions' pools57979%50%

The gate. It is right on some controls far more than others (Space 99%, Bright and Snap about 78%, Motion 62%, Body 63%), so a control is wired from the prediction only where the table has shown it can be. make perform-wirings learns the shipped table from the presets' Jacobians and 240 pool sounds' (the standard pool's and five more sessions'), and for each palette control counts, on every pool sound held out of the table in turn, how often the prediction wired it and how often that turned it the named way (KnobTable::agreement). A control passes when the lower end of the Wilson interval at two standard errors reaches 0.70 (PREDICT_GATE, GATE_Z): more than two turns in three the named way, after paying for how few turns there were, so a perfect record needs ten. One session's pool is too few to read it on: across six, one pool's Bright read 65% and another's Motion 72%. The rest wait for the measurement and read listening…, as do controls the prediction cannot reach (a guess does not say a patch can't).

ControlWiredNamed wayBound
Bright13982%0.746passes
Snap23183%0.776passes
Motion10860%0.506
Body1932%0.151
Grit5100%0.556
Space7399%0.924passes

Of the palette's twelve, Warmth, Thump, Round, Distance, Haze and Bite pass; Air, Heft, Punch, Throb, Sway and Lo-fi do not. shipped_wirings.rs pins the list. By ear, rendering each gated control turned fully up and fully down on a fresh session's 40 sounds (wire_predict --shipped), 81% moved the named way turned up and 66% both ways (ungated, 66% and 55%), and the prediction wires 58% of the controls the measurement reaches. "The named way" is the sign of the movement along the control's own direction, not how much else moves with it, so a purity row like the one above would read lower.

Grit and Space measure a median purity of about zero by knobs alone on most presets, because most patches contain no drive or reverb to turn. They are the controls most often drawn as search controls.

Grafts. Turning a search control first tries to give it something to turn (graft_for). Bright and Body get a flat EQ, placed below any stereo module that ends the chain, because above one it folds the patch to mono (Ghost Bell moved 0.27σ that way). Space, turned up, gets its amp release raised to 0.6 (≈250 ms). A reverb was tried first, and every effect here sits before the amp envelope: on First Bass with a reverb grafted, ∂Space/∂mix measured −0.04 and ∂Space/∂release +3.65. Grit gets nothing (see the open question). perform_inserts measures each graft. Over the preset bank, the EQ is transparent (median |Δz| 0.000; the unit test bounds it at 0.05σ). It opens Bright on 7 of the 10 presets where Bright was a search control, and Body on 15 of 48. The release graft opens Space on 41 of 46, at 1–2σ of reach. It is not transparent, and is not meant to be: it is what “farther” asked for.

Leverage. Nudging every knob of every preset by ±0.15\pm 0.15 and measuring how far φaudio\varphi_{\text{audio}} moves, in units of the library’s own per-coordinate spread: over 61 presets and 821 knobs, a patch’s four most audible knobs carry a median 68% of its total movement and its top eight 94%. 172 of the 821 knobs barely move it at all. That is what makes MAX_KNOBS =4= 4 a real operation rather than a random pick with a story attached.

ExampleProduces
cargo run -p auracle-features --example jacobian_probe --release > jac.csv∂φaudio/∂knob\partial\varphi_{\text{audio}}/\partial\text{knob} for every preset: the raw material for both purity rows
cargo run -p auracle-features --example leverage_probe --release > leverage.csvPer-knob leverage for every preset
cargo run -p auracle-session --example perform_wiring --release -- "First Bass"The shipped wiring on named presets: knobs, purity, reach, position, search
cargo run -p auracle-session --example offer_cost --release -- 12 3 20What an offer, an aimed offer and a drift cost, per preset and as a distribution, and how much of it is renders (offer_cost.mjs in auracle-wasm's examples is the wasm twin)
cargo run -p auracle-session --example perform_inserts --releaseFor each preset’s search controls, whether PERFORM’s graft is transparent and whether it makes the control reachable
cargo run -p auracle-session --example reach_census --release -- 24 7How many controls reach the patches of a fresh session pool, with verification, and how the gate would read with purity against the whole of φ
cargo run -p auracle-wasm --example palette_census --release -- 3 --prototypeThe palette: the eighteen directions, their cosines, how often each reaches the presets, and what measuring them costs natively
node crates/auracle-wasm/examples/palette_cost.mjs 4The same measurement’s cost in wasm, the six against the eighteen (make wasm first)

The two probes print CSV and the medians are computed from it. jacobian_probe uses central differences on raw φ at the same h=0.08h = 0.08, so it measures the same response as the shipped code without being bit-identical to it. perform_wiring runs the shipped jacobian and the bare-axis wire under a preset-library standardizer, and does not run verification; reach_census runs the shipped path (Engine::wire_controls) with verification.

The palette: eighteen directions

RFC-006 turns PERFORM’s controls into a palette of eighteen in six families, of which the player places up to eight. The engine defines all eighteen (PALETTE in perform.rs) and measures any of them exactly as it measures the six: one Jacobian, the ridge solve onto at most four knobs, the purity and reach gate, separation, and verification on real renders (Engine::wire_named; the arithmetic alone, unverified and without a render, is wire_set). The first six entries are CONTROLS, at the same indices, so an index names the same control in either list. The app wires the controls the player has placed on PERFORM’s panel, at most eight (PANEL_MAX in perform.js): for the six it sends no controls, so the request is the one it always was, and for any other panel it sends the panel’s set.

The set is asked for in palette order, whatever order the panel shows. separate makes the later of two collinear controls the search control, so the order asked is part of the answer; asking in palette order makes the answer a function of the set alone, so reordering the panel costs no renders, and the six keep the precedence the census above measured them with (“beside the six”). The page keys its cache of wirings (wireKey) by the patch and the set: the patch’s own key for the six, which is the key every kept and shipped wiring already had, and that key with #controls= and the set for any other. A set not measured on a patch yet borrows, control by control, from any set that was (borrowWiring), and is measured behind it.

How a control is named across the boundary. perform_wire’s controls is a JSON array of palette indices, read entry by entry: a non-negative whole number is an index, and an index out of range, a repeat, or anything else in the array (a negative, a fraction, a string) is dropped on its own. What is not an array at all means the six. An array with nothing valid in it wires nothing and renders only the patch itself. The reply’s wiring follows the order asked, and each entry carries its palette index (Wiring::index) beside its name. The page names a control back to the engine by that index (perform_offer’s control, perform_graft’s k), never by the entry’s position: asked for [16, 6], the first entry is Bite with index 16, and a position of 0 would aim along Bright. Only for the six asked in order are position and index the same.

The directions

Each of the twelve is a direction of its own over φ’s coordinates, normalized like the six:

FamilyControlLow · highWeights before normalizingWhat it means in φ
ToneBrightdark · brightcentroid_mean +1+1, rolloff_mean +1+1The six, above
ToneWarmthcold · warmbass_fraction +1+1, high_ratio −1-1Weight low down and a soft upper register: the highest note comes through quieter than the held one, as under a low-pass that does not follow the keys
ToneAirclosed · airyzcr_mean +1+1, rolloff_mean +12+\tfrac12, centroid_mean −12-\tfrac12The very top, above the notes: the highest partials and any breath, and the rolloff, measured against the centroid, so the top opens while the body of the sound stays
WeightBodythin · fullbass_fraction +1+1The six
WeightThumplight · thumpingbass_fraction +1+1, crest +1+1Weight low down that hits
WeightHeftslight · heavyrms_mean +1+1, bass_fraction +1+1Dense, held weight: frames full of level (which loudness normalization gives a sustained, low sound, since K-weighting counts the lows for less) and weight low down
DynamicsSnapbloom · snapattack_s −1-1, crest +1+1The six
DynamicsPunchgentle · punchycrest +1+1, rms_std +1+1The size of the hit against the rest: peaks, and a level that moves over the phrase
DynamicsRoundhard · roundattack_s +1+1, rolloff_mean −1-1A soft attack and few harmonics, the voice’s round
MovementMotionstill · restlessheld_centroid_std, motion_slow, motion_mid, motion_fast, each +1+1The six
MovementThrobsteady · throbbingmotion_mid +1+1Pulsing and tremolo: the held note’s motion at 2–8 Hz alone
MovementSwayfixed · swayingmotion_slow +1+1Sweeps and breathing: the held note’s motion at 0.5–2 Hz alone
SpaceSpaceclose · fartail_ratio +1+1The six
SpaceDistancenear · distanttail_ratio +1+1, attack_s +12+\tfrac12, crest −12-\tfrac12, rolloff_mean −12-\tfrac12What distance does to a sound: a longer tail, a softer attack, smaller peaks and a duller top
SpaceHazeclear · hazytail_ratio +1+1, motion_fast +1+1, crest −1-1A wash: a tail, a shimmer (the 8–30 Hz beating that detune and chorus give) and peaks smoothed away
CharacterGritsmooth · roughflatness_mean +1+1The six
CharacterBitemild · bitingflux_mean +1+1, zcr_mean +1+1An edge: the spectrum changing fast, and high partials (a filter that snaps open, a resonance that rings)
CharacterLo-ficlean · wornflatness_mean +1+1, rolloff_mean −1-1, motion_fast +1+1Worn like tape: hiss, a dull top and flutter

Three things φ lacks shaped these. It has no low-mid band, so Warmth is not quite the voice’s warm, “weight in the low middle, a soft top”. It measures weight low down (bass_fraction, below about 250 Hz) against a soft top of the keyboard (−high_ratio: the highest note quieter than the held one). That is close to the word, and not exactly it: a sound can gain low-middle warmth that this does not see, and lose some that it does not count. Defined with the spectrum’s top instead (rolloff_mean down with bass_fraction up), it moved with Body across the sounds (r=0.92r = 0.92), and on 28 of the 40 presets where both it and Bright reached it was Bright’s gesture, which is why it reads the keyboard’s top and not the spectrum’s. φ has no bit-depth or bandwidth feature, so a crusher reaches Lo-fi only through the flatness it adds. And its only space coordinate is tail_ratio, which the amp release sets (a reverb’s tail is cut at note-off), so Distance and Haze lean on what else distance and a wash do to a sound.

Two names for one control

The approved prototype (docs/notes/vision-2026-09/prototype/perform.js) defined the twelve as blends of the six. Five pairs of those blends were one direction, ∣cos⁡∣≥0.9\lvert\cos\rvert \ge 0.9: Air and Bright (+0.94+0.94), Softness and Snap (−0.94-0.94, Snap turned down), Wobble and Motion (+0.99+0.99), Distance and Space (+0.92+0.92), and Warmth and Air (−0.94-0.94). Punch was +0.89+0.89 to Snap. That is why each of the twelve above is its own direction. Redundancy was then measured three ways over the eighteen, on the presets and the shipped engine’s pool:

  • The directions themselves, e^i⊤e^j\hat e_i^\top \hat e_j: no pair reaches 0.90.9. The largest are Space and Distance (+0.76+0.76) and Body with each of Warmth, Thump and Heft (+0.71+0.71).
  • The sounds’ positions along them, correlated over the 62 presets and the pool of 40: no pair reaches 0.90.9. The closest are Motion and Throb (+0.88+0.88), Snap and Distance (−0.87-0.87), Motion and Sway (+0.85+0.85) and Throb and Sway (+0.83+0.83): a sound that moves tends to move in every band, and a distant one tends to have a slow attack.
  • The wirings: over the presets where both reach alone, how often their predicted movements are one gesture (∣cos⁡∣>0.8\lvert\cos\rvert > 0.8, what separate checks). Only Space and Distance do on more than half (9 of 14): where a patch can reach Space at all, it is through the release, and Distance turns it too. A first Heft that also carried −-crest was Thump turned down on two presets in three (both turned the amp envelope, opposite ways), and was changed to the definition above.

How often each reaches

Each control wired on each of the 62 presets, on the engine the shipped wirings are measured under. Alone is the control wired by itself: whether this patch has knobs that move it, purely and verifiably. Beside the six is the control wired after the six, so a control whose gesture on a patch is one of theirs (∣cos⁡∣>0.8\lvert\cos\rvert > 0.8) is a search control there: whether it adds a gesture the panel does not have. Both halves is both directions open after verification.

FamilyControlAloneBeside the sixBoth halvesMedian purityMedian reach (σ)
ToneBright79%79%74%0.811.64
ToneWarmth73%52%26%0.700.57
ToneAir66%48%35%0.600.47
WeightBody53%32%21%0.540.68
WeightThump81%40%27%0.701.55
WeightHeft76%52%19%0.650.77
DynamicsSnap82%82%32%0.991.56
DynamicsPunch73%60%15%0.890.92
DynamicsRound82%44%34%0.741.18
MovementMotion77%74%26%0.981.91
MovementThrob47%29%6%0.751.80
MovementSway55%34%5%0.942.67
SpaceSpace23%23%3%0.982.18
SpaceDistance94%39%32%0.551.16
SpaceHaze90%63%35%0.691.49
CharacterGrit11%6%11%0.580.29
CharacterBite81%58%37%0.730.72
CharacterLo-fi56%34%23%0.560.91

Purity and reach are medians over the presets the control reaches alone. For the six, “beside the six” is the panel as it is today: Body and Grit lose patches to the gestures of the controls wired before them. Alone, a median preset is reached by 12 of the eighteen (5 at least, 16 at most), against 3 of the six.

Read the table with one caution. Purity measures cross-talk against the named axes, not whether a control moved every coordinate it names, so a direction is reached through whichever of its coordinates the knobs move. On a patch where nothing moves the flatness, Lo-fi is reached by a duller top and flutter, and Distance (94%) by a softer attack and a duller top, since few patches can move their tail (Space reaches 23%). Throb and Sway rarely open both halves: a sound that is still cannot be made stiller.

What it costs

A measurement is n+1n + 1 renders for the Jacobian, shared by every control, then four per reachable control and two more for each retry. Wiring eighteen instead of six multiplies only the arithmetic (the solve per control takes microseconds) and the verification renders.

Per presetRenders, medianMeanMostNative s, meanwasm s, mean
The Jacobian alone1212.123
The six, today2526.2425.97.5
All eighteen4949.88411.214.2

The render counts are over all 62 presets (palette_census). The native times are from the same run, three presets at a time on an Apple M3 Max shared with other work, at 224 ms a render (173 ms in a quieter run). The wasm times are palette_cost.mjs under node, on one thread as the engine worker has, over every fourth preset (16), at 265 ms a render. That run boots its own pool from the same seed, and in wasm the pool comes out differently from the native one, so its counts differ a little per preset (median 28 renders for the six, 60 for the eighteen).

A control that reaches costs about five verification renders: 4.7 for the six, and 5.0 for the eighteen wired in palette order, where 7.5 controls reach a preset on average. So the eighteen double today’s measurement. The panel never needs all eighteen. To keep within today’s budget:

  1. Wire all eighteen by prediction from the one Jacobian the six already pay for. That is arithmetic, and enough for each palette entry to preview what it would do, drawn as a guess (ADR-012): verification closes some predicted reaches (Throb predicted on 71% of presets alone, verified on 47%).
  2. Verify only the placed controls, at most eight. A panel of the six and two of the twelve adds one or two controls that reach (the twelve reach a mean 46% of presets beside the six, 73% alone), so five to seven renders, a fifth to a quarter more than today’s measurement.
  3. Verify a control placed later from the memo: the Jacobian is already there, so it costs only its own four renders, and two more if it is retried at half travel.

The app does the second and the third: it measures the panel’s set and nothing off the panel, on the sound in hand, when PERFORM is in sight (measurePanel in perform.js), and the engine’s memo holds the Jacobian from the six’s measurement. The first, a preview of every palette entry by prediction, is not built (below).

The shipped preset wirings stay the six’s. With the twelve’s wirings beside them, apps/web/perform-wirings.json would weigh 391,740 bytes instead of 173,171 (2.3 times), fetched by every visitor for controls the panel does not show yet.

Names

Three of the prototype’s names are changed, and the maintainer approved them with Heft and the end words below on 2026-10-01 (voice):

  • Round (hard · round) for Softness (hard · soft): the voice’s word table already defines round as “a soft attack, few harmonics”, which is this direction.
  • Throb (steady · throbbing) for Wobble: Wobble Board is a preset, and a control of the same name reads as that sound. Throb is the tremolo module’s own tag. Pulse was the other candidate, and it is a wave shape.
  • Sway (fixed · swaying) for Drift: drift is already WANDER’s zone and the walk behind it (Engine::drift). Breath would read as Air’s breathiness, and Sweep Machine is a preset.

Heft (slight · heavy), the twelfth, is new. The four have their rows in the voice’s word table.

Drift: a local walk on the live knobs

Engine::drift samples the same target

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

restricted to this patch’s shape: every structural and categorical address, every continuous site without a live handle, and the player’s own locks are held fixed; the walk moves only live_knobs. Holding sites fixed in a Metropolis–Hastings walk is exact conditioning (Locks as conditional refinement), so the walk targets πβ(v∣xL)\pi_\beta(v \mid x_{\mathcal{L}}) over the knobs the voices can take live. Structure cannot change under the player’s hands, and nothing the walk moves needs a recompile to be heard.

The kernel is not refinement’s. Each step picks one free knob uniformly and proposes

v′  =  reflect⁡[0,1) ⁣(v+σ ξ),ξ∼N(0,1), v' ;=; \operatorname{reflect}_{[0,1)}!\big(v + \sigma,\xi\big), \qquad \xi \sim \mathcal N(0, 1),

accepted with probability min⁡ ⁣(1, πβ(x′)/πβ(x))\min!\big(1,\ \pi_\beta(x')/\pi_\beta(x)\big). The reflected Gaussian is symmetric, so there is no Hastings correction (Engine::drift). Refinement’s kernel, fugue’s adaptive single-site MH, starts every fresh chain at a step of scale 1 on each site and adapts it only after ten proposals there, so on a unit-interval knob each step of a short walk is a Gaussian as wide as the whole range, reflected back into it: close to a fresh draw. An offer of 8 steps with every site but the live knobs locked moved its farthest knob 0.36–0.68 of its range over the table’s 12 presets (its last row). A drift should wander, and how far is the Wander dial’s to say.

WanderStepsσ\sigmaFarthest knob moved (12 presets)
drift, at its start80.050.06–0.14
drift, at its top180.080.15–0.33
roam, at its top400.150.25–0.61
refinement’s kernel, the live knobs alone81, at first0.36–0.68

(cargo run -p auracle-session --example drift_distance --release.) Within each zone the pace rises linearly with the dial (wanderPace, in perform.js); roam starts at 24 steps and σ=0.10\sigma = 0.10. The walk returns its end state, or nothing if it ends where it started. Like refinement, a short walk’s end state is local movement on πβ\pi_\beta, not a draw from it (What is not sampled). Nothing enters the pool: a performance gesture is not a candidate until the player keeps it.

The wiring survives a small drift. The named controls are a linear model measured with 0.08 knob steps, so the instrument re-measures them only when some knob has left a 0.12 neighborhood of where they were measured (TRUST, in perform.js). A gentle drift usually stays inside and costs nothing; before, every glide was followed by a full re-measure of about 46 renders, which in drift kept the worker busy much of the time and queued offers behind it.

The instrument then glides the knobs from where they are to the returned values along a smoothstep, v(t)=v0+(v1−v0)(3u2−2u3)v(t) = v_0 + (v_1 - v_0)(3u^2 - 2u^3), over 2 to 6 seconds. The intermediate states are interpolations, not states of the walk. They are all valid patches, since the structure is fixed and the knob box is convex. A touch stops the glide where it is.

Offers

Engine::offer is the same walk with only the player’s locks, so structural moves are allowed: it may add, remove, or replace a module. It is also non-inserting. The instrument asks for 20 steps (40 in roam) and plays the result in the B slot, never as a jump. The Offer pad and Wander ask for this walk. A search control released more than 0.30.3 from its center (ASK_AT, in perform.js) asks for an aimed one (below), and springs back without moving a knob.

A search control’s offer is aimed

Engine::offer_toward walks the same kernel on the target tilted along the control’s direction e^\hat e, turned up (s=+1s = +1) or down (s=−1s = -1):

πγ(x)  ∝  pgrammar(x) exp⁡ ⁣(β (f(x)+γ s e^⊤z(x))), \pi_{\gamma}(x) ;\propto; p_{\text{grammar}}(x), \exp!\Big(\beta,\big(f(x) + \gamma, s, \hat e^\top z(x)\big)\Big),

where ff is the taste surrogate E[uθ(φ(x))]\E[u_\theta(\varphi(x))], or VetOnlyFitness before any fit, and γ\gamma is AIM_GAMMA =1= 1. At β=2\beta = 2, a proposal one σ further the asked way gains as much log-weight as one unit more of expected utility. TiltedFitness reads zz from the memo entry the inner fitness has just written, so the tilt costs no render, and a proposal that does not vet keeps its quarantine score untilted.

Twenty single-site steps rarely propose the one module that makes a patch rough (a noise source, a crusher), and the tilt cannot make a proposal the kernel never draws. So a walk that has not moved at least REACH_FLOOR the asked way,

s e^⊤(z(xend)−z(x0))  <  0.15, s,\hat e^\top\big(z(x_{\text{end}}) - z(x_0)\big) ;<; 0.15,

keeps walking from the state it stopped in, up to AIM_WALKS =3= 3 walks of the asked steps. It is one longer chain that stops as soon as it has arrived, not a filter: nothing grown is thrown away, which is what the rejected alternative (grow several undirected offers and keep the one that moved most) would have done.

The reply carries moved =e^⊤(z(offer)−z(home))= \hat e^\top\big(z(\text{offer}) - z(\text{home})\big) (Engine::moved_along), and B’s strip prints s⋅s \cdot moved in amber: grittier by 1.8σ. Below half of REACH_FLOOR (AIM_SAID, in perform.js: the line a verified half must clear to be said to turn that way), it says the walk did not get there, not grittier: this walk found no way there, and invites another turn.

The census. γ\gamma and the walk count were chosen by make offer-census (offer_census): 16 presets (every third in the bank), every control PERFORM draws as a search control on each (verified, as the app wires them: Grit on 14, Body on 14, Space on 12, and a few of the rest), turned both ways, two seeded offers of 20 steps a walk per arm, the same seeds in every arm so the arms are paired and 0×10{\times}1 is the Offer pad’s walk itself. The session is a filled pool (its standardizer) with a taste taught by the synthetic listener search_health climbs toward, which dislikes noise; the prior rows are the same session before any fit. A move counts when it is at least REACH_FLOOR the asked way; R∗=0.95R^\ast = 0.95σ is the median verified reach of a control that does reach, on the same presets.

γ×\gamma \times walks≥ floor, taught≥ R∗R^\ast, taughtmedian ΔE[u]\Delta\E[u], taught≥ floor, priorGrit up ≥ floor, taught / prior
0×10 \times 1 (Offer pad)22%13%+0.9223%7% / 14%
0.5×10.5 \times 126%16%+0.8431%14% / 35%
1×11 \times 134%23%+0.7435%17% / 35%
2×12 \times 139%22%+0.6336%25% / 32%
4×14 \times 140%23%+0.6238%32% / 32%
0×30 \times 332%18%+1.7241%17% / 32%
1×31 \times 3 (shipped)46%28%+1.3353%46% / 64%
2×32 \times 348%28%+1.1854%50% / 71%

(192 offers per arm. ΔE[u]\Delta\E[u] is the offer’s expected utility less the patch it grew from; the pool’s spread of E[u]\E[u] is 0.72.)

Reading it: the tilt buys most of what it can by γ=1\gamma = 1. Past that, more aim buys a few points of the floor, none of R∗R^\ast, and a lower E[u]\E[u], so 1 is the smallest γ\gamma at which the curve has flattened. The walks do more than the tilt for the control that needs them most. Grit turned up went from 7% (Offer pad) to 17% with the tilt alone and 46% with three walks, after a taste that dislikes noise, and from 14% to 64% before any fit. Continuing without the tilt (0×30 \times 3) does less than continuing with it (32% and 41% of all asks, against 46% and 53%). The taste cost is bounded: every arm’s median offer still climbs E[u]\E[u].

Some asks have nowhere to go, and no γ\gamma changes that: Grit down and Space down moved in none of 28 and 24 trials, because a patch with no noise sits at the floor of flatness and one with no tail at the floor of tail_ratio. B says not smoother then, which is the truth. Space up reaches 29–45%; the instrument asks for it only when the release graft did not reach.

What it costs in time. The tilt costs no render; the extra walks do. Timed natively on a shared four-core machine (offer_census --timed, each offer from a fresh memo holding only the patch it grew from, as in the app), one tilted walk took 5.1 s on average against 5.1 s for the untilted one, and the shipped offer, which walks again only when it has not arrived, 15.6 s against 6.0 s for the Offer pad’s. The instrument counts the seconds in B while it grows. Growing it on the render farm, and starting both ways at pointer-down, would take the wait off the player’s hands; that belongs to the farm’s walk path and is not built here.

Whether it holds the engine. It does not. A walk is a chain of steps, and a step costs at most one phrase render (about 0.15 s natively, 0.3 s in wasm under node on the machine this was timed on, offer_cost), so the engine worker runs an offer or a drift one step at a time and answers the player’s picks, Keeps and plays between steps. An offer you did not ask for (the spare grown while you play) gives way to one you did. Cutting a walk up does not change what it finds: for a seed, the offer is the same tree whatever the step sizes (the walk is the same code, run in pieces), and what you do between steps does not move it, because it keeps the target it began on and draws from a generator of its own.

Before any evidence: VetOnlyFitness

With no posterior there is no utility to climb, and the first version of PERFORM returned nothing for drift and offers until the player had made picks. But the posterior before any evidence is not undefined. It is the prior.

With a zero-mean prior on θ\theta and a single style, the prior expectation of utility is E[θ]⊤z=0\E[\theta]^\top z = 0 for every patch, so the target is pgrammarp_{\text{grammar}} itself. VetOnlyFitness writes that down, with the vetting gate kept:

f(x)  =  {0x vets−50otherwise (QUARANTINE_FITNESS)⟹πβ(x)  ∝  pgrammar(x) eβf(x). f(x) ;=; \begin{cases} 0 & x \text{ vets} \ -50 & \text{otherwise } (\texttt{QUARANTINE_FITNESS}) \end{cases} \qquad\Longrightarrow\qquad \pi_\beta(x) ;\propto; p_{\text{grammar}}(x), e^{\beta f(x)} .

At β=2\beta = 2 an unvetted term is down-weighted by e−100≈4×10−44e^{-100} \approx 4 \times 10^{-44}, so πβ\pi_\beta is pgrammarp_{\text{grammar}} restricted to listenable patches, to any precision that matters. The proposal distribution is the plain grammar prior too, since the taste tilt needs a posterior.

Every drift and offer reply carries whether it was taste-directed (Engine::has_taste), and the instrument says which: the line under Wander reads drift · gliding · no taste yet while a grammar-drawn drift glides, and drift · gliding once there is a taste to walk toward. Wiring the named controls needs no taste at all, only a standardizer.

The B slot

An offer is heard through a second LivePoly in the same AudioWorklet. It receives every note-on, note-off, bend, glide, unison, and arpeggiator message the first one does, and notes held when it loads are replayed into it, so it joins a chord already sounding. It renders continuously while loaded, so its envelopes and tails are in step with A when the mix moves.

The mix is equal-power, with the mix position mm smoothed per sample:

y=cos⁡ ⁣(π2m) yA+sin⁡ ⁣(π2m) yB,m←m+0.002 (m∗−m). y = \cos!\big(\tfrac{\pi}{2} m\big), y_A + \sin!\big(\tfrac{\pi}{2} m\big), y_B, \qquad m \leftarrow m + 0.002,(m^\ast - m).

The one-pole has a time constant of 500 samples, about 10 ms at 48 kHz, so a Peek is a gesture rather than a click. The gains satisfy gA2+gB2=1g_A^2 + g_B^2 = 1, which holds the summed power constant when A and B are uncorrelated. When they are nearly identical the sum rises by up to 3 dB at m=12m = \tfrac12, since cos⁡45°+sin⁡45°=2\cos 45° + \sin 45° = \sqrt2. An equal-gain law would have the opposite error, flat for identical sources and 3 dB down for unrelated ones. An offer is a different patch, so the equal-power error is the one taken.

B is loudness-matched. Its makeup gain is the one loudness normalization computes for the offer, TARGET_LUFS minus the phrase’s measured lufs_before, so the offer’s standard phrase plays at −18 LUFS. The gain is clamped only to the range the voice accepts (MAKEUP_MIN_DB to MAKEUP_MAX_DB, −24 to +60 dB, in auracle-wasm’s live.rs). It is the same makeup every live patch gets (live_makeup, in level.rs). It used to be clamped to ±12 dB, which left 43% of a fresh pool outside the clamp; a leveler on the summed polyphony now catches a held note that grows louder than the phrase predicted. Without the match a crossfade would mostly compare levels, and the louder side reliably wins. The match is made on the standard phrase at each patch’s own settings; a named control turned on A afterward changes A’s level without re-normalizing it.

Which way your taste leans

Under the model view (hold ⌥, or MODEL), each control carries its lean: how the model’s rating of the sound in hand changes as the sound moves along the control’s direction. With z0=z(x)z_0 = z(x) the sound’s standardized φ\varphi and e^\hat e the control’s unit direction, extended to all dd coordinates with zeros on the structural block (turning a control moves the sound, never the patch’s module counts), the lean of one posterior draw is the derivative of its utility along e^\hat e:

ℓ=∂∂t u ⁣(z0+t e^)∣t=0=θk⋆⊤e^,k⋆=arg⁡max⁡k θk⊤z0.\ell = \frac{\partial}{\partial t}, u!\left(z_0 + t,\hat e\right)\Big|{t=0} = \theta{k^\star}^\top \hat e, \qquad k^\star = \arg\max_k, \theta_k^\top z_0 .

The utility is a maximum of experts, piecewise linear, so its slope at a sound is the slope of the style that claims that sound, and k⋆k^\star is chosen per draw: a draw in which another style rates the sound highest answers with that style’s θ\theta. It is not the mean θ\theta of one style projected on e^\hat e, which would describe a style that may not be the one rating this sound. Far enough along e^\hat e another style can take the sound over, so a lean says what happens near the sound, not along the whole control. Which style a draw names does not depend on how the styles are labeled, so no alignment is needed.

The instrument is sent the importance-weighted mean and standard deviation of ℓ\ell over the draws (TastePosterior::slope, through Engine::lean; in utility per unit of zz moved toward the control’s high word), and draws it on the control’s dial: an arc from 12 o’clock toward the end it leans to, its ±1\pm 1 SD interval behind it, every control of the panel on one scale. A lean whose interval crosses zero (∣mean∣<SD\lvert \text{mean} \rvert < \text{SD}) is drawn as a guess, the rule LEARNING’s weights and PATCH’s worth chips use. Before the first fit there is no posterior and perform_lean answers null: nothing is drawn. Opening a taste file takes the posterior away until it is fitted again from the file (a file with nothing taught in it asks for no fit), and the leans go with it.

The lean is taken at the tree PERFORM measures, with no overrides. That tree's render is in the engine's memo once PERFORM has measured it (the measurement renders it first) or the bench has opened it, and then the lean is a few dot products per draw; a tree not in the memo costs one render, as ▶ does. A control turned since is not part of it, and it is asked again when the view comes up, the sound or the panel changes (for a sound with an AUDIO IN, a new audition clip of an input it reads too, since it is measured through it), and the posterior moves (a refit, a pick’s reweighting, a taste file opened), never per frame.

What is not done

  • An aimed offer is aimed at a direction, not at a sound. A search control’s offer tilts the target along e^\hat e (above); it does not aim at a reference sound. Breeding a generation toward a recording is a sound of your own, which the engine has and PERFORM's offers do not use. Nor is the aim itself tuned per patch or per player: repeated turns the same way do not ask harder, and Wander’s offers are not aimed at the last control turned (RFC-002’s open questions).
  • The palette doesn’t preview. A palette entry not on the panel is not measured, so the palette shows nothing of what it would do to this sound. The preview by prediction from the Jacobian (the first of the budget’s three points, above) needs the unverified wiring (wire_set) exposed to the page, and is not built. The twelve have no grafts yet, so a search control among them asks for an aimed offer only. The shipped preset wirings are the six’s, so a preset opened with a palette control on the panel plays the six at once and measures the rest.
  • The directions are fixed, not personal. The eighteen are the same for every player. A control along a fitted style θk\theta_k is a different and more interesting object, and not this one.
  • Most of what the player does here is logged, not fitted. Keep, Back, Take, each offer asked for, and each control turn are recorded as ImplicitEvents (perform_keep, perform_back, perform_take, perform_offer, perform_turn), and none of those events enters the likelihood. A Keep can mean I love this or stop drifting for a moment, and implicit signals stay out of the model until a fit using them can be validated against the explicit ones. The exception is an offer’s answer: once B has been heard for a second (HEARD_MS, in perform.js), Take, or asking for another offer, is recorded as a duel with the PerformOffer provenance, and it counts as a pick.
  • Combinations are not verified. Each control is verified alone, at v0v_0.
  • Motion hears one note. Its axis is built from the held note’s span, with the limits that implies.

Explaining a control: what a figure measures

A control's figure is the sound in hand rendered twice, the control at its center and turned, and each render measured the way φ measures it. The lesson on filters is the same sound rendered through a lowpass from the grammar, with that filter's response taken from its own impulse; while the cutoff moves, the page plays and draws a browser lowpass as a guess until that render lands.

PERFORM's controls are directions in φ, wired onto each patch's knobs by a measured Jacobian, so no control is a fixed effect: BRIGHT turns a filter's cutoff on one patch and a detune and an envelope on another. What a control does is therefore not something the app can draw from a recipe. It asks the engine. The figure (Plan-005 task 10, RFC-006 §9, ADR-012) draws what came back, and nothing else. This page is how.

The engine side lives in auracle_features::explain (the portrait), auracle_grammar::lowpass_response (the lesson's filter) and auracle_wasm's explain.rs (the bindings explain_render and lesson_filter). The page side is apps/web/explain.js; perform.js's explainOf says what to render, and words.js says what was measured.

What a figure asks for

When a player asks about the control at panel position ii (? with the pointer over it or keyboard focus on it, its ? chip, or a long press on a touch screen), PERFORM hands over the performed state twice, as knob overrides on the tree it is playing (explainOf):

  • made: every knob as it sounds now, but with control ii at c=0c = 0 (soundingOf with that control's value zeroed);
  • turned: the same with control ii at catc_\text{at}, where the player has it when ∣c∣≥0.05\lvert c \rvert \ge 0.05, else a full turn toward the end its wiring opens to (high, unless only the low half was confirmed on real renders). Absent for a search control and for one not measured yet: nothing turns them.

The worker answers explain in its soon lane, holding the floor, one render per turn with the player's gestures served between (breathe). Each render is WasmEngine::explain_render(tree, overrides, k): the performed tree featurized exactly as the pool is (featurize: render, vet, loudness normalization, φ), then its portrait, and where it measures on control kk's unit direction in σ (along, the same dot product as a wiring's position). A render that does not vet answers {error}.

Replies are cached by the page under the control, the tree, both override sets and the wiring's words (measured or not, a search control or not, the knobs it turns), so switching back to a control costs nothing; the page does not read along, so the model's revision is not in the key. One request is out at a time: a key asked for while one is out is not queued, and when the reply lands the answer is built again and asks for whatever its control is then, unless that control is still turning. An open figure follows its control: a turn, once it has rested for 300 ms (FOLLOW_MS; a reply that lands mid-turn and the page's 400 ms check both leave a turn in progress to it, and a different control switched to asks at once), a new measurement or a drift changes what explainOf returns, and the figure asks again. Measured in the browser, a three-second drag with an answer open sends at most two requests, plus one for each time the page itself stalled for longer than the rest, since a loaded page sees those gaps as rests (explain.spec.js). While they render, the last figure stays, dimmed, under measuring….

The portrait

Everything is measured on the normalized render (the buffer an audition plays), so two portraits compare as the model hears them: loudness matched, shape against shape. The constants are in auracle-features's explain.rs.

FieldWhatDrawn by
bandsThe long-term spectrum: every sounding frame's power (2048-sample Hann frames, hop 1024, as φ's), in 40 bands log-spaced from 35 Hz to 14 kHz, each the mean power density over its edges, in dB re the loudest band, floored at −60 (BANDS, LO_HZ, HI_HZ, FLOOR_DB)BRIGHT, BODY, WARMTH, AIR
heldThe held note's spectrum bin by bin up to 4 kHz (HELD_HI_HZ), C4's first fifteen harmonics and what lies between them, dB re its loudest binGRIT, BITE, LO-FI
onsetThe first note's level in φ's 4 ms attack windows, every 2 ms for 400 ms (ONSET_STEP_S, ONSET_S), re the note's peakSNAP, ROUND
levelThe phrase's level every 50 ms (LEVEL_STEP_S), dB re its RMS, with each note's gate (notes)SPACE, DISTANCE, HAZE, PUNCH, THUMP, HEFT
bright, loudThe held note's spectral centroid (Hz, magnitude-weighted as φ's) and frame level (dB re its loudest frame), frame by frame at a 512-sample hop from its onset to its gate closingMOTION, THROB, SWAY
factsφ's own coordinates of the render in their units (Facts)the sentence

bands is a face's band layout (BANDS, LO_HZ, HI_HZ and FLOOR_DB are face.rs's FACE_*, one definition, and band_edges_hz is face.rs's), so a figure's spectrum and the sound's face line up band for band: the portrait carries the render's own face too (Portrait::face, Face::of_f64), which a_sine_lands_in_its_band holds to the bands within a face's 0.5 dB step. The page draws the face (vessel.js drawVessel, whitened against the bank's faces) for BRIGHT, BODY, WARMTH and AIR and the lesson's shape, the as-made one dashed by its outline; with under four faces in the bank there is nothing to whiten against, and it draws the spectrum itself, which the lesson then calls "its spectrum". response_bands keeps its own band weighting, since an impulse response's transform is not a face's frame length.

Facts reads φ's raw audio coordinates back into units a sentence can say: centroid_mean, rolloff_mean and zcr_mean from φ's log axis to Hz; attack_s to ms; crest and high_ratio to dB; tail_ratio to the last 300 ms against the phrase in dB (floored at −60, φ's own floor); rms_mean to dBFS; bass_fraction to %; held_centroid_std from φ's log axis to octaves (one unit of that axis is the span from 20 Hz to Nyquist, about 10.1 octaves at 44.1 kHz, so it is multiplied by log⁡2(fNyquist/20)\log_2(f_\text{Nyquist}/20)); and the three motion bands from φ's 0.5log⁡20.5\log_2 of a variance to a standard deviation. That deviation is not in octaves alone: each band sums the variance of the held note's brightness, in octaves, and its level, in doublings (6 dB), floored at 0.01, so the sentence gives it as a number with both named, and its floor as none. facts_read_phi_in_its_units pins the inversions to Hz, ms, dB, %, octaves and the bands. flux_mean stays the index φ keeps, 0 to 1, said as one.

What each figure draws

Each control draws the measurement its direction is made of first, made dashed and turned lit (words.js FIGURE_OF), and its sentence says that coordinate made then turned:

ControlFigureThe sentence's measurement
BRIGHTspectrum, and the difference band by band (±24 dB)the center (centroid_mean)
BODY, WARMTH, THUMPspectrum; THUMP the levelthe share below 250 Hz (bass_fraction)
AIRspectrumwhere the top rolls off (rolloff_mean)
SNAP, ROUNDthe first 400 ms, each 90% crossing tickedthe attack (attack_s)
PUNCHthe levelthe peaks over the average (crest)
HEFTthe levelthe frames' level (rms_mean)
SPACE, DISTANCE, HAZEthe level, the last 300 ms shadedthe tail (tail_ratio)
MOTIONthe held note's brightness or level, whichever the turn moved more (an octave against 6 dB, φ's currency)the brightness's wander (held_centroid_std), in octaves
THROB, SWAYthe samethe 2–8 Hz or 0.5–2 Hz band (motion_mid, motion_slow), brightness and level together
GRIT, LO-FIthe held note's harmonicsflatness, in dB (flatness_mean)
BITEthe held note's harmonicsthe frame-to-frame change (flux_mean), an index from 0 to 1

The sentence's second half is the wiring's: the knobs it turns on this sound. For a search control it is the panel's own words (nothing here turns it, and turning it asks for an offer), with the made measurement alone.

What moves. A figure draws its turned measurement in along its own axis and holds: band by band from the bottom over two --d-move, or along time at the phrase's own pace. MOTION's runs the held note's measured track at its real rate, looping, while it is open. Nothing is interpolated between the two renders: no frame of any figure shows a setting the engine did not render. Under reduced motion every figure is drawn whole at once.

The lesson's filter

The lesson renders the sound in hand with lesson_filter, one call in soon per render: once as it is (no cutoff: the first step's shape and sound), and once for each cutoff the player drags to, one request out at a time with the latest cutoff waiting for it.

  • The filter is the grammar's own: an AudioNode::Filter of kind SvfLp at the knob cutoff, resonance LESSON_RESONANCE (0.345, a damping of 1.41 on quiver's filter: Butterworth, flat below the cutoff and 3 dB down at it), inserted on the output below any stereo effect that ends the chain (insert_at_output, the walk the Bright and Body grafts already use). The patch itself is never changed.
  • A patch with no room for one more module (the grammar's size ceilings: 16 of 150 pool draws of the shipped seed, none of the 62 presets) gets the same filter after the whole voice instead (placement: "after"): lowpass_apply runs quiver's Svf over the phrase's raw render, which is then vetted and normalized as featurize does every render. Nothing keytracks it there, so its corner is the held note's on every note, and the lesson says so. a_patch_with_no_room_gets_the_filter_after_it pins it. Measured by explain_lesson on those draws at a cutoff of 0.5: 133 rendered inside, 16 after, 1 silent.
  • When a render fails, the reply's error stands for its portrait (no_tree, silent, vet). The page says why in a sentence (words.js lessonTrouble), draws no curve or readout for that cutoff and plays nothing under through it; a cutoff that failed may render at another, so a drag still asks. When the sound itself fails, or its tree cannot be read, the lesson asks nothing more.
  • Its cutoff is the knob's corner on the held note, cutoff_hz: quiver's 20 · 1000^x Hz, which keytracking (KEYTRACK_AMT, half an octave per octave from C4) leaves unmoved on C4. PATCH prints a filter's cutoff knob in the same unit.
  • Its response is the impulse response of quiver's Svf driven as the compiler drives it (lowpass_response: the cutoff knob as is, the resonance knob through the compiler's mapping, keytracking at C4), 8192 samples, in the portrait's 40 bands (response_bands). lowpass_response_is_the_compiled_filter holds it to a compiled filter's gain on a sine at C4 within 0.25 dB, for four cutoffs and resonances.
  • Its sound is the filtered render's audition at the level every audition plays at (audition_pcm). Every render is normalized to −18 LUFS (TARGET_LUFS) as the pool's are, so a lower cutoff is heard darker, not quieter, and its spectrum is drawn against its own loudest band.
  • Before it lands, a guess. A render takes 0.2 to 0.5 s here and up to 2.4 s on the reference Air, one at a time, so on the filter's step the page loops the plain render through a Web Audio BiquadFilterNode (lowpass, Q −3.01 dB, which is Web Audio's unit for a Q of 1/√2, the Butterworth LESSON_RESONANCE sets), its frequency set in the move's own task from hzOfKnob, the page's copy of the grammar's map (the reply's cutoff_hz is the engine's, and the spec holds the two equal). Its level is lifted by an estimate, capped at 12 dB: the plain portrait's band power, each band counted by its width and by BS.1770's K-weighting as loudness.rs applies it, over what the browser's filter leaves of it. Measured at the output against the render on Reese and Glass Pad at six cutoffs from 100 Hz to 5.9 kHz, the two are within 0.8 dB. Its curve is the browser filter's own response (getFrequencyResponse), drawn dashed amber and marked not measured yet (ADR-012). When the render for the cutoff held lands, it fades in over the live path on a second looped source at the same place in the phrase, and its measured curve fades in over the guess (at once under reduced motion); the next move fades back. The browser's filter goes after the whole sound, with no keytracking, and the instrument's sits inside it, so the two can differ until the render lands. No guess is drawn at a cutoff that was refused, nor for a sound that never rendered.

What it costs

crates/auracle-wasm/examples/explain_cost.mjs times each binding on all 62 presets in the built package under node (V8, single-threaded, as the engine worker is), in CPU ms, median and maximum, on an Apple M3 Max shared with other jobs:

CallMedianMax
explain_render207462
lesson_filter233475
a plain render and φ (farm_render)199454

A portrait adds about 4% to the render it measures; a figure is two renders, about 0.4 s, and its reply is 5.4 KB of JSON (median). A cached figure opens at once.

Where the specimen and the engine differ

Prototype v2's explain.js drew each control as the effect it was in the prototype. The app's controls are not effects, so the engine was followed:

  • BRIGHT is not a filter. The specimen's figure is a lowpass response with its cutoff ("Here it cuts above 3.7 kHz"). The app draws the measured difference between two renders band by band, and says what moved and which knobs moved it: on Reese, a filter's cutoff; on another sound, something else.
  • SNAP, MOTION, GRIT and SPACE were an envelope, an LFO, a waveshaper's curve and a reverb's tail. The app draws the onset, the held note's brightness, its harmonics, and the phrase's level and tail, as rendered.
  • The patch with no room. The specimen's filter is a knob on its fixed chain; the app's goes after the voice on the one pool sound in ten that has no room for it, and says so.
  • The lesson's filter is added, not BRIGHT. The specimen's lesson drags BRIGHT; the app's adds the grammar's lowpass to a copy, so "BRIGHT turns a filter like this one, on any sound" became a sentence from this sound's wiring, and "Done puts BRIGHT back" became "Done leaves it as it was".
  • The shape is the face from four faces up. With fewer in the bank there is nothing to whiten against, and the spectrum stands in.
  • The keys play the sound without the lesson's filter: the lesson's sound is the phrase, looped, through the browser's lowpass and then the filtered render; the live voices are not given the filter.
  • A palette control's preview (the specimen's macroAnswer, a recipe over the six) is not drawn: each of the eighteen is its own direction, measured when placed.
  • Only the controls ask for now. The specimen also asks about a bank row's liking, the pair, the map's directions and the sound's face; those wait for their own tasks, and ⌘K's entries for the shell (Plan-005 task 2).

The model's guess: the next module

For the patch in hand, the model guesses which module you would add next. It renders up to eight of the modules that could go at the output, in the structural design's order, scores each by the posterior's draws of what it adds, and leads with the one whose lower bound is highest.

PATCH starts from nothing and grows a module at a time. The guess is the model's answer to "what next?": GUESS · FILTER at the output, with its reason in the model's italic. It lives in auracle_session::guess and reaches the browser as guess_plan, guess_rank, guess_skip and guess_take on WasmEngine. The design was chosen by measurement against synthetic listeners, in the design note docs/notes/suggest-2026-10/ in the repository; this page is what was built from it.

The candidates

Every edit the grammar allows that adds a module and takes none away, at three kinds of socket (guess_candidates):

  • the output: a processor inserted into the wire before the amp (StructOp::Insert at node), for each of the twenty processor kinds;
  • the root's modulation slot: a modulation source when the slot is empty, a shaper or combiner when it is filled (SetMod at node);
  • every empty socket: each of the six sources (Replace of a Silence leaf).

Each must pass validate_tree (the size, depth and modulation-depth ceilings), and no raw module count of φ_struct may fall while their sum rises. A patch that does not sound takes sources only: a processor over an empty socket is silent too. The seven presets measured (three of them in the table below) have 20 to 30 such candidates, and an empty patch has six. A deeper wire is ranked only when asked for by its module's key (at); the note found that deeper sockets add candidates the model rates highly by extrapolating, not ones a listener likes more, so the guess PATCH shows unasked looks at the output, and a deeper place is ranked only when the player asks what goes here? of a module (its ⋯, or Q).

At the grammar's ceiling there is no candidate, and the guess says so (full).

The score

Each candidate is rendered on the audition phrase and standardized, zcz_c. The patch is zpz_p, its own standardized φ, or zp=0z_p = 0, the pool's average sound, when it does not sound. Over the posterior's weighted draws θ(s)\theta^{(s)}, with the mixture utility uu, the gain is

g(s)  =  uθ(s)(zc)−uθ(s)(zp),gˉ=∑sws g(s),σg2=∑sws (g(s)−gˉ)2, g^{(s)} ;=; u_{\theta^{(s)}}(z_c) - u_{\theta^{(s)}}(z_p), \qquad \bar g = \sum_s w_s, g^{(s)}, \qquad \sigma_g^2 = \sum_s w_s, (g^{(s)} - \bar g)^2 ,

and the candidates are ranked by the lower bound gˉ−σg\bar g - \sigma_g, best first, ties in the candidates' order. Each guess also carries the pick forecast p=Pr⁡(you’d pick the patch with it over the patch)p = \Pr(\text{you'd pick the patch with it over the patch}), the same number the app shows beside every prediction with its sure word.

The top guess's lower bound can be negative. The list leads with the best bound, not with a module the model is sure will help: in the native run measured below, 12 of the 18 first guesses had a bound under zero, with forecasts from 49% to 76%. The words a guess is shown with have to allow for that.

The lower bound was the best criterion the note measured at both stages: after the warm start a listener would pick the patch with the guessed module over the patch 66% of the time, and the guess truly helps 71% of the time; after 78 picks, 70% and 78%. A random module is picked 51% of the time, and the grammar's own favorite 54%. The mean and the forecast trail the lower bound by 5 to 7 points at the warm start, where a few picks leave the posterior wide.

There is no trust region. A guess may sit far from every sound the player has heard: after a warm start that picked the three brightest presets, the first guess in an empty patch is noise, 4σ rougher than the pool's average, at 73%. The maintainer decided the lower bound is the guard for now.

Render order, and the floor

Rendering is the whole cost, so the candidates are rendered in the order the structural design would rank them: by the posterior mean of the patch's audio φ beside the candidate's structural φ, which needs no render (guess_plan). A crew that stops early has rendered the likeliest.

GUESS_FLOOR (8) is how many the engine worker renders when there is no render crew, one per turn with the player answered between them; GUESS_BUDGET_MS (3 000) is how much rendering it spends before it ranks what it has. The budget counts render time only, not the time the worker spends on the player's requests between renders, and it is checked after each render, so it can run over by one render. The note measured the first eight in this order (its dout8) against rendering every candidate: 66% against 66% picked at the warm start, 69% against 70% after 78 picks. On a crew the plan asks for all of them.

PatchCandidatesRenders, floor / allCPU s native, floor / allCPU s wasm, floor / all
An empty patch66 / 60.5 / 0.50.7 to 0.9
Sub & Sparkle208 / 201.4 / 3.31.9 / 4.7
Hornet308 / 301.4 / 5.21.9 / 6.6
Ceiling, the heaviest308 / 303.3 / 12.24.0 / 14.1

One render's CPU time is a median 184 ms natively and 251 ms in wasm under node. The plan and the ranking render nothing and cost 1 to 17 ms together. Measured on 2026-10-01 on an Apple M3 Max that other jobs were sharing, by crates/auracle-session/examples/guess_cost.rs and guess_cost.mjs in auracle-wasm, after both warm starts the note used; the native run starts each patch from a memo that holds only the patch. With no crew, the floor fits the 3 s render budget in wasm on every preset measured but the two heaviest (Deadfall and Ceiling, 3.3 to 4.2 s), where the worker ranks the seven or so it rendered in time. On a crew of six, every candidate of a preset would take about five renders' time, under 2.5 s (derived, not measured).

Why

A guess's reason is the largest part of its gain under the style most responsible for its sound, with Δz=zc−zp\Delta z = z_c - z_p and that style's posterior mean θˉ\bar\theta (GuessWhy::part). The parts considered are:

  • each of PERFORM's six named directions e^\hat e: the gain's projection on it, (θˉ⊤e^)(e^⊤Δz)(\bar\theta^\top \hat e)(\hat e^\top \Delta z), named by the end word the guess moves toward ("it moves toward dark"). The projection is positive only when the move and the lean point the same way along e^\hat e, so the word names where the gain comes from. Summing θˉjΔzj\bar\theta_j \Delta z_j over the direction's coordinates would not: for Bright, a centroid that rises while the rolloff falls further reads "toward dark", and a taste for the centroid alone gains from the rise;
  • each structural coordinate the guess changes, θˉjΔzj\bar\theta_j \Delta z_j ("more drive").

The largest positive part is the reason, with how far the guess moves along it in σ; a move under 0.005σ, which would print as 0.00σ, is never a reason. When no part is positive, no part leans the player's way, and the guess has no reason (why is null). For a patch that does not sound, the gain is over the pool's average, but the reason measures the move from the average sound with the patch's own structure, so it can name only what the guess changes, never the amp envelope an empty patch keeps.

A skip, and an undo

The ranking is a pure function of the tree, the posterior and the standardizer, so what a player has turned down has to be remembered outside it (GuessMemory):

  • A skip keeps that module's family away from that socket, for this patch. The model cannot tell a wavefolder from a bitcrusher: both are one coordinate of φ, n_drive. Skipping one and being offered the other would be the same guess again. A socket is named so that it outlives other edits: out, the output; mod:<uid>, a module's slot; in:<uid>, an empty socket; wire:<uid>, a deeper wire.
  • Undoing a taken guess counts as a skip. Taking a guess is an ordinary structural edit, so ⌘Z undoes it, and the tree it returns to would rank the same guess first again. When the patch in hand comes back to the tree a guess was taken on (compared by content, as patches always are), the guess is skipped. Opening a patch (another, or the same one again) or closing the bench forgets the guesses taken, so a later edit undone back to an old tree is not taken for an undo of a guess.
  • Only a current guess is taken. A guess is checked against the patch as it is when it is taken (guess_is_current: the same edit, socket and family among its candidates now), and refused with a reason otherwise: a source ranked for an empty socket, sent after the player filled it, would wipe what they placed. The check renders nothing.
  • Per patch: skips are kept by the pool id the patch was opened from, for the session; an import starts with none. Keep as new carries them, and the guesses taken, to the new sound: it is the same patch the player is working on, and what is skipped after the commit is filed under the new sound.

Neither a take nor a skip is evidence. Both are logged in the implicit stream, as a revert is, and stay out of the likelihood: a skip is confounded with curiosity, and keep as new already records what the player thought of an edit.

When it is computed

  • Nothing before the warm start. With no fitted posterior there is nothing to guess from (no_taste).
  • On a structural edit, a refit, or the player asking: never per knob step. Every candidate contains the patch, so a turned knob changes every render. A ranking carries observations, the count the posterior holds, so a page can tell one from before a refit.
  • Deterministic. The plan and the ranking draw no randomness, and the memo only saves renders (a hit is bit-identical to a miss), so a seeded session guesses the same whichever order the renders land in (one random stream per consumer).
  • Keyed by stimulus. A render's memo key is the content address under the engine's phrase, and each job's persistent key begins with the render namespace (farm_key). A row measured under another stimulus or another build is never absorbed, so it costs a render, never a wrong φ.

The engine worker

The worker answers guess in its later lane, holding the floor (no other long job starts while it runs; the player is answered between its steps), in two phases:

  • On a crew. It plans first: a refusal (no_taste, full) or a guess the memo already holds raises no crew. Otherwise, where a walk crew can be had (raised on demand as a generation raises one: not while a generation or ⚡ walks; while boot's own crew is filling the pool, the guess waits for it to finish, as a generation does), it plans every candidate (limit 0) and hands them out, one farm_render per idle worker, absorbing each result as it lands (memo_absorb, which refuses a row measured under another stimulus). It stops after GUESS_BUDGET_MS of wall-clock time, counted from when the crew is up. It renders nothing on the engine worker.
  • The floor. Then it renders with memo_render what is still owed of the first GUESS_FLOOR in order, one per turn: nothing after a crew that rendered them, everything with no crew (at width 0, ?farm=0 or a machine with one core, the page answers the request for a crew with none).

It ranks over every candidate when a crew ran, over the first eight when none did, and posts the ranking with the tree it ranked, or the error if the engine failed. A taken guess is edit_structure with the guess attached (guess_take), and a skip is guess_skip.

In PATCH

PATCH (apps/web/patch.js) asks for a guess once the bench has settled after an open or a structural edit, after each refit, and after a skip, only while PATCH is in sight, and never after a knob turn alone. A ranking made on a structure the bench has since left is dropped and asked again. It shows the first guess of the ranking:

  • GUESS · REVERB over the rack, with the reason in the model's italic (why: it moves toward still, as your picks lean, or your picks lean toward more reverbs for a structural coordinate, or no part of it leans your way when why is null), then the forecast p with its sure word, over your pool's average when against is pool. When the lower bound is under zero the line ends it may not help.
  • The module at its socket, dashed amber: above the cable an insert would cut, over the empty socket a replace would fill, under the module a set_mod would plug, with a dashed lead to that place. It is kept in the camera's view: with no room above, below the chain; with no room there, at the view's top. The catalog marks the guessed kind.
  • Add (a click, or Enter) sends the guess through the edit lane as edit_structure with guess; a refusal (the patch changed after that guess, so it was not placed) is said as any refused edit is, at once. Skip (its ×, or Delete) sends guess_skip and shows the next.
  • Refusals in words: no_taste and no_patch draw nothing; full says no guess: nothing more fits, so a module has to come out first; unmeasured says no guess yet: it hasn't heard this patch; a ranking with nothing in it says every module that fits was skipped, or that none passed the safety check.

Keep as new files later skips and takes under the kept sound's id (WasmEngine's guess_key, moved by the commit), not the id the bench was opened from, which a later commit's comparison still plays against.

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

In the app, a pair is chosen from the sounds that have arrived while the pool fills, at once. In a session opened with a seed in the address (?seed=) whose pool fills at boot, it is chosen from the sounds the fill's schedule names instead: the first four deals from the first 8, 16, 24 and 32 sounds of the pool, every later one from all of it, so a seed deals the same pairs while the pool fills at any machine speed. A session restored with its pool full deals from all of it from the first (the runtime). The measurement below deals from the whole pool.

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 backward.

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. Its levers are SessionConfig fields, with defaults in engine.rs:

LeverConfigDefault
Bias duels toward patches the player 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 sounds the player will pick. 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, and a pool of 40 to 48 patches 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. Every ▶ plays a pre-rendered, vetted, normalized buffer. The live keys play an edit before its check, and a failed check mutes them (below)
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 defense in depth.

voct_to_hz is clamped to ±32 octaves as of quiver-dsp 0.3.0 (Auracle pins 0.4.0), so the overflow Q198 recovers from can no longer be produced by pitch CV at all; what remains at the clamp is finite aliasing garbage, which the vet gate quarantines like any other.

Layer 1: the vetting gate

No candidate is ever auditioned unvetted. Every ▶ 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 “no audition is unvetted” true by construction rather than by discipline: there is no second path that could skip the check, because there is no second render.

The live keys play an edit before its check

The gate stands between a patch and every ▶. The keys are another path: the live voices (LivePoly, in the AudioWorklet) play the sound you're playing, and they take an edit before the engine has rendered and checked it, so that a turn is heard as it is made. What reaches them early, and what happens when its check fails:

EditReaches the voicesA failed check
A knob, the octave, a wavetable positionAt once, as a parameter written into the running voicesThe voices are muted until a check passes, and the alarm says so
A structural edit (a module placed, removed or rewired)As a new tree, as soon as the engine has made the edit (tree_json), at the previous tree’s makeup until the check’s reply corrects itThe same mute
An undo or a redo; PERFORM’s TakeAs a tree before its render, at the makeup it was measured at (an undo or redo lands on a tree measured when it was made, edit_known_makeup; a Take on the offer, measured as it grew). An undo or redo of a tree no longer remembered waits for its renderThe same mute
Any other selector (a VCO’s wave, a filter’s mode)With the check’s reply, after its render, at the makeup the render measuredNever reaches them: the voices keep the tree before it

So an unchecked edit can sound for as long as its render takes (a fraction of a second, longer while the engine is busy) before a failed check mutes it. Everything the voices play passes the master brickwall on the summed polyphony and the leveler in front of it (live.rs), which bound the level that reaches the output, not the sound. A selector waits because only its render measures the level it plays at: sent early, the previous tree's makeup was off by more than 3 dB on 46% of the presets' selector changes, up to 27 dB hot (crates/auracle-wasm/examples/selector_makeup.rs).

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

The keys play an edit before its check (above), so what bounds the live path is the patch itself. The compiled output chain is mandatory:

⟨audio⟩→DC blocker→VCA→Limiter→StereoOutput\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. It sits 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 the 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 warning classes the compiler uses on purpose. 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 that cannot be interpreted. 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 (limiters, LUFS normalization to a peak ceiling, vetted auditions, a mute when an edit's check fails), but the output level is ultimately the player’s. An edit at the keys plays until its check fails, which is a fraction of a second or longer (above). Nothing stops a limiter-bounded signal from being turned 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, and the audition clips (the session's, and each input slot's own)
BankEntryA patch’s tree (with any CAPTURE take in it), id, origin, name, pinned flag, and unjudged when it was kept as new and has not been in a pick yet (left out when false). Renders and features are re-derived on import. A sound kept aside because its only take couldn’t be read is a bank entry too, written back JSON-equal to what was loaded
ObservationLogEvery Feedback with its session index and raw φ\varphi by name
ProfileThe log plus the standardizer: the portable unit
TastePosteriorA snapshot, kept with the session (SessionState::fit: its draws, at most 500, and their weights, with how many observations it was fitted on), so a restore fits nothing. Recomputable from the log, and recomputed when the log or the standardizer comes back changed
OwnSoundA sound of your own: its name and the raw φ\varphi of the coordinates its file measures, by name. Never the audio

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.

A saved session does keep it, as a cache: a refit costs seconds on a slow laptop, and a restore that refitted held the first pair's sounds behind it. It is installed only when the log and the standardizer came back exactly as they were saved: a migration, a repair or a merged session sets it aside, and the session is fitted again after it loads. So it never disagrees with the evidence it is restored beside. A session saved before it was kept loads the same way, with no posterior until that fit.

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 player 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=z⋅s+μ\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
BankEntry::unjudgedfalseSessions saved before it existed held no sound protected until its first pick; every sound in them competes, as it did. Written only when true, so a session with no such sound saves byte for byte as before
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 of the taste is a downloaded profile (⌘K’s Download your taste command).

Restore goes sound by sound

Restoring re-measures the saved bank, which is the single most expensive thing the app does on load. It is one entry at a time in bank order (import_session_deferred_v2 → bank_absorb per entry → restore_finish), with fill_progress (“recalling 12 of 40 sounds…”) after each:

  • On the farm while a farm worker is ready, as the initial fill runs. See The web runtime. The first eight entries (FARM_AUDIO_AHEAD) are rendered with their audio whatever the cache holds; the rest are read from it where it has their rows.
  • In the engine worker otherwise (?farm=0, a machine too small for a farm, or none ready yet), with a yield to the message queue between entries, so the bar moves and requests are answered while it runs. Each entry is read from the render cache when it has the entry's row, and rendered and written back otherwise. A farm worker that reports ready later takes the rest.

The cache has a row for what a farm worker rendered and for what an earlier restore rendered in the engine worker, and for nothing else (see what writes a row), so a sound that joined the bank in play is rendered again by the next restore.

In the engine worker it was one synchronous call (import_session_checked) that re-rendered every entry, read nothing from the cache and posted nothing until it returned: 13.6 s for 40 sounds on one unchanging line in Firefox on an M-series laptop, and minutes on a CPU four or five times slower. That call is kept only for a binary without the deferred surface. Whichever path runs, the bank comes back as import_state builds it, in its order (deferred_restore_equals_import_state).

The persistent render cache

φ\varphi is a pure function of (term,spec)(\text{term}, \text{spec}), which is the determinism contract, so a featurization stored once can be replayed instead of re-rendered. Without that, every reload re-renders the whole bank from nothing: the app’s 40 sounds at ~0.5 s each, for numbers the machine computed yesterday.

Farm workers consult an IndexedDB store (auracle-renders) before rendering and write back every render they make. A restore the engine worker runs itself, with no farm worker ready, reads and writes the same rows under the same keys (bankPass in worker.js), so a session restored with no farm comes back from the store the next time, and so does one whose farm wrote its rows. The engine reports the hit rate per wave, and per restore it runs itself, into the app’s own log.

What writes a row

Only those two: a farm worker's render (a fill's draw, a restore's entry, a guess's candidate), and a render the engine worker's own restore makes. What the engine worker measures anywhere else goes into its memo and no further: a fill with no farm (fill_step), a generation's or ⚡'s children (walks return their child, not a row), a kept edit, an opened patch file, a preset taken in. Those are rendered again by the next restore, which writes their rows then. A row for each would make every return a read; it is not written today.

The engine worker opens the store once at boot, before any farm worker is handed the phrase: it creates the store on a first visit and stamps it with the namespace (below), so the farm workers’ own opens only read it. When each farm worker created and stamped it, their writes queued behind one another, and a first visit at six workers waited about 1.5 s longer for its first sounds. The farm waits two seconds at most for the stamp, which takes about 50 ms; past that, its workers open the store themselves, as they did before.

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 (3, in auracle-features’ cache.rs) is that missing coordinate for the featurizer. QUIVER_DSP_VERSION (0.4.0, beside it) is the same coordinate for the DSP library every render calls into: a quiver release can change a sample with no line of Auracle changing, as 0.4.0 did for Pluck and Delay. cache_namespace combines the three: the epoch, the quiver version, and a hash of the phrase spec without its audition clips. A clip is in the render_key of a patch that listens instead (audition clips), so a new clip never clears the store. 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. Move QUIVER_DSP_VERSION with the quiver dependency: a test reads Cargo.lock and fails while they disagree. When in doubt, bump: the cost is one cold boot.

Every row is stored under a key that begins with its namespace (farm_key: the namespace, then render_key), so a row written under another namespace is never a hit. The store’s own stamp is not enough for that: it is checked only when the store is opened, and a tab still running an older build goes on writing its rows after a newer tab has cleared and re-stamped it.

A hit is checked rather than trusted: pre_featurized re-derives the content address 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. Its row is still written: a restore with no farm asks for no audio, and reads 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. The engine worker’s own restore reads its rows in one transaction before its first entry. It waits for the stamp as a crew does (renderStoreReady, at most RENDER_STAMP_MS), then for the open and the read, at most RENDER_STAMP_MS again, so up to twice that in all. A store that opens after that is closed unread, and every entry is rendered and none written back. One that opens in time but whose rows come later costs the entries measured before they come: those are rendered, and the rest are read.

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 (Engine::pin_cap) so the pool can never be pinned solid. That state has no honest report, because it surfaces as insert_candidate returning None, which the app reports as children that did not rate above the sounds they would replace (3 were bred, but none rated above the sounds they would replace).

Kept as new, protected until a pick

Candidate::unjudged and BankEntry::unjudged: the other engine-side exemption, and not a pin. commit_edit (keep as new, and a shared patch imported through it) sets it on the sound it admits, after recording the comparison that kept it, which judges the original: the pick that keeps a sound is not a judgment of it. It clears the first time the sound is one of the two in a recorded pick: record_duel_as (every dealt pair, the warm start's, and a later kept edit's comparison with it as the original) clears both sides, and record_tree_duel (a PERFORM offer heard and answered) clears any member whose tree is either side; the app's perform_record also clears the sound in hand as it is in the bank, since PERFORM plays it with its controls moved (Engine::mark_judged). A cut (record_keep(false)) is an answer about the sound and clears it too; stars leave it set.

A PERFORM answer is recorded a window after it is given (eight seconds for a Take, so DON'T COUNT IT can drop it), and in that window the player can take the offer onto the bench and keep it as new. So the answer carries asOf, the newest pool id the page had shown when it was given, and record_tree_duel_as_of and mark_judged clear only members with an id at most asOf (ids are issued in order). A sound kept after the answer was given is not judged by it. The page's knowledge can only lag the engine's, which errs toward keeping a protection a little longer.

The rule is Candidate::kept() (pinned || unjudged), and with what the engine holds for the moment (a ⚡ seed in flight, the sounds on EVOLVE's table: Engine::spared) it is the one insert_candidate and eviction_order_by both apply, so retiring(), may_replace(), refine_finish, a ⚡ child's trim and a preset's insert all pass over it alike. (may_replace() passes over the table only for what a generation's end would take now, and lists the sounds the table could send it to as well: refinement.) It is not charged to pin_cap and the app shows no mark for it. The table is not saved: the app holds the pair it shows and the pair of a pick in its undo window (set_on_table), and a restore holds nothing.

Bounded at pool_size / 4 (Engine::unjudged_cap) on the flags of unsaved members: a keep that would put one more past the cap clears the oldest (lowest id; ids are issued in order), which stays in the pool as an ordinary member. A saved member is protected by its pin and is not counted; unsaving it bounds the flags again. With pins capped the same, at least half of a pool of four or more is never kept (below four, the caps' floor of one each can protect more than half). Every eviction also passes over any ⚡ seed in flight and the sounds on the table (at most ON_TABLE_MAX, four), and an insert over its own protect too (the sound a kept edit was made from). That still leaves an insert a member to evict in any pool of thirteen or more. In the app's 40 it leaves at least 14, more than the ten a generation's walks can put the pool over, so there an insert always lands and a generation's end always brings the pool back to size. A restore applies the cap again, for a file written under a larger one.

The web runtime

Five thread kinds, one wasm binary, and a set of constraints that shaped the architecture more than any design preference did.

For audio engineers: the patch graph, the modules and their types, compilation, the audition phrase, loudness, vetting, the features, and the live voices. 2:49 · chapters and transcript

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) → φ at boot, (context, job) → child for a generation’s walks and ⚡, task → posterior for a refit
Face painterAn OffscreenCanvas for each face of a sound being played, nothing elselive-paint.js: each frame of what is drawn over those faces, answered with a picture of each, where main's own paint of them is over budget
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 face painter is main's own, outside that star, and holds no wasm, and it runs only where it pays. The face of a sound being played answers what is heard (the guide): each frame, main measures what is heard and decides what is drawn over each such face (each outline's points, color and glow, and the trail's fade). The glow is a blur, and a browser that draws a canvas as it is called makes it on the thread that asks: on the main thread, in Firefox at a Retina screen's scale on a fast laptop (an M3 Max), that was 7 ms of every frame, and more on a slower machine. Chromium only records the calls, and draws them after the frame's script. So main times its own canvas calls against the live faces' 2 ms a frame, first while the page is idle, before any sound: one frame of the largest face, painted on a canvas that is never shown. Over budget (Firefox), main starts the painter then, and from the first frame, for the session, the painter makes those calls and answers with a picture of each face, which main shows as it is, so a frame is shown one round trip after it is read. Inside it (Chromium), main makes them itself and a frame shows in the frame that read it, for as long as they measure inside as the faces are played (the median of the last ten frames); once they measure over, main starts the painter and hands it each face as it stands. One frame is with the painter at a time. What main times is the calls: where Chromium draws a canvas in software (on a machine whose GPU it doesn't use), that drawing after the script lands on the main thread too, and neither measurement sees it yet. Main paints the faces itself, with the same calls, where the painter cannot run.

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 (PLAYABLE_AT, in worker.js), which is when the first pair 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.

Generated names are kept from the moment the bank is posted, and they don't depend on when that was. The farm folds results in batches, so playable can catch 8 sounds on one run and more on the next; each sound is named from the bank as it stood when it arrived, and the first eight from each other (NAME_FLOOR, in naming.rs, which must not exceed PLAYABLE_AT). A seed names its pool the same at any handover and any farm width (a_seed_names_its_pool_however_the_bank_was_handed_over).

Pairs are dealt at once, from however many sounds have arrived, so a pick never waits for more to arrive. That depends on the machine's speed, so a seed in the address (?seed=) deals them the way it names its pool. In a seeded session whose pool fills at boot, the kk-th deal draws only from the first 8(k+1)8(k+1) sounds of the pool (8, 16, 24, 32), in the order the seed's fill folds them in, whether or not the fill has finished by then, and every later deal from the whole pool (DealSchedule, in auracle-session). A deal whose sounds have not all arrived waits for them in the worker (dealsWaiting), and every other request is served meanwhile. A deal used to draw over however many sounds had arrived, so a seed dealt other pairs on a faster machine; now it deals the same pairs at any speed and farm width. An ordinary session has no schedule: the wait would cost a fast picker seconds on a slow machine, and its pairs are shared with no one. A session restored with its pool full has no fill and no schedule: every deal draws from the whole pool. In every session a uniform pair is drawn over every sound its schedule names (or every sound that has arrived), cut ones included, and one holding a cut sound is drawn again as the next deal, so a cut moves no pair but one holding the sound cut.

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 labeled share of one bar.

The render farm

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

capped at 2 when deviceMemory ≤ 4, and a width of 1 is taken as 0: below two workers the serial path is as fast (farmWidth, in main.js). Override with ?farm=k or localStorage["auracle-renderers"], from 0 to 8; 0 is the serial path, which builds the same pool.

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, and a browser that cannot structured-clone a WebAssembly.Module. So parallelism costs time and never content.

Boot waits FARM_HANDSHAKE_MS (5 s, in worker.js) for a farm worker to report ready. When none has, a crew with a worker still starting is kept: the fill (or a restore) begins in the engine worker, asks before each batch (each entry) whether a worker has reported ready since, and hands it the rest from there. A slow machine instantiating the engine in several workers at once can miss the window, and before this its crew sat out the whole boot. The two paths fold the same stream from the same cursor, so the pool is the same whichever took which part. A farm run that ended because every ready worker died leaves the draws it was handed and did not fold in issued (the engine's issue cursor past its fill cursor); the engine worker's batches fold some, and the next farm run hands the rest out again first, by index (draw_json), before it draws new ones.

A draw retired after two attempts (MAX_TRIES, in worker.js) is recorded, not hidden, but in the app’s own log (window.__aurLog) rather than as a console warning. It is a designed degradation, and the console gate holds a clean boot to zero warnings.

Walks on the farm

A generation is refine_seeds walks, and each is a pure function of the generation’s shared context and its own job (refinement). So after boot the farm comes back for them: the engine worker asks main for a crew when a generation or ⚡ evolve from this starts, main spawns it, and the crew is reaped after a minute with nothing to walk. Main compiles the wasm module once and keeps it, so a crew is an instantiation per worker, not a compile: where boot had a farm (four cores or more) the module was compiled then, and on a two- or three-core machine, where boot fills serially, the first crew compiles it. A browser that cannot hand a compiled module to a worker has each worker compile its own. A crew’s width is boot’s rule with a floor of one worker wherever there are two cores (walkWidth, in main.js), because even one worker takes the walk off the engine worker, which then answers everything else.

The context (the tilted prior, the posterior’s draws, the standardizer, the phrase: about 2.2 MB of JSON) goes to each worker once per generation, as one string, and farm_walk keeps its parse keyed by that exact text. Results are absorbed in job order, one per turn, whatever order they finished in, so the pool is the serial path’s at every width; natively a_generation_absorbed_in_any_completion_order_is_the_serial_one, and across the farm’s wire farm_walks_are_the_engines_walks_absorbed_in_job_order. A walk a worker cannot run, or a crew that never comes up, is walked in the engine worker from the engine’s own copy of the same job.

⚡ evolve from this is one job over the same path. It draws its job from the refine stream before it waits for a crew, so a generation asked for during a cold crew’s handshake cannot draw first and change the child: a seeded session breeds the same ⚡ child however warm the crew was. With no crew the engine walks that very job (refine_from_walk). A generation and ⚡ take turns in the engine worker, and a refit waits for both, so a ⚡ child is never absorbed into a generation at whatever job count its walk finished on. The engine keeps a ⚡ seed out of every eviction until its walk is absorbed or stopped.

The refit on the farm

A refit is one MCMC call, about a second on a fast laptop and about four on the reference machine for a mature log. The engine worker does only its two ends (fit_export, then fit_install), and a render worker the MCMC (farm_fit), handed out as a walk is. The export is the evidence as the fit reads it, Engine::fit_job: the log standardized on the scale it is fitted on (the standardizer refitted over the pool and the log, not yet adopted), the model's configuration, the chain's length, the last fit's lenses, and the seed of the generator the engine's own fit would use (the session seed and the log's length). The render worker aligns the new lenses to those (a search over every permutation, for every draw) and sends the draws back as bytes. The install adopts the scale and folds in the observations recorded since the export, as a pick between fits is. With nothing between them the three are the engine's fit, draw for draw: fit_posterior is the three in a row, and a_fit_on_the_farm_installs_the_draws_fit_makes holds it across the wire. Until the fit lands the engine answers with the posterior it has, so a deal asked for meanwhile is dealt under it. One fit is out at a time, a generation and ⚡ wait for it, and with no crew the engine fits the same export itself, after the player's requests.

A saved session keeps the posterior's draws and weights (SessionState::fit), so a restore installs them and fits nothing, when the log and the standardizer come back exactly as saved.

Replacement waits for the end: children join the pool as they are absorbed, and refine_finish retires the weakest members not kept (saved, or kept as new and not yet in a pick) once, when the last job lands or the player stops the generation. Weakest is judged under the posterior the generation opened with, as admission is, so picks made while it breeds (they reweight the posterior for the next pair) do not change which children are kept.

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.

Getting this wrong gives 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.

The engine worker's half of the messages has a level of its own: the worker-protocol tests (make worker-test, tests/worker/) run worker.js as it is, in Node over the built engine with no page, and hold what it answers and in what order (its lanes, a long job giving way, what reaches the farm).

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–(open)(open)

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. There is no crossover: every child grows from one seed.
  • Preferences persist. Every judgment enters an observation log that outlives the generation it was made in, the session it was made in, and, through a downloaded profile, 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; implicit signals logged, not in the likelihoodOne latent utility, three likelihoods; duels primary
Taste featuresφ_audio (18) + φ_struct (26) = φ ∈ ℝ⁴⁴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 styles, aligned post hoc; nameable and persistedA style 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; keep as new inserts a new candidate; optional “edit beats original” duel, provenance-taggedPanel and genome share one encoding, so edits, locks, and evolution cannot drift
Profile portabilityA profile = 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
Modules46 productions: 8 sources, 22 processors (two of them player kinds the prior never draws), 16 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; the live keys play an edit before its check and are muted if it failsOne render serves health-check, features, and playback; quarantine + fitness shaping teach evolution to avoid pathology; an edit is heard as it is made
A generation’s walksJobs and one shared context, walked side by side on a render-farm crew raised on demand, absorbed in job order; admissions and replacements judged under the posterior the generation opened withThe seeds are independent, so the generation takes about its slowest walk instead of the sum of ten; job-order absorption makes the pool the serial path’s at every width and whatever order the walks finish in. Parallelism inside one walk was rejected (MH is sequential, and a farm round trip per step is slower than whole walks), and so were fewer or shorter walks (measurably worse search)
A search control’s offerThe taste walk tilted along the control’s direction (AIM_GAMMA = 1), continued up to AIM_WALKS = 3 walks until it has moved that way; the reply says how far it didGrowing undirected offers and keeping the one that moved most spends renders on offers thrown away, and yields nothing when no walk heads that way; a graft needs a transparent module, and Grit has none. γ and the walk count are the census’s (a flattened curve, bounded taste cost)

What is not in this table

Two kinds of thing deliberately stay out of it, and three live on their own pages: open questions, which are decisions that have not been made rather than decisions that have; unraised directions, which are possibilities nobody has yet argued about either way (the distinction matters, because a reader cannot otherwise tell rejected from never considered); and what the audition cannot hear, which is the one premise underneath several rows of this table rather than a row of it.

Reversible details. Buffer sizes, the exact number of MCMC steps, which easing curve a knob uses. These live in the code and in the pages that quote them by name; a decisions log that tracks them stops being readable.

The pass-by-pass history. What changed when is CHANGELOG.md. This table is evergreen: it says what is true now and why, not what was true in March.

Milestones and the gates that closed them

Each milestone had a demo that either worked or did not. None of them closed on “the code is written”.

#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 download and open as a portable observation log plus its standardizer. Grid and radio modes remain open, and with them keep/kill’s triage surface: today it is emitted only by the bank’s cut, as a kill.

The pass-by-pass record is CHANGELOG.md.

The synthetic user, and why M3 was a gate

Before any UI existed, the taste crate was validated against a simulated user: ground-truth θ* (and ground-truth styles for K>1), synthetic duels/stars/keep-kills with realistic noise, asserting

  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 that learns 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 and workbench: shipped. The core loop is A/B pairs in EVOLVE; candidates land on the workbench in PATCH, where stars, free play, hand edits, locks, and downloads happen. TASTE and LEARNING are the model reporting on itself: the map, with each pick drawn as the direction it taught; the styles and their coefficients with credible intervals; and its forecasts, scored.
  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 module list, a bank with three tabs, session persistence in IndexedDB, and a 62-patch preset library across seven families.

Open questions

Things this design has not settled. They are written down here rather than left out, because a reference that only describes what works is not a description of the system.

Every entry below is a question this design raised. Possibilities it never raised are a separate register (unraised directions), and an entry there graduates onto this page as soon as someone can state the measurement that would settle it.

  • Tempered SMC for generation. The Boltzmann target is written down but not sampled from, and there is no crossover: every child grows from one seed. Whether a population sampler is worth the complexity over local climbing is untested; the measured non-concentration of the pool (it widens slightly over a session) weakens the diversity argument for it.

  • Cross-island discovery. Closed by measurement, and it was wrong. This entry read: “local refinement from island A will not find island B; a tempering schedule would cross the valley.” Measured against a bimodal synthetic user (make islands), 20.9 % of refinement events cross islands (13.5 % of all events decisively, with both ends more than 1.0 onto their island rather than hovering at the boundary), and 0 of 8 seeds ended with a pool on one island only.

    The reasoning was wrong about the geometry. The walk is not local in feature space: it is a reversible-jump walk over a tree grammar, and a single accepted structural move swaps a subtree, which is a large jump in φ. There is no valley to cross, because the search does not have to travel through the space between the islands. See Refinement.

  • Fan-out and feedback in the grammar. Two separate ceilings, deferred together because they are the two things the term algebra cannot say.

    This entry used to read “the grammar is DAG-only today”, which understated it in the direction that matters. The genome is a tree (term.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 sets the module ceiling (MAX_SIZE). 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 player 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 (imputed coordinates) 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. A mature fit is both slower and statistically thinner than an early one: every step’s likelihood scores every lens, and 225 + S sites over a fixed 10,000 steps is ≈ 44 sweeps per site. The fit no longer rebuilds the program per step (the kernel) and the chain no longer holds itself in memory, so what is left is the likelihood and 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 style 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 styles is not evidence that styles 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 (Best − Last): mean −0.153 ± 0.384, median −0.185, trimmed −0.113 ± 0.318, sign test 8 better / 8 worse (p = 1.000). As exact a tie as sixteen seeds can produce, and it does not clear zero at 2 se on any statistic, so the default 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 parallelizing inside a refinement cannot make a 16-seed measurement faster: the cores are already busy.

    What is left is real but smaller than the entry implies, and it is a UX number rather than a harness one: latency on a single refinement, which is the app’s ⚡ button. Filed as that, not as a build-configuration change.

  • Bright and Body are entangled on a filtered bass (open). On Acid Line the cutoff is the obvious brightness knob, +3.2σ of centroid per unit, and the named-control wiring does not use it: the same move drops bass_fraction by 4.6σ, which is Body’s axis, so the cross-talk gate is right to call it impure, and Bright reads search on the patch where a player most expects it. Solving in the six-dimensional named subspace instead of across φ was tried and reverted: with more knobs than named axes it can always cancel the cross-talk, and it did so musically: Ceiling’s “Bright” became a shorter release. Candidates: let Bright own a little of Body’s axis (a brightening is expected to thin a bass), or report a coupled control honestly (“brighter, and thinner”) instead of hiding it. Neither is decided. Update: the wiring now tries a control’s own sites first (Bright: cutoff, tone, …) and ranks knobs by effect rather than coefficient, and on the re-voiced Acid Line the cutoff alone clears the gate for Bright: purity 0.87, reach 2.85σ. The entanglement is still there, and now shows up on the other side. Body’s best move is the same cutoff turned the other way, so separate makes Body the search control.

  • Grit hears noise, not saturation (open). Grit’s axis is flatness_mean, and a drive on a tonal sound adds harmonics, which φ reads as Bright, not as flatness. On Iron Bass, a saw through a tube drive, drive moves flatness by 0.000σ per unit. Across 24 fresh-pool patches Grit reaches 0 to 1. A bitcrusher does move flatness. It is transparent only at 16 bits, though, where its slope is zero and the local measurement cannot see it, and it clips anything hotter than its ±5 V window. So PERFORM grafts nothing for Grit. What would fix it is a roughness descriptor in φ (inharmonic or beating partials, or Sethares-style sensory dissonance) with Grit’s axis on it. That is a φ change, and it needs a RENDER_EPOCH bump and a taste-model revalidation.

  • Remaining quiver hardening. Closed. voct_to_hz gained a ±32-octave clamp in quiver-dsp 0.3.0, and Auracle has pinned a clamped quiver since the September 2026 audit (0.3.3 then, 0.4.0 now). The clamp left renders inside ±32 octaves unchanged, so no RENDER_EPOCH was bumped for it; the render-cache namespace now carries the quiver version as its own coordinate (QUIVER_DSP_VERSION), which orphans the stored rows from 0.2.0 anyway. That is the right outcome, because for pathological CV (chained Offsets past ±32 octaves) the two versions render differently: 0.2.0 recovered an infinite increment by phase reset, 0.3.x aliases at a finite ~THz pitch. Both are garbage the vet gate quarantines; they are not the same garbage.

  • Frame silence is recognized only at exactly zero power (AU-F2, open). audio.rs splits the phrase into chains at frames whose unnormalized FFT power is below 1e-12, which is an amplitude of ≈2e-9 (−173 dBFS). Rests read as silent only because quiver’s Adsr snaps exactly to 0 at the end of its release and the VCA is multiplicative; a release or chord tail that outlasts a 0.15–0.2 s rest never gets a chain break, and the flux fix (#51) and the segment features depend on one. The likely fix is a threshold relative to the phrase (−60 dB of global RMS, say). It moves φ for every patch with a tail, so it is a RENDER_EPOCH bump and owes the measurement (φ over prior draws before and after, and make revalidate), which has not been made. Documented at the line rather than changed blind.

  • 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; the ZCR DC removal, when it shipped, took them to 16.9 / 9.7 / 5.9, and zcr_mean stopped tripping the collinearity flag. A third of the original argument was a coordinate bug rather than a modeling problem. They have risen since: today's sweep reads 18.7 / 12.0 / 6.8, and zcr_mean is over the flag again (Known collinearity has today's table).

    Two gates were run at ρ = 0.25 and they disagreed. The closed-loop gate, which scores θ recovery, improved (0.657 → 0.702). The 48-seed paired climb, which scores what the pool is worth to the listener, regressed: −0.579 ± 0.188 trimmed (−3.09 se), sign test 16 better / 32 worse (p = 0.029). So it ships at ρ = 0.

    Both results are real because they measure different things: pooling an ill-conditioned ridge regularizes estimating θ, and biases the search that consumes θ. The general point outlives the feature: a VIF says these coordinates move together across patches, which is a fact about φ; fusing their coefficients asserts a listener’s preferences move together, which is a fact about people and does not follow. Re-open if the listener model ever gains a reason to believe it does; the sweep and both gates are there to re-run.

Directions the design has not raised

The counterpart to the open questions. An open question is a decision this design put on the table and has not settled; what follows was never on the table. Written down so that not considered and considered and rejected stop looking alike from the outside.

Everything here came out of a review pass in August 2026 that read the books and the crates and asked what the architecture already supports that nobody has argued about. Nothing on this page has been decided, except §13, which has since shipped and is kept as a short record, and several entries name the reason they might be wrong.

Why this is a fourth register

PageHoldsThe state it records
Decisions logA choice, and what it beatSettled
MilestonesA deliverable and the gate that closed itDone, or not
Open questionsQuestions this design raisedUnsettled, and named
This pagePossibilities this design never raisedUnexamined
What the audition cannot hearOne premise, and where it costsAssumed

The last row is the odd one and the sharpest. An entry here is an opportunity; an entry there is a limit on what any amount of modeling can buy, because it names something the measurement does not contain. Two entries on this page, §2 and §3, have their strongest arguments over there rather than here.

The rule for leaving this page is what keeps it from becoming a wishlist. An entry graduates into open questions as soon as someone can state the measurement that would settle it, and into the decisions log once it is made. An entry that can be stated as neither is speculation and should be deleted rather than kept.

Each entry therefore names three things: the machinery that already exists, what stands in the way, and what would settle it. Most of these are one shell or one substitution away from working, which is the reason they are worth a page rather than an issue.

#DirectionLeans on
1An evidence path for the questions that need real sessionsProfile = log + standardizer
2Search toward a reference sound rather than toward a fitted tasteThe target consumes uu as a black box
3Declared context, which dissolves the per-style-phrase looppresets::CATEGORIES, SessionConfig::phrase
4Radio as the fix for observation volume, not as the third modeThe keep/kill likelihood, already fitted
5A multisample export, as the cheap exit into a DAWDeterministic headless rendering
6Refinement that runs while nobody is watchingThe native harnesses, the render farm
7Counterfactual explanation: the edit with the largest predicted gainTrace addresses, the structural edit ops
8A map whose axes are styles rather than principal componentsTasteMap, and its own converged flag
9The taste crate as a general preference-learning libraryφ enters by name, and nothing else is audio
10The measurements as published resultsThe changelog already holds them
11The production→brightness map, so audio taste can tilt proposals tooEvery (term, spec) → φ ever computed
12Building the cascade the φ split is justified byφ_struct is render-free
13Refinement seeds across the farm: shipped, now in the decisions logThe seeds are independent; the farm was idle
14A per-session cutpoint offset, by the argument already made for τs\tau_sThe τs\tau_s design, one likelihood over
15A sweep-denominated fit budget, as the alternative to capping Kfit_bench already measures the axis
16A per-provenance likelihood temperatureProvenanceScore, already computed

The pattern entries 11–16 came out of

Six of these are one observation wearing six hats: Auracle is a better instrument than it is a consumer of its own instruments. It pays to generate labeled data about itself and then reads that data once, literally.

RecordedConsumed as
Engine::style_shares, per fitnothing yet (§1)
Calibration split by Provenancea display (§16)
Quarantine reasonsone scalar, QUARANTINE_FITNESS (§12)
The persistent render cachean exact-match lookup (§11)
LineageEvent, with utilities at event timea caption (§7)

A cache is a lookup table; a model is a lookup table that generalizes. Every row above is the first of those and could be the second.


1. The register is gated on evidence that cannot arrive

The fit-cost entry in open questions says this in as many words: it is “open for want of data rather than for want of an instrument”, and Engine::style_shares was built to collect exactly the rows that bear on it: the ones where k == k_styles. Behind it sits the question the K cap is a proxy for, which is whether a player ever uses five styles at all.

Those rows are on other people’s machines, and there is no path off them. Persistence is unambiguous: IndexedDB under the page’s origin, no account, no server, nothing transmitted, and the only backup that exists is a profile the player downloaded by hand.

So a project whose method is close the question by measuring it has a class of questions it cannot close. That is a structural fact about the architecture, not a backlog item, and it is the single most consequential thing on this page.

The mechanism already exists. A Profile is the log plus its standardizer: self-contained, portable, raw φ\varphi stored by name, already exposed as ⌘K’s Download your taste. What is missing is a destination, not a format.

What this must not become. The decisions log keeps implicit signals out of the likelihood, and the guide is explicit that listen time, replays, and hovers are not recorded as preferences. That decision is about what the model is allowed to learn from, and it should survive this entry untouched. The property worth keeping is nothing leaves without a deliberate gesture at a file the player can read, which an opt-in donation of a downloaded profile satisfies and telemetry does not. A donation path that is a button plus a place to send the file (an issue attachment is sufficient; no server is required) keeps every word of persistence true.

What it would close, and what it opens. Directly: the K-cap question, whose evidence is the rows where k == k_styles. Indirectly, and worth more, a population prior on θ\theta. Cold start today is 44 coordinates starting from a prior mean of zero, against which the three-pick warm start buys 18 observations in thirty seconds. A hierarchical prior fitted across donated profiles is the only available lever that moves the starting point rather than the rate, and because the stack underneath is a probabilistic programming language, a hierarchical prior is a model that can be written rather than an inference engine that has to be built.

Two reasons it might be wrong, both worth stating before it is built. A donated corpus is self-selected: it answers how many styles does an enthusiast use, which is not the question. And a population prior asserts that other players’ preferences are evidence about each player’s, which is close to the assumption the max-of-experts design already refused at the level of one listener’s islands; refusing it within a person and accepting it across people needs an argument, not an analogy.

What would settle it. Cross-validation on donated profiles: does a population-prior warm start beat the three-pick warm start on held-out duels over a session’s first hundred observations? That is an offline measurement with no live user in it, and it can be run the day a corpus exists.

2. Target-directed search: “make it sound like this”

Partly built (Plan-005 task 11): a sound of your own. The engine measures a recording, names its nearest sounds and breeds a generation toward it, on the taste's target times a likelihood of the recording rather than on a distance alone. Measured, a recording keeps five coordinates of φ, not the twelve guessed below. What follows is the direction as it was written.

Every path through this system learns a utility. But the Boltzmann target consumes uu as a black box:

π(x)  ∝  pgrammar(x)⋅exp⁡ ⁣(β u(x))\pi(x) ;\propto; p_{\text{grammar}}(x)\cdot\exp!\big(\beta, u(x)\big)

Nothing in the search requires uu to be fitted. Substituting a distance to a reference vector,

uref(x)  =  −∥φ(x)−φref∥Wu_{\text{ref}}(x) ;=; -\lVert \varphi(x) - \varphi_{\text{ref}} \rVert_W

turns the whole apparatus (MH in trace space, reversible jump, vetting, the pool, and locks as exact conditional refinement) into a matcher, with no new algorithm. Locks are what make it more than a novelty: match this, and leave my filter section alone is a conditional match, which is a thing the machinery already does exactly rather than heuristically.

This is the most common real sound-design task, and it is the one shape of it the instrument cannot express today.

The distance can only be over the audio half, and that is a feature. A recording a player brings has no term, so it has no φ_struct at all: 26 of the 44 coordinates are not defined for it, and WW has to zero them. That is the right behavior rather than a limitation: sound like this, by whatever means, with the grammar prior left to supply the parsimony that keeps the means sane. A match that also scored structure would be asking the search to reproduce a topology the reference never had.

What actually stands in the way is φ_audio’s stimulus dependence. φ\varphi is measured under the standard phrase; the :p2 tag exists precisely because a coordinate’s meaning is relative to the stimulus that produced it, and a reference clip is not that phrase. Twelve of the eighteen φ_audio coordinates are whole-phrase statistics and survive the mismatch with a caveat about pitch content; the six segment-local ones (held_centroid_std and the three motion bands, on the first held note; high_ratio; chord_flatness_delta) find their roles by property (first note, highest note, first chord), and arbitrary audio may have none of them. The imputation is already honest, since an absent coordinate reads as no evidence, but here the absence lands on the axes describing timbral motion and register, which is where a listener’s “sounds like” often lives.

The honest form is therefore a partial match with the matched subset named on screen, rather than a match that quietly scores twelve coordinates and calls it a likeness.

There is also a reason to want this beyond convenience, and it is the workflow argument: duels ask which of two a listener prefers, and sound design is more often the pursuit of something already imagined. Target-directed search is the missing half of that loop rather than an extra mode of it.

What would settle it. No human is needed for the first pass: hold out a patch from the prior, treat its φ\varphi as the reference, and measure how far refinement closes the distance against a random-restart baseline over paired seeds, the same shape as the existing search_health measurements. If that fails, the idea is dead before any UI is drawn.

3. Declared context, and the circularity it dissolves

The per-style audition phrase entry is blocked on a loop, and the entry states it precisely: a style is discovered (an inference from φ\varphi), and φ\varphi is measured under a phrase, so a phrase chosen by style makes the stimulus depend on an inference that depends on the stimulus.

That loop is a property of discovery, not of per-context stimulus. It disappears entirely if the context is declared: I am hunting a bass tonight is a statement, not an inference, and a phrase chosen by it depends on nothing that was measured. Comparability holds by the argument the entry already makes: the phrase is a property of the SessionConfig, so everything in a pool is auditioned under one stimulus and duels stay apples-to-apples.

The declared context also already exists, half-built and discarded at the model boundary: presets::CATEGORIES is a seven-way family label (bass, lead, keys, pad, texture, perc, weird) carried by all 62 presets, used for copy and for spanning the warm start, and read by nothing downstream.

What it costs. The utility becomes u(x∣c)u(x \mid c), and there are two shapes for that. Appending a context block to φ\varphi keeps the model linear and lets a single coefficient mean brightness matters more to me in a lead; a per-context set of style weights is the heavier design, multiplying the coefficients that must be paid for by evidence, which is the resource the cold start is already short of. The first is the one that fits the model that exists.

The cost that will actually bite is historical: an observation becomes a judgment about a (patch, context) pair, and every vote already in every log has no context. Under the by-name imputation rule that reads as the standardizer’s mean, which is honest and weak: the correct behavior, and still a real dilution of a player’s history the first time it ships.

This does not close the open question; it reroutes it. Per-style phrases stay exactly as open as they are. What changes is that the per-context half of the value (a bassline for basses, a chord swell for pads) becomes reachable without answering the circularity at all.

The musical argument for declaring context is stronger than this modeling one, and it is on the other page: a preference fitted over a whole history converges on the average of a taste, and a player needs the corner they are working in tonight.

4. Radio is a throughput fix, not the third mode

Grid and radio remain open, and keep/kill is a fitted likelihood whose only surface, the bank’s cut, records kills alone. The usual reading is two modes left to build. There is a sharper one.

The two unbuilt modes are the ones that produce observations in bulk (grid judges a generation at a time, radio never stops), and radio in particular produces them at the rate of listening rather than at the rate of deciding. A duel costs concentrated attention and a session yields tens of them. That difference is not a matter of degree for this model, because several of its mechanisms are denominated in observation counts:

  • Recency weights a vote hh places back by wh=0.5 h/150w_h = 0.5^{,h/150}. A player whose entire history is a few hundred observations lives inside the first half-life, and forgetting (built, documented, and figured) never does anything.
  • K grows with evidence, so the multi-modal taste the max-of-experts design exists to represent needs enough log to discover a second style at all.
  • Calibration is a Brier skill score against 0.5, plus random check duels. Its error bars are a sample-size problem.

So radio is not the third mode. It is what makes the model’s own dynamics observable, and it should be ranked on that rather than on effort.

The counter-argument, which is real. Keep/kill is the weakest signal per observation, and radio would flood the log with it. Worse, recency is denominated in log positions, so a flood of cheap observations pushes a considered duel past the half-life faster in wall-clock terms: the unit silently changes meaning when the observation rate changes. If radio ships, the recency weight probably has to become per-signal-kind, or be denominated in time rather than in positions. That is a modeling decision hiding inside a UI mode, and finding it before building is most of the value of writing this entry down.

5. The bank does not leave the browser

What comes out today is a WAV of the player’s own playing, a patch file, and a profile. Everything a musician does next happens in a DAW. Lineage names the intended shells (nih-plug VST3/CLAP, then AUv3), and neither is started.

There is a cheaper exit that does not wait on a plugin, and the determinism contract is what makes it nearly free: φ\varphi, and the render it comes from, is a pure function of (term,spec)(\text{term}, \text{spec}), which is the property render_key and RENDER_EPOCH already assert. A multisample export (a grid of offline renders across pitch and velocity, plus an SFZ mapping, which is a plain-text format) puts any patch in the bank into every DAW that loads a sampler, today, with no audio-thread work and no shell.

A second, smaller one: the genome is a term with a compiler, so export this patch as quiver source costs a printer and makes a patch inspectable, diffable, and portable to a native host.

What it does not solve, and the entry should not pretend otherwise. A multisample is a snapshot of an instrument, not the instrument. Per-note modulation, the arpeggiator, anything that responds to how it is played, and the live parameter handles that make the rack worth touching do not survive sampling. This is a bridge that gets patches used while the real answer is built, and its value is entirely in being available years earlier.

6. The machine loop stops when the tab closes

The two-loop architecture describes a machine-paced loop that evaluates thousands of candidates against what it has learned. It does that only while a page is open and a person is sitting there.

Every piece of an offline mode exists. The harnesses (search_health, learn_synthetic, closed_loop_sweep) run the real grammar → render → vet → featurize pipeline natively; the render farm already parallelizes; a Profile already round-trips. Leave it running and come back to a pool that has been refined for an hour needs a shell, not an algorithm.

Two things are worth saying before anyone builds it. The pool would be refined against a θ\theta that is stale by exactly as long as the player was away. That is fine, because θ\theta moves slower than the pool does, but it should be stated rather than discovered. And a browser tab cannot do this: background timers are throttled and the tab may simply be discarded. So this is a native direction, and it is the first argument for a non-web shell that is not a plugin.

7. Explanation stops at the coefficients

TASTE reports the map, with each pick drawn as the direction it taught, and LEARNING the styles and θ\theta with credible intervals and the forecasts, scored. That is already more than the genre offers, and it is descriptive: it says what the model believes, not what to do about this patch.

The next rung is counterfactual, and trace addresses make it close to free. Enumerate the structural edit ops at every node plus a knob write at every parameter site, score each result with the fitted uu, and report the argmax: this patch sits 0.8 below your best; the single largest predicted gain is node/0#cut +0.3. Every ingredient (the address scheme, the ops, the utility, and the compiler that guarantees the result is playable) is built and tested.

Two caveats that decide whether it ships honestly. uu is a fitted quantity with uncertainty, so an argmax over it is an argmax over a surrogate: the same move the RefineKeep::Best measurement found to be lower-variance rather than better. A suggestion without its interval overclaims in exactly the way this project’s calibration surfaces exist to prevent. And a confidently-signed suggestion that is wrong is the failure a player notices and does not forgive, which argues for reporting the top few with intervals rather than one imperative.

This is also the concrete cash-out of the README’s claim against star-a-generation synths: that they cannot say why. Coefficients are an answer to that. A named edit is the answer a person asked for.

8. The map’s axes could be learned rather than principal

The taste map projects with PCA over standardized φ\varphi (the top two principal axes, by power iteration), and TasteMap carries a converged: [bool; 2] flag because the top two eigenvalues can near-tie. The doc comment names the reason: the brightness cluster is three genuine measurements of one perceptual thing. The map is already honest about being potentially unstable.

An alternative is to project onto learned directions instead: the top two style vectors θk\theta_k (orthogonalized), or utility against posterior standard deviation. Those axes are stable against the eigenvalue tie by construction. They are also nameable, which is the part that matters for a surface inviting someone to recognize territory: “more like your style 1” is a thing a person can hold, and “the first principal axis of a standardized feature matrix” is not.

Not obviously better, and the reason is worth keeping. A learned axis moves when the model does, so the territory changes meaning between fits. That is a different instability, moved from the solver to the model: arguably worse for a map, arguably better because it is explicable and can be announced.

What would settle it. Measure the rotation of each projection’s axes between consecutive fits over a session and compare. That is a small instrumentation on top of TasteMap and a number, not an opinion.

9. auracle-taste is domain-independent and welded to a synth

Strip the words. The crate learns a scalar utility over Rd\R^d from three observation kinds (pairwise comparisons, ordinal ratings against fitted cutpoints, and a threshold decision), with a max-of-experts form for multi-modal preference, recency weighting, a calibration report, and a synthetic-user harness that falsifies the whole thing headlessly.

None of that is about audio. The only audio-shaped thing is φ\varphi, and it enters by name through FitSet::build: the same property that makes profiles survive a feature-set change makes the crate indifferent to what the features measure. Fonts, color ramps, shader parameters, recipes, hyperparameters: the model does not know the difference.

The reason to wait is real and should be the stated one. Extraction is an API commitment, and the project is 0.x with a save format that still moves; publishing a crate whose serialization is not settled buys a migration obligation to strangers. That is a timing argument, which is a much better reason than “no time”, and it means the entry has a trigger: revisit at 1.0, or when a second consumer actually exists.

10. The measurements are a result, and results travel

The acquisition entry is a well-powered null. BALD ties uniform random pairing on cosine similarity to θ∗\theta^*, on rank correlation, and on excess nats across three regimes at 20 CRN-paired seeds, while beating dueling Thompson (t = 2.9 to 6.9) throughout. The literature on preference elicitation is full of acquisition functions and nearly empty of properly paired nulls, and a null is the result a practitioner most needs and least often finds.

The fused brightness prior is the same kind of thing: two gates that disagreed, with a general reason attached: a VIF is a fact about patches, and fusing coefficients asserts something about people, which does not follow.

Neither needs any work. They are written, sourced, and reproducible from a checkout. What is missing is a destination, and the gap between “recorded in a changelog” and “somewhere a person searching for the answer will find it” is the entire distance.

11. The model nobody has fitted is in the render cache

Proposals closes by explaining why only the structural half of θ\theta tilts the grammar:

Turning centroid_mean into a proposal tilt would require a model of which productions raise brightness, which is a model nobody has fitted.

That model’s training set already exists, in quantity, and the project is already paying to store it. Every (term,spec)→φ(\text{term}, \text{spec}) \to \varphi the system has ever computed is a labeled row, and the persistent render cache is exactly a table of them that survives reloads. pipeline_stats already draws 1,200 prior samples to compute the VIFs; the same draws are a design matrix. A ridge fit of the eighteen audio coordinates on the twenty-six structural ones is ∂brightness/∂production\partial\text{brightness}/\partial\text{production}, which is the missing map.

Why it is worth more than it sounds. The coefficients a player can actually recognize in themselves (bright, slow attack, long tail) are exactly the ones that today only score. The structural half does double duty (scores and proposes); the audio half is half-employed. The asymmetry is documented and argued for honestly, but it has never been costed, and it is costed in the currency the tilt exists to buy: a search that looks where the model expects to find things.

The objection, and why the existing design already answers it. Brightness is a property of the composition, not of a module (a filter’s effect depends on what precedes it), so a linear map is crude. But a tilt needs only a sign and a rough magnitude, and it is already clamped to [14,4][\tfrac14, 4]. The clamp exists to stop a confident coefficient starving a kind; it doubles as the rail that makes a crude map safe to consult.

What would settle it. The search_health --budget-ab shape: does audio-tilted proposal weighting beat structural-only tilting on a synthetic user’s true utility, over paired seeds? The harness that answered the 40 × 10 split answers this unchanged.

12. The screening cascade is cited, and unbuilt

auracle-features/src/structural.rs says, in the present tense:

These cost nothing (no compile, no render), which is what makes the screening cascade work: a struct-only surrogate prunes candidates before the expensive render path.

φ_struct and Refinement both repeat it, and it is part of the stated justification for φ\varphi being two-part at all.

Nothing in auracle-session prunes anything. SurrogateFitness::evaluate calls featurize_memo for every candidate it is handed (a full compile → render → vet → featurize), and there is no screen, cascade, or pre-filter in the crate. This entry is therefore half a direction and half a correction: either the cascade gets built, or structural.rs should say makes possible rather than works, as the two reference pages now do.

The risk profile is unusually good, and it is worth naming. A screen can only ever waste opportunity; it can never corrupt evidence. Anything actually injected is still really rendered, really vetted, and really featurized, so the observation log is untouched no matter how wrong the screen is. Very little else in this system has that property.

Where not to build it. Refinement already says its affordability comes from the render memo rather than from screening, because the walk re-scores its own current state every step. So the payoff is in pool fill, which is the path nobody has looked at.

And the cheapest version needs no model at all. Every quarantined candidate is a labeled (term→pathology)(\text{term} \to \text{pathology}) row, and today the label is consumed as a single scalar, QUARANTINE_FITNESS. A screen fitted on those labels prunes exactly the candidates whose render was guaranteed to be thrown away.

13. Ten independent walks, now run side by side

Shipped, and so no longer a direction: it left this page for the decisions log when it was built. It is kept here, short, because it was argued on this page first.

The entry observed that an MH walk is sequential but a generation’s ten seeds are not: they are independent by construction, search_health already walked them one thread per seed natively, and in the browser they ran one after another while the render farm sat idle. It named the blocker too: farm workers were stateless (term, phrase) → φ, and a walk needs the kernel and the posterior.

What shipped crosses that blocker with data rather than state. A generation is now jobs and a shared context (refinement): refine_jobs hands out one job per seed (its tree, step budget, and its own RNG seed) and one context (the tilted prior, the posterior, the standardizer, the phrase), a pure run_walk walks a job on any worker, and refine_absorb folds the results back in job order, so the pool is the serial path’s at every width. The context crosses to each walk worker once per generation. In the browser the walks run on a crew raised on demand (walks on the farm), and a generation takes about as long as its slowest walks rather than the sum of ten.

The prize the entry named, ⚡ latency, moved less than the entry hoped: ⚡ is one walk, so it still takes a walk’s time. What changed is that it walks on the farm, and the engine answers everything else while it does.

14. Stars have a global scale; keep/kill has a per-session one

Likelihoods argues for the per-session threshold exactly right:

Without the per-session threshold, a strict day and a generous day would average into a meaningless global bar, and both days’ data would be degraded by the other’s.

That argument is fully general over absolute-scale signals, and the star cutpoints are fitted globally. Worse, the asymmetry runs the wrong way round from ship state: keep/kill reaches the log only as kills, from the bank’s cut, while stars are a signal players actually emit. So the mechanism exists on the one-sided signal and is missing from the full scale.

The cutpoints do already absorb drift, which the page says and which is true: a player who becomes harsher over months moves them rather than θ\theta. What they cannot absorb is swing (Tuesday strict, Thursday generous), because a single global set of cutpoints has to average the two. Drift and swing are different phenomena and the model currently sees them as one.

The change is the smallest on this page: a per-session offset added to the cutpoints, the same shape as τs\tau_s, one extra latent per session. It also interacts with §15: under a fixed step budget, adding sites silently thins every other coefficient, which is precisely the problem that entry is about.

15. The fit budget is denominated in steps, not sweeps

10,000 fixed steps over 225+S225 + S sites at K=5K=5 is ~44 sweeps per site, against ~200 at K=1K=1. The posterior page states the consequence plainly: growing KK makes the fit both slower and statistically thinner. So the model that has learned the most about a player is fitted worse than a fresh one, and nothing on screen says so.

Open questions offers one remedy: cap KK at 3, gated on style_share evidence. There is a second that has never been written down: make the budget proportional to the site count, so that sweeps per site is the constant and the step count is derived. Then the cost of a rich model shows up honestly as wall time rather than dishonestly as degraded inference.

The two are different trades. Capping KK spends capability to buy cost; rescaling spends time to buy cost. Which is right depends on whether a mature fit’s wall time is actually a problem for a person, and that is a UX question nobody has asked, not a statistics question.

auracle-taste/examples/fit_bench.rs already measures the exact axis, so the comparison is a harness run rather than a project.

16. Provenance is scored, and then ignored

Calibration splits Brier skill by duel, heard_edit, self_report, and perform_offer, for a reason it states outright:

...there is no reason to believe they are equally reliable. Scoring them against forecasts the model made before either answer arrived is the only way to find out.

The likelihood then sums all four with identical weight. The measurement was built, the answer is knowable, and nothing consumes it: the same shape as §1, one loop shorter.

A per-provenance likelihood temperature closes it. The design detail that decides whether it is honest: the temperature should be a fitted parameter, not the measured skill plugged in, because tempering a likelihood by a point estimate derived from the same data is circular. Fitted, it costs a site per provenance and buys a sentence worth having: the model learns how much to trust each way you talk to it.


Uses this was not designed for

Not directions so much as observations about what the engine already is.

Sound sets, not sounds. A style plus a diversity constraint yields a coherent family: twelve UI sounds in one voice, a game’s one-shot set, an earcon family that a person can learn. The engine is closer to this than it looks: the pool already holds diverse candidates, the map already measures spread, and what is missing is a set-level objective (maximize total utility subject to a pairwise φ\varphi distance floor), which is a submodular selection over the pool rather than a new search. It is also a different product with a different buyer.

A teaching artifact. This reference is most of a course on typed MH, reversible jump, Boltzmann targets, ordinal likelihoods, and calibration, with a runnable instrument attached and, the part no course has, negative results with their measurements.

Sound design without a modular interface. Every gesture the instrument requires reduces to which of these two. That is an accessibility property rather than a beginner property, and it is not claimed anywhere.

An instrument for a real empirical question. Whether one listener’s timbre preference is multi-modal at all is testable, style_shares is already the recording of it, and the answer is interesting whichever way it falls. Blocked on §1, like everything else here that needs people.

One risk, which is not a direction

This reference is ~5,100 lines of Markdown against 38,518 lines of Rust (measured the day this page was written, and this page is 429 of the former), beside a user guide, a brand spec, and a changelog that carries measurement tables. That ratio is why this project is good, and it is also a wall. CONTRIBUTING.md invites contributions and enforces make check and make site-check. What it does not say is which parts of the unwritten bar are negotiable: whether a contributor changing a default is expected to arrive with a paired-seed measurement, whether a new φ coordinate needs a VIF table, whether an open question may be closed by argument.

Either answer is fine. A project may reasonably be a single-author artifact with the door decoratively open. The ambiguity is the thing that deters, and it costs one paragraph to remove.

What the audition cannot hear

One assumption runs through every page of this book: that preference measured under a fixed gesture is the same thing as musical taste. This page is where that assumption is examined and found to be doing more work than it can carry.

The argument is not new here. It is already in this book, made once, in the page that replaced the v1 phrase with the v2 one:

So the grammar could express patches the audition could never reveal, and the taste model was being asked to learn preferences over evidence that was not in φ\varphi. No amount of model improvement fixes that; it is a measurement problem.

From The standard phrase.

That reasoning was applied to four holes (slow pads, sub-Hz modulation, anything above Eb4, and polyphonic stacking), and each was closed with the cheapest segment that revealed it. Then it stopped. Everything below is the same sentence, still true, about the things the v2 phrase did not reach.

Why this is a separate register

Unraised directions holds possibilities the design never considered. This page holds something different and less comfortable: one premise the design commits to everywhere, and the eight places that commitment costs something. An entry there is an opportunity; an entry here is a limit on what any amount of modeling can currently buy.

#What cannot be heardCosts
1How a patch responds to playing, velocity above allThe object being modeled
2Modulation rate and shapeThe instrument’s best feature, unrewardable
3How loud a patch natively isA whole axis of ordinary preference
4How a patch sits against other materialWhy isolated judgments mislead
5Anything tempo-relativeThe difference between a sound and a part
6What the player is looking forConverges on the average of a taste
7(a conflation rather than a gap)A free elicitation improvement, unclaimed
8(an asset already fitted, unexposed)A control only this project could offer

Seven and eight are not limits of the audition; they are places where the same premise has quietly shaped the product rather than the measurement. They are here because they come from the same root.


1. A patch is a function from performance to sound, and it is sampled once

NoteSpan, the whole of what the renderer knows about a note, is:

pub struct NoteSpan {
    pub voct: f64,      // pitch, V/Oct from C4
    pub chord: usize,   // additional gate-synced voices
    pub on_start: usize,
    pub on_end: usize,
}

There is no velocity field, anywhere in the render path. Every audition note is struck identically.

Three feet away in the same repository, the live instrument takes MIDI velocity, pitch bend, and sustain, and the guide documents playing it that way. So the instrument responds to a dimension the measurement holds constant.

The consequence is not a missing coordinate; it is a category error about what a candidate is. A patch is a function from performance to sound. φ\varphi evaluates that function at a single point and hands the result to a model that then speaks about patches. Everything downstream (θ\theta, the styles, the map, and the calibration) is a faithful model of preference over point samples of instruments, presented as a model of preference over instruments.

What is invisible as a result, in rough order of how much a player would care:

  • Velocity response. The single largest determinant of whether a patch is playable at all.
  • Note-length behavior. Whether it blooms, or only holds.
  • Retrigger under fast playing. Whether the envelope survives a run.
  • Key tracking, beyond the one octave-up check high_ratio performs.
  • The arpeggiator. It ships in the instrument and appears in no audition.

This is the one thing on this page that cannot be fixed inside φ\varphi. Every other entry is a coordinate or a surface; this is a change to what is being measured. The in-idiom version is exactly the move the v2 phrase already made: a second render at a different velocity and articulation, contributing difference coordinates (Δ\Deltabrightness per velocity, Δ\Deltalevel per octave) rather than more absolute ones. Two renders instead of one is the same cost-for-coverage trade the v2 phrase paid knowingly, and the segment-local coordinates are the precedent for measuring a contrast rather than a level.

2. Modulation is the distinctive claim, and its rate is not measured

The instrument’s distinguishing feature is that modulation is a whole chain: an s&h rand → quantize → slew can reach a cutoff, the module list exists to show where a modulator may legally go, and nearly every module carries a mod slot.

Before the motion bands, this was everything φ\varphi recorded about that:

CoordinateSays
mod_density (struct)how much of the term is modulated
mod_depth_mean (struct)how deep the modulation is
centroid_std (audio)how much brightness moved over the phrase
held_centroid_std (audio)how much it moved within one held note

Rate appeared nowhere. Shape appeared nowhere. A 0.3 Hz filter sweep and a 7 Hz tremolo at equal depth landed on near-identical coordinates. A random sample-and-hold and a sine LFO were indistinguishable to every one of them.

So “I like slow evolving movement and dislike fast wobble” was not a preference that is hard to learn. It was inexpressible, in exactly the sense the log-axis argument uses about brightness on a linear-Hz axis, and for the same reason: the model is linear in φ\varphi, so a distinction absent from the coordinates is absent from the hypothesis space.

The second-order consequence is worse than the first. Proposal tilts read the structural coefficients, and mod_density is one of them, so the tilt can learn more modulation and cannot learn slower modulation. Before the motion bands the fitness could not either, and the instrument’s most distinctive capability was the one the search could not be rewarded for using well.

The measurement already has its window open. The held note exists, in the words of its own page, to reveal “sub-Hz modulation over a register-constant sustain”. The segment was built for this and then not measured for it. An autocorrelation of the centroid or RMS envelope across that span yields two coordinates:

  • dominant modulation rate, on the shared octave axis every other frequency in φ\varphi already uses, and
  • periodicity strength: how much of the movement is periodic at all, which is what separates an LFO from a random walk.

If two numbers could be added to φ\varphi, these are the two.

Status: rate is measured now; periodicity was tried and cannot be, in this window. Rate arrived as three motion bands rather than one dominant-rate number: a single “rate” coordinate is undefined for a static patch and meaningless for a random walk, while band energies are defined for everything and let a linear model hold slow yes, fast no as two coefficients of opposite sign. On the probe ladder the band that reads highest follows the LFO rate from 0.55 Hz to 13 Hz. Periodicity failed its own test: both an autocorrelation peak and the harmonic share of the modulation spectrum separate an LFO from a random walk at 2.7 Hz and above and not at all below 1.5 Hz, because the held span holds fewer than three slow cycles. That is the range evolving textures live in, so the coordinate was not shipped; it is now a stimulus-length question, and belongs with §1.

3. Loudness is normalized away, and the raw level is already computed

Normalizing every render to −18 LUFS is correct, and the reason given is the right one: loudness bias would poison the preference data.

The other half of that trade is not stated anywhere. Loudness is part of a sound’s identity. Hits hard, sits back, has weight are ordinary, strongly-held preferences, and they have been normalized out of existence by design. crest and rms_std recover dynamics within a patch; absolute level is gone.

What makes this an easy win rather than a lament is that the number already exists. VetReport measures peak and rms on the raw, pre-normalization render (deliberately, because the gate’s thresholds are about real output level), and Features keeps both, beside the raw loudness lufs_before. None of them reaches φ\varphi. The quantity how loud is this patch natively is computed on every candidate this project has ever rendered.

Adding log⁡(raw rms)\log(\text{raw rms}) as a coordinate reintroduces no playback bias, because playback stays normalized. The confound the loudness decision guards against is between what the player hears and what they are comparing, and a feature the model reads is neither. It simply lets the model see an axis that is currently invisible.

The honest caveat, which decides it: raw level is partly an artifact of gain staging inside the grammar rather than a property anyone hears. That makes it possibly noise, and it makes the question a single sweep on existing data rather than an argument.

4. Everything is judged in a silent room

No mix context, no drums, no key, no other material. Every duel asks which of these two do you prefer, in silence.

Musicians almost never choose sounds absolutely. The bass that wins in isolation is routinely the one that disappears under a kick; the pad that wins alone is the one that eats the vocal. φ\varphi carries bass_fraction and three brightness measures, but nothing about spectral occupancy relative to other material, because there is no other material for it to be relative to.

This is probably the deepest reason isolated-audition tools feel wrong to practitioners in a way that is hard to articulate, and it bites harder here than elsewhere: the premise of this project is that the instrument is learning what the player likes, and what it is learning is what they like in an empty room.

The scope that would test it is not “build a DAW”. One player-supplied backing loop, played underneath the audition, changes what every pick means, and it interacts directly with §3, because level against a bed is exactly the judgment normalization removes.

5. There is no tempo, and the presets are working around it by hand

An LFO’s rate is sampled as u01() and mapped to Hz. The live instrument has a tempo (the arpeggiator’s, which MIDI clock can set) and a SYNC switch, but SYNC reaches only step sequencers: it snaps each one’s rate to the nearest division of the tempo. LFO rates, and everything the audition renders, have no tempo at all. A Clock tempo exists for the euclidean sequencer’s own bpm port, and it is not a session-level musical clock.

The evidence that this costs something is in this repository’s own preset source, in comments:

rate: 0.55, // ≈83 bpm
rate: 0.62, // ≈107 bpm
p0: 0.55,   // ≈83 bpm

Preset authors are hand-computing, in comments, the thing a sync control would compute for an LFO. An unsynced modulator is among the most common reasons a good patch is unusable in a track: it can be set close by ear and it drifts over sixteen bars.

For an instrument whose output is meant to end up in music, tempo-relative rates are not a convenience. They are the difference between a sound and a part, and they would also give §2 a natural axis to express rate on, since a player’s preference about movement is far more plausibly “a cycle per bar” than “0.34 Hz”.

6. The loop is selection; sound design is pursuit

A duel asks which do you prefer. A player with a sound in their head wants get closer to this.

Locks plus ⚡ evolve from this is a genuine pursuit affordance and a good one. But the primary loop is judging what the player is handed, and the target-directed entry in the other register is better understood from here: it is not a feature, it is the missing half of a workflow.

There is a sharper form of this, and it is about what the model converges to. A preference fitted over everything a person has ever voted on approaches the average of their taste. Max-of-experts answers part of it (islands rather than a centroid, which is the whole reason for the max), but the islands are discovered from φ\varphi, not selected by intent. So on any given evening the instrument proposes toward the union of everything the player has ever liked, when what they need is the one corner they are working in tonight.

That is the musical argument for declared context, and it is a stronger one than the modeling argument on that page. It is the difference between a tool that knows the player and a tool that is useful today.

7. Comparability constrains the measurement, not the listener

The decisions log records the audition as “Standard 5.05 s phrase + free-play”, with the rationale “feature comparability requires fixed stimulus”.

The rationale is true of φ\varphi. It is not true of the person.

φ\varphi comes from a deterministic offline render. What a human hears while deciding is an independent choice, and could be anything at all, including the player’s own playing, on both patches, with the same lick. The coordinates would remain exactly as comparable, because nothing about them depends on what came out of the speakers during the vote.

So a duel in the player’s own hands costs φ\varphi nothing. It is a far more musical elicitation than voting on four notes chosen by the instrument, and it is the direct remedy for §1: a player testing velocity response themselves is measuring the dimension the phrase holds constant, even if the coordinates still cannot see it.

The cost, stated rather than hidden. The vote becomes a judgment about an experience the coordinates only partly describe. That adds noise, and adds bias if a player’s own gestures systematically emphasize something the phrase does not. But it is noise in the measurement of something a player cares about, against precision about something they do not. The project already owns the instrument that would detect it, since calibration by provenance exists precisely to score differently-collected answers against each other rather than assume they are equivalent. A PlayedDuel provenance would make this measurable on arrival.

8. Six fixed controls, when the personal axis is already fitted

The rack exposes every trace address, and the word macro does not appear in the web app. A player performing wants two to four hands-on controls, not one per address in the term. PERFORM’s six named controls (Bright, Snap, Motion, Body, Grit, and Space) are macros in all but name, but each is a fixed direction in φ\varphi, the same for every player (Performance).

The interesting part is that the fitted model already contains the personal axes. A style θk\theta_k is a direction in feature space whose meaning is what this player cares about. A macro that moves a patch along the model’s top learned direction is a control that is personal by construction (bite for one player, movement for another), and nothing else can offer it, because nothing else has fitted the model.

It is also the continuous form of the counterfactual explainer: same posterior, same trace addresses, same predicted-gain arithmetic. One reports a discrete edit; the other puts the direction under a finger.


If these were ordered by value per unit of work

  1. Two coordinates for modulation rate and periodicity (§2). Rate done, as three motion bands; periodicity blocked on stimulus length. Cheapest and highest value: it lets the search be rewarded for the instrument’s best feature, and the segment it needs already exists for this exact purpose.
  2. log(raw rms) as a coordinate (§3). Already measured on every candidate, currently discarded; recovers an axis of ordinary preference at zero audition cost.
  3. Velocity in the phrase, as difference coordinates (§1). The expensive one, and the only one that moves the modeled object from a sound toward an instrument.
  4. Tempo-relative modulation rates (§5). Turns output into something usable in a track, and gives §2 its natural axis.
  5. Declared context (§6), promoted from the other register on musical grounds.
  6. Duels in the player’s own hands (§7). Nearly free, conceptually the largest change, and measurable via provenance from the day it ships.

The summary worth keeping

This is an unusually rigorous instrument for measuring timbre preference under a fixed gesture, and much of the design reads as though that were the same thing as musical taste. It is not, and the gap between them is where every entry above lives.

The tool for closing it is already in the book. The grammar can express what the audition cannot reveal, and no amount of model improvement fixes a measurement problem: applied once, to four holes, and it deserves to be applied to the rest.

API documentation

Generated rustdoc for every crate in the workspace.

  • auracle_grammar: the genome, typed PCFG, trace codec, compiler, structural edits, presets
  • auracle_features: render, vet, LUFS-normalize, extract φ\varphi
  • auracle_taste: the utility model, three likelihoods, MCMC posterior, standardization
  • auracle_session: the two-loop engine, acquisition, calibration, persistence
  • auracle_wasm: WasmEngine 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^6RUNAWAY_RATIO in auracle-taste’s standardize.rs (private, so read it in the source)
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 labor 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(A≻B)=σ(uA−uB)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 signals a refit is due.

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.

Numerics

Mächler, M. (2012). Accurately Computing log(1 − exp(−|a|)), Assessed by the Rmpfr Package. Vignette of the R package Rmpfr. → log1mexp, the log⁡(1−e−d)\log(1 - e^{-d}) that keeps a star rating’s log-probability exact far outside its cutpoints and in a category squeezed nearly shut. Used in

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, and the bloat problem that a prior rather than a penalty addresses. Auracle takes the tree representation and not Koza’s subtree crossover: every child grows from one seed.

Takagi, H. (2001). Interactive Evolutionary Computation: Fusion of the Capabilities of EC Optimization and Human Evaluation. Proc. IEEE 89(9), 1275–1296. → The canonical statement of interactive evolution’s user-fatigue bottleneck, which is the problem the two-loop architecture and the learned surrogate exist to solve.

Audio features and loudness

ITU-R BS.1770-4 (2015). Algorithms to measure audio programme loudness and true-peak audio level. → K-weighting, 400 ms gated blocks, the two gates. Implemented here

EBU R 128 (2020). Loudness normalisation and permitted maximum level of audio signals. → The practice around BS.1770 that makes −18 LUFS a sensible target.

Peeters, G. (2004). A large set of audio features for sound description. CUIDADO project report, IRCAM. → Spectral centroid, spread, flatness, rolloff, and flux, in the definitions φ_audio uses.

Bregman, A. S. (1990). Auditory Scene Analysis. MIT Press. → Background for why octave-based frequency axes and segment-local measurements are the right coordinates for a perceptual feature vector.

Dau, T., Kollmeier, B. & Kohlrausch, A. (1997). Modeling auditory processing of amplitude modulation. I. Detection and masking with narrow-band carriers. JASA 102(5), 2892–2905. → The modulation filterbank: hearing sorts envelope fluctuation by rate. Why motion is measured in bands and not as one variance.

McDermott, J. H. & Simoncelli, E. P. (2011). Sound texture perception via statistics of the auditory periphery. Neuron 71(5), 926–940. → Band-wise modulation power is much of what makes a texture recognizable; the grounding for treating motion rate as a first-class axis of taste.

Statistics of the feature space

Belsley, D. A., Kuh, E. and Welsch, R. E. (1980). Regression Diagnostics: Identifying Influential Data and Sources of Collinearity. Wiley. → Variance inflation factors, the diagnostic that found two exact dependencies in φ_struct.

Haufe, S., Meinecke, F., Görgen, K., Dähne, S., Haynes, J.-D., Blankertz, B. and Bießmann, F. (2014). On the Interpretation of Weight Vectors of Linear Models in Multivariate Neuroimaging. NeuroImage 87, 96–110. → A direction’s pattern Σa\Sigma a versus its filter aa, and why a wiring tried and not shipped.

Schäfer, J. and Strimmer, K. (2005). A Shrinkage Approach to Large-Scale Covariance Matrix Estimation and Implications for Functional Genomics. Statistical Applications in Genetics and Molecular Biology 4(1), Article 32. → The shrinkage that made that measurement fair.

Huber, P. J. (1981). Robust Statistics. Wiley. → Winsorizing, and the reasoning behind using it as a fault detector rather than routinely.

The libraries

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–(open)(open)

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