Skip to main content

Module structural

Module structural 

Source
Expand description

φ_struct: render-free descriptors of the term itself.

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. They also capture taste axes audio features can’t fully separate (“likes supersaws”, “likes deep modulated chains”).

§φ carries families, not one column per module

StructFeatures keeps a raw counter per kind — the Styles tab and the auto-namer both want “two filters”, not “two subtractive stages” — but StructFeatures::NAMES and StructFeatures::to_vec collapse the forty-one module kinds into fourteen family counts plus seven term-level numbers — five about modulation and the amp envelope, two about how the term is arranged. Two reasons, and the second is the load-bearing one:

  • Nothing meaningful distinguishes them. n_fold, n_distortion and n_bitcrush all answer “how much nonlinear colour”; n_chorus, n_phaser, n_flanger, n_tremolo and n_vibrato all answer “how much periodic movement”. A user who likes drive does not first decide which drive.
  • Per-kind columns arrive as near-indicator variables. The prior draws bitcrush at 2.5%, ring mod at 2% and granular at 1.5%, so those columns are zero in ~19 of every 20 pool members. A coefficient fitted on a column that is almost always zero is estimated from a handful of rows, and the Styles tab renders it beside coefficients fitted on hundreds. Sixteen sparse columns also cost sixteen dimensions of posterior variance for the cold start to pay down before the model says anything at all. Five of wave 2A’s six newcomers would have arrived under 3% prevalence, and all five of wave 2B’s do. Wave 2C is the extreme case: measured over 1200 draws, each of its four CV processors appears in under 4% of patches and each of its six combiners in under 1% — a column that is zero in 99 rows of every 100, which is not a coefficient, it is a rounding error with a name in the Styles tab.

§What is deliberately not in φ

size, depth and n_mix. Every audio node increments exactly one raw counter, so size ≡ Σ n_*exactly, for every tree. Including it makes the design matrix rank-deficient: the Gaussian prior keeps the posterior proper, 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 shown in the Styles tab mean nothing individually), and poisons the taste→grammar proposal tilt, which reads exactly those coefficients. size − depth would be no better: it is still an exact linear combination of coordinates already present. The field is kept for display and naming; it just never reaches the model.

Dropping size alone was not enough, which a VIF sweep over 300 prior draws caught (cargo run -p auracle-features --example pipeline_stats --release -- 300). 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. Wave 2B is where that stops being a two-term statement — the compressor, ducker, gate and vocoder each take two audio subterms, exactly as mix and ring mod do — so the identity generalizes to:

n_vco + n_supersaw + n_noise + n_wavetable + n_pluck + n_formant
    + n_silence
    − n_mix − n_ringmod − n_comp − n_duck − n_gate − n_vocoder = 1
                                           (exactly, for every tree)

n_silence joins that sum as a source leaf, which 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 n_mix stays the column dropped. Leaving it out 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 hold than an exact dependency, because an exact one is visible in a VIF sweep and a near-exact one is a large number that looks like a judgment call.

(Wave 2A added one source and five unary operators, so it only moved a source term. Wave 2B’s Shift is unary too and does not appear here.)

— a second exact dependency, reported as VIF ≈ 10⁹ on every column in it.

