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}