auracle_features/structural.rs
1//! `φ_struct`: render-free descriptors of the term itself.
2//!
3//! These cost nothing (no compile, no render), which is what makes the
4//! screening cascade work: a struct-only surrogate prunes candidates before
5//! the expensive render path. They also capture taste axes audio features
6//! can't fully separate ("likes supersaws", "likes deep modulated chains").
7//!
8//! # φ carries **families**, not one column per module
9//!
10//! [`StructFeatures`] keeps a raw counter per kind — the Styles tab and the
11//! auto-namer both want "two filters", not "two subtractive stages" — but
12//! [`StructFeatures::NAMES`] and [`StructFeatures::to_vec`] collapse the
13//! forty-one module kinds into fourteen family counts plus seven term-level
14//! numbers — five about modulation and the amp envelope, two about how the
15//! term is arranged. Two reasons, and the second is the load-bearing one:
16//!
17//! - **Nothing meaningful distinguishes them.** `n_fold`, `n_distortion` and
18//! `n_bitcrush` all answer "how much nonlinear colour"; `n_chorus`,
19//! `n_phaser`, `n_flanger`, `n_tremolo` and `n_vibrato` all answer "how much
20//! periodic movement". A user who likes drive does not first decide *which*
21//! drive.
22//! - **Per-kind columns arrive as near-indicator variables.** The prior draws
23//! bitcrush at 2.5%, ring mod at 2% and granular at 1.5%, so those columns
24//! are zero in ~19 of every 20 pool members. A coefficient fitted on a
25//! column that is almost always zero is estimated from a handful of rows,
26//! and the Styles tab renders it beside coefficients fitted on hundreds.
27//! Sixteen sparse columns also cost sixteen dimensions of posterior variance
28//! for the cold start to pay down before the model says anything at all.
29//! Five of wave 2A's six newcomers would have arrived under 3% prevalence,
30//! and *all five* of wave 2B's do. Wave 2C is the extreme case: measured
31//! over 1200 draws, each of its four CV processors appears in under 4% of
32//! patches and each of its six combiners in under 1% — a column that is
33//! zero in 99 rows of every 100, which is not a coefficient, it is a
34//! rounding error with a name in the Styles tab.
35//!
36//! # What is deliberately *not* in φ
37//!
38//! **`size`, `depth` and `n_mix`.** Every audio node increments
39//! exactly one raw counter, so `size ≡ Σ n_*` — *exactly*, for every tree.
40//! Including it makes the design matrix rank-deficient: the Gaussian prior
41//! keeps the posterior proper, but there is an unidentified ridge along which
42//! the MH chain random-walks forever. That wrecks mixing, splits each
43//! coefficient arbitrarily between `size` and the counts (so the per-feature
44//! weights shown in the Styles tab mean nothing individually), and poisons the
45//! taste→grammar proposal tilt, which reads exactly those coefficients.
46//! `size − depth` would be no better: it is still an exact linear combination
47//! of coordinates already present. The field is kept for display and naming;
48//! it just never reaches the model.
49//!
50//! Dropping `size` alone was **not enough**, which a VIF sweep over 300 prior
51//! draws caught (`cargo run -p auracle-features --example pipeline_stats
52//! --release -- 300`). A tree is a forest of source leaves joined by
53//! productions that each take some number of audio children, so the leaf count
54//! exceeds the *total* branch count by exactly one. Wave 2B is where that stops
55//! being a two-term statement — the compressor, ducker, gate and vocoder each
56//! take two audio subterms, exactly as mix and ring mod do — so the identity
57//! generalizes to:
58//!
59//! ```text
60//! n_vco + n_supersaw + n_noise + n_wavetable + n_pluck + n_formant
61//! + n_silence
62//! − n_mix − n_ringmod − n_comp − n_duck − n_gate − n_vocoder = 1
63//! (exactly, for every tree)
64//! ```
65//!
66//! `n_silence` joins that sum as a source leaf, which is what it is: it has no
67//! children, so it ends a branch exactly as a `Vco` does. Joining keeps this
68//! **one** equation with **one** dropped column, and `n_mix` stays the column
69//! dropped. Leaving it out instead would make the identity exact for a tree
70//! with no holes and slack for one with them — near-exact almost always, which
71//! is a worse thing to hold than an exact dependency, because an exact one is
72//! visible in a VIF sweep and a near-exact one is a large number that looks
73//! like a judgment call.
74//!
75//! (Wave 2A added one source and five unary operators, so it only moved a
76//! source term. Wave 2B's `Shift` is unary too and does not appear here.)
77//!
78//! — a second exact dependency, reported as VIF ≈ 10⁹ on every column in it.
79//!
80//! That is **one** equation, so exactly **one** column has to go, and dropping
81//! more would remove real dimensions rather than redundant ones: with both
82//! binary counts gone, φ could not tell a crossfade from a ring modulator at
83//! all, which are about as different as two nodes in this grammar get. `n_mix`
84//! leaves (it is the one determined by the others, and its proposal tilt is
85//! recovered from the source coefficients in `auracle_session`'s
86//! `biased_prior`); the other five stay, but **never as columns of their own**:
87//! ring mod lives inside `n_drive`, the vocoder inside `n_filter`, and the
88//! compressor, ducker and gate inside `n_dynamics`.
89//!
90//! That last one is the case worth checking rather than assuming, because
91//! `n_dynamics` is *exactly* `n_comp + n_duck + n_gate` and it is a **retained**
92//! column — the only family in φ whose members are all on the wrong side of the
93//! identity. It is still safe, and the reason is that the identity needs each
94//! binary count *separately*: `n_ringmod` is only ever visible summed with
95//! folds, distortions and bitcrushers, and `n_vocoder` only summed with filters
96//! and eqs, so no linear combination of the retained columns isolates either
97//! and the equation cannot be reconstructed. `n_dynamics` on its own supplies
98//! three of the six binary terms and nothing supplies the other three.
99//! Confirmed empirically, not just argued: on a 1200-draw sweep every
100//! structural coordinate came back well under 10, with `n_dynamics` at 1.9.
101//!
102//! The prevalence argument points the same way independently: the prior draws
103//! ring mod into ~3.5% of patches and each of the four 2B binaries into fewer,
104//! so any of them as a standalone column would be the near-indicator variable
105//! this whole section exists to avoid.
106//!
107//! `depth` goes too, on a weaker but real argument: VIF ≈ 21.7. Not exact —
108//! the posterior stays proper — but a coefficient that unstable is not
109//! individually meaningful, and the Styles tab renders these per-feature
110//! weights as if they were.
111//!
112//! Still standing, and deliberately: `rolloff_mean` ≈ 18.4, `zcr_mean` ≈ 10.4
113//! against `centroid_mean` ≈ 5.9 (re-measured over 1200 draws of the 2C
114//! prior; they were 19.4 / 11.4 / 5.9 under 2B, 19.8 / 12.0 / 8.2 under 2A
115//! and 24.7 / 16.6 / 6.6 under v1). That is the brightness cluster — three
116//! genuine measurements of one perceptual thing. Dropping any of them
117//! discards real signal rather than redundancy, so the right fix is a
118//! shared/fused prior over the cluster, which is a modelling change rather
119//! than a feature change and is not in this pass.
120//!
121//! Every family coordinate came back under 4 on that sweep, the highest being
122//! `mod_depth_mean` at 3.8 — which is the whole reason the families exist.
123//! Forty separate module columns is the design that would not have. The three
124//! wave-2C additions specifically: `n_mod_shape` 1.6, `n_mod_logic` 1.3,
125//! `mod_depth_mean` 3.8, with `mod_density` rising from 2.7 to 4.1 as the one
126//! visible cost of adding a second modulation-shape coordinate beside it.
127//!
128//! # The families, and why each one is one column
129//!
130//! - `n_filter` = filter + eq + vocoder. Spectral tilt: a resonant filter and
131//! a tone control are the same question at different sharpnesses, and a
132//! vocoder is that question with the curve drawn by a signal.
133//! - `n_drive` = fold + distortion + bitcrush + ring mod. Nonlinear colour.
134//! - `n_time` = delay + granular + pitch shift. Smearing a signal in time.
135//! **Renamed** from `n_delay` in wave 2A, because the column now counts
136//! three ways of doing it and a name that says "delay" while counting
137//! granulators is a lie the Styles tab would render as if it meant
138//! something.
139//! - `n_mod_fx` = chorus + phaser + flanger + tremolo + vibrato. Periodic
140//! movement — an LFO on a short delay, an allpass chain or a gain.
141//! - `n_dynamics` = compressor + ducker + gate. Level shaped by a second
142//! signal. The one family whose members all sit inside the binary-node
143//! identity above, which is why that paragraph checks it rather than
144//! assuming it.
145//! - `n_mod_shape` = quantizer + slew + rectifier + clocked hold. CV that has
146//! been worked on before it lands.
147//! - `n_mod_logic` = euclid + min + max + and + or + xor + switch. Gate and
148//! decision CV. See below for why the euclid is counted here rather than
149//! with the other modulation leaves.
150//!
151//! # Wave 2C: the modulation sort has its own identity, and it is not exact
152//!
153//! Modulation became a recursive sort, so a patch now carries a *forest* of
154//! modulation terms as well as one audio tree — and forests have the same
155//! kind of leaf-versus-branch identity the audio tree does. With `f` filled
156//! slots, `p` binary combiners and `u` unary processors, the forest has
157//! exactly `f + p` leaves:
158//!
159//! ```text
160//! n_lfo + n_env + n_rand + n_follow + n_euclid
161//! = filled_slots + (n_min + n_max + n_and + n_or + n_xor + n_switch)
162//! ```
163//!
164//! Two things stop that reaching φ as a dependency. `filled_slots` is not a
165//! coordinate — `mod_density` is `filled/slots`, a *ratio*, and no linear
166//! combination recovers the numerator without the denominator. And the euclid
167//! is summed into `n_mod_logic` **with** the combiners rather than sitting
168//! with the other leaves, so the two sides of the equation are not separately
169//! visible: what φ carries is `n_euclid + Σcombiners`, and the identity needs
170//! them with opposite signs. Grouping it there is also the honest reading —
171//! what a euclidean generator emits is a gate, which is what the logic ops
172//! consume and produce — but the identity is the reason it is not a
173//! judgement call.
174//!
175//! `mod_depth_mean` is the new term-level number, and it is the coordinate
176//! that actually says "this person likes modulation that has been *shaped*":
177//! the counts say how many processors are in the patch, the mean depth says
178//! how deep the chains they sit in are, and a patch with four one-deep
179//! modulators is a different animal from one with a single three-deep chain.
180//! It is averaged over the *filled* slots only, so it is not a second reading
181//! of `mod_density` — measured, the two come back at 3.8 and 4.1 rather than
182//! at the double figures a restatement would give.
183//!
184//! # Wave 3: two coordinates that carry *arrangement*
185//!
186//! Everything above counts **what is in** the patch. Nothing above says
187//! **how it is put together**. `filter(mix(vco, vco))` and
188//! `mix(filter(vco), vco)` have byte-identical φ under the twenty-three
189//! columns that shipped before this wave — one filter, two VCOs, one mixer
190//! that is not a column anyway — and they are two different instruments: one
191//! filters the sum, the other filters a layer and leaves the other dry. Two
192//! coordinates fix that. **Four were written.** The two that were cut, and
193//! why, are the more useful half of this section, because both were cut by
194//! measurement rather than by argument and the arguments for them had looked
195//! fine.
196//!
197//! ## `branch_width_max`: the identity, walking back in
198//!
199//! WS-8 §4 asked for a **parallelism** coordinate on the reading that serial
200//! `vco→filter→drive` and parallel `mix(vco→filter, vco→drive)` "differ only
201//! in `n_mix`, which is deliberately not a φ column". That reading is wrong,
202//! and the identity two sections up is the reason: the leaf count is
203//! `1 + Σ binaries` **exactly**, so a patch cannot gain a mixer without
204//! gaining a source, and the six source counts have been in φ since v1. The
205//! serial patch has one VCO and the parallel one has two. φ could always see
206//! that.
207//!
208//! `branch_width_max` — the largest number of audio nodes sharing one level of
209//! the tree — was written, put through the VIF sweep, and came back at
210//! **10.4**, taking `n_vco` from 3.1 to 9.1 and every other source count with
211//! it. On trees this size (mean 3.1 nodes, mean depth 2.6) the widest level
212//! *is* the leaf set, so the column was very nearly `Σ sources`, which is very
213//! nearly `1 + Σ binaries`: the same rank deficiency `size` and `n_mix` were
214//! removed for, under a new name.
215//!
216//! The evolution measurement said the same thing independently. A synthetic
217//! listener whose taste was "likes wide patches" was learned to Spearman 0.709
218//! by the *old* feature set, with no arrangement coordinate at all — because
219//! wanting a wider tree is wanting more sources, and it was reading the source
220//! counts. So the honest statement of what was missing is not *parallelism*
221//! but **arrangement**: given the same modules, how are they wired.
222//!
223//! ## `mod_at_source`: a tie the measurement could not break
224//!
225//! The modulation half of φ carries density and chain length and **no
226//! destination**, so "an LFO on the filter" and "an LFO on the oscillator" are
227//! the same patch to the model. `mod_at_source` — mean over the filled slots
228//! of the owning module's distance from the root, over the tree's depth — is
229//! the obvious fix, and it measured well: VIF 3.0, spread across the whole
230//! `[0, 1]` range, no exact dependency anywhere near it.
231//!
232//! It is not in φ, and the honest reason is not the one the first run
233//! suggested. Over 8 seeds, three new columns took `search_health`'s pool
234//! climb from `+1.714` to `+1.320`, the best patch found from `8.154` to
235//! `6.503`, and the seeds that climbed from 7 of 8 to 5 of 8 — which reads as
236//! an unambiguous regression and was very nearly acted on as one. Re-run at
237//! **16 seeds with the per-seed numbers printed** (`--climb`, added for
238//! exactly this), the harness turned out to have a standard error of **±0.64**
239//! on that quantity, and the paired differences against the same seed list
240//! are:
241//!
242//! ```text
243//! two columns: gain +0.35 ± 0.73 best patch −1.00 ± 0.63
244//! three columns: gain −0.33 ± 0.74 best patch −0.93 ± 0.76
245//! ```
246//!
247//! Neither is a regression this instrument can see, and neither is an
248//! improvement. So the tie is broken on cost rather than on evidence, and the
249//! cost is real even when it is not measurable: **every column is a dimension
250//! of posterior variance the cold start has to pay down**, which is the
251//! general form of the near-indicator argument several sections up. Two
252//! columns answer the question this wave was asked — given the same modules,
253//! how are they wired — and the third answers a different one. It stays as a
254//! display field and is the obvious candidate for the next wave with evidence
255//! to spend, measured on its own so the attribution survives.
256//!
257//! The first reading is written down rather than quietly deleted because the
258//! failure it represents is the expensive one: an eight-seed run of a noisy
259//! harness looks exactly like a finding.
260//!
261//! ## The two that stayed
262//!
263//! Both are **ratios of shape sums**, and that is the design constraint, not a
264//! stylistic one. Every identity in the sections above is a *linear* equation
265//! over counts; a ratio of two path sums is not linear in any count, so
266//! neither can supply a term an identity needs, and neither re-admits `size`,
267//! `depth` or `n_mix` through a side door. Being ratios also keeps them
268//! scale-free, which is why they do not simply re-measure `depth` (VIF 21.7,
269//! dropped). Over 300 draws: `chain_balance` **2.7**, `frac_sidechained`
270//! **2.4**. The cost, stated rather than buried: the source counts rise a
271//! little (`n_vco` 3.1 → 4.3, `n_supersaw` 2.7 → 3.5) because how a patch is
272//! arranged is *correlated* with how many sources it has even though it is not
273//! determined by it, and `mod_density` and `mod_depth_mean` move by a tenth.
274//! Compare `branch_width_max`'s 10.4 and 3.1 → 9.1, which is the difference
275//! between a correlated column and a redundant one.
276//!
277//! - `chain_balance` — mean source-to-root path length over the longest one.
278//! 1.0 when every source is the same distance from the amp (a serial chain,
279//! or a symmetric mix); below 1.0 when one branch is a long chain and the
280//! other is a bare oscillator. This is the coordinate that separates the two
281//! patches at the top: 1.000 for `filter(mix(vco, vco))`, 0.833 for
282//! `mix(filter(vco), vco)`. It is the load-bearing one — it catches an
283//! asymmetric branch whichever side the chain is on.
284//! - `frac_sidechained` — of the binary nodes, the fraction whose **`/1`**
285//! child is not a bare source. That is the half `chain_balance` cannot say:
286//! *which* side. `/1` is the ducker's key, the compressor's sidechain and
287//! the vocoder's modulator, so the difference between a key that is a raw
288//! pulse and one that is a whole drum bus lands here and nowhere else.
289//! 0 when there are no binaries, on the same rule as `mod_density`.
290//!
291//! ## What the evolution loop said
292//!
293//! The standing rule is that a green `make check` says nothing about search
294//! health, so both columns went through `search_health` and
295//! `closed_loop_sweep` on both sides of the change. The table lives on
296//! `auracle-session`'s `search_health` module doc, next to the harness that
297//! produced it; the short version is that nothing regressed, the MH acceptance
298//! rate and the locked-refine hit rate went up slightly, and the calibration
299//! `r` was flat at 0.693 → 0.688 (se ±0.018).
300//!
301//! Two readings are worth repeating here because they are about *these
302//! columns* rather than about the harness:
303//!
304//! - **The model's ranking got better, for a listener who does not care about
305//! routing.** Spearman between the fitted utility and the truth went 0.318 →
306//! 0.389 and the true best patch survived its generation 48 times out of 48
307//! rather than 47. Two more coordinates to be wrong about did not make the
308//! ranking noisier; on this evidence they made the pool easier to tell
309//! apart.
310//! - **For a listener who *does* care, all four numbers move together.** A
311//! synthetic user who wants an asymmetric, sidechained routing is fitted at
312//! 0.705 rather than 0.662, and the search leaves them a pool that is 82%
313//! sidechained rather than 71.6%. The before arm is not blind — an
314//! asymmetric routing also *sounds* different, and the audio half of φ hears
315//! that — so what the columns buy is the model knowing why.
316//!
317//! What they deliberately do *not* carry: node order along a serial chain.
318//! `drive(filter(vco))` and `filter(drive(vco))` remain indistinguishable, and
319//! fixing that needs a coordinate per ordered pair of families — 91 of them —
320//! which is the near-indicator disaster the family scheme exists to avoid.
321//! The honest reading is that φ now sees the *graph*, not the *sequence*.
322
323use auracle_grammar::term::{AudioNode, ModNode, ModOp, PairOp, PatchTree};
324use serde::{Deserialize, Serialize};
325
326/// Named structural descriptors. `to_vec` order matches [`StructFeatures::NAMES`].
327///
328/// The `n_*` fields are raw per-kind counts for display; several of them are
329/// summed into families before they reach φ (see the module doc).
330#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
331pub struct StructFeatures {
332 /// Number of Vco sources.
333 pub n_vco: f64,
334 /// Number of Supersaw sources.
335 pub n_supersaw: f64,
336 /// Number of noise sources.
337 pub n_noise: f64,
338 /// Number of wavetable oscillators.
339 pub n_wavetable: f64,
340 /// Number of plucked strings.
341 pub n_pluck: f64,
342 /// Number of formant oscillators.
343 pub n_formant: f64,
344 /// Number of `Silence` leaves — sockets the player left unplugged.
345 ///
346 /// A φ coordinate, and it joins the source/binary identity in the module
347 /// doc as its own column rather than sitting outside it. Outside, the
348 /// identity would hold *exactly* whenever a tree has no holes and be
349 /// slack when it does — near-exact almost always, which is the harder
350 /// case to reason about, not the easier one. Inside, it stays one
351 /// equation with one dropped column and `n_mix` remains the one dropped.
352 ///
353 /// Its prior rate is ~0.5%, which for any other kind would be the
354 /// near-indicator-variable objection that kept `n_ringmod` out. It does
355 /// not apply here, because a hole's prevalence is set by the player's
356 /// edits and not by the prior — the one kind in the grammar of which that
357 /// is true.
358 pub n_silence: f64,
359 /// Number of Mix nodes. **Not a φ coordinate** — see the module doc's
360 /// exact identity. Kept for display.
361 pub n_mix: f64,
362 /// Number of ring modulators. Not a φ coordinate on its own — it is
363 /// counted inside the `n_drive` family, which is both what keeps it out
364 /// of the binary-node identity and what stops a 3.5%-prevalence column
365 /// reaching the model as a near-indicator variable.
366 pub n_ringmod: f64,
367 /// Number of filters.
368 pub n_filter: f64,
369 /// Number of EQs. Folded into the `n_filter` family in φ — both answer
370 /// "how much is the spectrum being tilted".
371 pub n_eq: f64,
372 /// Number of wavefolders. Folded into the `n_drive` family in φ.
373 pub n_fold: f64,
374 /// Number of distortions. Folded into the `n_drive` family in φ.
375 pub n_distortion: f64,
376 /// Number of bitcrushers. Folded into the `n_drive` family in φ.
377 pub n_bitcrush: f64,
378 /// Number of delays. Folded into the `n_time` family in φ.
379 pub n_delay: f64,
380 /// Number of granulators. Folded into the `n_time` family in φ.
381 pub n_granular: f64,
382 /// Number of pitch shifters. Folded into the `n_time` family in φ.
383 pub n_shift: f64,
384 /// Number of compressors. Folded into the `n_dynamics` family in φ.
385 pub n_comp: f64,
386 /// Number of duckers. Folded into the `n_dynamics` family in φ.
387 pub n_duck: f64,
388 /// Number of gates. Folded into the `n_dynamics` family in φ.
389 pub n_gate: f64,
390 /// Number of vocoders. Folded into the `n_filter` family in φ — a vocoder
391 /// is a filter bank whose curve is drawn by a second signal.
392 pub n_vocoder: f64,
393 /// Number of choruses. Folded into the `n_mod_fx` family in φ.
394 pub n_chorus: f64,
395 /// Number of phasers. Folded into the `n_mod_fx` family in φ.
396 pub n_phaser: f64,
397 /// Number of flangers. Folded into the `n_mod_fx` family in φ.
398 pub n_flanger: f64,
399 /// Number of tremolos. Folded into the `n_mod_fx` family in φ.
400 pub n_tremolo: f64,
401 /// Number of vibratos. Folded into the `n_mod_fx` family in φ.
402 pub n_vibrato: f64,
403 /// Number of reverbs.
404 pub n_reverb: f64,
405 /// Number of LFO modulators.
406 pub n_lfo: f64,
407 /// Number of envelope modulators.
408 pub n_env: f64,
409 /// Number of S&H random modulators.
410 pub n_rand: f64,
411 /// Number of envelope followers.
412 pub n_follow: f64,
413 /// Number of euclidean gate patterns. Folded into the `n_mod_logic`
414 /// family in φ.
415 pub n_euclid: f64,
416 /// Number of scale quantizers in modulation chains. `n_mod_shape` family.
417 pub n_quantize: f64,
418 /// Number of slew limiters in modulation chains. `n_mod_shape` family.
419 pub n_slew: f64,
420 /// Number of rectifiers in modulation chains. `n_mod_shape` family.
421 pub n_rectify: f64,
422 /// Number of clocked sample-and-holds in modulation chains.
423 /// `n_mod_shape` family.
424 pub n_hold: f64,
425 /// Number of CV minimum combiners. `n_mod_logic` family.
426 pub n_min: f64,
427 /// Number of CV maximum combiners. `n_mod_logic` family.
428 pub n_max: f64,
429 /// Number of gate ANDs. `n_mod_logic` family.
430 pub n_and: f64,
431 /// Number of gate ORs. `n_mod_logic` family.
432 pub n_or: f64,
433 /// Number of gate XORs. `n_mod_logic` family.
434 pub n_xor: f64,
435 /// Number of CV switches. `n_mod_logic` family.
436 pub n_switch: f64,
437 /// Mean nesting depth of the *filled* modulation slots (1 for a bare
438 /// modulator, 2 for one wrapped in a processor, …); 0 when nothing is
439 /// modulated.
440 pub mod_depth_mean: f64,
441 /// Tree depth. **Not a φ coordinate** — VIF ≈ 21.7 against the module
442 /// counts; see the module doc. Kept for display.
443 pub depth: f64,
444 /// Tree size (audio nodes). **Not a φ coordinate** — it is exactly the sum
445 /// of the raw `n_*` module counts above; see the module doc. Kept for
446 /// display.
447 pub size: f64,
448 /// Fraction of modulation slots actually filled.
449 pub mod_density: f64,
450 /// Largest number of audio nodes sharing one level of the tree. 1 for any
451 /// serial chain. **Not a φ coordinate** — a VIF sweep caught it at 10.4,
452 /// dragging every source count with it, because on trees this size it is
453 /// very nearly the leaf count and the leaf count is `1 + Σ binaries`
454 /// exactly. See the module doc's wave-3 section. Kept for display.
455 pub branch_width_max: f64,
456 /// Mean source-to-root path length divided by the longest one. 1.0 when
457 /// every source sits the same distance from the amp.
458 pub chain_balance: f64,
459 /// Of the binary nodes, the fraction whose `/1` child is not a bare
460 /// source. 0 when the tree has no binary node.
461 pub frac_sidechained: f64,
462 /// Mean over the *filled* modulation slots of the owning module's
463 /// distance from the root, over the tree's depth. 0 = modulation at the
464 /// output stage, 1 = modulation at the sources. **Not a φ coordinate** —
465 /// it is well conditioned (VIF 3.0) and answers a real question, and the
466 /// evolution measurement could not show that it paid for its dimension;
467 /// see the module doc's wave-3 section. Kept for display, and the obvious
468 /// candidate for the next wave that has evidence to spend.
469 pub mod_at_source: f64,
470 /// Amp attack (normalized).
471 pub amp_attack: f64,
472 /// Amp sustain (normalized).
473 pub amp_sustain: f64,
474 /// Amp release (normalized).
475 pub amp_release: f64,
476}
477
478impl StructFeatures {
479 /// Feature names in `to_vec` order.
480 ///
481 /// **Append-only.** The audio names come first in `Features::phi_names`,
482 /// so the end of this array is the end of φ — and a legacy observation
483 /// logged without names is re-read *positionally* by `FitSet::build`.
484 /// Appending leaves every one of those positions meaning what it meant;
485 /// inserting in the middle would silently re-label every vote ever cast.
486 pub const NAMES: [&'static str; 26] = [
487 "n_vco",
488 "n_supersaw",
489 "n_noise",
490 "n_wavetable",
491 "n_pluck",
492 "n_formant",
493 "n_silence",
494 "n_filter",
495 "n_drive",
496 "n_time",
497 "n_mod_fx",
498 "n_reverb",
499 "n_dynamics",
500 "n_lfo",
501 "n_env",
502 "n_rand",
503 "n_follow",
504 "n_mod_shape",
505 "n_mod_logic",
506 "mod_density",
507 "mod_depth_mean",
508 "amp_attack",
509 "amp_sustain",
510 "amp_release",
511 "chain_balance",
512 "frac_sidechained",
513 ];
514
515 /// The φ_struct coordinates that are bounded to `[0, 1]` — three
516 /// normalized genome sites read straight off the term, and four ratios of
517 /// two counts.
518 ///
519 /// A subset of [`Self::NAMES`] and *not* a reordering of it: the counts
520 /// have no upper bound, so a range check over the whole vector could only
521 /// be a finiteness check. Named here so the debug assertion below, the
522 /// featurizer's quarantine and the saved-log repair all read one list
523 /// instead of three that drift.
524 pub const UNIT_NAMES: [&'static str; 7] = [
525 "mod_density",
526 "mod_depth_mean",
527 "amp_attack",
528 "amp_sustain",
529 "amp_release",
530 "chain_balance",
531 "frac_sidechained",
532 ];
533
534 /// This term's values for [`Self::UNIT_NAMES`], in that order.
535 pub fn unit_coordinates(&self) -> [f64; 7] {
536 [
537 self.mod_density,
538 self.mod_depth_mean,
539 self.amp_attack,
540 self.amp_sustain,
541 self.amp_release,
542 self.chain_balance,
543 self.frac_sidechained,
544 ]
545 }
546
547 /// Nonlinear colour: wavefolder + distortion + bitcrusher + ring mod.
548 pub fn n_drive(&self) -> f64 {
549 self.n_fold + self.n_distortion + self.n_bitcrush + self.n_ringmod
550 }
551
552 /// Spectral tilt: filter + EQ + vocoder. A tone control and a resonant
553 /// filter are the same question asked at different sharpnesses — "how much
554 /// is this patch's balance being reshaped" — and a vocoder is that
555 /// question again with the curve drawn by a signal instead of by a knob.
556 /// Each of the three arrives at well under 3% prevalence, which is exactly
557 /// the near-indicator column the family scheme exists to keep out of φ.
558 pub fn n_filter_family(&self) -> f64 {
559 self.n_filter + self.n_eq + self.n_vocoder
560 }
561
562 /// Smearing in time: delay + granular + pitch shift. Named `n_time`
563 /// rather than `n_delay` because the column counts three ways of doing it,
564 /// and a name that says "delay" while counting granulators is the kind of
565 /// quiet dishonesty the Styles tab would render as if it meant something.
566 /// The pitch shifter belongs here on mechanism *and* on sound: it is a
567 /// rolling buffer read at the wrong rate, and what it produces is the
568 /// smeared, grainy artefact family the other two produce.
569 pub fn n_time(&self) -> f64 {
570 self.n_delay + self.n_granular + self.n_shift
571 }
572
573 /// Level shaped by a second signal: compressor + ducker + gate.
574 ///
575 /// One column for the three, on the usual family argument — a user who
576 /// likes a pumping patch does not first decide whether the pump comes from
577 /// a ratio, a duck depth or a gate range. It is also the *only* safe way
578 /// to carry them: see the module doc's generalized identity, which these
579 /// three are on the wrong side of.
580 pub fn n_dynamics(&self) -> f64 {
581 self.n_comp + self.n_duck + self.n_gate
582 }
583
584 /// Periodic movement: chorus + phaser + flanger + tremolo + vibrato.
585 /// Each is an LFO applied to *something* — a short delay, an allpass
586 /// chain, a gain — against or instead of the dry signal. The delay line,
587 /// the granulator and the reverb are not here because they place a sound
588 /// in a space rather than move it.
589 pub fn n_mod_fx(&self) -> f64 {
590 self.n_chorus + self.n_phaser + self.n_flanger + self.n_tremolo + self.n_vibrato
591 }
592
593 /// CV that has been **shaped** before it lands: quantizer + slew +
594 /// rectifier + clocked hold.
595 ///
596 /// Two columns for eleven modules, not eleven, on exactly the argument
597 /// that produced `n_drive` and `n_mod_fx` — and here the prevalence case
598 /// is stronger still, since the prior draws the whole `Op` production into
599 /// 4% of slots and each of the four then takes a quarter of that. What the
600 /// column asks is "does this person like modulation that has been worked
601 /// on", which is one question.
602 pub fn n_mod_shape(&self) -> f64 {
603 self.n_quantize + self.n_slew + self.n_rectify + self.n_hold
604 }
605
606 /// Gate and decision CV: euclid + min + max + and + or + xor + switch.
607 ///
608 /// The euclidean generator belongs with the combiners rather than with the
609 /// other leaves because what it produces is a *gate*, which is what the
610 /// six combiners consume and (for three of them) emit. It also has to be
611 /// here for an identity reason: a modulation forest of `f` filled slots
612 /// with `p` binary combiners has exactly `f + p` leaves, so a φ that
613 /// carried the combiners apart from the euclid would let that equation be
614 /// reconstructed from `n_lfo`, `n_env`, `n_rand`, `n_follow` and
615 /// `mod_density`. Summing the two sides into one column breaks it.
616 pub fn n_mod_logic(&self) -> f64 {
617 self.n_euclid
618 + self.n_min
619 + self.n_max
620 + self.n_and
621 + self.n_or
622 + self.n_xor
623 + self.n_switch
624 }
625
626 /// Flatten to a vector in [`Self::NAMES`] order.
627 pub fn to_vec(&self) -> Vec<f64> {
628 vec![
629 self.n_vco,
630 self.n_supersaw,
631 self.n_noise,
632 self.n_wavetable,
633 self.n_pluck,
634 self.n_formant,
635 self.n_silence,
636 self.n_filter_family(),
637 self.n_drive(),
638 self.n_time(),
639 self.n_mod_fx(),
640 self.n_reverb,
641 self.n_dynamics(),
642 self.n_lfo,
643 self.n_env,
644 self.n_rand,
645 self.n_follow,
646 self.n_mod_shape(),
647 self.n_mod_logic(),
648 self.mod_density,
649 self.mod_depth_mean,
650 self.amp_attack,
651 self.amp_sustain,
652 self.amp_release,
653 self.chain_balance,
654 self.frac_sidechained,
655 ]
656 }
657}
658
659/// Extract [`StructFeatures`] from a term (no compile, no render).
660pub fn struct_features(tree: &PatchTree) -> StructFeatures {
661 let mut f = StructFeatures {
662 depth: tree.root.depth() as f64,
663 size: tree.root.size() as f64,
664 amp_attack: tree.amp.attack,
665 amp_sustain: tree.amp.sustain,
666 amp_release: tree.amp.release,
667 ..Default::default()
668 };
669 let mut t = Tally {
670 tree_depth: f.depth as usize,
671 ..Default::default()
672 };
673 walk(&tree.root, &mut f, &mut t, 0);
674 f.mod_density = if t.slots > 0 {
675 t.filled as f64 / t.slots as f64
676 } else {
677 0.0
678 };
679 // Averaged over the **filled** slots, not over all of them: an empty slot
680 // has no modulation term whose depth could be measured, and folding a zero
681 // in for it would make the coordinate a second, noisier reading of
682 // `mod_density`.
683 f.mod_depth_mean = if t.filled > 0 {
684 t.depth_sum as f64 / t.filled as f64
685 } else {
686 0.0
687 };
688 // The shape numbers. Two of the four reach φ, and both of those are
689 // *ratios*, so that neither is linear in any count — see the module doc's
690 // wave-3 section for why that is the load-bearing property and not a
691 // stylistic one, and for what the other two cost.
692 f.branch_width_max = t.level_width.iter().copied().max().unwrap_or(0) as f64;
693 // `leaf_depth_max` is the longest source-to-root path, not `tree.depth()`:
694 // for an audio tree they are the same number (every leaf is a source), and
695 // taking it from the walk keeps the ratio's two halves measured by the
696 // same pass. A one-node tree gives 1/1.
697 f.chain_balance = if t.leaves > 0 && t.leaf_depth_max > 0 {
698 (t.leaf_depth_sum as f64 / t.leaves as f64) / t.leaf_depth_max as f64
699 } else {
700 0.0
701 };
702 f.frac_sidechained = if t.binaries > 0 {
703 t.sidechained as f64 / t.binaries as f64
704 } else {
705 0.0
706 };
707 f.mod_at_source = if t.filled > 0 {
708 t.mod_site_sum / t.filled as f64
709 } else {
710 0.0
711 };
712 // The invariant, shouted where it is cheapest to hear it. Every coordinate
713 // in `UNIT_NAMES` is either a normalized genome site read straight through
714 // or a ratio of two counts, so all seven live in [0,1] for any term the
715 // grammar can produce — and `amp_sustain` sat at 1e30 for four patches and
716 // six cells of the persisted log precisely because nothing ever said so.
717 //
718 // `debug_assert` and not a clamp: by the time a term reaches the featurizer
719 // it has already been through `finish()`/`clamp_domains`, so a violation
720 // here is a hole in *that*, and quietly repairing it a second time would be
721 // how the hole stays open. Release builds are covered by the quarantine in
722 // `crate::featurize`, which refuses the row rather than trusting it.
723 #[cfg(debug_assertions)]
724 {
725 for (name, v) in StructFeatures::UNIT_NAMES.iter().zip(f.unit_coordinates()) {
726 debug_assert!(
727 v.is_finite() && (0.0..=1.0).contains(&v),
728 "φ_struct coordinate {name} left its domain: {v} (term: {})",
729 tree.root.to_sexpr()
730 );
731 }
732 }
733 f
734}
735
736/// The running numbers a walk collects that are not per-kind counters:
737/// modulation slots, and the shape sums the wave-3 coordinates are ratios of.
738///
739/// One owned struct rather than a fistful of `&mut usize` parameters because
740/// `walk` threads it through twenty-six arms, and a transposed pair of
741/// `usize`s at one call site would be silent.
742#[derive(Default)]
743struct Tally {
744 /// Modulation slots seen (one per module that owns one).
745 slots: usize,
746 /// Of those, the ones carrying a term.
747 filled: usize,
748 /// Sum of those terms' nesting depths.
749 depth_sum: usize,
750 /// Audio nodes seen at each distance from the root, indexed by distance.
751 /// Its maximum is `branch_width_max`.
752 level_width: Vec<usize>,
753 /// Source leaves seen.
754 leaves: usize,
755 /// Sum of their path lengths to the root, in nodes (a root leaf is 1).
756 leaf_depth_sum: usize,
757 /// The longest of those paths.
758 leaf_depth_max: usize,
759 /// Nodes with two audio children.
760 binaries: usize,
761 /// Of those, the ones whose `/1` child is itself a processor.
762 sidechained: usize,
763 /// Sum over filled slots of the owning module's distance from the root,
764 /// already divided by the tree's depth — so it is a sum of fractions in
765 /// `[0, 1]` and the mean needs no second normalisation.
766 mod_site_sum: f64,
767 /// Depth of the whole tree, needed to normalise `mod_site_sum` as the
768 /// walk goes rather than in a second pass.
769 tree_depth: usize,
770}
771
772/// Count one modulation slot, and every node in the term hanging off it.
773///
774/// The *slot* is counted once no matter how deep the chain is: `mod_density`
775/// answers "how many of this patch's modulation destinations are driven",
776/// which is a question about the audio tree's slots, and letting a nested
777/// chain inflate the denominator would make a patch with one long chain read
778/// as less modulated than one with a single LFO. Depth is the coordinate that
779/// carries chain length, and it is separate for that reason.
780/// `d` is the owning module's distance from the root, which is what
781/// `mod_at_source` is a mean of — the slot's own position in the *audio* tree,
782/// as distinct from `mod_depth_mean`, which measures the CV term hanging off
783/// it. A one-node tree has no interval to normalise against and its only
784/// module *is* the source, so it reads 1.
785fn count_mod(m: &ModNode, f: &mut StructFeatures, t: &mut Tally, d: usize) {
786 t.slots += 1;
787 if matches!(m, ModNode::None) {
788 return;
789 }
790 t.filled += 1;
791 t.depth_sum += m.depth();
792 t.mod_site_sum += if t.tree_depth > 1 {
793 d as f64 / (t.tree_depth - 1) as f64
794 } else {
795 1.0
796 };
797 count_mod_nodes(m, f);
798}
799
800/// The per-kind counters for one modulation term, recursing through the
801/// shapers.
802fn count_mod_nodes(m: &ModNode, f: &mut StructFeatures) {
803 match m {
804 ModNode::None => {}
805 ModNode::Lfo { .. } => f.n_lfo += 1.0,
806 ModNode::Env { .. } => f.n_env += 1.0,
807 ModNode::Rand { .. } => f.n_rand += 1.0,
808 ModNode::Follow { .. } => f.n_follow += 1.0,
809 ModNode::Euclid { .. } => f.n_euclid += 1.0,
810 ModNode::Op { kind, input, .. } => {
811 match kind {
812 ModOp::Quantize => f.n_quantize += 1.0,
813 ModOp::Slew => f.n_slew += 1.0,
814 ModOp::Rectify => f.n_rectify += 1.0,
815 ModOp::Hold => f.n_hold += 1.0,
816 }
817 count_mod_nodes(input, f);
818 }
819 ModNode::Pair { kind, a, b, .. } => {
820 match kind {
821 PairOp::Min => f.n_min += 1.0,
822 PairOp::Max => f.n_max += 1.0,
823 PairOp::And => f.n_and += 1.0,
824 PairOp::Or => f.n_or += 1.0,
825 PairOp::Xor => f.n_xor += 1.0,
826 PairOp::Switch => f.n_switch += 1.0,
827 }
828 count_mod_nodes(a, f);
829 count_mod_nodes(b, f);
830 }
831 }
832}
833
834fn walk(n: &AudioNode, f: &mut StructFeatures, t: &mut Tally, d: usize) {
835 // Shape first, and taken from `AudioNode::children` rather than from the
836 // match below: that method is the term's own statement of each
837 // production's arity, and it is spelled out there without a wildcard for
838 // exactly this reason, so a production added later cannot make these
839 // coordinates quietly wrong while still compiling.
840 if t.level_width.len() <= d {
841 t.level_width.resize(d + 1, 0);
842 }
843 t.level_width[d] += 1;
844 let kids = n.children();
845 match kids.len() {
846 0 => {
847 t.leaves += 1;
848 t.leaf_depth_sum += d + 1;
849 t.leaf_depth_max = t.leaf_depth_max.max(d + 1);
850 }
851 2 => {
852 t.binaries += 1;
853 // `children` orders them as the keys do — `/0` is the through
854 // path (mix a, comp/gate input, duck input, vocoder carrier) and
855 // `/1` is the second one. "Not a bare source" is "has children of
856 // its own", which is the same test one line up.
857 t.sidechained += usize::from(!kids[1].children().is_empty());
858 }
859 _ => {}
860 }
861 // Sources with no slot, then the unary processors, then the six binary
862 // nodes — every arm bumps exactly one counter, which is the identity
863 // `size ≡ Σ n_*` the module doc rests on. The arms that recurse into two
864 // children `return` rather than falling through to the shared tail, so the
865 // tail's `(input, modulation)` pair only ever describes a unary node.
866 let (input, modulation): (Option<&AudioNode>, Option<&ModNode>) = match n {
867 AudioNode::Vco { modulation, .. } => {
868 f.n_vco += 1.0;
869 (None, Some(modulation))
870 }
871 AudioNode::Supersaw { modulation, .. } => {
872 f.n_supersaw += 1.0;
873 (None, Some(modulation))
874 }
875 AudioNode::Noise { .. } => {
876 f.n_noise += 1.0;
877 (None, None)
878 }
879 AudioNode::Silence { .. } => {
880 f.n_silence += 1.0;
881 (None, None)
882 }
883 AudioNode::Wavetable { modulation, .. } => {
884 f.n_wavetable += 1.0;
885 (None, Some(modulation))
886 }
887 AudioNode::Pluck { modulation, .. } => {
888 f.n_pluck += 1.0;
889 (None, Some(modulation))
890 }
891 AudioNode::Formant { modulation, .. } => {
892 f.n_formant += 1.0;
893 (None, Some(modulation))
894 }
895 AudioNode::Filter {
896 input, modulation, ..
897 } => {
898 f.n_filter += 1.0;
899 (Some(input), Some(modulation))
900 }
901 AudioNode::Fold {
902 input, modulation, ..
903 } => {
904 f.n_fold += 1.0;
905 (Some(input), Some(modulation))
906 }
907 AudioNode::Distortion {
908 input, modulation, ..
909 } => {
910 f.n_distortion += 1.0;
911 (Some(input), Some(modulation))
912 }
913 AudioNode::Bitcrush {
914 input, modulation, ..
915 } => {
916 f.n_bitcrush += 1.0;
917 (Some(input), Some(modulation))
918 }
919 AudioNode::Delay {
920 input, modulation, ..
921 } => {
922 f.n_delay += 1.0;
923 (Some(input), Some(modulation))
924 }
925 AudioNode::Chorus {
926 input, modulation, ..
927 } => {
928 f.n_chorus += 1.0;
929 (Some(input), Some(modulation))
930 }
931 AudioNode::Phaser {
932 input, modulation, ..
933 } => {
934 f.n_phaser += 1.0;
935 (Some(input), Some(modulation))
936 }
937 AudioNode::Reverb {
938 input, modulation, ..
939 } => {
940 f.n_reverb += 1.0;
941 (Some(input), Some(modulation))
942 }
943 AudioNode::Flanger {
944 input, modulation, ..
945 } => {
946 f.n_flanger += 1.0;
947 (Some(input), Some(modulation))
948 }
949 AudioNode::Tremolo {
950 input, modulation, ..
951 } => {
952 f.n_tremolo += 1.0;
953 (Some(input), Some(modulation))
954 }
955 AudioNode::Vibrato {
956 input, modulation, ..
957 } => {
958 f.n_vibrato += 1.0;
959 (Some(input), Some(modulation))
960 }
961 AudioNode::Eq {
962 input, modulation, ..
963 } => {
964 f.n_eq += 1.0;
965 (Some(input), Some(modulation))
966 }
967 AudioNode::Granular {
968 input, modulation, ..
969 } => {
970 f.n_granular += 1.0;
971 (Some(input), Some(modulation))
972 }
973 AudioNode::Shift {
974 input, modulation, ..
975 } => {
976 f.n_shift += 1.0;
977 (Some(input), Some(modulation))
978 }
979 AudioNode::Mix { a, b, .. } => {
980 f.n_mix += 1.0;
981 walk(a, f, t, d + 1);
982 walk(b, f, t, d + 1);
983 return;
984 }
985 AudioNode::RingMod { a, b, .. } => {
986 f.n_ringmod += 1.0;
987 walk(a, f, t, d + 1);
988 walk(b, f, t, d + 1);
989 return;
990 }
991 // The 2B binaries are the shape neither branch above had: two audio
992 // children *and* a modulation slot. Counting the slot and then falling
993 // through to the `/0` walk (as the unary arms do) would silently drop
994 // the whole `/1` subtree from every count, so they walk both children
995 // here and return, with the slot counted explicitly first.
996 AudioNode::Comp {
997 input,
998 sidechain,
999 modulation,
1000 ..
1001 } => {
1002 f.n_comp += 1.0;
1003 count_mod(modulation, f, t, d);
1004 walk(input, f, t, d + 1);
1005 walk(sidechain, f, t, d + 1);
1006 return;
1007 }
1008 AudioNode::Duck {
1009 input,
1010 key,
1011 modulation,
1012 ..
1013 } => {
1014 f.n_duck += 1.0;
1015 count_mod(modulation, f, t, d);
1016 walk(input, f, t, d + 1);
1017 walk(key, f, t, d + 1);
1018 return;
1019 }
1020 AudioNode::Gate {
1021 input,
1022 sidechain,
1023 modulation,
1024 ..
1025 } => {
1026 f.n_gate += 1.0;
1027 count_mod(modulation, f, t, d);
1028 walk(input, f, t, d + 1);
1029 walk(sidechain, f, t, d + 1);
1030 return;
1031 }
1032 AudioNode::Vocoder {
1033 carrier,
1034 modulator,
1035 modulation,
1036 ..
1037 } => {
1038 f.n_vocoder += 1.0;
1039 count_mod(modulation, f, t, d);
1040 walk(carrier, f, t, d + 1);
1041 walk(modulator, f, t, d + 1);
1042 return;
1043 }
1044 };
1045 if let Some(m) = modulation {
1046 count_mod(m, f, t, d);
1047 }
1048 if let Some(i) = input {
1049 walk(i, f, t, d + 1);
1050 }
1051}