use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use warble_eval_compare::CompareResult;
use crate::context::hash_str;
use crate::{score_value, Backend, GoldenCase};
pub(crate) const FRONTMATTER_MODEL: &str = "frontmatter";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trace {
pub case_id: String,
pub agent_sha: String,
pub model: String,
pub context_version: Option<String>,
pub context_sha: String,
pub question: String,
pub sql_executed: Option<String>,
pub result: serde_json::Value,
#[serde(default)]
pub cost: Option<f64>,
pub latency_ms: u64,
#[serde(default)]
pub turns: Option<u64>,
pub tool_calls: Option<u64>,
#[serde(default)]
pub backend: Backend,
}
pub struct CaseKey<'a> {
pub case_id: &'a str,
pub question: &'a str,
pub agent_sha: &'a str,
pub model: &'a str,
pub context_sha: &'a str,
pub sample: u32,
pub backend: Backend,
pub max_turns: Option<u32>,
pub tier_binding: Option<&'a str>,
}
impl CaseKey<'_> {
fn canonical(&self) -> String {
let sample = self.sample.to_string();
let max_turns = self.max_turns.map(|n| n.to_string());
let mut parts: Vec<&str> = vec![
self.case_id,
self.question,
self.agent_sha,
self.model,
self.context_sha,
&sample,
];
if self.backend != Backend::default() {
parts.push(self.backend.as_str());
}
if let Some(ref max_turns) = max_turns {
parts.push(max_turns);
}
if let Some(tier_binding) = self.tier_binding {
parts.push(tier_binding);
}
serde_json::to_string(&parts).expect("array of strings serializes")
}
pub fn hash(&self) -> Result<String, String> {
hash_str(&self.canonical())
}
}
pub struct TraceStore {
dir: PathBuf,
enabled: bool,
}
impl TraceStore {
pub fn new(dir: PathBuf, enabled: bool) -> Self {
TraceStore { dir, enabled }
}
pub fn disabled() -> Self {
TraceStore {
dir: PathBuf::new(),
enabled: false,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn entry_path(&self, key_hash: &str) -> PathBuf {
self.dir.join(format!("{key_hash}.json"))
}
pub fn load(&self, key: &CaseKey) -> Option<Trace> {
if !self.enabled {
return None;
}
let hash = key.hash().ok()?;
let raw = std::fs::read_to_string(self.entry_path(&hash)).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn store(&self, key: &CaseKey, trace: &Trace) -> Result<(), String> {
if !self.enabled {
return Ok(());
}
let hash = key.hash()?;
std::fs::create_dir_all(&self.dir)
.map_err(|e| format!("create cache dir {}: {e}", self.dir.display()))?;
let json = serde_json::to_string_pretty(trace).map_err(|e| e.to_string())?;
std::fs::write(self.entry_path(&hash), json)
.map_err(|e| format!("write trace {}: {e}", self.entry_path(&hash).display()))
}
}
pub fn rescore(trace: &Trace, case: &GoldenCase) -> Option<CompareResult> {
score_value(&trace.result, case)
}
#[cfg(test)]
mod tests {
use super::*;
use warble_eval_compare::{MatchMode, Tolerance};
fn trace_with(result: serde_json::Value) -> Trace {
Trace {
case_id: "q1".into(),
agent_sha: "AAAA".into(),
model: "opus".into(),
context_version: Some("driftwood@c0ffee".into()),
context_sha: "CCCC".into(),
question: "how many orders?".into(),
sql_executed: None,
result,
cost: Some(0.12),
latency_ms: 20_000,
turns: Some(5),
tool_calls: None,
backend: Backend::default(),
}
}
fn case_expecting(rows: serde_json::Value) -> GoldenCase {
let yaml = format!(
"id: q1\nquestion: \"how many orders?\"\nmatch: scalar\nexpected: {{ columns: [n], rows: {rows} }}\n"
);
serde_yaml::from_str(&yaml).expect("golden case parses")
}
#[test]
fn key_hash_is_stable_and_expectation_independent() {
let k1 = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 0,
backend: Backend::default(),
max_turns: None,
tier_binding: None,
};
assert_eq!(k1.hash().unwrap(), k1.hash().unwrap());
assert_eq!(k1.hash().unwrap().len(), 40, "git blob sha is 40 hex");
}
#[test]
fn changing_a_key_component_changes_the_hash() {
let base = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 0,
backend: Backend::default(),
max_turns: None,
tier_binding: None,
};
let base_hash = base.hash().unwrap();
for changed in [
CaseKey {
question: "how many customers?",
..key_copy(&base)
},
CaseKey {
agent_sha: "BBBB",
..key_copy(&base)
},
CaseKey {
model: "haiku",
..key_copy(&base)
},
CaseKey {
context_sha: "DDDD",
..key_copy(&base)
},
CaseKey {
sample: 1,
..key_copy(&base)
},
CaseKey {
backend: Backend::ClaudeAgentSdk,
..key_copy(&base)
},
CaseKey {
max_turns: Some(1),
..key_copy(&base)
},
] {
assert_ne!(base_hash, changed.hash().unwrap());
}
}
fn key_copy<'a>(k: &CaseKey<'a>) -> CaseKey<'a> {
CaseKey {
case_id: k.case_id,
question: k.question,
agent_sha: k.agent_sha,
model: k.model,
context_sha: k.context_sha,
sample: k.sample,
backend: k.backend,
max_turns: k.max_turns,
tier_binding: k.tier_binding,
}
}
#[test]
fn store_round_trips_and_no_cache_never_hits() {
let dir = tempfile::tempdir().unwrap();
let key = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 0,
backend: Backend::default(),
max_turns: None,
tier_binding: None,
};
let trace = trace_with(serde_json::json!({"columns":["n"],"rows":[[42]]}));
let enabled = TraceStore::new(dir.path().to_path_buf(), true);
enabled.store(&key, &trace).unwrap();
let loaded = enabled.load(&key).expect("hit");
assert_eq!(loaded.result, trace.result);
assert_eq!(loaded.turns, Some(5));
let disabled = TraceStore::new(dir.path().to_path_buf(), false);
assert!(disabled.load(&key).is_none());
disabled.store(&key, &trace).unwrap();
}
#[test]
fn rescore_reuses_result_against_the_current_expectation() {
let trace = trace_with(serde_json::json!({"columns":["n"],"rows":[[42]]}));
let pass = rescore(&trace, &case_expecting(serde_json::json!([[42]]))).unwrap();
assert!(pass.pass, "cached 42 matches expected 42");
let fail = rescore(&trace, &case_expecting(serde_json::json!([[99]]))).unwrap();
assert!(!fail.pass, "cached 42 no longer matches expected 99");
let _ = Tolerance::default();
let _ = MatchMode::Scalar;
}
#[test]
fn canonical_key_is_a_six_element_array_for_the_default_backend() {
let key = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 2,
backend: Backend::default(),
max_turns: None,
tier_binding: None,
};
let parsed: Vec<String> = serde_json::from_str(&key.canonical()).unwrap();
assert_eq!(
parsed,
vec!["q1", "how many orders?", "AAAA", "opus", "CCCC", "2"]
);
}
#[test]
fn canonical_key_is_a_seven_element_array_for_a_non_default_backend() {
let key = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 2,
backend: Backend::ClaudeAgentSdk,
max_turns: None,
tier_binding: None,
};
let parsed: Vec<String> = serde_json::from_str(&key.canonical()).unwrap();
assert_eq!(
parsed,
vec![
"q1",
"how many orders?",
"AAAA",
"opus",
"CCCC",
"2",
"claude-agent-sdk"
]
);
}
#[test]
fn max_turns_none_key_is_byte_identical_to_the_pre_max_turns_key() {
let default_backend = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 2,
backend: Backend::default(),
max_turns: None,
tier_binding: None,
};
assert_eq!(
default_backend.canonical(),
r#"["q1","how many orders?","AAAA","opus","CCCC","2"]"#
);
let sdk_backend = CaseKey {
backend: Backend::ClaudeAgentSdk,
..key_copy(&default_backend)
};
assert_eq!(
sdk_backend.canonical(),
r#"["q1","how many orders?","AAAA","opus","CCCC","2","claude-agent-sdk"]"#
);
}
#[test]
fn max_turns_some_key_differs_from_the_none_key() {
let none_key = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 0,
backend: Backend::ClaudeAgentSdk,
max_turns: None,
tier_binding: None,
};
let some_key = CaseKey {
max_turns: Some(1),
..key_copy(&none_key)
};
assert_ne!(none_key.hash().unwrap(), some_key.hash().unwrap());
assert_ne!(none_key.canonical(), some_key.canonical());
assert_eq!(
some_key.canonical(),
r#"["q1","how many orders?","AAAA","opus","CCCC","0","claude-agent-sdk","1"]"#
);
}
#[test]
fn tier_binding_none_key_is_byte_identical_to_the_pre_tier_binding_key() {
let default_backend = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "opus",
context_sha: "CCCC",
sample: 2,
backend: Backend::default(),
max_turns: None,
tier_binding: None,
};
assert_eq!(
default_backend.canonical(),
r#"["q1","how many orders?","AAAA","opus","CCCC","2"]"#
);
let sdk_backend = CaseKey {
backend: Backend::ClaudeAgentSdk,
..key_copy(&default_backend)
};
assert_eq!(
sdk_backend.canonical(),
r#"["q1","how many orders?","AAAA","opus","CCCC","2","claude-agent-sdk"]"#
);
let sdk_backend_capped = CaseKey {
max_turns: Some(1),
..key_copy(&sdk_backend)
};
assert_eq!(
sdk_backend_capped.canonical(),
r#"["q1","how many orders?","AAAA","opus","CCCC","2","claude-agent-sdk","1"]"#
);
}
#[test]
fn tier_binding_some_key_differs_and_cannot_collide_with_any_single_model_key() {
let flat_key = CaseKey {
case_id: "q1",
question: "how many orders?",
agent_sha: "AAAA",
model: "sonnet",
context_sha: "CCCC",
sample: 0,
backend: Backend::ClaudeAgentSdk,
max_turns: None,
tier_binding: None,
};
let tiered_key = CaseKey {
tier_binding: Some("strong=sonnet,cheap=haiku,orchestrator=sonnet"),
..key_copy(&flat_key)
};
assert_ne!(flat_key.hash().unwrap(), tiered_key.hash().unwrap());
assert_ne!(flat_key.canonical(), tiered_key.canonical());
assert_eq!(
tiered_key.canonical(),
r#"["q1","how many orders?","AAAA","sonnet","CCCC","0","claude-agent-sdk","strong=sonnet,cheap=haiku,orchestrator=sonnet"]"#
);
}
}