Skip to main content

amaters_net/
circuit_cache.rs

1//! FHE compiled-circuit cache.
2//!
3//! Memoises the result of [`amaters_core::compute::PredicateCompiler::compile`] keyed on the
4//! normalised debug-formatted form of the predicate. Because circuit
5//! compilation is a pure function of the predicate structure (no side-effects,
6//! no I/O), a cache hit is always safe to use.
7//!
8//! # Design
9//!
10//! - **Key derivation**: the predicate is formatted via its `Debug` impl and
11//!   the resulting UTF-8 bytes are hashed with blake3 to produce a 32-byte
12//!   cache key.  This is deterministic as long as `Debug` output is stable,
13//!   which it is for the `Predicate` type in this codebase.
14//! - **LRU eviction**: the `CacheInner` maintains an insertion-order
15//!   `VecDeque`; on every hit the accessed key is moved to the back (MRU
16//!   position) so that the front always holds the least-recently-used entry.
17//! - **Clone-able handle**: `CircuitCache` wraps its mutable interior in
18//!   `Arc<Mutex<…>>` so that clones share the same backing store, enabling
19//!   cheap per-request handles without re-initialising the cache.
20//! - **Zero external dependencies**: all dependencies (`blake3`, `parking_lot`)
21//!   are already listed in `amaters-net`'s `Cargo.toml`.
22
23use amaters_core::compute::Circuit;
24use amaters_core::types::Predicate;
25use parking_lot::Mutex;
26use std::collections::{HashMap, VecDeque};
27use std::sync::Arc;
28
29// ---------------------------------------------------------------------------
30// CircuitCacheKey
31// ---------------------------------------------------------------------------
32
33/// Opaque 32-byte cache key derived from a predicate's debug representation.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35pub struct CircuitCacheKey([u8; 32]);
36
37impl CircuitCacheKey {
38    /// Derive a cache key from a predicate.
39    ///
40    /// The predicate is converted to a stable byte sequence via its `Debug`
41    /// implementation and then hashed with blake3.
42    pub fn from_predicate(predicate: &Predicate) -> Self {
43        let repr = format!("{predicate:?}");
44        let hash = blake3::hash(repr.as_bytes());
45        Self(*hash.as_bytes())
46    }
47
48    /// Return the raw 32-byte hash.
49    pub fn as_bytes(&self) -> &[u8; 32] {
50        &self.0
51    }
52}
53
54// ---------------------------------------------------------------------------
55// CircuitCacheStats
56// ---------------------------------------------------------------------------
57
58/// A point-in-time snapshot of circuit cache statistics.
59#[derive(Debug, Clone, Default)]
60pub struct CircuitCacheStats {
61    /// Total cache hits (key found and returned).
62    pub hits: u64,
63    /// Total cache misses (key not present; compilation was triggered).
64    pub misses: u64,
65    /// Total evictions due to the LRU capacity limit being reached.
66    pub evictions: u64,
67    /// Number of entries currently held in the cache.
68    pub current_size: usize,
69}
70
71impl CircuitCacheStats {
72    /// Cache hit rate as a value in `[0.0, 1.0]`.
73    ///
74    /// Returns `0.0` when no lookups have been performed yet.
75    pub fn hit_rate(&self) -> f64 {
76        let total = self.hits + self.misses;
77        if total == 0 {
78            0.0
79        } else {
80            self.hits as f64 / total as f64
81        }
82    }
83}
84
85// ---------------------------------------------------------------------------
86// CircuitCacheConfig
87// ---------------------------------------------------------------------------
88
89/// Configuration for a [`CircuitCache`] instance.
90#[derive(Debug, Clone)]
91pub struct CircuitCacheConfig {
92    /// Maximum number of compiled circuits to retain in memory.
93    ///
94    /// When the cache is full and a new circuit must be inserted, the
95    /// least-recently-used entry is evicted.
96    pub max_entries: usize,
97}
98
99impl Default for CircuitCacheConfig {
100    fn default() -> Self {
101        Self { max_entries: 256 }
102    }
103}
104
105impl CircuitCacheConfig {
106    /// Create a configuration with the given capacity limit.
107    pub fn new(max_entries: usize) -> Self {
108        Self { max_entries }
109    }
110}
111
112// ---------------------------------------------------------------------------
113// CacheInner (unsynchronised)
114// ---------------------------------------------------------------------------
115
116/// Unsynchronised interior state of the cache.
117///
118/// Protected externally by `parking_lot::Mutex`.
119struct CacheInner {
120    /// The compiled circuits, keyed by their hash.
121    map: HashMap<CircuitCacheKey, Circuit>,
122    /// LRU order: front = least recently used, back = most recently used.
123    order: VecDeque<CircuitCacheKey>,
124    /// Accumulated statistics.
125    stats: CircuitCacheStats,
126    /// Capacity limit and other config values.
127    config: CircuitCacheConfig,
128}
129
130impl CacheInner {
131    fn new(config: CircuitCacheConfig) -> Self {
132        let capacity = config.max_entries;
133        Self {
134            map: HashMap::with_capacity(capacity),
135            order: VecDeque::with_capacity(capacity),
136            stats: CircuitCacheStats::default(),
137            config,
138        }
139    }
140
141    /// Look up a key and return a clone of the stored circuit if present.
142    ///
143    /// On a hit the entry is promoted to the MRU (back) position and the hit
144    /// counter is incremented.  On a miss the miss counter is incremented.
145    fn get(&mut self, key: &CircuitCacheKey) -> Option<Circuit> {
146        if let Some(circuit) = self.map.get(key).cloned() {
147            self.stats.hits += 1;
148            // Promote to MRU
149            if let Some(pos) = self.order.iter().position(|k| k == key) {
150                self.order.remove(pos);
151                self.order.push_back(key.clone());
152            }
153            Some(circuit)
154        } else {
155            self.stats.misses += 1;
156            None
157        }
158    }
159
160    /// Insert a compiled circuit under the given key.
161    ///
162    /// If `key` already exists the call is a no-op (idempotent insert: the
163    /// first compilation wins and redundant compilations from concurrent
164    /// requests are discarded cheaply).  If the cache is at capacity the LRU
165    /// entry is evicted first.
166    fn insert(&mut self, key: CircuitCacheKey, circuit: Circuit) {
167        if self.map.contains_key(&key) {
168            return; // already cached; first write wins
169        }
170        // Evict until there is room
171        while self.map.len() >= self.config.max_entries {
172            if let Some(lru_key) = self.order.pop_front() {
173                self.map.remove(&lru_key);
174                self.stats.evictions += 1;
175            } else {
176                break; // empty order queue — should not happen
177            }
178        }
179        self.order.push_back(key.clone());
180        self.map.insert(key, circuit);
181        self.stats.current_size = self.map.len();
182    }
183
184    /// Return a snapshot of the current statistics (with `current_size` updated).
185    fn stats_snapshot(&self) -> CircuitCacheStats {
186        CircuitCacheStats {
187            current_size: self.map.len(),
188            ..self.stats.clone()
189        }
190    }
191}
192
193// ---------------------------------------------------------------------------
194// CircuitCache (public, Clone-able handle)
195// ---------------------------------------------------------------------------
196
197/// Thread-safe LRU cache for compiled FHE circuits.
198///
199/// Cloning a `CircuitCache` produces a second handle that shares the same
200/// underlying cache, making it cheap to hand out per-request copies.
201///
202/// # Example
203///
204/// ```rust,ignore
205/// let cache = CircuitCache::new(CircuitCacheConfig::default());
206/// let circuit = cache.get_or_compile(&predicate, || {
207///     let mut compiler = PredicateCompiler::new();
208///     compiler.compile(&predicate, EncryptedType::U8)
209/// })?;
210/// ```
211#[derive(Clone)]
212pub struct CircuitCache {
213    inner: Arc<Mutex<CacheInner>>,
214}
215
216impl std::fmt::Debug for CircuitCache {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        let stats = self.inner.lock().stats_snapshot();
219        f.debug_struct("CircuitCache")
220            .field("current_size", &stats.current_size)
221            .field("hits", &stats.hits)
222            .field("misses", &stats.misses)
223            .field("evictions", &stats.evictions)
224            .finish()
225    }
226}
227
228impl CircuitCache {
229    /// Create a new cache with the given configuration.
230    pub fn new(config: CircuitCacheConfig) -> Self {
231        Self {
232            inner: Arc::new(Mutex::new(CacheInner::new(config))),
233        }
234    }
235
236    /// Look up a pre-computed cache key in the cache.
237    ///
238    /// Returns `Some(Circuit)` on a hit or `None` on a miss.
239    pub fn get(&self, key: &CircuitCacheKey) -> Option<Circuit> {
240        self.inner.lock().get(key)
241    }
242
243    /// Insert a compiled circuit into the cache.
244    ///
245    /// If the key is already present the call is a no-op.
246    pub fn insert(&self, key: CircuitCacheKey, circuit: Circuit) {
247        self.inner.lock().insert(key, circuit);
248    }
249
250    /// Get a cached circuit or compile it on a miss.
251    ///
252    /// On a cache miss `compile_fn` is called exactly once to produce the
253    /// circuit, which is then stored before being returned.  On a cache hit
254    /// `compile_fn` is never called.
255    ///
256    /// # Errors
257    ///
258    /// Returns the error produced by `compile_fn` on a miss if compilation
259    /// fails.  Cache hits never return an error from this method.
260    pub fn get_or_compile<F>(
261        &self,
262        predicate: &Predicate,
263        compile_fn: F,
264    ) -> amaters_core::error::Result<Circuit>
265    where
266        F: FnOnce() -> amaters_core::error::Result<Circuit>,
267    {
268        let key = CircuitCacheKey::from_predicate(predicate);
269
270        // Fast path: cache hit — lock acquired and released before compile_fn
271        if let Some(cached) = self.get(&key) {
272            return Ok(cached);
273        }
274
275        // Slow path: compile and cache
276        let circuit = compile_fn()?;
277        self.insert(key, circuit.clone());
278        Ok(circuit)
279    }
280
281    /// Return a point-in-time snapshot of the cache statistics.
282    pub fn stats(&self) -> CircuitCacheStats {
283        self.inner.lock().stats_snapshot()
284    }
285
286    /// Number of entries currently held in the cache.
287    pub fn len(&self) -> usize {
288        self.inner.lock().map.len()
289    }
290
291    /// Whether the cache is empty.
292    pub fn is_empty(&self) -> bool {
293        self.len() == 0
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Tests
299// ---------------------------------------------------------------------------
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use amaters_core::compute::{CircuitBuilder, EncryptedType};
305    use amaters_core::types::{CipherBlob, ColumnRef, Predicate};
306
307    /// Build the simplest valid circuit: a single Load("x") node with type U8.
308    fn make_simple_circuit() -> Circuit {
309        let mut builder = CircuitBuilder::new();
310        builder.declare_variable("x", EncryptedType::U8);
311        let x = builder.load("x");
312        builder.build(x).expect("simple circuit should build")
313    }
314
315    /// Construct a simple deterministic predicate for use as a test fixture.
316    fn make_predicate() -> Predicate {
317        Predicate::Eq(
318            ColumnRef::new("col".to_string()),
319            CipherBlob::new(b"test".to_vec()),
320        )
321    }
322
323    // 1. Initial lookup is a miss; subsequent lookup is a hit.
324    #[test]
325    fn test_cache_miss_then_hit() {
326        let cache = CircuitCache::new(CircuitCacheConfig::default());
327        let pred = make_predicate();
328        let key = CircuitCacheKey::from_predicate(&pred);
329
330        // First lookup: miss
331        assert!(cache.get(&key).is_none());
332        assert_eq!(cache.stats().misses, 1);
333        assert_eq!(cache.stats().hits, 0);
334
335        // Insert the circuit
336        cache.insert(key.clone(), make_simple_circuit());
337
338        // Second lookup: hit
339        assert!(cache.get(&key).is_some());
340        assert_eq!(cache.stats().hits, 1);
341    }
342
343    // 2. Eviction fires when capacity is exceeded.
344    #[test]
345    fn test_eviction_at_capacity() {
346        let cache = CircuitCache::new(CircuitCacheConfig::new(2));
347
348        let preds: Vec<Predicate> = (0u8..3)
349            .map(|i| Predicate::Eq(ColumnRef::new(format!("col{i}")), CipherBlob::new(vec![i])))
350            .collect();
351
352        // Fill to capacity
353        for pred in &preds[..2] {
354            let k = CircuitCacheKey::from_predicate(pred);
355            cache.insert(k, make_simple_circuit());
356        }
357        assert_eq!(cache.len(), 2);
358
359        // Insert a third entry — LRU (first inserted) must be evicted
360        let key3 = CircuitCacheKey::from_predicate(&preds[2]);
361        cache.insert(key3, make_simple_circuit());
362        assert_eq!(cache.len(), 2);
363        assert_eq!(cache.stats().evictions, 1);
364
365        // The first predicate's key should have been evicted
366        let key0 = CircuitCacheKey::from_predicate(&preds[0]);
367        assert!(
368            cache.get(&key0).is_none(),
369            "LRU entry should have been evicted"
370        );
371    }
372
373    // 3. get_or_compile does NOT invoke the closure on a cache hit.
374    #[test]
375    fn test_get_or_compile_hit_skips_compile_fn() {
376        let cache = CircuitCache::new(CircuitCacheConfig::default());
377        let pred = make_predicate();
378        let key = CircuitCacheKey::from_predicate(&pred);
379        cache.insert(key, make_simple_circuit());
380
381        let mut called = false;
382        let result = cache.get_or_compile(&pred, || {
383            called = true;
384            Ok(make_simple_circuit())
385        });
386        assert!(result.is_ok(), "get_or_compile should succeed");
387        assert!(!called, "compile_fn must not be called on a cache hit");
388    }
389
390    // 4. get_or_compile invokes the closure on a cache miss and caches the result.
391    #[test]
392    fn test_get_or_compile_miss_calls_compile_fn_and_caches() {
393        let cache = CircuitCache::new(CircuitCacheConfig::default());
394        let pred = make_predicate();
395
396        let mut call_count = 0u32;
397        let result = cache.get_or_compile(&pred, || {
398            call_count += 1;
399            Ok(make_simple_circuit())
400        });
401        assert!(result.is_ok());
402        assert_eq!(call_count, 1, "compile_fn should be called exactly once");
403
404        // Second call: should be served from cache
405        let result2 = cache.get_or_compile(&pred, || {
406            call_count += 1;
407            Ok(make_simple_circuit())
408        });
409        assert!(result2.is_ok());
410        assert_eq!(
411            call_count, 1,
412            "compile_fn must not be called again on a subsequent hit"
413        );
414    }
415
416    // 5. Two structurally different predicates produce distinct keys.
417    #[test]
418    fn test_different_predicates_different_keys() {
419        let p1 = Predicate::Eq(ColumnRef::new("a".to_string()), CipherBlob::new(vec![1]));
420        let p2 = Predicate::Eq(ColumnRef::new("b".to_string()), CipherBlob::new(vec![2]));
421        let k1 = CircuitCacheKey::from_predicate(&p1);
422        let k2 = CircuitCacheKey::from_predicate(&p2);
423        assert_ne!(k1, k2, "distinct predicates must map to distinct keys");
424    }
425
426    // 6. The same predicate always produces the same key (determinism).
427    #[test]
428    fn test_same_predicate_same_key() {
429        let pred = make_predicate();
430        let k1 = CircuitCacheKey::from_predicate(&pred);
431        let k2 = CircuitCacheKey::from_predicate(&pred);
432        assert_eq!(k1, k2, "same predicate must always map to the same key");
433    }
434
435    // 7. Clone shares the underlying cache.
436    #[test]
437    fn test_clone_shares_underlying_cache() {
438        let cache1 = CircuitCache::new(CircuitCacheConfig::default());
439        let cache2 = cache1.clone();
440        let pred = make_predicate();
441        let key = CircuitCacheKey::from_predicate(&pred);
442        cache1.insert(key.clone(), make_simple_circuit());
443        assert!(
444            cache2.get(&key).is_some(),
445            "cloned handle must see entries inserted via the original handle"
446        );
447    }
448
449    // 8. hit_rate returns 0.0 before any lookups.
450    #[test]
451    fn test_hit_rate_zero_when_no_lookups() {
452        let cache = CircuitCache::new(CircuitCacheConfig::default());
453        let stats = cache.stats();
454        assert!(
455            (stats.hit_rate() - 0.0).abs() < f64::EPSILON,
456            "hit_rate should be 0.0 with no lookups"
457        );
458    }
459
460    // 9. hit_rate reflects accumulated hit/miss counts accurately.
461    #[test]
462    fn test_hit_rate_accuracy() {
463        let cache = CircuitCache::new(CircuitCacheConfig::default());
464        let pred = make_predicate();
465        let key = CircuitCacheKey::from_predicate(&pred);
466        cache.insert(key.clone(), make_simple_circuit());
467
468        // 3 hits
469        for _ in 0..3 {
470            let _ = cache.get(&key);
471        }
472        // 1 miss (different key)
473        let other = Predicate::Gt(ColumnRef::new("z".to_string()), CipherBlob::new(vec![9]));
474        let _ = cache.get(&CircuitCacheKey::from_predicate(&other));
475
476        let stats = cache.stats();
477        // 3 / (3 + 1) == 0.75
478        assert!(
479            (stats.hit_rate() - 0.75).abs() < 1e-9,
480            "expected hit_rate 0.75, got {}",
481            stats.hit_rate()
482        );
483    }
484
485    // 10. Idempotent insert: a second insert with the same key is a no-op.
486    #[test]
487    fn test_idempotent_insert() {
488        let cache = CircuitCache::new(CircuitCacheConfig::default());
489        let pred = make_predicate();
490        let key = CircuitCacheKey::from_predicate(&pred);
491
492        cache.insert(key.clone(), make_simple_circuit());
493        cache.insert(key.clone(), make_simple_circuit()); // second insert same key
494        assert_eq!(cache.len(), 1, "duplicate insert must not grow the cache");
495    }
496
497    // 11. LRU ordering: accessing an entry promotes it, protecting it from eviction.
498    #[test]
499    fn test_lru_promotion_protects_from_eviction() {
500        let cache = CircuitCache::new(CircuitCacheConfig::new(2));
501
502        let pred_a = Predicate::Eq(ColumnRef::new("a".to_string()), CipherBlob::new(vec![0]));
503        let pred_b = Predicate::Gt(ColumnRef::new("b".to_string()), CipherBlob::new(vec![1]));
504        let pred_c = Predicate::Lt(ColumnRef::new("c".to_string()), CipherBlob::new(vec![2]));
505
506        let key_a = CircuitCacheKey::from_predicate(&pred_a);
507        let key_b = CircuitCacheKey::from_predicate(&pred_b);
508        let key_c = CircuitCacheKey::from_predicate(&pred_c);
509
510        // Insert A and B (fills capacity)
511        cache.insert(key_a.clone(), make_simple_circuit());
512        cache.insert(key_b.clone(), make_simple_circuit());
513
514        // Access A (promotes it to MRU; B becomes LRU)
515        let _ = cache.get(&key_a);
516
517        // Insert C — should evict B (LRU), not A
518        cache.insert(key_c.clone(), make_simple_circuit());
519
520        assert!(cache.get(&key_a).is_some(), "A should survive (promoted)");
521        assert!(cache.get(&key_b).is_none(), "B should be evicted (LRU)");
522        assert!(cache.get(&key_c).is_some(), "C was just inserted");
523    }
524
525    // 12. Debug formatting does not panic and contains expected fields.
526    #[test]
527    fn test_debug_format() {
528        let cache = CircuitCache::new(CircuitCacheConfig::default());
529        let dbg = format!("{:?}", cache);
530        assert!(
531            dbg.contains("CircuitCache"),
532            "debug string should name the type"
533        );
534    }
535}