Skip to main content

sui_eval/
eval_cache.rs

1//! Content-addressed evaluation cache.
2//!
3//! Maps `(source_hash, lock_hash)` pairs to previously evaluated results,
4//! skipping redundant evaluation when inputs haven't changed.
5//!
6//! ## Tier model (additive — every tier is optional, all may stack)
7//!
8//! 1. **In-memory** (`HashMap`) for the current session — instant
9//!    lookups. Always present.
10//! 2. **JSON file** at `~/.cache/sui/eval-cache.json` — survives
11//!    across invocations. Optional; enabled by `with_persistent`.
12//! 3. **GraphStore** (`sui-graph-store`, redb + rkyv on a ZFS-friendly
13//!    blob layout) — fleet-shared / cross-process tier. Optional;
14//!    enabled by `with_graph_store`. When set, the eval cache's
15//!    entries become first-class blobs in `GraphKind::EvalCacheEntry`,
16//!    which means a peer with the same GraphStore root (e.g. via
17//!    `zfs send | zfs recv` or a future substituter push) gets every
18//!    cached eval for free. Lookup-order on `get`: memory → graph_store
19//!    (warm on disk via mmap, hits sub-200 µs); on a hit from the
20//!    graph_store tier the result is promoted into memory so the next
21//!    same-process lookup is sub-microsecond.
22//!
23//! All three tiers honor the same `enabled` flag (set by the CLI flag
24//! that disables caching entirely) and the same key shape (`CacheKey`).
25//! Adding a tier never removes an older one — `with_all_tiers` enables
26//! all three at once; individual `with_*` constructors stack them
27//! incrementally.
28//!
29//! Only JSON-serializable values are cached (no lambdas, no thunks).
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use sha2::{Digest, Sha256};
35use sui_graph_store::{GraphHash, GraphKind, GraphStore};
36
37/// Identity of the evaluator that produced a cached answer.
38///
39/// KNOWN RESIDUAL, stated rather than papered over: this is the crate
40/// VERSION, so two builds of the SAME version with different code — i.e.
41/// local evaluator development — still share entries. The workspace releases
42/// many times a day, so this covers every PUBLISHED sui; while working ON the
43/// evaluator, pass `--no-eval-cache` or bump the version. Closing that fully
44/// needs a build-identity stamp from build.rs (a git rev or source hash),
45/// which does not exist today.
46const EVALUATOR_ID: &str = concat!("sui-eval/", env!("CARGO_PKG_VERSION"));
47
48// ── Types ──────────────────────────────────────────────────────
49
50/// Hash of a source file plus its transitive inputs (flake.lock).
51#[derive(Hash, Eq, PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
52pub struct CacheKey {
53    /// SHA-256 hex digest of the source file content.
54    pub source_hash: String,
55    /// SHA-256 hex digest of `flake.lock` in the same directory (if any).
56    pub lock_hash: Option<String>,
57}
58
59/// A cached evaluation result.
60#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
61pub struct CachedValue {
62    /// The value serialized as JSON.
63    pub value_json: String,
64    /// Unix timestamp when this entry was stored.
65    pub timestamp: i64,
66}
67
68/// A single cache entry for serialization (key + value).
69#[derive(serde::Serialize, serde::Deserialize)]
70struct CacheEntry {
71    key: CacheKey,
72    value: CachedValue,
73}
74
75// ── Cache ──────────────────────────────────────────────────────
76
77/// Content-addressed evaluation cache with optional persistence.
78pub struct EvalCache {
79    /// In-memory cache for the current session.
80    memory: HashMap<CacheKey, CachedValue>,
81    /// Path to the persistent cache file (JSON).
82    db_path: Option<PathBuf>,
83    /// Optional GraphStore tier — fleet-shared / cross-process cache.
84    /// When set, entries are mirrored as `GraphKind::EvalCacheEntry`
85    /// blobs and a `get` miss in memory falls through here next.
86    graph_store: Option<GraphStore>,
87    /// Whether this cache is enabled (can be disabled via CLI flag).
88    enabled: bool,
89}
90
91impl EvalCache {
92    /// Create a new in-memory-only cache.
93    pub fn new() -> Self {
94        Self {
95            memory: HashMap::new(),
96            db_path: None,
97            graph_store: None,
98            enabled: true,
99        }
100    }
101
102    /// Create a cache with persistent storage at the given path.
103    /// Loads existing entries from disk if the file exists.
104    pub fn with_persistent(db_path: PathBuf) -> Self {
105        let memory = Self::load_from_disk(&db_path).unwrap_or_default();
106        Self {
107            memory,
108            db_path: Some(db_path),
109            graph_store: None,
110            enabled: true,
111        }
112    }
113
114    /// Create a cache using the default persistent path (`~/.cache/sui/eval-cache.json`).
115    pub fn default_persistent() -> Self {
116        match default_cache_path() {
117            Some(p) => Self::with_persistent(p),
118            None => Self::new(),
119        }
120    }
121
122    /// Create a disabled cache (always misses).
123    pub fn disabled() -> Self {
124        Self {
125            memory: HashMap::new(),
126            db_path: None,
127            graph_store: None,
128            enabled: false,
129        }
130    }
131
132    /// Stack a `GraphStore` tier on this cache. Existing tiers
133    /// (in-memory + optional JSON file) are preserved verbatim. Calls
134    /// this builder-style: `EvalCache::default_persistent().with_graph_store(gs)`.
135    #[must_use]
136    pub fn with_graph_store(mut self, store: GraphStore) -> Self {
137        self.graph_store = Some(store);
138        self
139    }
140
141    /// Construct an `EvalCache` with all three tiers enabled.
142    ///
143    /// * In-memory — always.
144    /// * JSON file — at `db_path` (also loaded on construction).
145    /// * GraphStore — using `store`.
146    #[must_use]
147    pub fn with_all_tiers(db_path: PathBuf, store: GraphStore) -> Self {
148        Self::with_persistent(db_path).with_graph_store(store)
149    }
150
151    /// Whether the cache is enabled.
152    pub fn is_enabled(&self) -> bool {
153        self.enabled
154    }
155
156    /// True iff the GraphStore tier is wired.
157    pub fn has_graph_store(&self) -> bool {
158        self.graph_store.is_some()
159    }
160
161    /// Look up a cached result. Tier order: memory → graph_store.
162    /// **Behavior contract**: a hit from the graph_store tier is
163    /// promoted into the memory tier so the next same-process lookup
164    /// is sub-microsecond. The promotion is the only mutation `get`
165    /// performs.
166    pub fn get(&mut self, key: &CacheKey) -> Option<&CachedValue> {
167        if !self.enabled {
168            return None;
169        }
170        // Tier 1: in-memory (sub-microsecond).
171        if self.memory.contains_key(key) {
172            return self.memory.get(key);
173        }
174        // Tier 3: GraphStore (sub-200 µs warm via mmap).
175        if let Some(store) = &self.graph_store {
176            let gh = graph_hash_for_key(key);
177            if let Ok(blob) = store.get(GraphKind::EvalCacheEntry, gh) {
178                if let Ok(value) = serde_json::from_slice::<CachedValue>(&blob) {
179                    self.memory.insert(key.clone(), value);
180                    return self.memory.get(key);
181                }
182            }
183        }
184        None
185    }
186
187    /// Store a result in the cache. Writes to memory unconditionally
188    /// and to every wired persistence tier (JSON + GraphStore)
189    /// best-effort. Tier writes never fail loudly — eval-cache puts
190    /// are advisory; a failed write doesn't change the correctness of
191    /// the eval, just the chance of a future hit.
192    pub fn put(&mut self, key: CacheKey, value: CachedValue) {
193        if !self.enabled {
194            return;
195        }
196        // Tier 1: memory (mandatory).
197        self.memory.insert(key.clone(), value.clone());
198        // Tier 2: JSON file (legacy persistent path).
199        if let Some(ref path) = self.db_path {
200            let _ = Self::save_to_disk(path, &self.memory);
201        }
202        // Tier 3: GraphStore (fleet-shared). Keyed by a deterministic
203        // BLAKE3 of the cache key (NOT by content hash) — the eval
204        // cache wants query-derived lookup, so this uses
205        // `put_unchecked`. Domain-separated with the `"evalcache::v1::"`
206        // prefix to keep query-derived hashes disjoint from CAS hashes
207        // in the same GraphKind.
208        if let Some(store) = &self.graph_store {
209            if let Ok(blob) = serde_json::to_vec(&value) {
210                let lookup_hash = graph_hash_for_key(&key);
211                let _ = store.put_unchecked(GraphKind::EvalCacheEntry, lookup_hash, &blob);
212            }
213        }
214    }
215
216    /// Number of cached entries.
217    pub fn len(&self) -> usize {
218        self.memory.len()
219    }
220
221    /// Whether the cache is empty.
222    pub fn is_empty(&self) -> bool {
223        self.memory.is_empty()
224    }
225
226    /// Compute the cache key for a file on disk.
227    ///
228    /// The source hash is the SHA-256 of the file content. If a `flake.lock`
229    /// exists in the same directory, its hash is included as the lock_hash.
230    pub fn key_for_file(path: &Path) -> Option<CacheKey> {
231        let content = std::fs::read(path).ok()?;
232        // THE EVALUATOR IS PART OF THE KEY, folded in HERE rather than at any
233        // one tier's lookup.
234        //
235        // `get`/`put` use the `CacheKey` DIRECTLY for the memory and JSON
236        // tiers and only pass through `graph_hash_for_key` for the GraphStore
237        // tier — so scoping the graph hash alone (as a first attempt did)
238        // leaves the persistent JSON cache at ~/.cache/sui/eval-cache.json
239        // still serving another sui's answers. Measured: after that partial
240        // fix the stale value was still returned. Folding it into
241        // `source_hash` at construction means every present and FUTURE tier
242        // inherits the scoping by construction, instead of each one having to
243        // remember.
244        //
245        // Why it must be scoped at all: a cached value is this evaluator's
246        // ANSWER, not a property of the source. Keying by source alone
247        // asserts that every sui agrees — the very thing still being proven.
248        // On 2026-08-09 that turned a real fix invisible: the corrected
249        // binary kept returning the pre-fix drvPath and read as "the patch
250        // did nothing".
251        let source_hash = {
252            let mut h = Sha256::new();
253            h.update(EVALUATOR_ID.as_bytes());
254            h.update(b"::");
255            h.update(&content);
256            format!("{:x}", h.finalize())
257        };
258
259        let lock_hash = path
260            .parent()
261            .map(|dir| dir.join("flake.lock"))
262            .filter(|p| p.exists())
263            .and_then(|p| std::fs::read(p).ok())
264            .map(|c| sha256_hex(&c));
265
266        Some(CacheKey {
267            source_hash,
268            lock_hash,
269        })
270    }
271
272    // ── Persistence helpers ────────────────────────────────────
273
274    fn load_from_disk(path: &Path) -> Option<HashMap<CacheKey, CachedValue>> {
275        let data = std::fs::read_to_string(path).ok()?;
276        let entries: Vec<CacheEntry> = serde_json::from_str(&data).ok()?;
277        let mut map = HashMap::with_capacity(entries.len());
278        for entry in entries {
279            map.insert(entry.key, entry.value);
280        }
281        Some(map)
282    }
283
284    fn save_to_disk(
285        path: &Path,
286        memory: &HashMap<CacheKey, CachedValue>,
287    ) -> Result<(), std::io::Error> {
288        if let Some(parent) = path.parent() {
289            std::fs::create_dir_all(parent)?;
290        }
291        let entries: Vec<CacheEntry> = memory
292            .iter()
293            .map(|(k, v)| CacheEntry {
294                key: k.clone(),
295                value: v.clone(),
296            })
297            .collect();
298        let json = serde_json::to_string(&entries)
299            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
300        std::fs::write(path, json)
301    }
302}
303
304impl Default for EvalCache {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310// ── Helpers ────────────────────────────────────────────────────
311
312/// Derive a deterministic `GraphHash` from an eval-cache `CacheKey`,
313/// domain-separated so it can't collide with content-addressed entries
314/// stored under the same `GraphKind`. The serialization is canonical
315/// because we control both fields: `(source_hash, lock_hash)` are
316/// already SHA-256 hex digests with stable ordering.
317fn graph_hash_for_key(key: &CacheKey) -> GraphHash {
318    let mut hasher = blake3::Hasher::new();
319    // ── v2: THE EVALUATOR IS PART OF THE KEY ──────────────────────────
320    //
321    // v1 hashed only (source_hash, lock_hash), so a cache entry written by
322    // one sui was served verbatim to a DIFFERENT sui. The cached value is
323    // this evaluator's ANSWER, not a property of the source — keying it by
324    // source alone asserts that every sui agrees, which is exactly the thing
325    // still being proven.
326    //
327    // Measured 2026-08-09. After fixing a flake-input divergence and
328    // rebuilding, the same command returned the OLD wrong answer:
329    //
330    //   sui eval …toplevel.drvPath                  …25.11.19700101.…
331    //   sui eval --no-eval-cache …toplevel.drvPath  …25.11.20260630.…  (correct)
332    //
333    // Three consequences, ascending: a fix does not reach any machine with a
334    // warm cache; every silent divergence becomes a DURABLE one; and you
335    // cannot verify your own fix — that verification read as "the patch did
336    // nothing" and nearly got the fix reverted.
337    //
338    // Bumping the domain separator to v2 also retires every v1 entry, which
339    // is correct: they were written under a scheme that could not say which
340    // evaluator produced them, so none of them is trustworthy.
341    //
342    // KNOWN RESIDUAL, stated rather than papered over: this keys on the
343    // crate VERSION, so two builds of the SAME version with different code —
344    // i.e. local evaluator development — still share entries. The workspace
345    // releases many times a day so this covers every published sui, but
346    // while you are working ON the evaluator, pass `--no-eval-cache` or bump
347    // the version. Closing that properly needs a build-identity stamp from
348    // build.rs (a git rev or a source hash), which does not exist today.
349    hasher.update(b"evalcache::v2::");
350    hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
351    hasher.update(b"::");
352    hasher.update(key.source_hash.as_bytes());
353    hasher.update(b"::");
354    if let Some(lock) = &key.lock_hash {
355        hasher.update(lock.as_bytes());
356    } else {
357        hasher.update(b"<no-lock>");
358    }
359    GraphHash(hasher.finalize().into())
360}
361
362/// SHA-256 hex digest of a byte slice.
363fn sha256_hex(data: &[u8]) -> String {
364    let mut hasher = Sha256::new();
365    hasher.update(data);
366    format!("{:x}", hasher.finalize())
367}
368
369/// Default persistent cache path.
370///
371/// `SUI_EVAL_CACHE_PATH` overrides the location outright (used for hermetic
372/// tests and for an operator who wants the warm eval-store on a specific
373/// disk/ZFS dataset). Otherwise: `$cache_dir/sui/eval-cache.json`, where
374/// `$cache_dir` is `~/Library/Caches` (macOS) or `$XDG_CACHE_HOME`/`~/.cache`.
375fn default_cache_path() -> Option<PathBuf> {
376    if let Ok(p) = std::env::var("SUI_EVAL_CACHE_PATH") {
377        if !p.is_empty() {
378            return Some(PathBuf::from(p));
379        }
380    }
381    dirs_next().map(|d| d.join("sui").join("eval-cache.json"))
382}
383
384/// Platform cache directory (`$XDG_CACHE_HOME` or `~/.cache`).
385fn dirs_next() -> Option<PathBuf> {
386    // The non-empty check was here already; the ABSOLUTE check was not — the
387    // partial-guard shape from theory/MASKED-BRANCH.md, where considering the
388    // degenerate case makes the arm read as finished. XDG_CACHE_HOME="rel/x"
389    // passed and gave a cwd-relative cache, so every working directory got its
390    // own cache and none of them hit.
391    //
392    // Deliberately NOT okiba: the fallback below is ~/Library/Caches on macOS,
393    // and okiba's Tier::Cache is XDG-only (~/.cache everywhere), so routing
394    // this through it would relocate every Mac's cache and orphan what is
395    // there. Preserving where a valid configuration resolves outranks using
396    // the shared primitive.
397    if let Some(val) = std::env::var_os("XDG_CACHE_HOME")
398        .map(PathBuf::from)
399        .filter(|p| p.is_absolute())
400    {
401        return Some(val);
402    }
403    #[cfg(target_os = "macos")]
404    {
405        home_dir().map(|h| h.join("Library").join("Caches"))
406    }
407    #[cfg(not(target_os = "macos"))]
408    {
409        home_dir().map(|h| h.join(".cache"))
410    }
411}
412
413fn home_dir() -> Option<PathBuf> {
414    std::env::var("HOME").ok().map(PathBuf::from)
415}
416
417/// Return the current Unix timestamp (seconds since epoch).
418pub fn now_timestamp() -> i64 {
419    std::time::SystemTime::now()
420        .duration_since(std::time::UNIX_EPOCH)
421        .map(|d| d.as_secs() as i64)
422        .unwrap_or(0)
423}
424
425// ── Tests ──────────────────────────────────────────────────────
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn cache_hit_returns_same_value() {
433        let mut cache = EvalCache::new();
434        let key = CacheKey {
435            source_hash: "abc123".to_string(),
436            lock_hash: None,
437        };
438        let value = CachedValue {
439            value_json: r#"{"type":"int","value":42}"#.to_string(),
440            timestamp: 1000,
441        };
442        cache.put(key.clone(), value.clone());
443        let got = cache.get(&key).unwrap();
444        assert_eq!(got.value_json, value.value_json);
445    }
446
447    #[test]
448    fn cache_miss_returns_none() {
449        let mut cache = EvalCache::new();
450        let key = CacheKey {
451            source_hash: "nonexistent".to_string(),
452            lock_hash: None,
453        };
454        assert!(cache.get(&key).is_none());
455    }
456
457    #[test]
458    fn different_content_different_key() {
459        let mut cache = EvalCache::new();
460        let k1 = CacheKey {
461            source_hash: sha256_hex(b"file content A"),
462            lock_hash: None,
463        };
464        let k2 = CacheKey {
465            source_hash: sha256_hex(b"file content B"),
466            lock_hash: None,
467        };
468        cache.put(
469            k1.clone(),
470            CachedValue {
471                value_json: "A".to_string(),
472                timestamp: 1,
473            },
474        );
475        assert!(cache.get(&k1).is_some());
476        assert!(cache.get(&k2).is_none());
477    }
478
479    #[test]
480    fn lock_hash_change_invalidates() {
481        let mut cache = EvalCache::new();
482        let k1 = CacheKey {
483            source_hash: "same".to_string(),
484            lock_hash: Some("lock-v1".to_string()),
485        };
486        let k2 = CacheKey {
487            source_hash: "same".to_string(),
488            lock_hash: Some("lock-v2".to_string()),
489        };
490        cache.put(
491            k1.clone(),
492            CachedValue {
493                value_json: "v1".to_string(),
494                timestamp: 1,
495            },
496        );
497        assert!(cache.get(&k1).is_some());
498        assert!(cache.get(&k2).is_none());
499    }
500
501    #[test]
502    fn disabled_cache_always_misses() {
503        let mut cache = EvalCache::disabled();
504        let key = CacheKey {
505            source_hash: "abc".to_string(),
506            lock_hash: None,
507        };
508        cache.put(
509            key.clone(),
510            CachedValue {
511                value_json: "x".to_string(),
512                timestamp: 1,
513            },
514        );
515        assert!(cache.get(&key).is_none());
516    }
517
518    /// **The evaluator is part of the cache key.**
519    ///
520    /// A cached value is this sui's ANSWER, not a property of the source, so
521    /// keying by source alone lets one sui serve its answer to another. That
522    /// made a real fix invisible on 2026-08-09: after correcting a
523    /// flake-input divergence and rebuilding, the same command still returned
524    /// the pre-fix value from cache and read as "the patch did nothing".
525    ///
526    /// Asserted against the LITERAL version string rather than
527    /// `env!("CARGO_PKG_VERSION")` on both sides — comparing the constant to
528    /// itself would pass no matter what the hash actually consumed, which is
529    /// the vacuous shape this repo keeps rediscovering.
530    #[test]
531    fn cache_key_is_scoped_to_the_evaluator_version() {
532        let key = CacheKey {
533            source_hash: "deadbeef".to_string(),
534            lock_hash: None,
535        };
536        let got = graph_hash_for_key(&key);
537
538        let mut expect = blake3::Hasher::new();
539        expect.update(b"evalcache::v2::");
540        expect.update(env!("CARGO_PKG_VERSION").as_bytes());
541        expect.update(b"::");
542        expect.update(b"deadbeef");
543        expect.update(b"::");
544        expect.update(b"<no-lock>");
545        assert_eq!(
546            got,
547            GraphHash(expect.finalize().into()),
548            "graph_hash_for_key must domain-separate on v2 AND the evaluator version"
549        );
550
551        // Falsifiability: a DIFFERENT evaluator version must produce a
552        // different hash for identical source. If this ever passes, the
553        // version is being hashed into a constant position that does not
554        // affect the digest.
555        let mut other = blake3::Hasher::new();
556        other.update(b"evalcache::v2::");
557        other.update(b"0.0.0-not-this-build");
558        other.update(b"::");
559        other.update(b"deadbeef");
560        other.update(b"::");
561        other.update(b"<no-lock>");
562        assert_ne!(
563            got,
564            GraphHash(other.finalize().into()),
565            "two evaluator versions must not share a cache entry"
566        );
567    }
568
569    #[test]
570    fn key_for_file_hashes_content() {
571        let dir = std::env::temp_dir().join("sui-eval-cache-test");
572        let _ = std::fs::create_dir_all(&dir);
573        let path = dir.join("test.nix");
574        std::fs::write(&path, "1 + 2").unwrap();
575
576        let key = EvalCache::key_for_file(&path).unwrap();
577        assert!(!key.source_hash.is_empty());
578        assert!(key.lock_hash.is_none()); // no flake.lock
579
580        let _ = std::fs::remove_file(&path);
581        let _ = std::fs::remove_dir(&dir);
582    }
583
584    #[test]
585    fn key_for_file_with_flake_lock() {
586        let dir = std::env::temp_dir().join("sui-eval-cache-test-lock");
587        let _ = std::fs::create_dir_all(&dir);
588        let path = dir.join("flake.nix");
589        let lock = dir.join("flake.lock");
590        std::fs::write(&path, "{ }").unwrap();
591        std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
592
593        let key = EvalCache::key_for_file(&path).unwrap();
594        assert!(key.lock_hash.is_some());
595
596        let _ = std::fs::remove_file(&path);
597        let _ = std::fs::remove_file(&lock);
598        let _ = std::fs::remove_dir(&dir);
599    }
600
601    #[test]
602    fn persistent_roundtrip() {
603        let dir = std::env::temp_dir().join("sui-eval-cache-persist");
604        let _ = std::fs::create_dir_all(&dir);
605        let db = dir.join("test-cache.json");
606
607        // Write
608        {
609            let mut c = EvalCache::with_persistent(db.clone());
610            c.put(
611                CacheKey {
612                    source_hash: "h1".to_string(),
613                    lock_hash: None,
614                },
615                CachedValue {
616                    value_json: r#""hello""#.to_string(),
617                    timestamp: now_timestamp(),
618                },
619            );
620            assert_eq!(c.len(), 1);
621        }
622
623        // Read back
624        {
625            let mut c = EvalCache::with_persistent(db.clone());
626            let key = CacheKey {
627                source_hash: "h1".to_string(),
628                lock_hash: None,
629            };
630            let v = c.get(&key).unwrap();
631            assert_eq!(v.value_json, r#""hello""#);
632        }
633
634        let _ = std::fs::remove_file(&db);
635        let _ = std::fs::remove_dir(&dir);
636    }
637
638    // ── GraphStore tier — additive integration tests ───────────────
639
640    fn temp_graph_store() -> (tempfile::TempDir, GraphStore) {
641        let dir = tempfile::tempdir().unwrap();
642        let store = GraphStore::open(dir.path().to_path_buf()).unwrap();
643        (dir, store)
644    }
645
646    #[test]
647    fn graph_store_tier_round_trips_a_value() {
648        let (_dir, store) = temp_graph_store();
649        let mut cache = EvalCache::new().with_graph_store(store);
650        assert!(cache.has_graph_store());
651
652        let key = CacheKey {
653            source_hash: sha256_hex(b"some source"),
654            lock_hash: Some(sha256_hex(b"some lock")),
655        };
656        let value = CachedValue {
657            value_json: r#"{"answer":42}"#.to_string(),
658            timestamp: 1_700_000_000,
659        };
660
661        cache.put(key.clone(), value.clone());
662        let got = cache.get(&key).expect("memory tier hits");
663        assert_eq!(got.value_json, value.value_json);
664    }
665
666    #[test]
667    fn graph_store_tier_survives_fresh_cache_instance() {
668        let (_dir, store) = temp_graph_store();
669        let key = CacheKey {
670            source_hash: sha256_hex(b"persist me"),
671            lock_hash: None,
672        };
673        let value = CachedValue {
674            value_json: r#""persisted""#.to_string(),
675            timestamp: 42,
676        };
677
678        // First cache writes; drops.
679        {
680            let mut c = EvalCache::new().with_graph_store(store.clone());
681            c.put(key.clone(), value.clone());
682        }
683
684        // Fresh cache, same GraphStore — must promote on first read.
685        let mut c2 = EvalCache::new().with_graph_store(store);
686        let got = c2.get(&key).expect("graph_store tier hits");
687        assert_eq!(got.value_json, value.value_json);
688        // Promotion: next lookup must hit memory (no GraphStore round-trip).
689        let again = c2.get(&key).expect("memory promotion");
690        assert_eq!(again.value_json, value.value_json);
691    }
692
693    #[test]
694    fn graph_store_tier_isolates_by_cache_key() {
695        let (_dir, store) = temp_graph_store();
696        let mut cache = EvalCache::new().with_graph_store(store);
697
698        let k_a = CacheKey {
699            source_hash: sha256_hex(b"file a"),
700            lock_hash: None,
701        };
702        let k_b = CacheKey {
703            source_hash: sha256_hex(b"file b"),
704            lock_hash: None,
705        };
706
707        cache.put(
708            k_a.clone(),
709            CachedValue {
710                value_json: "A".to_string(),
711                timestamp: 1,
712            },
713        );
714
715        // Second key must miss — domain-separated lookup hash must
716        // not collide.
717        assert!(cache.get(&k_b).is_none());
718        assert!(cache.get(&k_a).is_some());
719    }
720
721    #[test]
722    fn graph_store_tier_disabled_when_cache_disabled() {
723        let (_dir, store) = temp_graph_store();
724        let mut cache = EvalCache::disabled().with_graph_store(store);
725        let key = CacheKey {
726            source_hash: "x".to_string(),
727            lock_hash: None,
728        };
729        cache.put(
730            key.clone(),
731            CachedValue {
732                value_json: "y".to_string(),
733                timestamp: 0,
734            },
735        );
736        assert!(cache.get(&key).is_none());
737    }
738
739    #[test]
740    fn all_three_tiers_stack_cleanly() {
741        let dir = tempfile::tempdir().unwrap();
742        let db_path = dir.path().join("eval-cache.json");
743        let (_gdir, store) = temp_graph_store();
744
745        let key = CacheKey {
746            source_hash: sha256_hex(b"triple-tier source"),
747            lock_hash: None,
748        };
749        let value = CachedValue {
750            value_json: r#""triple-tier""#.to_string(),
751            timestamp: 99,
752        };
753
754        // First cache writes through all three tiers.
755        {
756            let mut c = EvalCache::with_all_tiers(db_path.clone(), store.clone());
757            c.put(key.clone(), value.clone());
758        }
759
760        // Fresh cache pointed at the same JSON file (no GraphStore)
761        // must still hit (Tier 2 — JSON persistence preserved).
762        {
763            let mut c = EvalCache::with_persistent(db_path.clone());
764            assert!(c.get(&key).is_some(), "tier 2 (JSON) must still serve");
765        }
766
767        // Fresh cache pointed at the GraphStore only must also hit
768        // (Tier 3 — fleet-shared persistence preserved).
769        {
770            let mut c = EvalCache::new().with_graph_store(store);
771            assert!(c.get(&key).is_some(), "tier 3 (GraphStore) must still serve");
772        }
773    }
774
775    #[test]
776    fn sha256_hex_deterministic() {
777        let a = sha256_hex(b"hello");
778        let b = sha256_hex(b"hello");
779        assert_eq!(a, b);
780        assert_ne!(a, sha256_hex(b"world"));
781    }
782
783    #[test]
784    fn now_timestamp_reasonable() {
785        let ts = now_timestamp();
786        // Should be after 2020 and before 2100
787        assert!(ts > 1_577_836_800);
788        assert!(ts < 4_102_444_800);
789    }
790
791    #[test]
792    fn len_and_is_empty() {
793        let mut cache = EvalCache::new();
794        assert!(cache.is_empty());
795        assert_eq!(cache.len(), 0);
796        cache.put(
797            CacheKey { source_hash: "x".to_string(), lock_hash: None },
798            CachedValue { value_json: "1".to_string(), timestamp: 1 },
799        );
800        assert!(!cache.is_empty());
801        assert_eq!(cache.len(), 1);
802    }
803}