Skip to main content

auracle_features/
cache.rs

1//! Content-addressed memoization of [`crate::featurize`] (design L0).
2//!
3//! φ is a pure function of `(term, spec)` — that is the determinism contract
4//! [`crate::render`] states — so any featurization the engine has already
5//! performed can be replayed instead of re-rendered. That matters because the
6//! engine performs the *same* featurization repeatedly and unavoidably:
7//!
8//! - `fugue-ppl`'s adaptive single-site MH executes the model **twice per
9//!   step**, once to re-score the current trace — which is bit-identically the
10//!   tree the previous step accepted. Every refinement step therefore renders
11//!   one tree it has already rendered.
12//! - `Engine::insert_candidate` re-featurizes the tree the refinement walk (or
13//!   the edit bench) just featurized, to obtain the φ it admits it with.
14//!
15//! Neither is a bug to be deleted — the first is inside a dependency's kernel,
16//! the second is the honest way to admit a candidate. A memo removes the cost
17//! without touching either. It is *exactly* lossless: a hit returns the same
18//! [`Features`] object the miss produced, so nothing downstream — least of all
19//! the raw φ that enters the observation log — can tell the two apart.
20//!
21//! ## Keys
22//!
23//! [`render_key`] is `fnv1a128` over `serde_json` of the term and of the
24//! phrase spec. FNV rather than `DefaultHasher` because `DefaultHasher`'s
25//! output is explicitly not guaranteed stable across Rust releases, and this
26//! key is meant to be persistable (design L2) — a toolchain bump must not
27//! silently invalidate every stored row. `serde_json` is deterministic across
28//! runs and platforms for these types: field order is the struct's, and floats
29//! round-trip exactly under the `float_roundtrip` feature the workspace pins.
30//!
31//! ## Persisting a row: the key is not enough
32//!
33//! [`render_key`] addresses `(term, spec)`, which is everything φ depends on
34//! *given a fixed featurizer*. A stored row survives a reload only if the code
35//! that would recompute it agrees, and the key cannot see that code: change the
36//! normalizer, a descriptor's formula, or the vet gate, and the same key now
37//! names a different measurement.
38//!
39//! [`RENDER_EPOCH`] is that missing coordinate, and [`cache_namespace`]
40//! combines the two. Bumping the epoch orphans every stored row at once, which
41//! is the intended and only correct response to φ moving — a persistent cache
42//! whose invalidation is *anything less than* total is a cache that will one
43//! day serve a number from a featurizer that no longer exists.
44
45use std::collections::HashMap;
46use std::sync::{Arc, Mutex};
47
48use auracle_grammar::PatchTree;
49use serde::{Deserialize, Serialize};
50
51use crate::phrase::PhraseSpec;
52use crate::pipeline::{featurize, Features, FeaturizeError};
53use crate::render::Audition;
54
55const FNV_OFFSET_128: u128 = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58d;
56const FNV_PRIME_128: u128 = 0x0000_0000_0100_0000_0000_0000_0000_013b;
57
58fn fnv1a128(state: u128, bytes: &[u8]) -> u128 {
59    let mut h = state;
60    for b in bytes {
61        h ^= *b as u128;
62        h = h.wrapping_mul(FNV_PRIME_128);
63    }
64    h
65}
66
67/// The exact bytes a key is computed over for a term.
68///
69/// Deterministic across runs and platforms: `serde_json` emits struct fields
70/// in declaration order and shortest-round-trip floats (ryu).
71pub fn canonical_tree_json(tree: &PatchTree) -> String {
72    // Uids are stripped first, and this is load-bearing rather than tidy. A
73    // `uid` is UI identity: two trees that differ only in theirs are the same
74    // patch and render the same audio, so a key that saw them would miss on
75    // *every* refinement step (the chain re-scores a tree it just rendered, and
76    // that tree comes back from the trace decoder with fresh identities) and
77    // would invalidate every persistable row the moment the editor renamed a
78    // node. Cleared uids also serialize to nothing — the field is skipped when
79    // unset — so this key is byte-identical to the one this cache used before
80    // identities existed, and no stored entry is orphaned by their arrival.
81    let mut plain = tree.clone();
82    plain.clear_uids();
83    serde_json::to_string(&plain).expect("PatchTree always serializes")
84}
85
86/// Generation of the featurizer itself.
87///
88/// **Bump this whenever a stored [`CachedFeatures`] computed by the previous
89/// build would differ from what this build computes for the same `(term,
90/// spec)`.** [`render_key`] cannot detect that: it hashes the inputs, and this
91/// is a change in the function.
92///
93/// Concretely, bump on any change to: a φ coordinate's formula or its set,
94/// loudness normalization (including `loudness::PEAK_CEILING` and
95/// `pipeline::TARGET_LUFS`), the vetting thresholds, or the compiler's mapping
96/// from a term to quiver modules. When in doubt, bump — the cost is one cold
97/// boot, and the cost of not bumping is a posterior fitted on rows from two
98/// different featurizers with no way to tell which is which.
99///
100/// | epoch | what changed |
101/// |---|---|
102/// | 1 | first persistent cache; peak-capped loudness normalization |
103pub const RENDER_EPOCH: u32 = 1;
104
105/// The persistent cache's namespace for one stimulus: `"e<epoch>:<spec hash>"`.
106///
107/// Two coordinates, because two independent things invalidate a stored row —
108/// the featurizer changing ([`RENDER_EPOCH`]) and the stimulus changing (the
109/// spec). The spec is folded into [`render_key`] as well, so this is redundant
110/// for correctness and useful for operations: it makes a whole stimulus's rows
111/// a contiguous, droppable prefix instead of scattered keys that can only be
112/// evicted by trying them.
113pub fn cache_namespace(spec: &PhraseSpec) -> String {
114    let spec_json = serde_json::to_string(spec).expect("PhraseSpec always serializes");
115    let h = fnv1a128(FNV_OFFSET_128, spec_json.as_bytes());
116    format!("e{RENDER_EPOCH}:{h:032x}")
117}
118
119/// Content address of one `(term, spec)` featurization, 32 lowercase hex
120/// chars.
121///
122/// The spec is folded in because φ is only defined relative to the stimulus:
123/// two engines with different phrases must never share an entry. A `0xff`
124/// separator (not a valid byte anywhere in either JSON) keeps the
125/// concatenation unambiguous.
126pub fn render_key(tree: &PatchTree, spec: &PhraseSpec) -> String {
127    let tree_json = canonical_tree_json(tree);
128    let spec_json = serde_json::to_string(spec).expect("PhraseSpec always serializes");
129    let mut h = fnv1a128(FNV_OFFSET_128, tree_json.as_bytes());
130    h = fnv1a128(h, &[0xff]);
131    h = fnv1a128(h, spec_json.as_bytes());
132    format!("{h:032x}")
133}
134
135/// Everything [`featurize`] produces except the samples — the persistable
136/// unit, and what a memo hit returns.
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct CachedFeatures {
139    /// Content address ([`render_key`]).
140    pub key: String,
141    /// The extracted features, byte-for-byte what `featurize` returned.
142    pub features: Features,
143    /// Sample index where each note's gate opened.
144    pub note_onsets: Vec<usize>,
145    /// Length of the render in samples.
146    pub n_samples: usize,
147}
148
149/// Memo occupancy and hit accounting.
150#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
151pub struct MemoStats {
152    /// Featurizations served from the memo.
153    pub hits: u64,
154    /// Featurizations that had to render.
155    pub misses: u64,
156    /// Resident feature entries.
157    pub features: usize,
158    /// Resident audition buffers.
159    pub audio: usize,
160    /// Bytes held by those audition buffers.
161    pub audio_bytes: usize,
162}
163
164struct MemoInner {
165    feature_cap: usize,
166    audio_cap: usize,
167    tick: u64,
168    features: HashMap<String, (u64, CachedFeatures)>,
169    audio: HashMap<String, (u64, Arc<Audition>)>,
170    hits: u64,
171    misses: u64,
172}
173
174impl MemoInner {
175    fn next_tick(&mut self) -> u64 {
176        self.tick += 1;
177        self.tick
178    }
179}
180
181/// Evict least-recently-used entries until `map` fits `cap`.
182///
183/// Linear scan per eviction: with the shipped caps (2048 φ / 12 buffers) that
184/// is a few thousand integer compares against the ~0.5 s render an eviction
185/// is making room for, so a proper intrusive LRU would be complexity bought
186/// with nothing.
187fn evict_to<V>(map: &mut HashMap<String, (u64, V)>, cap: usize) {
188    while map.len() > cap {
189        let Some(oldest) = map
190            .iter()
191            .min_by_key(|(_, (t, _))| *t)
192            .map(|(k, _)| k.clone())
193        else {
194            return;
195        };
196        map.remove(&oldest);
197    }
198}
199
200/// A bounded, content-addressed featurization memo.
201///
202/// Cheap to clone (shared interior) and guarded by a `Mutex`, and every access
203/// here is uncontended.
204///
205/// This used to say the `Send + Sync` shape was for `fugue_evo::Fitness`, "if
206/// the `parallel` feature were ever enabled". Enabling it would do nothing:
207/// every `rayon` use in fugue-evo is under `#[cfg(feature = "classic")]` — the
208/// classic EC layer's `algorithms/`, `population/` and `fitness/` — and the
209/// workspace takes fugue-evo with `["std", "ppl"]`, driving refinement itself
210/// through `inference::mh::EvolutionChain`. The flag would compile rayon in and
211/// change no code path this crate reaches.
212///
213/// Parallelising refinement is still possible, just Auracle-side and worth
214/// less than it looks: `search_health` and the `refinement_improves_pool`
215/// floor already spawn a thread per seed and saturate the cores, so a
216/// measurement would not get faster. What it would buy is latency on a *single*
217/// refinement — the app's ⚡ button — which is a UX win rather than a harness
218/// one, and this type is shaped for it either way.
219///
220/// Two tiers, both LRU, because they cost three orders of magnitude apart:
221/// ~1 KB of φ against ~565 KB of audio. Keeping thousands of the former and a
222/// dozen of the latter is what lets a whole refinement generation stay
223/// resident while audition memory stays flat.
224#[derive(Clone)]
225pub struct RenderMemo(Arc<Mutex<MemoInner>>);
226
227/// Feature entries retained. A refinement generation is a few hundred
228/// featurizations; 2048 keeps a whole session's worth of walks resident at
229/// ~2 MB.
230pub const DEFAULT_FEATURE_CAP: usize = 2048;
231/// Audition buffers retained (~565 KB each at the default phrase) — enough
232/// for the current duel pair, the bench, and recent history, at ~7 MB.
233pub const DEFAULT_AUDIO_CAP: usize = 12;
234
235impl Default for RenderMemo {
236    fn default() -> Self {
237        Self::new(DEFAULT_FEATURE_CAP, DEFAULT_AUDIO_CAP)
238    }
239}
240
241impl std::fmt::Debug for RenderMemo {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.debug_struct("RenderMemo")
244            .field("stats", &self.stats())
245            .finish()
246    }
247}
248
249impl RenderMemo {
250    /// A memo holding `feature_cap` φ entries and `audio_cap` audition
251    /// buffers, both LRU.
252    pub fn new(feature_cap: usize, audio_cap: usize) -> Self {
253        Self(Arc::new(Mutex::new(MemoInner {
254            feature_cap,
255            audio_cap,
256            tick: 0,
257            features: HashMap::new(),
258            audio: HashMap::new(),
259            hits: 0,
260            misses: 0,
261        })))
262    }
263
264    /// A memo that stores nothing — the null object for callers that want the
265    /// unmemoized path without a second code path.
266    pub fn disabled() -> Self {
267        Self::new(0, 0)
268    }
269
270    /// Features for `key`, if resident. Counts as a use for LRU purposes.
271    pub fn get(&self, key: &str) -> Option<CachedFeatures> {
272        let mut m = self.0.lock().expect("memo poisoned");
273        let t = m.next_tick();
274        let e = m.features.get_mut(key)?;
275        e.0 = t;
276        Some(e.1.clone())
277    }
278
279    /// Audition buffer for `key`, if resident. Counts as a use.
280    ///
281    /// Shared, not copied: a ~565 KB buffer is handed out as an [`Arc`] so
282    /// that looking one up costs a refcount bump rather than a half-megabyte
283    /// memcpy. Callers that need to own samples clone the inner value
284    /// explicitly, which makes every deep copy of an audition visible at its
285    /// call site.
286    pub fn get_audio(&self, key: &str) -> Option<Arc<Audition>> {
287        let mut m = self.0.lock().expect("memo poisoned");
288        let t = m.next_tick();
289        let e = m.audio.get_mut(key)?;
290        e.0 = t;
291        Some(Arc::clone(&e.1))
292    }
293
294    /// Store a featurization, optionally with its audition buffer.
295    pub fn put(&self, entry: CachedFeatures, audio: Option<Arc<Audition>>) {
296        let mut m = self.0.lock().expect("memo poisoned");
297        let t = m.next_tick();
298        if let Some(a) = audio {
299            if m.audio_cap > 0 {
300                m.audio.insert(entry.key.clone(), (t, a));
301                let cap = m.audio_cap;
302                evict_to(&mut m.audio, cap);
303            }
304        }
305        if m.feature_cap > 0 {
306            m.features.insert(entry.key.clone(), (t, entry));
307            let cap = m.feature_cap;
308            evict_to(&mut m.features, cap);
309        }
310    }
311
312    /// Occupancy and hit accounting.
313    pub fn stats(&self) -> MemoStats {
314        let m = self.0.lock().expect("memo poisoned");
315        MemoStats {
316            hits: m.hits,
317            misses: m.misses,
318            features: m.features.len(),
319            audio: m.audio.len(),
320            audio_bytes: m.audio.values().map(|(_, a)| a.bytes()).sum(),
321        }
322    }
323
324    /// Drop everything. Used when the phrase spec changes under a live
325    /// engine, which would otherwise leave keys from two stimuli in one map.
326    pub fn clear(&self) {
327        let mut m = self.0.lock().expect("memo poisoned");
328        m.features.clear();
329        m.audio.clear();
330    }
331
332    fn record(&self, hit: bool) {
333        let mut m = self.0.lock().expect("memo poisoned");
334        if hit {
335            m.hits += 1;
336        } else {
337            m.misses += 1;
338        }
339    }
340}
341
342/// [`featurize`], consulting `memo` first and populating it on a miss.
343///
344/// `want_audio` says whether the caller has any use for samples. It is not a
345/// hint: with it `false` this function never converts f64→f32 and never
346/// touches the audio tier, so the refinement surrogate — which runs this twice
347/// per MH step and discards audio every time — pays for φ and nothing else.
348/// Asking for audio you will not play costs a ~565 KB conversion on a miss and
349/// keeps a buffer alive on a hit, which is the whole expense the memo exists
350/// to remove.
351///
352/// With `want_audio`, returns the audition buffer **when this call rendered it
353/// or found it still resident**; a hit whose buffer has aged out of the small
354/// audio tier yields `None`, and callers that need one regardless re-derive it
355/// with [`crate::render_playback`]. The buffer is shared with the memo through
356/// an [`Arc`], so producing it allocates once.
357///
358/// Only successes are memoized. A quarantined or uncompilable term is
359/// re-attempted on every request, which costs a render — but the trees that
360/// repeat are precisely the ones MH has *accepted*, and an accepted tree
361/// vetted by construction. Caching failures would buy a rounding error and
362/// require the vet report to survive round-tripping through the memo, where a
363/// stale one would be a DESIGN §2.1 gate bypass.
364pub fn featurize_memo(
365    tree: &PatchTree,
366    spec: &PhraseSpec,
367    memo: &RenderMemo,
368    want_audio: bool,
369) -> Result<(CachedFeatures, Option<Arc<Audition>>), FeaturizeError> {
370    let key = render_key(tree, spec);
371    if let Some(hit) = memo.get(&key) {
372        memo.record(true);
373        let audio = if want_audio {
374            memo.get_audio(&key)
375        } else {
376            None
377        };
378        return Ok((hit, audio));
379    }
380    memo.record(false);
381    let v = featurize(tree, spec)?;
382    let audition = want_audio.then(|| Arc::new(v.render.to_audition()));
383    let entry = CachedFeatures {
384        key,
385        features: v.features,
386        note_onsets: v.render.note_onsets,
387        n_samples: v.render.samples.len(),
388    };
389    memo.put(entry.clone(), audition.clone());
390    Ok((entry, audition))
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::render::render_playback;
397    use auracle_grammar::term::{AmpEnv, AudioNode, Uid, Waveform};
398
399    fn tree(detune: f64) -> PatchTree {
400        PatchTree {
401            amp: AmpEnv {
402                attack: 0.05,
403                decay: 0.3,
404                sustain: 0.8,
405                release: 0.3,
406            },
407            root: AudioNode::Vco {
408                uid: Uid::NEW,
409                wave: Waveform::Saw,
410                octave: 0,
411                detune,
412                mod_depth: 0.0,
413                modulation: auracle_grammar::term::ModNode::None,
414            },
415        }
416    }
417
418    /// The whole contract: a hit is indistinguishable from a miss.
419    #[test]
420    fn memo_hit_equals_fresh_featurize() {
421        let spec = PhraseSpec::default();
422        let memo = RenderMemo::default();
423        let t = tree(0.5);
424        let fresh = featurize(&t, &spec).unwrap();
425        let (miss, _) = featurize_memo(&t, &spec, &memo, true).unwrap();
426        let (hit, _) = featurize_memo(&t, &spec, &memo, true).unwrap();
427        assert_eq!(fresh.features.phi(), miss.features.phi());
428        assert_eq!(fresh.features.phi(), hit.features.phi());
429        assert_eq!(fresh.features.gain_db, hit.features.gain_db);
430        assert_eq!(fresh.features.lufs_before, hit.features.lufs_before);
431        assert_eq!(fresh.render.note_onsets, hit.note_onsets);
432        assert_eq!(fresh.render.samples.len(), hit.n_samples);
433        let s = memo.stats();
434        assert_eq!((s.hits, s.misses), (1, 1), "second call must not render");
435    }
436
437    /// Keys separate distinct terms and distinct stimuli, and are stable.
438    #[test]
439    fn keys_are_content_addressed() {
440        let spec = PhraseSpec::default();
441        assert_eq!(render_key(&tree(0.5), &spec), render_key(&tree(0.5), &spec));
442        assert_ne!(render_key(&tree(0.5), &spec), render_key(&tree(0.6), &spec));
443        let other = PhraseSpec {
444            seed: spec.seed ^ 1,
445            ..spec.clone()
446        };
447        assert_ne!(
448            render_key(&tree(0.5), &spec),
449            render_key(&tree(0.5), &other),
450            "a different stimulus is a different φ"
451        );
452        assert_eq!(render_key(&tree(0.5), &spec).len(), 32);
453    }
454
455    /// Node identities are UI bookkeeping and the content address must not see
456    /// them.
457    ///
458    /// If it did, every refinement step would miss a row it had just written —
459    /// the chain re-scores a tree it just rendered, and that tree comes back
460    /// from the trace decoder with fresh identities — and every persisted row
461    /// would be orphaned the moment the editor touched a patch. The second
462    /// assertion is the migration half: a settled tree keys exactly as the same
463    /// term did before uids existed, so nothing already stored is lost.
464    #[test]
465    fn keys_ignore_node_identity() {
466        let spec = PhraseSpec::default();
467        let plain = tree(0.5);
468        let (mut a, mut b) = (plain.clone(), plain.clone());
469        a.ensure_uids();
470        b.ensure_uids();
471        assert_ne!(a.root.uid().0, b.root.uid().0, "distinct settlings");
472        assert_eq!(render_key(&a, &spec), render_key(&b, &spec));
473        assert_eq!(render_key(&a, &spec), render_key(&plain, &spec));
474        assert!(!canonical_tree_json(&a).contains("uid"));
475    }
476
477    /// `render_playback` replays the recorded gain, so the buffer it produces
478    /// is the one `featurize` normalized — bit for bit. This is what makes a
479    /// lazily-materialized audition safe to hand to the audio path.
480    #[test]
481    fn render_playback_is_bit_identical() {
482        let spec = PhraseSpec::default();
483        for detune in [0.0, 0.5, 0.9] {
484            let t = tree(detune);
485            let v = featurize(&t, &spec).unwrap();
486            let replayed = render_playback(&t, &spec, v.features.gain_db).unwrap();
487            let direct = v.render.to_audition();
488            assert_eq!(replayed.sample_rate, direct.sample_rate);
489            assert_eq!(
490                replayed.samples, direct.samples,
491                "lazy audition drifted from the featurized render"
492            );
493        }
494    }
495
496    /// Both tiers stay bounded, and the audio tier is the one that shrinks.
497    #[test]
498    fn caps_are_enforced() {
499        let spec = PhraseSpec::default();
500        let memo = RenderMemo::new(3, 1);
501        for i in 0..4 {
502            featurize_memo(&tree(0.1 * (i as f64 + 1.0)), &spec, &memo, true).unwrap();
503        }
504        let s = memo.stats();
505        assert_eq!(s.features, 3, "feature tier over cap");
506        assert_eq!(s.audio, 1, "audio tier over cap");
507        assert!(s.audio_bytes > 0);
508        memo.clear();
509        assert_eq!(memo.stats().features, 0);
510    }
511
512    /// A zero-cap memo is a working no-op, not a panic or a leak.
513    #[test]
514    fn disabled_memo_stores_nothing() {
515        let spec = PhraseSpec::default();
516        let memo = RenderMemo::disabled();
517        let t = tree(0.5);
518        featurize_memo(&t, &spec, &memo, true).unwrap();
519        let (again, _) = featurize_memo(&t, &spec, &memo, true).unwrap();
520        assert_eq!(memo.stats().features, 0);
521        assert_eq!(memo.stats().misses, 2);
522        assert_eq!(
523            again.features.phi(),
524            featurize(&t, &spec).unwrap().features.phi()
525        );
526    }
527}