Skip to main content

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}