Skip to main content

somatize_core/
cache.rs

1//! Content-addressable caching — keys, traits, and metadata.
2//!
3//! [`CacheKey`] is a SHA-256 hash of computation inputs. Two cache keys:
4//! - **State key**: `hash(config + training_data)` — for fit() results
5//! - **Output key**: `hash(config + state + input)` — for forward() results
6//!
7//! [`CacheStore`] is the K/V interface; implementations live in soma-runtime.
8
9use crate::error::Result;
10use crate::value::Value;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::fmt;
15
16/// Content-addressable hash identifying a computation.
17#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct CacheKey(pub [u8; 32]);
19
20impl CacheKey {
21    /// Create a cache key by hashing arbitrary byte slices.
22    pub fn from_parts(parts: &[&[u8]]) -> Self {
23        let mut hasher = Sha256::new();
24        for part in parts {
25            // Length-prefix each part to avoid collisions between
26            // concat("ab", "c") and concat("a", "bc")
27            hasher.update((part.len() as u64).to_le_bytes());
28            hasher.update(part);
29        }
30        Self(hasher.finalize().into())
31    }
32
33    /// Create a cache key for a filter's trained state.
34    /// key = hash(filter_config_hash + x_hash [+ y_hash])
35    ///
36    /// The labels `y` are part of the key: the same features trained
37    /// against different labels must never collide. `None` and
38    /// `Some(...)` always produce distinct keys (different part counts,
39    /// and every part is length-prefixed).
40    pub fn for_state(config_hash: &CacheKey, x_hash: &CacheKey, y_hash: Option<&CacheKey>) -> Self {
41        match y_hash {
42            Some(y) => Self::from_parts(&[&config_hash.0, &x_hash.0, b"y", &y.0]),
43            None => Self::from_parts(&[&config_hash.0, &x_hash.0]),
44        }
45    }
46
47    /// Create a cache key for a filter's output.
48    /// key = hash(filter_config_hash + state_hash + input_data_hash)
49    pub fn for_output(
50        config_hash: &CacheKey,
51        state_hash: &CacheKey,
52        input_hash: &CacheKey,
53    ) -> Self {
54        Self::from_parts(&[&config_hash.0, &state_hash.0, &input_hash.0])
55    }
56
57    /// Hash arbitrary serializable data.
58    pub fn hash_data(data: &[u8]) -> Self {
59        Self::from_parts(&[data])
60    }
61
62    /// Hash a [`Value`] for use as cache-key material.
63    ///
64    /// Not `hash_data(serde_json::to_vec(value))`, which is what the
65    /// runtime used to do. JSON has no way to write a non-finite float:
66    /// `serde_json` turns NaN *and* every infinity into `null`, silently.
67    /// A tensor of NaN and a tensor of +∞ therefore serialized to the same
68    /// bytes, hashed to the same key, and the second one was answered with
69    /// the first one's cached output.
70    ///
71    /// Floats are hashed by their bit pattern instead, so every distinct
72    /// value gets a distinct key. Two consequences worth knowing: the two
73    /// NaN encodings are different keys (they are different bit patterns),
74    /// and `0.0` and `-0.0` are different keys too. Both are the safe
75    /// direction — a redundant miss costs a recomputation, a false hit
76    /// costs a wrong answer.
77    pub fn for_value(value: &Value) -> Self {
78        let mut hasher = Sha256::new();
79        Self::absorb(&mut hasher, value);
80        Self(hasher.finalize().into())
81    }
82
83    fn absorb(hasher: &mut Sha256, value: &Value) {
84        // A leading tag per variant keeps `Bytes(b"x")` and `Object(b"x")`
85        // apart, and a length prefix keeps concatenations apart.
86        match value {
87            Value::Tensor { values, shape } => {
88                hasher.update([0u8]);
89                hasher.update((shape.len() as u64).to_le_bytes());
90                for dim in shape {
91                    hasher.update((*dim as u64).to_le_bytes());
92                }
93                hasher.update((values.len() as u64).to_le_bytes());
94                for v in values.iter() {
95                    hasher.update(v.to_bits().to_le_bytes());
96                }
97            }
98            Value::Text(text) => {
99                hasher.update([5u8]);
100                hasher.update((text.len() as u64).to_le_bytes());
101                hasher.update(text.as_bytes());
102            }
103            Value::Json(json) => {
104                hasher.update([1u8]);
105                // `serde_json::Value` cannot hold a non-finite number, and
106                // its object maps are ordered, so this round-trip is both
107                // lossless and deterministic.
108                let bytes = serde_json::to_vec(json.as_ref()).unwrap_or_default();
109                hasher.update((bytes.len() as u64).to_le_bytes());
110                hasher.update(&bytes);
111            }
112            Value::Bytes(bytes) => {
113                hasher.update([2u8]);
114                hasher.update((bytes.len() as u64).to_le_bytes());
115                hasher.update(bytes.as_slice());
116            }
117            Value::Object(bytes) => {
118                hasher.update([3u8]);
119                hasher.update((bytes.len() as u64).to_le_bytes());
120                hasher.update(bytes.as_slice());
121            }
122            Value::Empty => hasher.update([4u8]),
123            // No catch-all on purpose. `Value` is `#[non_exhaustive]`
124            // downstream but not here, so a new variant fails to compile
125            // until someone decides how it hashes — which beats it
126            // silently sharing a key with whatever the fallback picked.
127        }
128    }
129
130    /// Returns the hex representation.
131    pub fn to_hex(&self) -> String {
132        self.0.iter().map(|b| format!("{b:02x}")).collect()
133    }
134}
135
136impl fmt::Debug for CacheKey {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "CacheKey({}...)", &self.to_hex()[..12])
139    }
140}
141
142impl fmt::Display for CacheKey {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        write!(f, "{}", &self.to_hex()[..16])
145    }
146}
147
148/// Which storage tier a cached entry lives in.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
150pub enum CacheTier {
151    /// In-process memory (the LRU tier).
152    Memory,
153    /// Local disk (the persistent action-record + blob store).
154    Local,
155    /// A remote backend shared across machines.
156    Remote,
157}
158
159/// Where a cached value originated.
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub enum Origin {
162    /// Produced by executing a node — the common case.
163    Computed {
164        /// Node that produced the value.
165        node_id: String,
166        /// Run the computation happened in.
167        run_id: String,
168    },
169    /// Loaded from an external source rather than computed.
170    Ingested {
171        /// Where the value came from (path, URL, dataset name).
172        source: String,
173    },
174    /// Produced by a stream executor over a time window.
175    Streamed {
176        /// Inclusive start of the window.
177        window_start: DateTime<Utc>,
178        /// Exclusive end of the window.
179        window_end: DateTime<Utc>,
180    },
181}
182
183/// Metadata about a cached entry, queryable without loading the value.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct EntryMeta {
186    /// The entry's cache key.
187    pub key: CacheKey,
188    /// Encoded size of the stored value in bytes.
189    pub size_bytes: u64,
190    /// When the entry was stored.
191    pub created_at: DateTime<Utc>,
192    /// Last read, the signal LRU eviction ranks by.
193    pub last_accessed: DateTime<Utc>,
194    /// Time-to-live, `None` for entries that never expire.
195    pub ttl: Option<std::time::Duration>,
196    /// Where the value came from.
197    pub origin: Origin,
198}
199
200/// The K/V cache store interface.
201///
202/// Implementations may be in-memory, on-disk (RocksDB/sled),
203/// or remote (S3). The tiered cache composes multiple stores.
204pub trait CacheStore: Send + Sync {
205    /// Look up the value stored under `key`, `None` on a miss.
206    fn get(&self, key: &CacheKey) -> Result<Option<Value>>;
207
208    /// Store `value` under `key`, replacing any existing entry.
209    fn put(&self, key: &CacheKey, value: &Value) -> Result<()>;
210
211    /// Whether `key` has an entry, without loading the value.
212    fn exists(&self, key: &CacheKey) -> Result<bool>;
213
214    /// Delete the entry under `key`; absent keys are not an error.
215    fn remove(&self, key: &CacheKey) -> Result<()>;
216
217    /// The entry's [`EntryMeta`], without loading the value.
218    fn metadata(&self, key: &CacheKey) -> Result<Option<EntryMeta>>;
219
220    /// Store a value together with its provenance. Stores that persist
221    /// metadata should override this; the default discards the origin.
222    fn put_with_origin(&self, key: &CacheKey, value: &Value, origin: &Origin) -> Result<()> {
223        let _ = origin;
224        self.put(key, value)
225    }
226
227    /// Store a freshly-computed value with its full provenance record:
228    /// origin, wall-clock compute cost, and the producer's determinism
229    /// declaration. Cost-aware eviction needs the compute time — a tiny
230    /// value that took days must outlive a huge one that took seconds.
231    /// The default discards the extra metadata.
232    fn put_computed(
233        &self,
234        key: &CacheKey,
235        value: &Value,
236        origin: &Origin,
237        compute: std::time::Duration,
238        deterministic: bool,
239    ) -> Result<()> {
240        let _ = (compute, deterministic);
241        self.put_with_origin(key, value, origin)
242    }
243
244    /// Which tier this store is, for reporting.
245    ///
246    /// A single-tier store answers with its own kind. [`CacheTier::Memory`]
247    /// is the default because the in-memory store is the one people write
248    /// by hand; a store that is anything else should say so.
249    fn tier(&self) -> CacheTier {
250        CacheTier::Memory
251    }
252
253    /// Like [`CacheStore::get`], but also reports which tier served the value.
254    ///
255    /// A composed store overrides this — that is the whole point. Without
256    /// it, a hit served from disk is indistinguishable from one served from
257    /// RAM, and the numbers that are supposed to tell you whether the disk
258    /// tier is earning its keep say only that the cache was used.
259    fn get_located(&self, key: &CacheKey) -> Result<Option<(Value, CacheTier)>> {
260        Ok(self.get(key)?.map(|value| (value, self.tier())))
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn cache_key_deterministic() {
270        let k1 = CacheKey::from_parts(&[b"hello", b"world"]);
271        let k2 = CacheKey::from_parts(&[b"hello", b"world"]);
272        assert_eq!(k1, k2);
273    }
274
275    #[test]
276    fn cache_key_sensitive_to_content() {
277        let k1 = CacheKey::from_parts(&[b"hello", b"world"]);
278        let k2 = CacheKey::from_parts(&[b"hello", b"world!"]);
279        assert_ne!(k1, k2);
280    }
281
282    #[test]
283    fn cache_key_sensitive_to_part_boundaries() {
284        // "ab" + "c" must differ from "a" + "bc"
285        let k1 = CacheKey::from_parts(&[b"ab", b"c"]);
286        let k2 = CacheKey::from_parts(&[b"a", b"bc"]);
287        assert_ne!(k1, k2);
288    }
289
290    #[test]
291    fn cache_key_for_state() {
292        let config = CacheKey::hash_data(b"scaler_config");
293        let data = CacheKey::hash_data(b"training_data");
294        let state_key = CacheKey::for_state(&config, &data, None);
295
296        // Same inputs → same key
297        let state_key2 = CacheKey::for_state(&config, &data, None);
298        assert_eq!(state_key, state_key2);
299
300        // Different data → different key
301        let data2 = CacheKey::hash_data(b"different_data");
302        let state_key3 = CacheKey::for_state(&config, &data2, None);
303        assert_ne!(state_key, state_key3);
304    }
305
306    #[test]
307    fn cache_key_for_state_sensitive_to_labels() {
308        let config = CacheKey::hash_data(b"config");
309        let x = CacheKey::hash_data(b"features");
310        let y1 = CacheKey::hash_data(b"labels_a");
311        let y2 = CacheKey::hash_data(b"labels_b");
312
313        let unsupervised = CacheKey::for_state(&config, &x, None);
314        let supervised_a = CacheKey::for_state(&config, &x, Some(&y1));
315        let supervised_b = CacheKey::for_state(&config, &x, Some(&y2));
316
317        assert_ne!(unsupervised, supervised_a);
318        assert_ne!(supervised_a, supervised_b);
319    }
320
321    #[test]
322    fn for_value_deterministic_and_sensitive() {
323        let v1 = Value::tensor(vec![1.0, 2.0], vec![2]);
324        let v2 = Value::tensor(vec![1.0, 2.0], vec![2]);
325        let v3 = Value::tensor(vec![1.0, 2.0], vec![1, 2]);
326
327        assert_eq!(CacheKey::for_value(&v1), CacheKey::for_value(&v2));
328        // Same data, different shape → different hash
329        assert_ne!(CacheKey::for_value(&v1), CacheKey::for_value(&v3));
330    }
331
332    /// JSON writes NaN and both infinities as `null`, so hashing a value's
333    /// JSON gave three distinct tensors one key — and the second one was
334    /// answered with the first one's cached output.
335    #[test]
336    fn for_value_separates_non_finite_floats() {
337        let nan = Value::tensor(vec![f64::NAN], vec![1]);
338        let pos = Value::tensor(vec![f64::INFINITY], vec![1]);
339        let neg = Value::tensor(vec![f64::NEG_INFINITY], vec![1]);
340
341        assert_eq!(
342            serde_json::to_vec(&nan).unwrap(),
343            serde_json::to_vec(&pos).unwrap(),
344            "the premise: JSON really does flatten these together"
345        );
346
347        assert_ne!(CacheKey::for_value(&nan), CacheKey::for_value(&pos));
348        assert_ne!(CacheKey::for_value(&pos), CacheKey::for_value(&neg));
349        // And -0.0 is not 0.0, for the same reason.
350        assert_ne!(
351            CacheKey::for_value(&Value::tensor(vec![0.0], vec![1])),
352            CacheKey::for_value(&Value::tensor(vec![-0.0], vec![1]))
353        );
354    }
355
356    /// A tag per variant: two variants that wrap the same bytes are
357    /// different values and must not share a key.
358    #[test]
359    fn for_value_separates_variants_holding_the_same_bytes() {
360        assert_ne!(
361            CacheKey::for_value(&Value::Bytes(std::sync::Arc::new(b"x".to_vec()))),
362            CacheKey::for_value(&Value::Object(std::sync::Arc::new(b"x".to_vec())))
363        );
364    }
365
366    #[test]
367    fn cache_key_for_output() {
368        let config = CacheKey::hash_data(b"config");
369        let state = CacheKey::hash_data(b"state");
370        let input = CacheKey::hash_data(b"input");
371        let key = CacheKey::for_output(&config, &state, &input);
372
373        // Different state → different key
374        let state2 = CacheKey::hash_data(b"state2");
375        let key2 = CacheKey::for_output(&config, &state2, &input);
376        assert_ne!(key, key2);
377    }
378
379    #[test]
380    fn cache_key_hex_and_display() {
381        let key = CacheKey::hash_data(b"test");
382        let hex = key.to_hex();
383        assert_eq!(hex.len(), 64); // 32 bytes = 64 hex chars
384
385        let display = format!("{key}");
386        assert_eq!(display.len(), 16); // truncated display
387
388        let debug = format!("{key:?}");
389        assert!(debug.starts_with("CacheKey("));
390    }
391
392    #[test]
393    fn cache_key_serde_roundtrip() {
394        let key = CacheKey::hash_data(b"test_data");
395        let json = serde_json::to_string(&key).unwrap();
396        let deserialized: CacheKey = serde_json::from_str(&json).unwrap();
397        assert_eq!(key, deserialized);
398    }
399}