That is one equation, so exactly one column has to go, and dropping more would remove real dimensions rather than redundant ones: with both binary counts gone, φ could not tell a crossfade from a ring modulator at all, which are about as different as two nodes in this grammar get. n_mix leaves (it is the one determined by the others, and its proposal tilt is recovered from the source coefficients in auracle_session’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 the compressor, ducker and gate inside n_dynamics.

That last one is the case worth checking rather than assuming, because n_dynamics is exactly n_comp + n_duck + n_gate and it is a retained column — the only family in φ whose members are all on the wrong side of the identity. It is still safe, and the reason is that the identity needs each binary count separately: n_ringmod is only ever visible summed with folds, distortions and bitcrushers, and n_vocoder only summed with filters and eqs, so no linear combination of the retained columns isolates either and the equation cannot be reconstructed. n_dynamics on its own supplies three of the six binary terms and nothing supplies the other three. Confirmed empirically, not just argued: on a 1200-draw sweep every structural coordinate came back well under 10, with n_dynamics at 1.9.

The prevalence argument points the same way independently: the prior draws ring mod into ~3.5% of patches and each of the four 2B binaries into fewer, so any of them as a standalone column would be the near-indicator variable this whole section exists to avoid.

depth goes too, on a weaker but real argument: VIF ≈ 21.7. Not exact — the posterior stays proper — but a coefficient that unstable is not individually meaningful, and the Styles tab renders these per-feature weights as if they were.

Still standing, and deliberately: rolloff_mean ≈ 18.4, zcr_mean ≈ 10.4 against centroid_mean ≈ 5.9 (re-measured over 1200 draws of the 2C prior; they were 19.4 / 11.4 / 5.9 under 2B, 19.8 / 12.0 / 8.2 under 2A and 24.7 / 16.6 / 6.6 under v1). That is the brightness cluster — three genuine measurements of one perceptual thing. Dropping any of them discards real signal rather than redundancy, so the right fix is a shared/fused prior over the cluster, which is a modelling change rather than a feature change and is not in this pass.

Every family coordinate came back under 4 on that sweep, the highest being mod_depth_mean at 3.8 — which is the whole reason the families exist. Forty separate module columns is the design that would not have. The three wave-2C additions specifically: n_mod_shape 1.6, n_mod_logic 1.3, mod_depth_mean 3.8, with mod_density rising from 2.7 to 4.1 as the one visible cost of adding a second modulation-shape coordinate beside it.

§The families, and why each one is one column

  • n_filter = filter + eq + vocoder. Spectral tilt: a resonant filter and a tone control are the same question at different sharpnesses, and a vocoder is that question with the curve drawn by a signal.
  • n_drive = fold + distortion + bitcrush + ring mod. Nonlinear colour.
  • n_time = delay + granular + pitch shift. Smearing a signal in time. Renamed from n_delay in wave 2A, because the column now counts three ways of doing it and a name that says “delay” while counting granulators is a lie the Styles tab would render as if it meant something.
  • n_mod_fx = chorus + phaser + flanger + tremolo + vibrato. Periodic movement — an LFO on a short delay, an allpass chain or a gain.
  • n_dynamics = compressor + ducker + gate. Level shaped by a second signal. The one family whose members all sit inside the binary-node identity above, which is why that paragraph checks it rather than assuming it.
  • n_mod_shape = quantizer + slew + rectifier + clocked hold. CV that has been worked on before it lands.
  • n_mod_logic = euclid + min + max + and + or + xor + switch. Gate and decision CV. See below for why the euclid is counted here rather than with the other modulation leaves.

§Wave 2C: the modulation sort has its own identity, and it is not exact

Modulation became a recursive sort, so a patch now carries a forest of modulation terms as well as one audio tree — and forests have the same kind of leaf-versus-branch identity the audio tree does. With f filled slots, p binary combiners and u unary processors, the forest has exactly f + p leaves:

n_lfo + n_env + n_rand + n_follow + n_euclid
    = filled_slots + (n_min + n_max + n_and + n_or + n_xor + n_switch)

Two things stop that reaching φ as a dependency. filled_slots is not a coordinate — mod_density is filled/slots, a ratio, and no linear combination recovers the numerator without the denominator. And the euclid is summed into n_mod_logic with the combiners rather than sitting with the other leaves, so the two sides of the equation are not separately visible: what φ carries is n_euclid + Σcombiners, and the identity needs them with opposite signs. Grouping it there is also the honest reading — what a euclidean generator emits is a gate, which is what the logic ops consume and produce — but the identity is the reason it is not a judgement call.

mod_depth_mean is the new term-level number, and it is the coordinate that actually says “this person likes modulation that has been shaped”: the counts say how many processors are in the patch, the mean depth says how deep the chains they sit in are, and a patch with four one-deep modulators is a different animal from one with a single three-deep chain. It is averaged over the filled slots only, so it is not a second reading of mod_density — measured, the two come back at 3.8 and 4.1 rather than at the double figures a restatement would give.

§Wave 3: two coordinates that carry arrangement

Everything above counts what is in the patch. Nothing above says how it is put together. filter(mix(vco, vco)) and mix(filter(vco), vco) have byte-identical φ under the twenty-three columns that shipped before this wave — one filter, two VCOs, one mixer that is not a column anyway — and they are two different instruments: one filters the sum, the other filters a layer and leaves the other dry. Two coordinates fix that. Four were written. The two that were cut, and why, are the more useful half of this section, because both were cut by measurement rather than by argument and the arguments for them had looked fine.

§branch_width_max: the identity, walking back in

WS-8 §4 asked for a parallelism coordinate on the reading that serial vco→filter→drive and parallel mix(vco→filter, vco→drive) “differ only in n_mix, which is deliberately not a φ column”. That reading is wrong, and the identity two sections up is the reason: the leaf count is 1 + Σ binaries exactly, so a patch cannot gain a mixer without gaining a source, and the six source counts have been in φ since v1. The serial patch has one VCO and the parallel one has two. φ could always see that.

branch_width_max — the largest number of audio nodes sharing one level of the tree — was written, put through the VIF sweep, and came back at 10.4, taking n_vco from 3.1 to 9.1 and every other source count with it. On trees this size (mean 3.1 nodes, mean depth 2.6) the widest level is the leaf set, so the column was very nearly Σ sources, which is very nearly 1 + Σ binaries: the same rank deficiency size and n_mix were removed for, under a new name.

The evolution measurement said the same thing independently. A synthetic listener whose taste was “likes wide patches” was learned to Spearman 0.709 by the old feature set, with no arrangement coordinate at all — because wanting a wider tree is wanting more sources, and it was reading the source counts. So the honest statement of what was missing is not parallelism but arrangement: given the same modules, how are they wired.

§mod_at_source: a tie the measurement could not break

The modulation half of φ carries density and chain length and no destination, so “an LFO on the filter” and “an LFO on the oscillator” are the same patch to the model. mod_at_source — mean over the filled slots of the owning module’s distance from the root, over the tree’s depth — is the obvious fix, and it measured well: VIF 3.0, spread across the whole [0, 1] range, no exact dependency anywhere near it.

It is not in φ, and the honest reason is not the one the first run suggested. Over 8 seeds, three new columns took search_health’s pool climb from +1.714 to +1.320, the best patch found from 8.154 to 6.503, and the seeds that climbed from 7 of 8 to 5 of 8 — which reads as an unambiguous regression and was very nearly acted on as one. Re-run at 16 seeds with the per-seed numbers printed (--climb, added for exactly this), the harness turned out to have a standard error of ±0.64 on that quantity, and the paired differences against the same seed list are:

two columns:    gain +0.35 ± 0.73     best patch −1.00 ± 0.63
three columns:  gain −0.33 ± 0.74     best patch −0.93 ± 0.76

Neither is a regression this instrument can see, and neither is an improvement. So the tie is broken on cost rather than on evidence, and the cost is real even when it is not measurable: every column is a dimension of posterior variance the cold start has to pay down, which is the general form of the near-indicator argument several sections up. Two columns answer the question this wave was asked — given the same modules, how are they wired — and the third answers a different one. It stays as a display field and is the obvious candidate for the next wave with evidence to spend, measured on its own so the attribution survives.

The first reading is written down rather than quietly deleted because the failure it represents is the expensive one: an eight-seed run of a noisy harness looks exactly like a finding.

§The two that stayed

Both are ratios of shape sums, and that is the design constraint, not a stylistic one. Every identity in the sections above is a linear equation over counts; a ratio of two path sums is not linear in any count, so neither can supply a term an identity needs, and neither re-admits size, depth or n_mix through a side door. Being ratios also keeps them scale-free, which is why they do not simply re-measure depth (VIF 21.7, dropped). Over 300 draws: chain_balance 2.7, frac_sidechained 2.4. The cost, stated rather than buried: the source counts rise a little (n_vco 3.1 → 4.3, n_supersaw 2.7 → 3.5) because how a patch is arranged is correlated with how many sources it has even though it is not determined by it, and mod_density and mod_depth_mean move by a tenth. Compare branch_width_max’s 10.4 and 3.1 → 9.1, which is the difference between a correlated column and a redundant one.

  • chain_balance — mean source-to-root path length over the longest one. 1.0 when every source is the same distance from the amp (a serial chain, or a symmetric mix); below 1.0 when one branch is a long chain and the other is a bare oscillator. This is the coordinate that separates the two patches at the top: 1.000 for filter(mix(vco, vco)), 0.833 for mix(filter(vco), vco). It is the load-bearing one — it catches an asymmetric branch whichever side the chain is on.
  • frac_sidechained — of the binary nodes, the fraction whose /1 child is not a bare source. That is the half chain_balance cannot say: which side. /1 is the ducker’s key, the compressor’s sidechain and the vocoder’s modulator, so the difference between a key that is a raw pulse and one that is a whole drum bus lands here and nowhere else. 0 when there are no binaries, on the same rule as mod_density.

§What the evolution loop said

The standing rule is that a green make check says nothing about search health, so both columns went through search_health and closed_loop_sweep on both sides of the change. The table lives on auracle-session’s search_health module doc, next to the harness that produced it; the short version is that nothing regressed, the MH acceptance rate and the locked-refine hit rate went up slightly, and the calibration r was flat at 0.693 → 0.688 (se ±0.018).

Two readings are worth repeating here because they are about these columns rather than about the harness:

  • The model’s ranking got better, for a listener who does not care about routing. Spearman between the fitted utility and the truth went 0.318 → 0.389 and the true best patch survived its generation 48 times out of 48 rather than 47. Two more coordinates to be wrong about did not make the ranking noisier; on this evidence they made the pool easier to tell apart.
  • For a listener who does care, all four numbers move together. A synthetic user who wants an asymmetric, sidechained routing is fitted at 0.705 rather than 0.662, and the search leaves them a pool that is 82% sidechained rather than 71.6%. The before arm is not blind — an asymmetric routing also sounds different, and the audio half of φ hears that — so what the columns buy is the model knowing why.

What they deliberately do not carry: node order along a serial chain. drive(filter(vco)) and filter(drive(vco)) remain indistinguishable, and fixing that needs a coordinate per ordered pair of families — 91 of them — which is the near-indicator disaster the family scheme exists to avoid. The honest reading is that φ now sees the graph, not the sequence.

Structs§

StructFeatures
Named structural descriptors. to_vec order matches StructFeatures::NAMES.

Functions§

struct_features
Extract StructFeatures from a term (no compile, no render).