1use std::collections::hash_map::DefaultHasher;
18use std::fs;
19use std::hash::{Hash, Hasher};
20use std::path::{Path, PathBuf};
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24
25use crate::session_cost::{prompt_cache_read_create_regression, signed_token_delta};
26
27pub const PROMPT_CACHE_HISTORY_SCHEMA_VERSION: u64 = 1;
28
29const CACHED_RATIO_DROP_THRESHOLD_PCT: f64 = 5.0;
33
34const MAX_HISTORY_SAMPLES: usize = 200;
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PromptCacheEffectivenessSample {
42 pub schema_version: u64,
43 pub recorded_at_unix_secs: u64,
44 pub session_source: String,
45 pub session_path: String,
46 #[serde(skip_serializing_if = "Option::is_none", default)]
47 pub session_modified_unix_secs: Option<u64>,
48 pub prompt_tokens: u64,
49 pub cached_input_tokens: u64,
50 pub cache_creation_input_tokens: u64,
51 #[serde(skip_serializing_if = "Option::is_none", default)]
52 pub cached_input_ratio: Option<f64>,
53 pub net_cached_input_tokens: i64,
54 pub read_create_regressions: usize,
55}
56
57impl PromptCacheEffectivenessSample {
58 #[allow(clippy::too_many_arguments)]
63 pub fn from_tokens(
64 recorded_at_unix_secs: u64,
65 session_source: impl Into<String>,
66 session_path: impl Into<String>,
67 session_modified_unix_secs: Option<u64>,
68 prompt_tokens: u64,
69 cached_input_tokens: u64,
70 cache_creation_input_tokens: u64,
71 ) -> Self {
72 let cached_input_ratio = (prompt_tokens > 0).then_some(
73 ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
74 );
75 let net_cached_input_tokens =
76 signed_token_delta(cached_input_tokens, cache_creation_input_tokens);
77 let read_create_regressions = usize::from(
78 prompt_cache_read_create_regression(cached_input_tokens, cache_creation_input_tokens)
79 .is_some(),
80 );
81 Self {
82 schema_version: PROMPT_CACHE_HISTORY_SCHEMA_VERSION,
83 recorded_at_unix_secs,
84 session_source: session_source.into(),
85 session_path: session_path.into(),
86 session_modified_unix_secs,
87 prompt_tokens,
88 cached_input_tokens,
89 cache_creation_input_tokens,
90 cached_input_ratio,
91 net_cached_input_tokens,
92 read_create_regressions,
93 }
94 }
95
96 fn identity(&self) -> (Option<u64>, u64, u64, u64) {
100 (
101 self.session_modified_unix_secs,
102 self.prompt_tokens,
103 self.cached_input_tokens,
104 self.cache_creation_input_tokens,
105 )
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct PromptCacheCrossRunRegression {
113 pub kind: String,
114 pub detail: String,
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize)]
120pub struct PromptCacheCrossRunComparison {
121 pub session_source: String,
122 pub session_path: String,
123 pub samples_recorded: usize,
124 pub appended: bool,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 pub previous: Option<PromptCacheEffectivenessSample>,
127 pub current: PromptCacheEffectivenessSample,
128 #[serde(skip_serializing_if = "Vec::is_empty", default)]
129 pub regressions: Vec<PromptCacheCrossRunRegression>,
130}
131
132impl PromptCacheCrossRunComparison {
133 pub fn has_regression(&self) -> bool {
134 !self.regressions.is_empty()
135 }
136}
137
138pub fn prompt_cache_history_dir(root: &Path) -> PathBuf {
140 root.join(".tsift/prompt-cache-history")
141}
142
143pub fn prompt_cache_history_key(session_source: &str, session_path: &str) -> String {
147 let mut hasher = DefaultHasher::new();
148 session_source.hash(&mut hasher);
149 "\u{0}".hash(&mut hasher);
150 session_path.hash(&mut hasher);
151 let digest = hasher.finish();
152 format!("{}-{digest:016x}", sanitize_key_component(session_source))
153}
154
155fn sanitize_key_component(value: &str) -> String {
156 let cleaned: String = value
157 .chars()
158 .map(|c| {
159 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
160 c.to_ascii_lowercase()
161 } else {
162 '-'
163 }
164 })
165 .collect();
166 let trimmed = cleaned.trim_matches('-');
167 if trimmed.is_empty() {
168 "session".to_string()
169 } else {
170 trimmed.chars().take(40).collect()
171 }
172}
173
174pub fn prompt_cache_history_path(root: &Path, session_source: &str, session_path: &str) -> PathBuf {
176 prompt_cache_history_dir(root).join(format!(
177 "{}.jsonl",
178 prompt_cache_history_key(session_source, session_path)
179 ))
180}
181
182pub fn load_prompt_cache_history(
185 root: &Path,
186 session_source: &str,
187 session_path: &str,
188) -> Vec<PromptCacheEffectivenessSample> {
189 let path = prompt_cache_history_path(root, session_source, session_path);
190 let Ok(text) = fs::read_to_string(&path) else {
191 return Vec::new();
192 };
193 text.lines()
194 .filter(|line| !line.trim().is_empty())
195 .filter_map(|line| serde_json::from_str::<PromptCacheEffectivenessSample>(line).ok())
196 .collect()
197}
198
199pub fn record_prompt_cache_sample(
208 root: &Path,
209 sample: PromptCacheEffectivenessSample,
210) -> Result<PromptCacheCrossRunComparison> {
211 let existing = load_prompt_cache_history(root, &sample.session_source, &sample.session_path);
212
213 let previous = existing
215 .iter()
216 .rev()
217 .find(|prior| prior.identity() != sample.identity())
218 .cloned();
219 let regressions = detect_cross_run_regressions(previous.as_ref(), &sample);
220
221 let already_recorded = existing
222 .last()
223 .is_some_and(|last| last.identity() == sample.identity());
224
225 let mut samples = existing;
226 let appended = !already_recorded;
227 if appended {
228 samples.push(sample.clone());
229 if samples.len() > MAX_HISTORY_SAMPLES {
230 let overflow = samples.len() - MAX_HISTORY_SAMPLES;
231 samples.drain(0..overflow);
232 }
233 write_prompt_cache_history(root, &sample.session_source, &sample.session_path, &samples)?;
234 }
235
236 Ok(PromptCacheCrossRunComparison {
237 session_source: sample.session_source.clone(),
238 session_path: sample.session_path.clone(),
239 samples_recorded: samples.len(),
240 appended,
241 previous,
242 current: sample,
243 regressions,
244 })
245}
246
247fn write_prompt_cache_history(
248 root: &Path,
249 session_source: &str,
250 session_path: &str,
251 samples: &[PromptCacheEffectivenessSample],
252) -> Result<()> {
253 let path = prompt_cache_history_path(root, session_source, session_path);
254 if let Some(parent) = path.parent() {
255 fs::create_dir_all(parent).with_context(|| {
256 format!(
257 "creating prompt-cache history directory: {}",
258 parent.display()
259 )
260 })?;
261 }
262 let mut body = String::new();
263 for sample in samples {
264 let line = serde_json::to_string(sample)
265 .context("serializing prompt-cache effectiveness sample")?;
266 body.push_str(&line);
267 body.push('\n');
268 }
269 fs::write(&path, body)
270 .with_context(|| format!("writing prompt-cache history: {}", path.display()))?;
271 Ok(())
272}
273
274pub fn detect_cross_run_regressions(
279 previous: Option<&PromptCacheEffectivenessSample>,
280 current: &PromptCacheEffectivenessSample,
281) -> Vec<PromptCacheCrossRunRegression> {
282 let Some(previous) = previous else {
283 return Vec::new();
284 };
285 let mut regressions = Vec::new();
286
287 if let (Some(prev_ratio), Some(curr_ratio)) =
288 (previous.cached_input_ratio, current.cached_input_ratio)
289 {
290 let drop = prev_ratio - curr_ratio;
291 if drop >= CACHED_RATIO_DROP_THRESHOLD_PCT {
292 regressions.push(PromptCacheCrossRunRegression {
293 kind: "cached_input_ratio_drop".to_string(),
294 detail: format!(
295 "cached_input_ratio fell {drop:.2} points ({prev_ratio:.2}% -> {curr_ratio:.2}%) vs previous run"
296 ),
297 });
298 }
299 }
300
301 if current.net_cached_input_tokens < previous.net_cached_input_tokens {
302 let crossed_negative =
303 previous.net_cached_input_tokens >= 0 && current.net_cached_input_tokens < 0;
304 let detail = if crossed_negative {
305 format!(
306 "net_cached_input_tokens went negative ({} -> {}) — the session now spends more on cache creation than it saves on reads",
307 previous.net_cached_input_tokens, current.net_cached_input_tokens
308 )
309 } else {
310 format!(
311 "net_cached_input_tokens fell {} -> {} vs previous run",
312 previous.net_cached_input_tokens, current.net_cached_input_tokens
313 )
314 };
315 if crossed_negative
319 || (previous.net_cached_input_tokens > 0
320 && current.net_cached_input_tokens * 2 < previous.net_cached_input_tokens)
321 {
322 regressions.push(PromptCacheCrossRunRegression {
323 kind: "net_cached_input_tokens_drop".to_string(),
324 detail,
325 });
326 }
327 }
328
329 if current.read_create_regressions > previous.read_create_regressions {
330 regressions.push(PromptCacheCrossRunRegression {
331 kind: "read_create_regressions_increase".to_string(),
332 detail: format!(
333 "read_create_regressions rose {} -> {} vs previous run",
334 previous.read_create_regressions, current.read_create_regressions
335 ),
336 });
337 }
338
339 regressions
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345 use tempfile::tempdir;
346
347 fn sample(
348 recorded_at: u64,
349 modified: u64,
350 prompt: u64,
351 cached: u64,
352 creation: u64,
353 ) -> PromptCacheEffectivenessSample {
354 PromptCacheEffectivenessSample::from_tokens(
355 recorded_at,
356 "claude",
357 "/proj/session.jsonl",
358 Some(modified),
359 prompt,
360 cached,
361 creation,
362 )
363 }
364
365 #[test]
366 fn from_tokens_derives_ratio_net_and_regression() {
367 let s = sample(100, 1, 1_000, 800, 100);
368 assert_eq!(s.cached_input_ratio, Some(80.0));
369 assert_eq!(s.net_cached_input_tokens, 700);
370 assert_eq!(s.read_create_regressions, 0);
372
373 let degraded = sample(100, 1, 1_000, 100, 800);
374 assert_eq!(degraded.net_cached_input_tokens, -700);
375 assert_eq!(degraded.read_create_regressions, 1);
377 }
378
379 #[test]
380 fn first_recording_has_no_previous_and_no_regression() {
381 let dir = tempdir().unwrap();
382 let comparison =
383 record_prompt_cache_sample(dir.path(), sample(100, 1, 1_000, 800, 100)).unwrap();
384 assert!(comparison.appended);
385 assert_eq!(comparison.samples_recorded, 1);
386 assert!(comparison.previous.is_none());
387 assert!(!comparison.has_regression());
388 }
389
390 #[test]
391 fn cross_run_ratio_drop_is_detected_and_persisted() {
392 let dir = tempdir().unwrap();
393 record_prompt_cache_sample(dir.path(), sample(100, 1, 1_000, 900, 100)).unwrap();
394 let comparison =
395 record_prompt_cache_sample(dir.path(), sample(200, 2, 1_000, 700, 100)).unwrap();
396
397 assert_eq!(comparison.samples_recorded, 2);
398 assert!(comparison.previous.is_some());
399 let kinds: Vec<_> = comparison
400 .regressions
401 .iter()
402 .map(|r| r.kind.as_str())
403 .collect();
404 assert!(
405 kinds.contains(&"cached_input_ratio_drop"),
406 "expected ratio-drop regression, got {kinds:?}"
407 );
408
409 let loaded = load_prompt_cache_history(dir.path(), "claude", "/proj/session.jsonl");
411 assert_eq!(loaded.len(), 2);
412 assert_eq!(loaded[0].cached_input_ratio, Some(90.0));
413 assert_eq!(loaded[1].cached_input_ratio, Some(70.0));
414 }
415
416 #[test]
417 fn net_cached_going_negative_is_flagged() {
418 let dir = tempdir().unwrap();
419 record_prompt_cache_sample(dir.path(), sample(100, 1, 1_000, 800, 100)).unwrap();
420 let comparison =
421 record_prompt_cache_sample(dir.path(), sample(200, 2, 1_000, 100, 800)).unwrap();
422 let kinds: Vec<_> = comparison
423 .regressions
424 .iter()
425 .map(|r| r.kind.as_str())
426 .collect();
427 assert!(kinds.contains(&"net_cached_input_tokens_drop"));
428 assert!(kinds.contains(&"read_create_regressions_increase"));
429 }
430
431 #[test]
432 fn unchanged_session_is_not_re_recorded() {
433 let dir = tempdir().unwrap();
434 record_prompt_cache_sample(dir.path(), sample(100, 1, 1_000, 800, 100)).unwrap();
435 let comparison =
437 record_prompt_cache_sample(dir.path(), sample(500, 1, 1_000, 800, 100)).unwrap();
438 assert!(!comparison.appended);
439 assert_eq!(comparison.samples_recorded, 1);
440 let loaded = load_prompt_cache_history(dir.path(), "claude", "/proj/session.jsonl");
441 assert_eq!(loaded.len(), 1);
442 assert_eq!(loaded[0].recorded_at_unix_secs, 100);
443 }
444
445 #[test]
446 fn small_ratio_jitter_is_not_a_regression() {
447 let dir = tempdir().unwrap();
448 record_prompt_cache_sample(dir.path(), sample(100, 1, 1_000, 900, 100)).unwrap();
449 let comparison =
451 record_prompt_cache_sample(dir.path(), sample(200, 2, 1_000, 880, 100)).unwrap();
452 assert!(!comparison.has_regression(), "{:?}", comparison.regressions);
453 }
454
455 #[test]
456 fn history_key_is_filesystem_safe_and_stable() {
457 let a = prompt_cache_history_key("claude", "/home/x/proj/Plan File.md");
458 let b = prompt_cache_history_key("claude", "/home/x/proj/Plan File.md");
459 assert_eq!(a, b);
460 assert!(a.starts_with("claude-"));
461 assert!(
462 a.chars()
463 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
464 );
465 let other = prompt_cache_history_key("codex", "/home/x/proj/Plan File.md");
466 assert_ne!(a, other);
467 }
468}