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}