auracle_features/pipeline.rs
1//! The full featurization pipeline: compile → render → **vet** → normalize →
2//! extract. One render serves the vet report, the features, and (upstream in
3//! the session layer) the audition buffer — the vetting gate's "no candidate is
4//! ever played live unvetted" is enforced here by construction.
5
6use auracle_grammar::PatchTree;
7use quiver::PatchError;
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::audio::{audio_features, AudioFeatures};
12use crate::loudness::normalize_to;
13use crate::phrase::PhraseSpec;
14use crate::render::{render_phrase, RenderedPhrase};
15use crate::structural::{struct_features, StructFeatures};
16use crate::vet::{vet, VetConfig, VetFailure, VetReport};
17
18/// Target integrated loudness for audition and feature extraction.
19pub const TARGET_LUFS: f64 = -18.0;
20
21/// Why featurization rejected a candidate.
22#[derive(Debug, Error)]
23pub enum FeaturizeError {
24 /// The term failed to compile into a quiver patch (grammar bug — the
25 /// typed prior should make this unreachable).
26 #[error("compile failed: {0}")]
27 Compile(#[from] PatchError),
28 /// The render was quarantined by the vetting gate.
29 #[error("quarantined: {0}")]
30 Quarantined(#[from] VetFailure),
31 /// A continuous site of the term sits outside its declared range, so the
32 /// φ this would produce is not a measurement of anything.
33 ///
34 /// The quarantine used to catch only *audio* pathology — a render that was
35 /// silent, clipped or DC-dominated — which is a gate on the sound and not
36 /// on the term. `amp.sustain = 1e30` renders perfectly well (the limiter
37 /// bounds it), so it passed the vet, and its φ then entered the observation
38 /// log where one row's outlier set the whole `amp_sustain` column's scale
39 /// and killed the coordinate. A row the model cannot interpret must not be
40 /// recorded as evidence, and this is the last place that can say so.
41 #[error("out of domain: {value} at {site} (every knob is normalized 0–1)")]
42 OutOfDomain {
43 /// The offending trace address.
44 site: String,
45 /// The value found there.
46 value: f64,
47 },
48 /// An extracted coordinate is not a finite number.
49 ///
50 /// Distinct from [`Self::OutOfDomain`]: the term was legal, so this is the
51 /// *measurement* having gone wrong (an audio descriptor over a degenerate
52 /// buffer), and it names the coordinate rather than a genome site.
53 #[error("feature {name} is not finite ({value})")]
54 NonFiniteFeature {
55 /// The φ coordinate's name.
56 name: String,
57 /// The value computed for it.
58 value: f64,
59 },
60}
61
62/// Everything the taste model and audition path need for one candidate.
63#[derive(Clone, Debug, Serialize, Deserialize)]
64pub struct Features {
65 /// Perceptual descriptors of the normalized render.
66 pub audio: AudioFeatures,
67 /// Render-free structural descriptors of the term.
68 pub structural: StructFeatures,
69 /// Raw-render measurements from the vet gate.
70 pub vet: VetReport,
71 /// Integrated loudness before normalization (LUFS).
72 pub lufs_before: f64,
73 /// Gain applied (dB) — toward [`TARGET_LUFS`], but never past
74 /// [`auracle_features::PEAK_CEILING`](crate::loudness::PEAK_CEILING).
75 pub gain_db: f64,
76 /// Makeup gain given up so the render would not clip, in dB (≥ 0).
77 ///
78 /// Zero for most patches. Positive means this one auditions *below*
79 /// [`TARGET_LUFS`] because its crest factor would not let it reach the
80 /// target without going over full scale — so a surface comparing two
81 /// candidates' levels can say which of them was pulled down and by how
82 /// much, rather than presenting a peak-limited patch as a quiet one.
83 ///
84 /// `#[serde(default)]` is forward-looking rather than a migration.
85 /// Features cross the farm as [`crate::CachedFeatures`] within a single
86 /// boot, where the worker's version stamp guarantees one build on both
87 /// ends, and nothing persists them across boots today — `BankEntry` stores
88 /// trees, so a reloaded pool is re-featurized under whatever normalizer is
89 /// current. The default matters the moment the persistent render cache
90 /// lands, and the thing to note there is that a row written before this
91 /// ceiling carries a `gain_db` that *would* clip: normalization changing
92 /// has to invalidate the cache namespace, not merely default a field.
93 #[serde(default)]
94 pub peak_reduction_db: f64,
95}
96
97impl Features {
98 /// The concatenated feature vector `φ(x) = [φ_audio ; φ_struct]`.
99 pub fn phi(&self) -> Vec<f64> {
100 let mut v = self.audio.to_vec();
101 v.extend(self.structural.to_vec());
102 v
103 }
104
105 /// Names for [`Self::phi`] entries, in order.
106 pub fn phi_names() -> Vec<&'static str> {
107 AudioFeatures::NAMES
108 .iter()
109 .chain(StructFeatures::NAMES.iter())
110 .copied()
111 .collect()
112 }
113}
114
115/// A vetted, loudness-normalized render plus its features — the single
116/// artifact one candidate costs.
117#[derive(Clone, Debug)]
118pub struct VettedCandidate {
119 /// The normalized render (this exact buffer is what audition plays).
120 pub render: RenderedPhrase,
121 /// The extracted features.
122 pub features: Features,
123}
124
125/// Run the full pipeline for one term.
126pub fn featurize(tree: &PatchTree, spec: &PhraseSpec) -> Result<VettedCandidate, FeaturizeError> {
127 // Before the render, not after: a term with a knob outside its range is not
128 // a candidate that happens to sound bad, it is a term whose φ would be a
129 // lie, and the ~600 ms render is wasted on it either way. This is the gate
130 // that keeps the observation log clean — every row in the log came through
131 // here.
132 if let Some((site, value)) = tree.domain_violations().into_iter().next() {
133 return Err(FeaturizeError::OutOfDomain { site, value });
134 }
135 let mut render = render_phrase(tree, spec)?;
136 let report = vet(&render.samples, &VetConfig::for_spec(spec))?;
137 // A signal that passed the RMS floor always clears the loudness gate in
138 // practice; treat a `None` here as silence for safety.
139 let norm = normalize_to(&mut render.samples, render.sample_rate, TARGET_LUFS)
140 .ok_or(VetFailure::Silent { rms: report.rms })?;
141 let audio = audio_features(&render);
142 let structural = struct_features(tree);
143 let features = Features {
144 audio,
145 structural,
146 vet: report,
147 lufs_before: norm.lufs_before,
148 gain_db: norm.gain_db,
149 peak_reduction_db: norm.peak_reduction_db,
150 };
151 // The second half of the same guard, on the vector rather than the term.
152 // Costs one pass over 37 doubles against a render that took most of a
153 // second, and it is the only thing standing between a NaN out of a
154 // spectral descriptor and a posterior fit that returns all-NaN θ.
155 for (name, value) in Features::phi_names().iter().zip(features.phi()) {
156 if !value.is_finite() {
157 return Err(FeaturizeError::NonFiniteFeature {
158 name: (*name).to_string(),
159 value,
160 });
161 }
162 }
163 Ok(VettedCandidate { render, features })
164}