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    if let Ok(val) = std::env::var("XDG_CACHE_HOME") {
387        if !val.is_empty() {
388            return Some(PathBuf::from(val));
389        }
390    }
391    #[cfg(target_os = "macos")]
392    {
393        home_dir().map(|h| h.join("Library").join("Caches"))
394    }
395    #[cfg(not(target_os = "macos"))]
396    {
397        home_dir().map(|h| h.join(".cache"))
398    }
399}
400
401fn home_dir() -> Option<PathBuf> {
402    std::env::var("HOME").ok().map(PathBuf::from)
403}
404
405/// Return the current Unix timestamp (seconds since epoch).
406pub fn now_timestamp() -> i64 {
407    std::time::SystemTime::now()
408        .duration_since(std::time::UNIX_EPOCH)
409        .map(|d| d.as_secs() as i64)
410        .unwrap_or(0)
411}
412
413// ── Tests ──────────────────────────────────────────────────────
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn cache_hit_returns_same_value() {
421        let mut cache = EvalCache::new();
422        let key = CacheKey {
423            source_hash: "abc123".to_string(),
424            lock_hash: None,
425        };
426        let value = CachedValue {
427            value_json: r#"{"type":"int","value":42}"#.to_string(),
428            timestamp: 1000,
429        };
430        cache.put(key.clone(), value.clone());
431        let got = cache.get(&key).unwrap();
432        assert_eq!(got.value_json, value.value_json);
433    }
434
435    #[test]
436    fn cache_miss_returns_none() {
437        let mut cache = EvalCache::new();
438        let key = CacheKey {
439            source_hash: "nonexistent".to_string(),
440            lock_hash: None,
441        };
442        assert!(cache.get(&key).is_none());
443    }
444
445    #[test]
446    fn different_content_different_key() {
447        let mut cache = EvalCache::new();
448        let k1 = CacheKey {
449            source_hash: sha256_hex(b"file content A"),
450            lock_hash: None,
451        };
452        let k2 = CacheKey {
453            source_hash: sha256_hex(b"file content B"),
454            lock_hash: None,
455        };
456        cache.put(
457            k1.clone(),
458            CachedValue {
459                value_json: "A".to_string(),
460                timestamp: 1,
461            },
462        );
463        assert!(cache.get(&k1).is_some());
464        assert!(cache.get(&k2).is_none());
465    }
466
467    #[test]
468    fn lock_hash_change_invalidates() {
469        let mut cache = EvalCache::new();
470        let k1 = CacheKey {
471            source_hash: "same".to_string(),
472            lock_hash: Some("lock-v1".to_string()),
473        };
474        let k2 = CacheKey {
475            source_hash: "same".to_string(),
476            lock_hash: Some("lock-v2".to_string()),
477        };
478        cache.put(
479            k1.clone(),
480            CachedValue {
481                value_json: "v1".to_string(),
482                timestamp: 1,
483            },
484        );
485        assert!(cache.get(&k1).is_some());
486        assert!(cache.get(&k2).is_none());
487    }
488
489    #[test]
490    fn disabled_cache_always_misses() {
491        let mut cache = EvalCache::disabled();
492        let key = CacheKey {
493            source_hash: "abc".to_string(),
494            lock_hash: None,
495        };
496        cache.put(
497            key.clone(),
498            CachedValue {
499                value_json: "x".to_string(),
500                timestamp: 1,
501            },
502        );
503        assert!(cache.get(&key).is_none());
504    }
505
506    /// **The evaluator is part of the cache key.**
507    ///
508    /// A cached value is this sui's ANSWER, not a property of the source, so
509    /// keying by source alone lets one sui serve its answer to another. That
510    /// made a real fix invisible on 2026-08-09: after correcting a
511    /// flake-input divergence and rebuilding, the same command still returned
512    /// the pre-fix value from cache and read as "the patch did nothing".
513    ///
514    /// Asserted against the LITERAL version string rather than
515    /// `env!("CARGO_PKG_VERSION")` on both sides — comparing the constant to
516    /// itself would pass no matter what the hash actually consumed, which is
517    /// the vacuous shape this repo keeps rediscovering.
518    #[test]
519    fn cache_key_is_scoped_to_the_evaluator_version() {
520        let key = CacheKey {
521            source_hash: "deadbeef".to_string(),
522            lock_hash: None,
523        };
524        let got = graph_hash_for_key(&key);
525
526        let mut expect = blake3::Hasher::new();
527        expect.update(b"evalcache::v2::");
528        expect.update(env!("CARGO_PKG_VERSION").as_bytes());
529        expect.update(b"::");
530        expect.update(b"deadbeef");
531        expect.update(b"::");
532        expect.update(b"<no-lock>");
533        assert_eq!(
534            got,
535            GraphHash(expect.finalize().into()),
536            "graph_hash_for_key must domain-separate on v2 AND the evaluator version"
537        );
538
539        // Falsifiability: a DIFFERENT evaluator version must produce a
540        // different hash for identical source. If this ever passes, the
541        // version is being hashed into a constant position that does not
542        // affect the digest.
543        let mut other = blake3::Hasher::new();
544        other.update(b"evalcache::v2::");
545        other.update(b"0.0.0-not-this-build");
546        other.update(b"::");
547        other.update(b"deadbeef");
548        other.update(b"::");
549        other.update(b"<no-lock>");
550        assert_ne!(
551            got,
552            GraphHash(other.finalize().into()),
553            "two evaluator versions must not share a cache entry"
554        );
555    }
556
557    #[test]
558    fn key_for_file_hashes_content() {
559        let dir = std::env::temp_dir().join("sui-eval-cache-test");
560        let _ = std::fs::create_dir_all(&dir);
561        let path = dir.join("test.nix");
562        std::fs::write(&path, "1 + 2").unwrap();
563
564        let key = EvalCache::key_for_file(&path).unwrap();
565        assert!(!key.source_hash.is_empty());
566        assert!(key.lock_hash.is_none()); // no flake.lock
567
568        let _ = std::fs::remove_file(&path);
569        let _ = std::fs::remove_dir(&dir);
570    }
571
572    #[test]
573    fn key_for_file_with_flake_lock() {
574        let dir = std::env::temp_dir().join("sui-eval-cache-test-lock");
575        let _ = std::fs::create_dir_all(&dir);
576        let path = dir.join("flake.nix");
577        let lock = dir.join("flake.lock");
578        std::fs::write(&path, "{ }").unwrap();
579        std::fs::write(&lock, r#"{"nodes":{}}"#).unwrap();
580
581        let key = EvalCache::key_for_file(&path).unwrap();
582        assert!(key.lock_hash.is_some());
583
584        let _ = std::fs::remove_file(&path);
585        let _ = std::fs::remove_file(&lock);
586        let _ = std::fs::remove_dir(&dir);
587    }
588
589    #[test]
590    fn persistent_roundtrip() {
591        let dir = std::env::temp_dir().join("sui-eval-cache-persist");
592        let _ = std::fs::create_dir_all(&dir);
593        let db = dir.join("test-cache.json");
594
595        // Write
596        {
597            let mut c = EvalCache::with_persistent(db.clone());
598            c.put(
599                CacheKey {
600                    source_hash: "h1".to_string(),
601                    lock_hash: None,
602                },
603                CachedValue {
604                    value_json: r#""hello""#.to_string(),
605                    timestamp: now_timestamp(),
606                },
607            );
608            assert_eq!(c.len(), 1);
609        }
610
611        // Read back
612        {
613            let mut c = EvalCache::with_persistent(db.clone());
614            let key = CacheKey {
615                source_hash: "h1".to_string(),
616                lock_hash: None,
617            };
618            let v = c.get(&key).unwrap();
619            assert_eq!(v.value_json, r#""hello""#);
620        }
621
622        let _ = std::fs::remove_file(&db);
623        let _ = std::fs::remove_dir(&dir);
624    }
625
626    // ── GraphStore tier — additive integration tests ───────────────
627
628    fn temp_graph_store() -> (tempfile::TempDir, GraphStore) {
629        let dir = tempfile::tempdir().unwrap();
630        let store = GraphStore::open(dir.path().to_path_buf()).unwrap();
631        (dir, store)
632    }
633
634    #[test]
635    fn graph_store_tier_round_trips_a_value() {
636        let (_dir, store) = temp_graph_store();
637        let mut cache = EvalCache::new().with_graph_store(store);
638        assert!(cache.has_graph_store());
639
640        let key = CacheKey {
641            source_hash: sha256_hex(b"some source"),
642            lock_hash: Some(sha256_hex(b"some lock")),
643        };
644        let value = CachedValue {
645            value_json: r#"{"answer":42}"#.to_string(),
646            timestamp: 1_700_000_000,
647        };
648
649        cache.put(key.clone(), value.clone());
650        let got = cache.get(&key).expect("memory tier hits");
651        assert_eq!(got.value_json, value.value_json);
652    }
653
654    #[test]
655    fn graph_store_tier_survives_fresh_cache_instance() {
656        let (_dir, store) = temp_graph_store();
657        let key = CacheKey {
658            source_hash: sha256_hex(b"persist me"),
659            lock_hash: None,
660        };
661        let value = CachedValue {
662            value_json: r#""persisted""#.to_string(),
663            timestamp: 42,
664        };
665
666        // First cache writes; drops.
667        {
668            let mut c = EvalCache::new().with_graph_store(store.clone());
669            c.put(key.clone(), value.clone());
670        }
671
672        // Fresh cache, same GraphStore — must promote on first read.
673        let mut c2 = EvalCache::new().with_graph_store(store);
674        let got = c2.get(&key).expect("graph_store tier hits");
675        assert_eq!(got.value_json, value.value_json);
676        // Promotion: next lookup must hit memory (no GraphStore round-trip).
677        let again = c2.get(&key).expect("memory promotion");
678        assert_eq!(again.value_json, value.value_json);
679    }
680
681    #[test]
682    fn graph_store_tier_isolates_by_cache_key() {
683        let (_dir, store) = temp_graph_store();
684        let mut cache = EvalCache::new().with_graph_store(store);
685
686        let k_a = CacheKey {
687            source_hash: sha256_hex(b"file a"),
688            lock_hash: None,
689        };
690        let k_b = CacheKey {
691            source_hash: sha256_hex(b"file b"),
692            lock_hash: None,
693        };
694
695        cache.put(
696            k_a.clone(),
697            CachedValue {
698                value_json: "A".to_string(),
699                timestamp: 1,
700            },
701        );
702
703        // Second key must miss — domain-separated lookup hash must
704        // not collide.
705        assert!(cache.get(&k_b).is_none());
706        assert!(cache.get(&k_a).is_some());
707    }
708
709    #[test]
710    fn graph_store_tier_disabled_when_cache_disabled() {
711        let (_dir, store) = temp_graph_store();
712        let mut cache = EvalCache::disabled().with_graph_store(store);
713        let key = CacheKey {
714            source_hash: "x".to_string(),
715            lock_hash: None,
716        };
717        cache.put(
718            key.clone(),
719            CachedValue {
720                value_json: "y".to_string(),
721                timestamp: 0,
722            },
723        );
724        assert!(cache.get(&key).is_none());
725    }
726
727    #[test]
728    fn all_three_tiers_stack_cleanly() {
729        let dir = tempfile::tempdir().unwrap();
730        let db_path = dir.path().join("eval-cache.json");
731        let (_gdir, store) = temp_graph_store();
732
733        let key = CacheKey {
734            source_hash: sha256_hex(b"triple-tier source"),
735            lock_hash: None,
736        };
737        let value = CachedValue {
738            value_json: r#""triple-tier""#.to_string(),
739            timestamp: 99,
740        };
741
742        // First cache writes through all three tiers.
743        {
744            let mut c = EvalCache::with_all_tiers(db_path.clone(), store.clone());
745            c.put(key.clone(), value.clone());
746        }
747
748        // Fresh cache pointed at the same JSON file (no GraphStore)
749        // must still hit (Tier 2 — JSON persistence preserved).
750        {
751            let mut c = EvalCache::with_persistent(db_path.clone());
752            assert!(c.get(&key).is_some(), "tier 2 (JSON) must still serve");
753        }
754
755        // Fresh cache pointed at the GraphStore only must also hit
756        // (Tier 3 — fleet-shared persistence preserved).
757        {
758            let mut c = EvalCache::new().with_graph_store(store);
759            assert!(c.get(&key).is_some(), "tier 3 (GraphStore) must still serve");
760        }
761    }
762
763    #[test]
764    fn sha256_hex_deterministic() {
765        let a = sha256_hex(b"hello");
766        let b = sha256_hex(b"hello");
767        assert_eq!(a, b);
768        assert_ne!(a, sha256_hex(b"world"));
769    }
770
771    #[test]
772    fn now_timestamp_reasonable() {
773        let ts = now_timestamp();
774        // Should be after 2020 and before 2100
775        assert!(ts > 1_577_836_800);
776        assert!(ts < 4_102_444_800);
777    }
778
779    #[test]
780    fn len_and_is_empty() {
781        let mut cache = EvalCache::new();
782        assert!(cache.is_empty());
783        assert_eq!(cache.len(), 0);
784        cache.put(
785            CacheKey { source_hash: "x".to_string(), lock_hash: None },
786            CachedValue { value_json: "1".to_string(), timestamp: 1 },
787        );
788        assert!(!cache.is_empty());
789        assert_eq!(cache.len(), 1);
790    }
791}