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}