Skip to main content

auracle_features/
lib.rs

1//! # auracle-features
2//!
3//! The feature pipeline: renders every candidate patch under an identical
4//! stimulus and extracts the feature vector `φ(x) = [φ_audio ; φ_struct]`
5//! that the taste model scores.
6//!
7//! ## Pipeline invariants (the reference: *Audition*, *Features*)
8//!
9//! - **Standard phrase** ([`phrase::PhraseSpec`]): a fixed short mono phrase;
10//!   features are only comparable across patches under an identical stimulus.
11//! - **Determinism** ([`render`]): quiver's RNG is re-seeded per render, so
12//!   `(term, spec)` → bit-identical samples.
13//! - **Vetting gate** ([`vet`]): raw renders are inspected for non-finite,
14//!   silent, runaway, or DC-dominated output *before* anything else; failures
15//!   are quarantined and never auditioned.
16//! - **LUFS normalization** ([`loudness`]): K-weighted gated loudness matched
17//!   to a fixed target before audition *and* feature extraction — otherwise
18//!   "louder" poisons the preference data.
19//!
20//! - **Memoization** ([`cache`]): because `(term, spec) → φ` is pure, a
21//!   featurization the engine has already performed is replayed rather than
22//!   re-rendered. A hit is indistinguishable from a miss by construction —
23//!   the same [`pipeline::Features`] object comes back either way.
24//!
25//! [`pipeline::featurize`] composes it all; the [`pipeline::VettedCandidate`]
26//! it returns carries the exact buffer audition will play. [`render::Audition`]
27//! is that buffer in the f32 form every consumer actually wants, and
28//! [`render::render_playback`] reproduces it bit-identically from a term plus
29//! its recorded `gain_db`, which is what makes deferring the buffer safe.
30
31pub mod audio;
32pub mod cache;
33pub mod loudness;
34pub mod phrase;
35pub mod pipeline;
36pub mod render;
37pub mod structural;
38pub mod vet;
39
40pub use audio::{audio_features, AudioFeatures};
41pub use cache::{
42    cache_namespace, canonical_tree_json, featurize_memo, render_key, CachedFeatures, MemoStats,
43    RenderMemo, DEFAULT_AUDIO_CAP, DEFAULT_FEATURE_CAP, RENDER_EPOCH,
44};
45pub use loudness::{integrated_lufs, normalize_to, MAX_GAIN_DB, PEAK_CEILING};
46pub use phrase::PhraseSpec;
47pub use pipeline::{featurize, Features, FeaturizeError, VettedCandidate, TARGET_LUFS};
48pub use render::{render_phrase, render_playback, Audition, RenderedPhrase};
49pub use structural::{struct_features, StructFeatures};
50pub use vet::{vet, VetConfig, VetFailure, VetReport};
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use auracle_grammar::term::{AmpEnv, AudioNode, ModNode, NoiseColor, Uid, Waveform};
56    use auracle_grammar::{PatchGrammarPrior, PatchTree};
57
58    /// A tree that is all holes renders silent and the gate quarantines it.
59    ///
60    /// This is the designed path for `Silence`, and it is what makes a small
61    /// nonzero prior weight safe rather than reckless. The prior can propose a
62    /// hole; if a whole patch collapses to one, the render is exactly zero,
63    /// `vet` returns `Silent`, and the candidate never reaches the pool — so
64    /// evolution is free to discover that holes are bad instead of being
65    /// forbidden from representing one.
66    #[test]
67    fn an_all_silence_tree_is_quarantined_as_silent() {
68        let tree = PatchTree {
69            amp: AmpEnv {
70                attack: 0.1,
71                decay: 0.3,
72                sustain: 1.0,
73                release: 0.3,
74            },
75            root: AudioNode::Silence { uid: Uid::NEW },
76        };
77        match featurize(&tree, &PhraseSpec::default()) {
78            Err(e) => assert!(
79                e.to_string().contains("silent"),
80                "an empty patch must fail as silent, not as {e}"
81            ),
82            Ok(_) => panic!("a patch of nothing but holes passed the vet gate"),
83        }
84    }
85
86    /// A hole inside a live patch is *not* quarantined — it is one muted
87    /// branch of a mixer, which is an ordinary patch and must stay auditionable.
88    #[test]
89    fn a_hole_beside_a_source_still_renders() {
90        let tree = PatchTree {
91            amp: AmpEnv {
92                attack: 0.1,
93                decay: 0.3,
94                sustain: 1.0,
95                release: 0.3,
96            },
97            root: AudioNode::Mix {
98                uid: Uid::NEW,
99                balance: 0.5,
100                a: Box::new(AudioNode::Silence { uid: Uid::NEW }),
101                b: Box::new(AudioNode::Noise {
102                    uid: Uid::NEW,
103                    color: NoiseColor::White,
104                }),
105            },
106        };
107        featurize(&tree, &PhraseSpec::default())
108            .expect("half a mixer is still a patch you can hear");
109    }
110
111    /// Every built-in preset renders and passes the vetting gate — a preset
112    /// that can't be auditioned must never ship.
113    #[test]
114    fn presets_pass_vetting() {
115        let spec = PhraseSpec::default();
116        for (name, tree) in auracle_grammar::presets() {
117            featurize(&tree, &spec).unwrap_or_else(|e| panic!("preset {name} failed vetting: {e}"));
118        }
119    }
120
121    /// **Nothing leaves the pipeline able to clip.**
122    ///
123    /// The gate belongs here rather than in `loudness`, because the claim that
124    /// matters is about the buffer `featurize` hands out — the one audition
125    /// plays and the one preference data is collected on — not about a
126    /// function in isolation. A clipped audition collects a vote about
127    /// clipping rather than about the patch.
128    ///
129    /// Measured over 150 prior draws before the ceiling existed: 15% of vetted
130    /// renders peaked over full scale, worst case 4.06. Run
131    /// `cargo run -p auracle-features --example norm_peak --release` for the
132    /// distribution; this is the always-on floor under it, over the presets
133    /// (hand-authored, and the loudest thing a new user meets) plus a sample of
134    /// the prior.
135    #[test]
136    fn no_vetted_render_leaves_above_the_peak_ceiling() {
137        let spec = PhraseSpec::default();
138        let mut checked = 0usize;
139        let mut pulled = 0usize;
140
141        let mut check = |what: &str, vc: &VettedCandidate| {
142            let peak = vc.render.samples.iter().fold(0.0f64, |p, s| p.max(s.abs()));
143            assert!(
144                peak <= PEAK_CEILING + 1e-9,
145                "{what}: normalized peak {peak:.3} is over the {PEAK_CEILING:.2} ceiling"
146            );
147            // The reduction has to be *reported* as well as applied, or a
148            // surface cannot tell a peak-limited patch from a quiet one.
149            assert!(vc.features.peak_reduction_db >= 0.0);
150            if vc.features.peak_reduction_db > 0.0 {
151                pulled += 1;
152            }
153            checked += 1;
154        };
155
156        for (name, tree) in auracle_grammar::presets() {
157            let vc = featurize(&tree, &spec).expect("preset vets");
158            check(&format!("preset {name}"), &vc);
159        }
160
161        let mut rng = StdRng::seed_from_u64(0xE05);
162        let prior = PatchGrammarPrior::default();
163        for i in 0..24 {
164            let (tree, _): (PatchTree, Trace) = run(
165                PriorHandler {
166                    rng: &mut rng,
167                    trace: Trace::default(),
168                },
169                prior.model(),
170            );
171            // Quarantined draws are never auditioned, so they have no peak to
172            // make a claim about.
173            if let Ok(vc) = featurize(&tree, &spec) {
174                check(&format!("prior draw {i}"), &vc);
175            }
176        }
177
178        assert!(checked > 0, "nothing was checked");
179        println!("{checked} renders under the ceiling, {pulled} of them pulled down to get there");
180    }
181    use fugue::runtime::handler::run;
182    use fugue::runtime::interpreters::PriorHandler;
183    use fugue::Trace;
184    use fugue_evo::inference::prior::GenomePrior;
185    use rand::rngs::StdRng;
186    use rand::SeedableRng;
187
188    fn amp() -> AmpEnv {
189        AmpEnv {
190            attack: 0.05,
191            decay: 0.3,
192            sustain: 0.8,
193            release: 0.3,
194        }
195    }
196
197    fn vco(wave: Waveform) -> PatchTree {
198        PatchTree {
199            amp: amp(),
200            root: AudioNode::Vco {
201                uid: Uid::NEW,
202                wave,
203                octave: 0,
204                detune: 0.5,
205                mod_depth: 0.0,
206                modulation: ModNode::None,
207            },
208        }
209    }
210
211    /// Determinism: identical (term, spec) → bit-identical render and
212    /// features, including for stochastic modules (noise).
213    #[test]
214    fn renders_are_deterministic() {
215        let spec = PhraseSpec::default();
216        let noisy = PatchTree {
217            amp: amp(),
218            root: AudioNode::Filter {
219                uid: Uid::NEW,
220                kind: auracle_grammar::term::FilterKind::SvfLp,
221                cutoff: 0.5,
222                resonance: 0.4,
223                mod_depth: 0.3,
224                modulation: ModNode::Lfo {
225                    uid: Uid::NEW,
226                    wave: Waveform::Triangle,
227                    rate: 0.5,
228                },
229                input: Box::new(AudioNode::Noise {
230                    uid: Uid::NEW,
231                    color: NoiseColor::White,
232                }),
233            },
234        };
235        for tree in [vco(Waveform::Saw), noisy] {
236            let a = render_phrase(&tree, &spec).unwrap();
237            let b = render_phrase(&tree, &spec).unwrap();
238            assert_eq!(a.samples, b.samples, "bit-identical renders");
239            let fa = featurize(&tree, &spec).unwrap();
240            let fb = featurize(&tree, &spec).unwrap();
241            assert_eq!(fa.features.phi(), fb.features.phi());
242        }
243    }
244
245    /// The features order by physics: saw is brighter than sine; noise is
246    /// flatter than either; slower amp attack → longer measured attack.
247    #[test]
248    fn features_track_physics() {
249        let spec = PhraseSpec::default();
250        let saw = featurize(&vco(Waveform::Saw), &spec).unwrap().features;
251        let sine = featurize(&vco(Waveform::Sine), &spec).unwrap().features;
252        assert!(
253            saw.audio.centroid_mean > sine.audio.centroid_mean,
254            "saw centroid {} should exceed sine {}",
255            saw.audio.centroid_mean,
256            sine.audio.centroid_mean
257        );
258
259        let noise = featurize(
260            &PatchTree {
261                amp: amp(),
262                root: AudioNode::Noise {
263                    uid: Uid::NEW,
264                    color: NoiseColor::White,
265                },
266            },
267            &spec,
268        )
269        .unwrap()
270        .features;
271        assert!(noise.audio.flatness_mean > saw.audio.flatness_mean);
272        assert!(noise.audio.flatness_mean > 0.1);
273
274        let slow = PatchTree {
275            amp: AmpEnv {
276                attack: 0.7,
277                ..amp()
278            },
279            root: vco(Waveform::Saw).root,
280        };
281        let slow_f = featurize(&slow, &spec).unwrap().features;
282        assert!(
283            slow_f.audio.attack_s > saw.audio.attack_s,
284            "slow attack {} should exceed fast {}",
285            slow_f.audio.attack_s,
286            saw.audio.attack_s
287        );
288    }
289
290    /// Normalization lands renders near the target loudness (within 1 LU),
291    /// for both loud and quiet sources.
292    #[test]
293    fn normalization_hits_target() {
294        let spec = PhraseSpec::default();
295        for tree in [vco(Waveform::Saw), vco(Waveform::Sine)] {
296            let v = featurize(&tree, &spec).unwrap();
297            let lufs_after =
298                loudness::integrated_lufs(&v.render.samples, v.render.sample_rate).unwrap();
299            assert!(
300                (lufs_after - TARGET_LUFS).abs() < 1.0,
301                "normalized loudness {lufs_after} not near {TARGET_LUFS}"
302            );
303        }
304    }
305
306    /// The vet gate quarantines silence (a phrase whose gate never opens).
307    #[test]
308    fn vet_quarantines_silence() {
309        let spec = PhraseSpec {
310            notes: vec![crate::phrase::Note {
311                voct: 0.0,
312                on_s: 0.0,
313                off_s: 1.0,
314                chord: Vec::new(),
315            }],
316            ..Default::default()
317        };
318        let err = featurize(&vco(Waveform::Saw), &spec).unwrap_err();
319        assert!(
320            matches!(err, FeaturizeError::Quarantined(VetFailure::Silent { .. })),
321            "expected Silent quarantine, got: {err}"
322        );
323    }
324
325    /// Brightness lives on an **octave** axis, not a linear-Hz one: equal
326    /// frequency *ratios* must move the coordinate equally, or a linear model
327    /// in it cannot express "a shade brighter" anywhere but the top octave.
328    #[test]
329    fn spectral_axis_is_logarithmic() {
330        use crate::audio::log_axis;
331        let ny = 22_050.0;
332        let octave_low = log_axis(400.0, ny) - log_axis(200.0, ny);
333        let octave_high = log_axis(16_000.0, ny) - log_axis(8_000.0, ny);
334        assert!(
335            (octave_low - octave_high).abs() < 1e-12,
336            "an octave is {octave_low} down low but {octave_high} up high"
337        );
338        // Anchored and normalized: 20 Hz is 0, Nyquist is 1.
339        assert!(log_axis(20.0, ny).abs() < 1e-12);
340        assert!((log_axis(ny, ny) - 1.0).abs() < 1e-12);
341        // Sub-anchor frequencies clamp rather than diverge.
342        assert_eq!(log_axis(1.0, ny), 0.0);
343    }
344
345    /// `attack_s` must stay a *continuous* axis at the fast end. Flooring the
346    /// 90%-crossing to the analysis-window index collapsed every percussive
347    /// patch to exactly zero — a spike, not a coordinate, and standardizing a
348    /// spike gives the model a feature that is one value for most of the pool.
349    #[test]
350    fn fast_attacks_are_resolved_not_floored() {
351        let spec = PhraseSpec::default();
352        let measure = |attack: f64| {
353            featurize(
354                &PatchTree {
355                    amp: AmpEnv { attack, ..amp() },
356                    root: AudioNode::Vco {
357                        uid: Uid::NEW,
358                        wave: Waveform::Saw,
359                        octave: 0,
360                        detune: 0.5,
361                        mod_depth: 0.0,
362                        modulation: ModNode::None,
363                    },
364                },
365                &spec,
366            )
367            .unwrap()
368            .features
369            .audio
370            .attack_s
371        };
372        let (a0, a1, a2) = (measure(0.0), measure(0.02), measure(0.05));
373        assert!(
374            a0 < a1 && a1 < a2,
375            "attack not monotone/resolved: {a0} {a1} {a2}"
376        );
377    }
378
379    /// The v1 stimulus, kept as a fixture: the phrase whose blind spots the
380    /// v2 default exists to remove. The gates below assert both directions —
381    /// that v2 discriminates, *and* that v1 could not, so the next person
382    /// reading a failure knows what the segment is for.
383    fn v1_spec() -> PhraseSpec {
384        use crate::phrase::Note;
385        PhraseSpec {
386            notes: vec![
387                Note {
388                    voct: 0.0,
389                    on_s: 0.60,
390                    off_s: 0.15,
391                    chord: Vec::new(),
392                },
393                Note {
394                    voct: 3.0 / 12.0,
395                    on_s: 0.25,
396                    off_s: 0.10,
397                    chord: Vec::new(),
398                },
399                Note {
400                    voct: -1.0,
401                    on_s: 0.80,
402                    off_s: 1.25,
403                    chord: Vec::new(),
404                },
405            ],
406            ..Default::default()
407        }
408    }
409
410    fn filtered(cutoff: f64, modulation: ModNode) -> PatchTree {
411        PatchTree {
412            amp: amp(),
413            root: AudioNode::Filter {
414                uid: Uid::NEW,
415                kind: auracle_grammar::term::FilterKind::SvfLp,
416                cutoff,
417                resonance: 0.3,
418                mod_depth: 0.8,
419                modulation,
420                input: Box::new(vco(Waveform::Saw).root),
421            },
422        }
423    }
424
425    /// Slow attacks resolve well past the old 0.75 s onset window. Under the
426    /// v1 phrase every attack knob position from ~0.7 up measured the same
427    /// (the envelope was still rising when the window closed, so t90 pinned
428    /// to the window end); the 1.8 s held note spreads that range back out.
429    #[test]
430    fn slow_attacks_resolve_beyond_the_old_window() {
431        let attack_under = |spec: &PhraseSpec, attack: f64| {
432            featurize(
433                &PatchTree {
434                    amp: AmpEnv { attack, ..amp() },
435                    root: vco(Waveform::Saw).root,
436                },
437                spec,
438            )
439            .unwrap()
440            .features
441            .audio
442            .attack_s
443        };
444        let (v1, v2) = (v1_spec(), PhraseSpec::default());
445        let v1_gap = attack_under(&v1, 0.82) - attack_under(&v1, 0.7);
446        let v2_gap = attack_under(&v2, 0.82) - attack_under(&v2, 0.7);
447        assert!(
448            v1_gap.abs() < 0.05,
449            "v1 no longer saturates ({v1_gap:.3}) — this gate's premise moved"
450        );
451        assert!(
452            v2_gap > 0.10,
453            "v2 fails to separate slow attacks ({v2_gap:.3})"
454        );
455        // And the axis stays monotone through the newly-resolved range.
456        let (a, b, c) = (
457            attack_under(&v2, 0.6),
458            attack_under(&v2, 0.7),
459            attack_under(&v2, 0.82),
460        );
461        assert!(a < b && b < c, "not monotone: {a:.3} {b:.3} {c:.3}");
462    }
463
464    /// A register-constant held note makes sub-Hz modulation a measurable
465    /// fact. `held_centroid_std` is near-zero for a static patch and orders
466    /// of magnitude larger with a slow LFO on the filter — including at
467    /// ~0.1 Hz, which the whole v1 phrase was too short to witness.
468    #[test]
469    fn held_note_reveals_sub_hz_modulation() {
470        let spec = PhraseSpec::default();
471        let hcs = |m: ModNode| {
472            featurize(&filtered(0.4, m), &spec)
473                .unwrap()
474                .features
475                .audio
476                .held_centroid_std
477        };
478        let still = hcs(ModNode::None);
479        let slow = hcs(ModNode::Lfo {
480            uid: Uid::NEW,
481            wave: Waveform::Triangle,
482            rate: 0.45, // ≈ 0.4 Hz
483        });
484        let crawl = hcs(ModNode::Lfo {
485            uid: Uid::NEW,
486            wave: Waveform::Triangle,
487            rate: 0.3, // ≈ 0.1 Hz
488        });
489        assert!(still < 0.005, "static patch moves on its own: {still:.4}");
490        assert!(
491            slow > 10.0 * still.max(1e-4) && slow > 0.03,
492            "0.4 Hz motion invisible: {slow:.4} vs still {still:.4}"
493        );
494        assert!(
495            crawl > 10.0 * still.max(1e-4) && crawl > 0.01,
496            "0.1 Hz motion invisible: {crawl:.4} vs still {still:.4}"
497        );
498    }
499
500    /// The C5 note exposes whether a patch speaks in the upper register: a
501    /// dark low-cutoff filter chokes it (strongly negative `high_ratio`)
502    /// while an open patch carries it at roughly the held note's level.
503    #[test]
504    fn high_note_reveals_register_response() {
505        let spec = PhraseSpec::default();
506        let dark = featurize(&filtered(0.12, ModNode::None), &spec)
507            .unwrap()
508            .features
509            .audio
510            .high_ratio;
511        let open = featurize(&vco(Waveform::Saw), &spec)
512            .unwrap()
513            .features
514            .audio
515            .high_ratio;
516        assert!(
517            dark < open - 0.3,
518            "register response indistinct: dark {dark:.3} vs open {open:.3}"
519        );
520        assert!(
521            open.abs() < 0.5,
522            "open patch should speak evenly: {open:.3}"
523        );
524    }
525
526    /// The chord note really is a second voice: the render is bit-identical
527    /// up to the chord onset, diverges inside it, carries ~2× the energy of
528    /// the mono render there, and the chord feature goes live exactly (and
529    /// only) when the phrase has a chord.
530    #[test]
531    fn chord_segment_stacks_a_second_voice() {
532        let spec = PhraseSpec::default();
533        assert_eq!(spec.max_voices(), 2);
534        let mut mono = spec.clone();
535        for n in &mut mono.notes {
536            n.chord.clear();
537        }
538        let tree = vco(Waveform::Saw);
539        let poly_r = render_phrase(&tree, &spec).unwrap();
540        let mono_r = render_phrase(&tree, &mono).unwrap();
541        let chord = poly_r
542            .spans
543            .iter()
544            .find(|s| s.chord > 0)
545            .expect("chord span");
546        assert_eq!(
547            poly_r.samples[..chord.on_start],
548            mono_r.samples[..chord.on_start],
549            "chord voice leaked ahead of its onset"
550        );
551        assert_ne!(
552            poly_r.samples[chord.on_start..chord.on_end],
553            mono_r.samples[chord.on_start..chord.on_end],
554            "chord segment is not polyphonic"
555        );
556        let energy = |s: &[f64]| s.iter().map(|x| x * x).sum::<f64>();
557        let ratio = energy(&poly_r.samples[chord.on_start..chord.on_end])
558            / energy(&mono_r.samples[chord.on_start..chord.on_end]);
559        assert!(
560            (1.4..=3.0).contains(&ratio),
561            "dyad energy ratio {ratio:.2} outside the plausible band"
562        );
563        let poly_f = featurize(&tree, &spec).unwrap().features.audio;
564        let mono_f = featurize(&tree, &mono).unwrap().features.audio;
565        assert_ne!(poly_f.chord_flatness_delta, 0.0);
566        assert_eq!(
567            mono_f.chord_flatness_delta, 0.0,
568            "chord feature must read 'no evidence' without a chord"
569        );
570    }
571
572    /// The default stimulus keeps its advertised shape — each clause here is
573    /// one of the four blind spots the v2 phrase exists to remove, so a
574    /// "harmless" retiming that reopens one fails loudly.
575    #[test]
576    fn the_default_phrase_keeps_its_advertised_shape() {
577        let spec = PhraseSpec::default();
578        let first = &spec.notes[0];
579        assert!(
580            first.on_s >= 1.5,
581            "held note too short to reveal slow attacks / sub-Hz motion"
582        );
583        assert!(
584            spec.notes.iter().any(|n| n.voct >= 1.0),
585            "no note above the old Eb4 ceiling"
586        );
587        assert!(
588            spec.notes.iter().any(|n| !n.chord.is_empty()),
589            "no polyphonic segment"
590        );
591        let last = spec.notes.last().unwrap();
592        assert!(
593            last.chord.is_empty() && last.off_s >= 1.0,
594            "tail window must stay last, long, and mono"
595        );
596        assert!(
597            spec.total_seconds() <= 5.5,
598            "stimulus creep: {:.2}s — the render budget was ~2× v1",
599            spec.total_seconds()
600        );
601    }
602
603    /// The two exact collinearities φ must not contain, checked on the term
604    /// rather than assumed: `size` is the sum of every module count, and the
605    /// sources exceed the binary nodes by exactly one. Either one makes the
606    /// design matrix rank-deficient — an unidentified ridge for the sampler
607    /// to wander along, and per-feature weights the Styles tab would render
608    /// as if they meant something individually.
609    ///
610    /// Wave 2B is where the second identity stops being about two nodes:
611    /// `Comp`, `Duck`, `Gate` and `Vocoder` each take two audio subterms, so
612    /// all six binary counts appear in it. The φ-side consequence is checked
613    /// in `phi_hides_every_binary_count_inside_a_family` below.
614    ///
615    /// Wave 2C adds a **third** identity, over the modulation forest rather
616    /// than the audio tree — the leaves of a forest exceed its binary nodes by
617    /// its tree count — and it is asserted here alongside the other two. It
618    /// does not reach φ, for the reason spelled out in
619    /// [`crate::structural`]: its tree count is `filled_slots`, which φ only
620    /// carries inside the `mod_density` ratio, and the euclid sits on the
621    /// same side of the sum as the combiners rather than opposite them.
622    #[test]
623    fn phi_carries_no_exact_collinearity() {
624        let names = Features::phi_names();
625        // `n_delay` is here because wave 2A *renamed* it to `n_time`: the
626        // column counts granulators now, and a stale name in φ is a Styles
627        // tab row that says "delay" about something else.
628        for gone in [
629            "size",
630            "depth",
631            "n_mix",
632            "n_ringmod",
633            "n_delay",
634            "n_comp",
635            "n_duck",
636            "n_gate",
637            "n_vocoder",
638        ] {
639            assert!(!names.contains(&gone), "`{gone}` is back in φ");
640        }
641        let spec = PhraseSpec::default();
642        for (name, tree) in auracle_grammar::presets() {
643            let f = featurize(&tree, &spec).unwrap().features;
644            let s = &f.structural;
645            let sources =
646                s.n_vco + s.n_supersaw + s.n_noise + s.n_wavetable + s.n_pluck + s.n_formant;
647            let binaries = s.n_mix + s.n_ringmod + s.n_comp + s.n_duck + s.n_gate + s.n_vocoder;
648            let sum = sources
649                + binaries
650                + s.n_filter
651                + s.n_eq
652                + s.n_fold
653                + s.n_distortion
654                + s.n_bitcrush
655                + s.n_delay
656                + s.n_granular
657                + s.n_shift
658                + s.n_chorus
659                + s.n_phaser
660                + s.n_flanger
661                + s.n_tremolo
662                + s.n_vibrato
663                + s.n_reverb;
664            assert_eq!(s.size, sum, "{name}: size is not the sum of the counts");
665            // Every production is unary except the six that take two audio
666            // inputs, so every tree is a forest of `sources` leaves joined by
667            // `sources − 1` binary nodes. This is ONE equation, so exactly one
668            // column has to leave φ — `n_mix`. The other five stay, each
669            // inside a family that also counts something outside the identity,
670            // which is what stops it coming back.
671            assert_eq!(
672                sources - binaries,
673                1.0,
674                "{name}: the collinearity this test guards is not what it says"
675            );
676            // The modulation forest's own identity. `mod_density` is
677            // `filled/slots`, so the filled count has to be reconstructed
678            // here rather than read off φ — which is exactly why the equation
679            // is not available to a linear model.
680            let slots = mod_slots(&tree);
681            let filled = (s.mod_density * slots as f64).round();
682            let mod_leaves = s.n_lfo + s.n_env + s.n_rand + s.n_follow + s.n_euclid;
683            let combiners = s.n_min + s.n_max + s.n_and + s.n_or + s.n_xor + s.n_switch;
684            assert_eq!(
685                mod_leaves - combiners,
686                filled,
687                "{name}: the modulation forest does not have one more leaf \
688                 per tree than it has binary nodes"
689            );
690            // …and neither side of it is separately visible in φ: the euclid
691            // is summed *with* the combiners, not against them.
692            assert!(
693                s.n_mod_logic() >= s.n_euclid + combiners - 1e-9,
694                "{name}: n_mod_logic stopped hiding the euclid with the \
695                 combiners, which is what keeps the identity out of φ"
696            );
697        }
698    }
699
700    /// How many modulation slots a tree has, counted the way
701    /// `struct_features` does — one per module that owns one, regardless of
702    /// how deep the chain hanging off it goes.
703    fn mod_slots(tree: &PatchTree) -> usize {
704        // A slot's address is `<owner>/m#mod` and nothing deeper: the nodes
705        // *inside* a chain live at `<owner>/m/0#mod` and below, and they are
706        // not slots — which is the same distinction `count_mod` draws.
707        auracle_grammar::describe(tree)
708            .modules
709            .iter()
710            .flat_map(|m| &m.structural_addrs)
711            .filter(|a| a.ends_with("/m#mod"))
712            .count()
713    }
714
715    /// No **retained** φ coordinate isolates a binary count, so the identity
716    /// above cannot be reconstructed from what φ carries.
717    ///
718    /// This is the check `n_dynamics` needs and the earlier families did not:
719    /// it is *exactly* `n_comp + n_duck + n_gate`, three of the six binary
720    /// terms, with no unary member diluting it. The argument that it is
721    /// nonetheless safe rests entirely on the other three terms being
722    /// unrecoverable — so that is what gets asserted, by constructing the one
723    /// tree that would expose a family with no unary member and checking each
724    /// family moves when something outside the identity is added to it.
725    #[test]
726    fn phi_hides_every_binary_count_inside_a_family() {
727        use auracle_grammar::term::{DriveMode, FilterKind};
728        let saw = || vco(Waveform::Saw).root;
729        // n_drive must move for a fold, which is not a binary node — so
730        // `n_drive` alone never reads back as `n_ringmod`.
731        let folded = StructFeatures {
732            n_fold: 1.0,
733            ..Default::default()
734        };
735        assert!(folded.n_drive() > 0.0 && folded.n_ringmod == 0.0);
736        // Same for the filter family and the vocoder.
737        let tilted = StructFeatures {
738            n_eq: 1.0,
739            ..Default::default()
740        };
741        assert!(tilted.n_filter_family() > 0.0 && tilted.n_vocoder == 0.0);
742        // `n_dynamics` has no such dilution, and does not need one: it
743        // contributes three of the six binary terms and nothing in φ supplies
744        // `n_mix`, `n_ringmod` or `n_vocoder` separately from a family that
745        // also counts unary nodes.
746        let dynamics = PatchTree {
747            amp: amp(),
748            root: AudioNode::Duck {
749                uid: Uid::NEW,
750                amount: 0.7,
751                threshold: 0.4,
752                release: 0.35,
753                mod_depth: 0.0,
754                modulation: ModNode::None,
755                input: Box::new(AudioNode::Distortion {
756                    uid: Uid::NEW,
757                    drive: 0.4,
758                    tone: 0.5,
759                    mode: DriveMode::Soft,
760                    mod_depth: 0.0,
761                    modulation: ModNode::None,
762                    input: Box::new(saw()),
763                }),
764                key: Box::new(AudioNode::Filter {
765                    uid: Uid::NEW,
766                    kind: FilterKind::SvfLp,
767                    cutoff: 0.5,
768                    resonance: 0.2,
769                    mod_depth: 0.0,
770                    modulation: ModNode::None,
771                    input: Box::new(saw()),
772                }),
773            },
774        };
775        let s = struct_features(&dynamics);
776        assert_eq!(s.n_dynamics(), 1.0);
777        assert_eq!(s.size, 5.0, "the key branch was not counted");
778        assert_eq!(s.n_vco, 2.0, "the key branch's source was not counted");
779        // …and the identity holds on a tree whose binary node is a 2B one.
780        assert_eq!(s.n_vco - s.n_duck, 1.0);
781    }
782
783    /// Structural features count exactly what's in the tree.
784    #[test]
785    fn struct_features_count_the_tree() {
786        let tree = PatchTree {
787            amp: amp(),
788            root: AudioNode::Delay {
789                uid: Uid::NEW,
790                time: 0.5,
791                feedback: 0.5,
792                mix: 0.5,
793                mod_depth: 0.0,
794                modulation: ModNode::None,
795                input: Box::new(AudioNode::Filter {
796                    uid: Uid::NEW,
797                    kind: auracle_grammar::term::FilterKind::Ladder,
798                    cutoff: 0.5,
799                    resonance: 0.5,
800                    mod_depth: 0.5,
801                    modulation: ModNode::Env {
802                        uid: Uid::NEW,
803                        attack: 0.2,
804                        decay: 0.6,
805                    },
806                    input: Box::new(AudioNode::Mix {
807                        uid: Uid::NEW,
808                        balance: 0.5,
809                        a: Box::new(vco(Waveform::Saw).root),
810                        b: Box::new(AudioNode::Noise {
811                            uid: Uid::NEW,
812                            color: NoiseColor::Pink,
813                        }),
814                    }),
815                }),
816            },
817        };
818        let f = struct_features(&tree);
819        assert_eq!(f.n_delay, 1.0);
820        assert_eq!(f.n_filter, 1.0);
821        assert_eq!(f.n_mix, 1.0);
822        assert_eq!(f.n_vco, 1.0);
823        assert_eq!(f.n_noise, 1.0);
824        assert_eq!(f.n_env, 1.0);
825        assert_eq!(f.n_lfo, 0.0);
826        assert_eq!(f.size, 5.0);
827        assert_eq!(f.depth, 4.0);
828        // Three slots — the delay, the filter, and the vco, whose slot reaches
829        // pitch — with only the filter's filled. The mix and the noise have
830        // none: two audio inputs and no parameter respectively.
831        assert_eq!(f.mod_density, 1.0 / 3.0);
832        // Families, not per-kind columns: the ladder is the only `n_drive`
833        // candidate here and there is none, so the coordinate reads zero.
834        assert_eq!(f.n_drive(), 0.0);
835        assert_eq!(f.n_mod_fx(), 0.0);
836        // The filter family is the filter alone; the time family the delay.
837        assert_eq!(f.n_filter_family(), 1.0);
838        assert_eq!(f.n_time(), 1.0);
839        assert_eq!(f.to_vec().len(), StructFeatures::NAMES.len());
840        // Shape. Levels are delay(1) · filter(1) · mix(1) · vco+noise(2), so
841        // the tree is two wide at its widest; both sources sit four nodes from
842        // the root, so it is perfectly balanced; the mix's `/1` is a bare
843        // noise source, so nothing is sidechained; and the one filled slot is
844        // on the filter, one step down a four-deep tree.
845        assert_eq!(f.branch_width_max, 2.0);
846        assert_eq!(f.chain_balance, 1.0);
847        assert_eq!(f.frac_sidechained, 0.0);
848        assert!((f.mod_at_source - 1.0 / 3.0).abs() < 1e-12);
849    }
850
851    /// The wave-3 shape coordinates: two patches with **identical counts** and
852    /// different routing must land on different φ.
853    ///
854    /// This is WS-8 §4's acceptance test in one assertion. `filter(mix(a, b))`
855    /// filters the sum; `mix(filter(a), b)` filters one layer and leaves the
856    /// other dry. One filter, two VCOs, one mixer either way — so under the
857    /// twenty-three columns that shipped before this wave the two patches were
858    /// *the same point*, and no amount of voting could have taught the model
859    /// which one the user meant.
860    #[test]
861    fn shape_separates_serial_from_parallel() {
862        let mix = |a: AudioNode, b: AudioNode| AudioNode::Mix {
863            uid: Uid::NEW,
864            balance: 0.5,
865            a: Box::new(a),
866            b: Box::new(b),
867        };
868        let filter = |input: AudioNode| AudioNode::Filter {
869            uid: Uid::NEW,
870            kind: auracle_grammar::term::FilterKind::SvfLp,
871            cutoff: 0.5,
872            resonance: 0.2,
873            mod_depth: 0.0,
874            modulation: ModNode::None,
875            input: Box::new(input),
876        };
877        let src = || vco(Waveform::Saw).root;
878
879        let sum_then_filter = struct_features(&PatchTree {
880            amp: amp(),
881            root: filter(mix(src(), src())),
882        });
883        let filter_one_layer = struct_features(&PatchTree {
884            amp: amp(),
885            root: mix(filter(src()), src()),
886        });
887
888        // Every count agrees, which is the point.
889        for (a, b) in [
890            (sum_then_filter.n_vco, filter_one_layer.n_vco),
891            (sum_then_filter.n_filter, filter_one_layer.n_filter),
892            (sum_then_filter.n_mix, filter_one_layer.n_mix),
893            (sum_then_filter.size, filter_one_layer.size),
894            (sum_then_filter.depth, filter_one_layer.depth),
895        ] {
896            assert_eq!(a, b);
897        }
898        // Both are two wide, which is why width is not the coordinate that
899        // does the work here (and, per the module doc, is not a φ coordinate
900        // at all). Balance is: sources at three and three against three and
901        // two.
902        assert_eq!(sum_then_filter.branch_width_max, 2.0);
903        assert_eq!(filter_one_layer.branch_width_max, 2.0);
904        assert_eq!(sum_then_filter.chain_balance, 1.0);
905        assert!((filter_one_layer.chain_balance - 5.0 / 6.0).abs() < 1e-12);
906        assert_ne!(sum_then_filter.to_vec(), filter_one_layer.to_vec());
907
908        // A serial chain is one node wide however long it gets — the property
909        // the proposal tilt reads to decide whether to offer a binary at all.
910        let serial = struct_features(&PatchTree {
911            amp: amp(),
912            root: filter(filter(filter(src()))),
913        });
914        assert_eq!(serial.branch_width_max, 1.0);
915        assert_eq!(serial.chain_balance, 1.0);
916
917        // `frac_sidechained` asks whether the second input is a chain of its
918        // own. Bare source on the right: no. A filter on the right: yes.
919        assert_eq!(
920            struct_features(&PatchTree {
921                amp: amp(),
922                root: mix(src(), src()),
923            })
924            .frac_sidechained,
925            0.0
926        );
927        assert_eq!(
928            struct_features(&PatchTree {
929                amp: amp(),
930                root: mix(src(), filter(src())),
931            })
932            .frac_sidechained,
933            1.0
934        );
935    }
936
937    /// Pipeline over prior samples: most draws featurize; quarantines are
938    /// only ever the legitimate classes; φ has the documented dimension and
939    /// is always finite.
940    #[test]
941    fn pipeline_over_prior_samples() {
942        let spec = PhraseSpec::default();
943        let prior = PatchGrammarPrior::default();
944        let mut rng = StdRng::seed_from_u64(7);
945        let n = 30;
946        let mut ok = 0;
947        for _ in 0..n {
948            let (tree, _) = run(
949                PriorHandler {
950                    rng: &mut rng,
951                    trace: Trace::default(),
952                },
953                prior.model(),
954            );
955            match featurize(&tree, &spec) {
956                Ok(v) => {
957                    ok += 1;
958                    let phi = v.features.phi();
959                    assert_eq!(phi.len(), Features::phi_names().len());
960                    assert!(phi.iter().all(|x| x.is_finite()));
961                }
962                Err(FeaturizeError::Quarantined(_)) => {}
963                Err(e) => panic!("unexpected pipeline error: {e}"),
964            }
965        }
966        assert!(ok * 2 > n, "only {ok}/{n} prior samples featurized");
967    }
968
969    /// A term with a knob outside its range never becomes a row.
970    ///
971    /// The heart of M1: `amp.sustain = 1e30` **renders fine** — quiver's
972    /// limiter bounds the voice — so it sailed through a vet gate that only
973    /// asks about the audio, and its φ went into the observation log where it
974    /// killed the `amp_sustain` column. The quarantine has to be able to
975    /// refuse the *term*, not just the sound it makes.
976    #[test]
977    fn an_out_of_domain_term_is_quarantined() {
978        let spec = PhraseSpec::default();
979        let prior = PatchGrammarPrior::default();
980        let mut rng = StdRng::seed_from_u64(31);
981        let (mut tree, _) = run(
982            PriorHandler {
983                rng: &mut rng,
984                trace: Trace::default(),
985            },
986            prior.model(),
987        );
988        // The exact shape found in the shipped session.
989        tree.amp.sustain = 1e30;
990        match featurize(&tree, &spec) {
991            Err(FeaturizeError::OutOfDomain { site, value }) => {
992                assert_eq!(site, "amp#sustain");
993                assert_eq!(value, 1e30);
994            }
995            other => panic!("the sentinel got through the quarantine: {other:?}"),
996        }
997        // …and the same term, repaired, is an ordinary candidate again.
998        assert_eq!(tree.clamp_domains(), 1);
999        assert!(!matches!(
1000            featurize(&tree, &spec),
1001            Err(FeaturizeError::OutOfDomain { .. })
1002        ));
1003    }
1004}