warble_eval_runner/cache.rs
1//! Trace cache — content-addressed per-case results + 0-LLM re-scoring.
2//!
3//! Every replayed golden case emits a **trace**: the agent's captured `{columns, rows}` result plus
4//! the run's cost/latency/turns and the inputs that determined it — `(case, agent_sha, model,
5//! context_sha, sample)`. The trace is written under a content-addressed key so a later run can
6//! reuse it:
7//!
8//! - **Re-score without re-run** (the killer): a golden's `expected`/`tolerance`/`match` can change
9//! while the agent, model, MDL, and sample index do not. The key deliberately excludes the
10//! expectation, so such a change still *hits* — and [`rescore`] re-compares the cached result
11//! against the **current** expectation with **zero LLM calls**. The v1→v2 golden-calibration
12//! rerun goes from ~90 min to sub-second.
13//! - **Content-addressed skip**: identical `(case, agent_sha, model, context_sha, sample)` → reuse
14//! the cached result on a plain re-run too (no re-sampling). `--no-cache` is the escape hatch that
15//! forces every case to re-run and refreshes the cache.
16//!
17//! The key material reuses the same `git hash-object` content addressing as `eval verify-context`
18//! (the MDL SHA): `agent_sha` = SHA of the dispatched agent dir, `context_sha` = MDL SHA of the
19//! bound project, and the cache filename is the SHA of the canonical key string. No new deps, no
20//! network, single machine — the OSS boundary is preserved.
21//!
22//! No silent caps: the caller surfaces hit/miss counts in the report and per-case `[cache]` markers,
23//! and a re-scored run is never mistaken for a fresh one.
24
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28use warble_eval_compare::CompareResult;
29
30use crate::context::hash_str;
31use crate::{score_value, GoldenCase};
32
33/// Sentinel `model` component for the ablation / frontmatter path, where there is no whole-run
34/// `--model` override (the per-step tier binding is baked into the agent, so `agent_sha` already
35/// distinguishes the points). Keeps the key total for every run.
36pub(crate) const FRONTMATTER_MODEL: &str = "frontmatter";
37
38/// One captured case result. The `result` is stored as the raw `{columns, rows}` JSON value (the
39/// same object `capture` embeds) so no `Serialize` is needed on the compare crate's `Table`; it is
40/// deserialized back into a `Table` at re-score time.
41///
42/// `sql_executed` and `tool_calls` are schema slots the P4 trace shape reserves, but the single-shot
43/// `claude -p --output-format json` envelope does not expose either (only `num_turns`), so they are
44/// captured as `None` today. `turns` (from `num_turns`) is the delivered per-case diagnostic; full
45/// SQL / tool-call granularity would need `--output-format stream-json` (a later change).
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Trace {
48 pub case_id: String,
49 pub agent_sha: String,
50 pub model: String,
51 /// The golden's symbolic `context_version` pin (informational), e.g. `driftwood@a1b2c3d`.
52 pub context_version: Option<String>,
53 /// The computed MDL SHA of the bound project — the `context_sha` key component.
54 pub context_sha: String,
55 /// The question the result answers. Part of the key: if a case's question changes under the
56 /// same id, the cached result is for a different prompt and must be re-run (a miss).
57 pub question: String,
58 /// The SQL the agent executed. Not exposed by the `--output-format json` result envelope; `None`.
59 pub sql_executed: Option<String>,
60 /// The captured `{columns, rows}` result object.
61 pub result: serde_json::Value,
62 pub cost: f64,
63 pub latency_ms: u64,
64 /// Conversation turns (`num_turns`) — the round-trip count ("4–8 round-trips" per case, typically).
65 pub turns: u64,
66 /// Tool-call count. Not exposed by the JSON result envelope; `None` (see struct docs).
67 pub tool_calls: Option<u64>,
68}
69
70/// The inputs that determine a case's result. Everything here is in the cache key; the golden's
71/// expectation (expected/tolerance/match) deliberately is **not**, which is what makes re-scoring a
72/// changed expectation a cache hit.
73pub struct CaseKey<'a> {
74 pub case_id: &'a str,
75 pub question: &'a str,
76 pub agent_sha: &'a str,
77 /// The whole-run `--model` binding, or `FRONTMATTER_MODEL` on the ablation path.
78 pub model: &'a str,
79 pub context_sha: &'a str,
80 /// Which repeated-sample run this is (0-indexed). Distinct samples of the same case are
81 /// distinct cache entries — the pass-rate methodology depends on each sample actually invoking
82 /// the agent (or replaying its OWN prior trace), not silently collapsing onto sample 0.
83 pub sample: u32,
84}
85
86impl CaseKey<'_> {
87 /// The canonical, order-fixed key string that gets hashed. Encoded as a fixed-order JSON array
88 /// so a field value can never spoof the delimiter structure — a `question` may legitimately
89 /// contain newlines or `=`, which a plain `k=v\n` join could use to collide with a different
90 /// logical key (relevant once goldens are machine-generated, not just hand-authored). `sample`
91 /// is stringified and placed last so the first five elements stay stable for anyone comparing
92 /// keys across a `samples`-unaware era.
93 fn canonical(&self) -> String {
94 serde_json::to_string(&[
95 self.case_id,
96 self.question,
97 self.agent_sha,
98 self.model,
99 self.context_sha,
100 &self.sample.to_string(),
101 ])
102 .expect("array of strings serializes")
103 }
104
105 /// Content-addressed hash of the key → the cache entry's filename stem.
106 pub fn hash(&self) -> Result<String, String> {
107 hash_str(&self.canonical())
108 }
109}
110
111/// A content-addressed store of [`Trace`]s under one directory (`<dir>/<key-hash>.json`).
112///
113/// When `enabled` is false (the `--no-cache` escape hatch) [`load`](Self::load) always misses and
114/// [`store`](Self::store) is a no-op, so a run behaves exactly as it did before P4.
115pub struct TraceStore {
116 dir: PathBuf,
117 enabled: bool,
118}
119
120impl TraceStore {
121 /// A store rooted at `dir`. `enabled = false` disables both read and write (`--no-cache`).
122 pub fn new(dir: PathBuf, enabled: bool) -> Self {
123 TraceStore { dir, enabled }
124 }
125
126 /// An always-missing store (used when the cache key material can't be computed, so the run
127 /// still proceeds — every case just re-runs and nothing is cached).
128 pub fn disabled() -> Self {
129 TraceStore {
130 dir: PathBuf::new(),
131 enabled: false,
132 }
133 }
134
135 pub fn is_enabled(&self) -> bool {
136 self.enabled
137 }
138
139 pub fn dir(&self) -> &Path {
140 &self.dir
141 }
142
143 fn entry_path(&self, key_hash: &str) -> PathBuf {
144 self.dir.join(format!("{key_hash}.json"))
145 }
146
147 /// Load a cached trace for `key`, or `None` on a disabled store, a missing entry, or an
148 /// unreadable/corrupt one (a corrupt entry is treated as a miss — the case just re-runs).
149 pub fn load(&self, key: &CaseKey) -> Option<Trace> {
150 if !self.enabled {
151 return None;
152 }
153 let hash = key.hash().ok()?;
154 let raw = std::fs::read_to_string(self.entry_path(&hash)).ok()?;
155 serde_json::from_str(&raw).ok()
156 }
157
158 /// Write `trace` under `key`. A no-op on a disabled store. Creates the cache dir on first write.
159 pub fn store(&self, key: &CaseKey, trace: &Trace) -> Result<(), String> {
160 if !self.enabled {
161 return Ok(());
162 }
163 let hash = key.hash()?;
164 std::fs::create_dir_all(&self.dir)
165 .map_err(|e| format!("create cache dir {}: {e}", self.dir.display()))?;
166 let json = serde_json::to_string_pretty(trace).map_err(|e| e.to_string())?;
167 std::fs::write(self.entry_path(&hash), json)
168 .map_err(|e| format!("write trace {}: {e}", self.entry_path(&hash).display()))
169 }
170}
171
172/// Re-score a cached `trace` against a golden case's **current** expectation — the 0-LLM path.
173/// Dispatches on the case's [`crate::ResultKind`] via [`score_value`] just like a fresh run does, so
174/// a Table case's cached `{columns,rows}` and a Verdict case's cached envelope both re-project the
175/// same way. Returns `None` if the cached result doesn't have the shape the case's kind expects (a
176/// corrupt entry, or a case whose `result_kind` changed since the trace was captured; the caller then
177/// re-runs the case). This is what makes "only the golden's expected changed" cost zero LLM calls:
178/// the result is reused, only the comparison is redone.
179pub fn rescore(trace: &Trace, case: &GoldenCase) -> Option<CompareResult> {
180 score_value(&trace.result, case)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use warble_eval_compare::{MatchMode, Tolerance};
187
188 fn trace_with(result: serde_json::Value) -> Trace {
189 Trace {
190 case_id: "q1".into(),
191 agent_sha: "AAAA".into(),
192 model: "opus".into(),
193 context_version: Some("driftwood@c0ffee".into()),
194 context_sha: "CCCC".into(),
195 question: "how many orders?".into(),
196 sql_executed: None,
197 result,
198 cost: 0.12,
199 latency_ms: 20_000,
200 turns: 5,
201 tool_calls: None,
202 }
203 }
204
205 fn case_expecting(rows: serde_json::Value) -> GoldenCase {
206 // Build a GoldenCase via YAML so we don't depend on field visibility.
207 let yaml = format!(
208 "id: q1\nquestion: \"how many orders?\"\nmatch: scalar\nexpected: {{ columns: [n], rows: {rows} }}\n"
209 );
210 serde_yaml::from_str(&yaml).expect("golden case parses")
211 }
212
213 #[test]
214 fn key_hash_is_stable_and_expectation_independent() {
215 let k1 = CaseKey {
216 case_id: "q1",
217 question: "how many orders?",
218 agent_sha: "AAAA",
219 model: "opus",
220 context_sha: "CCCC",
221 sample: 0,
222 };
223 // Same inputs → same hash (content-addressed, deterministic).
224 assert_eq!(k1.hash().unwrap(), k1.hash().unwrap());
225 assert_eq!(k1.hash().unwrap().len(), 40, "git blob sha is 40 hex");
226 }
227
228 #[test]
229 fn changing_a_key_component_changes_the_hash() {
230 let base = CaseKey {
231 case_id: "q1",
232 question: "how many orders?",
233 agent_sha: "AAAA",
234 model: "opus",
235 context_sha: "CCCC",
236 sample: 0,
237 };
238 let base_hash = base.hash().unwrap();
239 // Each of the five key components moves the hash (→ a miss → a re-run).
240 for changed in [
241 CaseKey {
242 question: "how many customers?",
243 ..key_copy(&base)
244 },
245 CaseKey {
246 agent_sha: "BBBB",
247 ..key_copy(&base)
248 },
249 CaseKey {
250 model: "haiku",
251 ..key_copy(&base)
252 },
253 CaseKey {
254 context_sha: "DDDD",
255 ..key_copy(&base)
256 },
257 CaseKey {
258 sample: 1,
259 ..key_copy(&base)
260 },
261 ] {
262 assert_ne!(base_hash, changed.hash().unwrap());
263 }
264 }
265
266 // Helper to copy a CaseKey's fields (CaseKey holds &str, so this is cheap).
267 fn key_copy<'a>(k: &CaseKey<'a>) -> CaseKey<'a> {
268 CaseKey {
269 case_id: k.case_id,
270 question: k.question,
271 agent_sha: k.agent_sha,
272 model: k.model,
273 context_sha: k.context_sha,
274 sample: k.sample,
275 }
276 }
277
278 #[test]
279 fn store_round_trips_and_no_cache_never_hits() {
280 let dir = tempfile::tempdir().unwrap();
281 let key = CaseKey {
282 case_id: "q1",
283 question: "how many orders?",
284 agent_sha: "AAAA",
285 model: "opus",
286 context_sha: "CCCC",
287 sample: 0,
288 };
289 let trace = trace_with(serde_json::json!({"columns":["n"],"rows":[[42]]}));
290
291 let enabled = TraceStore::new(dir.path().to_path_buf(), true);
292 enabled.store(&key, &trace).unwrap();
293 let loaded = enabled.load(&key).expect("hit");
294 assert_eq!(loaded.result, trace.result);
295 assert_eq!(loaded.turns, 5);
296
297 // A disabled store (the --no-cache path) never hits, even with the entry on disk.
298 let disabled = TraceStore::new(dir.path().to_path_buf(), false);
299 assert!(disabled.load(&key).is_none());
300 // …and writing is a silent no-op.
301 disabled.store(&key, &trace).unwrap();
302 }
303
304 #[test]
305 fn rescore_reuses_result_against_the_current_expectation() {
306 let trace = trace_with(serde_json::json!({"columns":["n"],"rows":[[42]]}));
307
308 // Golden still expects 42 → pass, using only the cached result (no LLM).
309 let pass = rescore(&trace, &case_expecting(serde_json::json!([[42]]))).unwrap();
310 assert!(pass.pass, "cached 42 matches expected 42");
311
312 // The golden's expected changes to 99 → the SAME cached result now fails the re-score.
313 // This is the "only the expectation changed" path: 0 LLM, correct new verdict.
314 let fail = rescore(&trace, &case_expecting(serde_json::json!([[99]]))).unwrap();
315 assert!(!fail.pass, "cached 42 no longer matches expected 99");
316
317 let _ = Tolerance::default();
318 let _ = MatchMode::Scalar;
319 }
320
321 #[test]
322 fn canonical_key_is_a_six_element_array() {
323 let key = CaseKey {
324 case_id: "q1",
325 question: "how many orders?",
326 agent_sha: "AAAA",
327 model: "opus",
328 context_sha: "CCCC",
329 sample: 2,
330 };
331 let parsed: Vec<String> = serde_json::from_str(&key.canonical()).unwrap();
332 assert_eq!(
333 parsed,
334 vec!["q1", "how many orders?", "AAAA", "opus", "CCCC", "2"]
335 );
336 }
337}