Skip to main content

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}