mnemo_engine/result_cache.rs
1//! Exact-key result cache (Phase 10.1 of the level-up plan).
2//!
3//! **This is NOT the page cache** (that's `mnemo/src/cache.rs`, which
4//! caches decrypted pages inside the pager). This module implements a
5//! *user-facing key/value cache* backed by the same encrypted-page
6//! storage engine as the memory catalog — a `.mnemo` file becomes both a
7//! durable agent memory store AND a durable exact-key cache for LLM
8//! tool-call results, prompt→completion pairs, HTTP responses, and
9//! anything else an agent wants to memoize.
10//!
11//! ## Model
12//!
13//! Each cache entry lives at a `(namespace, key)` pair. The engine
14//! SHA-256-hashes `key` — callers pass the raw input string (a prompt,
15//! a tool-call JSON, a URL, whatever), the engine handles the hashing.
16//! Namespaces isolate independent caches inside one file so `llm` and
17//! `http` and `tool` never collide.
18//!
19//! On-disk layout mirrors the memory catalog + record split:
20//!
21//! - **Cache directory** — a serialized `Vec<CacheDirectoryEntry>` in
22//! its own encrypted page run, pointed at by
23//! [`crate::format::Header`]'s `cache_start` / `cache_pages` /
24//! `cache_len` fields (added in format v8). Small per-entry
25//! overhead; the directory is what a lookup scans.
26//! - **Cache records** — the payloads. One [`CacheEntry`] per record,
27//! serialized as MessagePack and stored in the same encrypted-page
28//! append-only region the memory records use. Never rewritten on
29//! read; access stats (`accessed_at`, `access_count`) live on the
30//! directory entry only (the v5 recall-side-trick).
31//!
32//! ## Flush policy
33//!
34//! [`CacheFlushPolicy`] is per-handle:
35//!
36//! - `Strict` (default): every `cache_put` dirties the directory; the
37//! next call to `Mnemo::flush()` persists everything in one
38//! WAL-committed transaction.
39//! - `Batched { max_dirty, max_age }`: `cache_put` still dirties the
40//! directory in memory, but the engine tracks pending-dirty count
41//! and first-dirty timestamp. When either threshold trips, the
42//! engine internally calls `flush()` — persisting the whole
43//! transaction (including any pending memory writes, which is safe
44//! because they were queued for a flush anyway; the only effect is
45//! earlier-than-user-expected durability).
46//!
47//! **Memory writes are never batched** — `remember` behaves exactly as
48//! it always did, requiring a user-driven `flush()` to persist. Only
49//! cache mutations get the relaxed lane.
50//!
51//! ## Crash semantics
52//!
53//! Losing unflushed (Batched-lane) cache entries on crash is a cache
54//! *miss*, never corruption. Every commit — whether Strict or Batched —
55//! goes through the same single-fsync WAL transaction that already
56//! backs memory writes. The append-only-snapshot invariant is untouched.
57
58use serde::{Deserialize, Serialize};
59use sha2::{Digest, Sha256};
60use std::collections::HashMap;
61use std::time::{Duration, Instant};
62
63// --- Public tunables and types -------------------------------------------
64
65/// Default per-namespace entry-count budget. Once a namespace holds this
66/// many live entries, `cache_put` evicts the LRU entry before inserting.
67pub const DEFAULT_MAX_ENTRIES: usize = 10_000;
68
69/// Default per-namespace byte budget (values + directory overhead).
70/// Once exceeded, `cache_put` evicts LRU until the total falls back
71/// under the cap.
72pub const DEFAULT_MAX_BYTES: u64 = 64 * 1024 * 1024;
73
74/// Default `max_dirty` for [`CacheFlushPolicy::Batched`].
75pub const DEFAULT_BATCH_MAX_DIRTY: usize = 128;
76
77/// Default `max_age` for [`CacheFlushPolicy::Batched`].
78pub const DEFAULT_BATCH_MAX_AGE: Duration = Duration::from_secs(5);
79
80/// Default similarity threshold for [`crate::Mnemo::cache_get_semantic`].
81/// Lookups below this are treated as misses. Chosen conservatively to
82/// avoid false hits on subtly-different prompts; callers can override
83/// per-lookup. Model-dependent — text-embedding-ada-002 and BGE at
84/// this threshold rarely surface unrelated content, but if you're
85/// working with a lossier embedder consider raising it.
86pub const DEFAULT_SEMANTIC_THRESHOLD: f32 = 0.97;
87
88/// Length in chars of the human-readable key preview stored on every
89/// directory entry. Long enough to be recognizable, short enough that
90/// the directory scan stays fast on large caches.
91pub const KEY_PREVIEW_MAX_CHARS: usize = 120;
92
93/// Options accepted by `Mnemo::cache_put`. Defaults describe an
94/// untyped, never-expiring, non-cost-hinted entry.
95#[derive(Clone, Debug)]
96pub struct CachePutOpts {
97 /// Free-form content-type label. Convention: `"json"`, `"text"`,
98 /// `"bytes"`, or a MIME string. Not interpreted by the engine —
99 /// preserved verbatim on `cache_get` so callers can round-trip
100 /// their own type discipline.
101 pub content_type: String,
102 /// Time-to-live in seconds from the moment of `cache_put`. `None`
103 /// means "never expires by itself" (still evictable via LRU or
104 /// namespace purge).
105 pub ttl_secs: Option<u64>,
106 /// Optional hint of how expensive the miss-side computation was,
107 /// in milliseconds. Recorded per-entry; today used only as a
108 /// tie-breaker for future cost-aware eviction (see the GDSF
109 /// follow-up note in the level-up plan).
110 pub cost_hint_ms: Option<u32>,
111}
112
113impl Default for CachePutOpts {
114 fn default() -> Self {
115 Self {
116 content_type: "text".into(),
117 ttl_secs: None,
118 cost_hint_ms: None,
119 }
120 }
121}
122
123/// Options for [`crate::Mnemo::cache_put_semantic`]. Same as
124/// [`CachePutOpts`] plus a **required** `model` string — a hit from a
125/// different embedding model's cache is a semantics bug, not a win, so
126/// the engine forces callers to declare the model and matches it on
127/// lookup.
128///
129/// The vector itself is a separate argument to `cache_put_semantic`
130/// (not inside opts) so callers who already own the vector don't have
131/// to `.clone()` it into the struct.
132#[derive(Clone, Debug)]
133pub struct SemanticCachePutOpts {
134 /// Free-form content-type label recorded on the entry.
135 pub content_type: String,
136 /// TTL in seconds. Omit for no expiry.
137 pub ttl_secs: Option<u64>,
138 /// Cost hint recorded on the entry (see [`CachePutOpts::cost_hint_ms`]).
139 pub cost_hint_ms: Option<u32>,
140 /// Embedding model identifier. Matched exactly on lookup; a
141 /// different model's cache is invisible to this query. Convention:
142 /// `"text-embedding-3-small"`, `"bge-large-en-v1.5"`, etc.
143 pub model: String,
144}
145
146impl SemanticCachePutOpts {
147 /// Construct with just the required `model`; all other fields
148 /// default (content_type = "text", no TTL, no cost hint).
149 pub fn new(model: impl Into<String>) -> Self {
150 Self {
151 content_type: "text".into(),
152 ttl_secs: None,
153 cost_hint_ms: None,
154 model: model.into(),
155 }
156 }
157}
158
159/// A returned cache hit. `accessed_at` / `access_count` are updated on
160/// the directory *before* this is constructed, so the values here
161/// reflect the post-touch state (mirroring the recall-time convention).
162#[derive(Clone, Debug)]
163pub struct CachedValue {
164 /// Value bytes as stored by `cache_put`.
165 pub value: Vec<u8>,
166 /// Content-type label as stored by `cache_put`.
167 pub content_type: String,
168 /// Unix seconds when this entry was created.
169 pub created_at: i64,
170 /// Unix seconds when this entry was last accessed (this hit).
171 pub accessed_at: i64,
172 /// Number of hits recorded on this entry (including the current one).
173 pub access_count: u32,
174 /// TTL configured on `cache_put`, if any.
175 pub ttl_secs: Option<u64>,
176}
177
178/// Per-namespace eviction budget. `cache_put` enforces both caps: on
179/// insert, the LRU is evicted until *both* live-entry count and live
180/// byte total fit under the caps.
181#[derive(Clone, Copy, Debug)]
182pub struct CacheBudget {
183 /// Hard cap on live directory entries for this namespace.
184 pub max_entries: usize,
185 /// Hard cap on live payload bytes for this namespace.
186 pub max_bytes: u64,
187}
188
189impl Default for CacheBudget {
190 fn default() -> Self {
191 Self {
192 max_entries: DEFAULT_MAX_ENTRIES,
193 max_bytes: DEFAULT_MAX_BYTES,
194 }
195 }
196}
197
198/// Per-handle flush policy for cache writes. Memory writes ignore this
199/// and always behave as `Strict`.
200#[derive(Clone, Copy, Debug, Default)]
201pub enum CacheFlushPolicy {
202 /// Every `cache_put` dirties the directory; the caller's next
203 /// `Mnemo::flush()` persists everything.
204 #[default]
205 Strict,
206 /// The engine internally calls `flush()` when either the pending
207 /// dirty count reaches `max_dirty` OR the first pending dirty
208 /// entry is older than `max_age`.
209 Batched {
210 /// Auto-flush after this many pending cache mutations.
211 max_dirty: usize,
212 /// Auto-flush this long after the first pending mutation.
213 max_age: Duration,
214 },
215}
216
217/// Summary statistics for a namespace (or the whole cache).
218#[derive(Clone, Copy, Debug, Default)]
219pub struct CacheStats {
220 /// Live (non-deleted, non-expired) entries.
221 pub entries: usize,
222 /// Live payload bytes.
223 pub bytes: u64,
224 /// Hits recorded on this handle since open.
225 pub hits: u64,
226 /// Misses recorded on this handle since open (includes TTL-expired
227 /// and non-existent keys — both look the same to the caller).
228 pub misses: u64,
229 /// Hits divided by (hits + misses); `0.0` if no lookups.
230 pub hit_rate: f64,
231 /// Directory entries evicted (by LRU-under-budget) since open.
232 pub evictions: u64,
233}
234
235// --- On-disk types (crate-internal) --------------------------------------
236
237/// The MessagePack record body of a cache entry. Stored in the same
238/// encrypted-page region as memory records.
239///
240/// `vector` and `model` are the semantic-cache extension (Phase 10.2).
241/// `#[serde(default)]` keeps pre-semantic v8 entries — where the
242/// on-disk record has no vector / no model — round-trip-safe under
243/// the extended schema: rmp-serde decodes missing tail fields as
244/// their `Default::default()`, so old entries surface as `None`/`None`
245/// (exactly the "exact-key only, no vector" state we want).
246#[derive(Serialize, Deserialize, Clone, Debug)]
247pub(crate) struct CacheEntry {
248 pub namespace: String,
249 pub key_hash: [u8; 32],
250 pub key_preview: String,
251 pub value: Vec<u8>,
252 pub content_type: String,
253 pub created_at: i64,
254 pub accessed_at: i64,
255 pub access_count: u32,
256 pub ttl_secs: Option<u64>,
257 pub cost_hint_ms: Option<u32>,
258 pub size_bytes: u64,
259 /// Semantic-cache: embedding vector for this entry, or `None` for
260 /// pure exact-key entries.
261 #[serde(default)]
262 pub vector: Option<Vec<f32>>,
263 /// Semantic-cache: embedding model identifier this vector was
264 /// computed under, or `None` for pure exact-key entries. Matched
265 /// exactly on `cache_get_semantic` — different-model hits are bugs.
266 #[serde(default)]
267 pub model: Option<String>,
268}
269
270/// Directory entry — the "catalog row" for a cache record. Access stats
271/// live here (not on the [`CacheEntry`]) so a hit doesn't require a full
272/// record rewrite (mirrors the v5 recall trick).
273///
274/// `size_bytes` is duplicated from the entry body so budget enforcement
275/// runs against the directory alone without decrypting record pages.
276#[derive(Serialize, Deserialize, Clone, Debug)]
277pub(crate) struct CacheDirectoryEntry {
278 pub namespace: String,
279 pub key_hash: [u8; 32],
280 pub start_page: u64,
281 pub page_count: u32,
282 pub len: u32,
283 pub deleted: bool,
284 pub accessed_at: i64,
285 pub access_count: u32,
286 pub size_bytes: u64,
287 pub ttl_secs: Option<u64>,
288 pub created_at: i64,
289 /// Semantic-cache: embedding vector duplicated onto the directory
290 /// entry so lookup can scan without reading (and decrypting) every
291 /// record body. `None` on pure exact-key entries. `#[serde(default)]`
292 /// keeps pre-semantic v8 directories decoding under the extended
293 /// schema.
294 #[serde(default)]
295 pub vector: Option<Vec<f32>>,
296 /// Semantic-cache: model identifier. `None` on pure exact-key entries.
297 #[serde(default)]
298 pub model: Option<String>,
299}
300
301impl CacheDirectoryEntry {
302 /// Has the entry passed its TTL at `now`? An entry with no TTL is
303 /// never expired.
304 pub(crate) fn is_expired(&self, now: i64) -> bool {
305 match self.ttl_secs {
306 Some(ttl) => now.saturating_sub(self.created_at) as u64 >= ttl,
307 None => false,
308 }
309 }
310}
311
312// --- Key hashing ---------------------------------------------------------
313
314/// SHA-256 hash a caller-supplied cache key. Deterministic and platform-
315/// independent (endianness-safe because the input is a byte string, not
316/// a typed value).
317pub fn hash_key(key: &str) -> [u8; 32] {
318 let mut h = Sha256::new();
319 h.update(key.as_bytes());
320 h.finalize().into()
321}
322
323/// Truncate a key to at most [`KEY_PREVIEW_MAX_CHARS`] chars (not
324/// bytes), suffixing `…` when cut. Preserves char-boundary safety for
325/// UTF-8 previews.
326pub fn key_preview(key: &str) -> String {
327 if key.chars().count() <= KEY_PREVIEW_MAX_CHARS {
328 return key.to_string();
329 }
330 let mut out: String = key.chars().take(KEY_PREVIEW_MAX_CHARS - 1).collect();
331 out.push('…');
332 out
333}
334
335// --- In-memory directory index -------------------------------------------
336
337/// In-memory index over the on-disk directory: given `(namespace,
338/// key_hash)`, find the position of the live entry in the directory
339/// `Vec`. Absent entries and deleted tombstones are not indexed.
340#[derive(Debug, Default)]
341pub(crate) struct DirectoryIndex {
342 // Namespace name → key_hash → index into the directory Vec.
343 by_ns: HashMap<String, HashMap<[u8; 32], usize>>,
344}
345
346impl DirectoryIndex {
347 pub(crate) fn new() -> Self {
348 Self {
349 by_ns: HashMap::new(),
350 }
351 }
352
353 /// Build an index over a directory `Vec`, skipping deleted entries.
354 pub(crate) fn build(entries: &[CacheDirectoryEntry]) -> Self {
355 let mut idx = Self::new();
356 for (i, e) in entries.iter().enumerate() {
357 if !e.deleted {
358 idx.insert(&e.namespace, e.key_hash, i);
359 }
360 }
361 idx
362 }
363
364 pub(crate) fn insert(&mut self, namespace: &str, key_hash: [u8; 32], pos: usize) {
365 self.by_ns
366 .entry(namespace.to_string())
367 .or_default()
368 .insert(key_hash, pos);
369 }
370
371 pub(crate) fn remove(&mut self, namespace: &str, key_hash: &[u8; 32]) {
372 if let Some(ns) = self.by_ns.get_mut(namespace) {
373 ns.remove(key_hash);
374 }
375 }
376
377 pub(crate) fn get(&self, namespace: &str, key_hash: &[u8; 32]) -> Option<usize> {
378 self.by_ns.get(namespace)?.get(key_hash).copied()
379 }
380}
381
382// --- In-memory hit/miss counters -----------------------------------------
383
384/// Per-handle hit/miss/eviction counters. Persisted portion is aggregate
385/// evictions (via the directory tombstones themselves); hits and misses
386/// are in-memory only and reset on reopen.
387#[derive(Debug, Default)]
388pub(crate) struct CounterState {
389 pub hits: u64,
390 pub misses: u64,
391 pub evictions: u64,
392}
393
394// --- Batched-lane bookkeeping --------------------------------------------
395
396/// Tracks pending-batch state for [`CacheFlushPolicy::Batched`]. In
397/// `Strict` mode all fields stay zero; the store consults this before
398/// deciding whether a `cache_put` should trigger an auto-flush.
399#[derive(Debug, Default)]
400pub(crate) struct BatchState {
401 /// Count of cache mutations since the last commit.
402 pub dirty_count: usize,
403 /// Wallclock of the first mutation since the last commit — used to
404 /// evaluate `max_age`.
405 pub first_dirty_at: Option<Instant>,
406}
407
408impl BatchState {
409 pub(crate) fn record_dirty(&mut self) {
410 self.dirty_count += 1;
411 if self.first_dirty_at.is_none() {
412 self.first_dirty_at = Some(Instant::now());
413 }
414 }
415
416 pub(crate) fn reset(&mut self) {
417 self.dirty_count = 0;
418 self.first_dirty_at = None;
419 }
420
421 /// Should the store auto-flush now, given the current batch state
422 /// and policy? Always `false` for `Strict`.
423 pub(crate) fn should_auto_flush(&self, policy: &CacheFlushPolicy) -> bool {
424 match policy {
425 CacheFlushPolicy::Strict => false,
426 CacheFlushPolicy::Batched { max_dirty, max_age } => {
427 if self.dirty_count >= *max_dirty {
428 return true;
429 }
430 if let Some(t0) = self.first_dirty_at {
431 if t0.elapsed() >= *max_age {
432 return true;
433 }
434 }
435 false
436 }
437 }
438 }
439}
440
441// --- Unit tests ---------------------------------------------------------
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446
447 #[test]
448 fn hash_key_is_deterministic_and_32_bytes() {
449 let a = hash_key("hello");
450 let b = hash_key("hello");
451 assert_eq!(a, b);
452 assert_eq!(a.len(), 32);
453 // Different input → different hash (SHA-256 collision would be a
454 // major cryptographic event; this just guards typo bugs).
455 assert_ne!(a, hash_key("hello!"));
456 }
457
458 #[test]
459 fn key_preview_truncates_with_ellipsis() {
460 let short = "hi there";
461 assert_eq!(key_preview(short), short);
462 let long = "x".repeat(200);
463 let out = key_preview(&long);
464 assert_eq!(out.chars().count(), KEY_PREVIEW_MAX_CHARS);
465 assert!(out.ends_with('…'));
466 }
467
468 #[test]
469 fn directory_index_locates_live_entries() {
470 let entries = vec![
471 CacheDirectoryEntry {
472 namespace: "llm".into(),
473 key_hash: [1u8; 32],
474 start_page: 100,
475 page_count: 1,
476 len: 42,
477 deleted: false,
478 accessed_at: 0,
479 access_count: 0,
480 size_bytes: 42,
481 ttl_secs: None,
482 created_at: 0,
483 vector: None,
484 model: None,
485 },
486 CacheDirectoryEntry {
487 namespace: "http".into(),
488 key_hash: [2u8; 32],
489 start_page: 101,
490 page_count: 1,
491 len: 42,
492 deleted: true, // tombstone — should be skipped
493 accessed_at: 0,
494 access_count: 0,
495 size_bytes: 42,
496 ttl_secs: None,
497 created_at: 0,
498 vector: None,
499 model: None,
500 },
501 ];
502 let idx = DirectoryIndex::build(&entries);
503 assert_eq!(idx.get("llm", &[1u8; 32]), Some(0));
504 assert_eq!(idx.get("http", &[2u8; 32]), None, "tombstones not indexed");
505 }
506
507 #[test]
508 fn batch_state_triggers_on_count() {
509 let mut b = BatchState::default();
510 let p = CacheFlushPolicy::Batched {
511 max_dirty: 3,
512 max_age: Duration::from_secs(3600),
513 };
514 b.record_dirty();
515 b.record_dirty();
516 assert!(!b.should_auto_flush(&p));
517 b.record_dirty();
518 assert!(b.should_auto_flush(&p));
519 }
520
521 #[test]
522 fn ttl_expiry_matches_created_at() {
523 let now = 1_000_000_i64;
524 let fresh = CacheDirectoryEntry {
525 namespace: "".into(),
526 key_hash: [0u8; 32],
527 start_page: 0,
528 page_count: 0,
529 len: 0,
530 deleted: false,
531 accessed_at: 0,
532 access_count: 0,
533 size_bytes: 0,
534 ttl_secs: Some(60),
535 created_at: now - 30,
536 vector: None,
537 model: None,
538 };
539 assert!(!fresh.is_expired(now), "30s old with 60s TTL is fresh");
540 let stale = CacheDirectoryEntry {
541 created_at: now - 120,
542 ..fresh.clone()
543 };
544 assert!(stale.is_expired(now), "120s old with 60s TTL is expired");
545 let no_ttl = CacheDirectoryEntry {
546 ttl_secs: None,
547 ..fresh
548 };
549 assert!(!no_ttl.is_expired(now + 1_000_000_000));
550 }
551}