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}