Skip to main content

tsift_agent_doc/
prompt_cache_history.rs

1//! Cross-session prompt-cache effectiveness history (#avbq).
2//!
3//! `session-cost` / `session-review` evaluate prompt-cache effectiveness for a
4//! single transcript at a time, so the gate is per-fixture / per-transcript
5//! only. A regression that only shows up when you compare *across* runs — a
6//! steadily falling cached-input ratio, net cached tokens sliding negative, or
7//! read/create regressions creeping in over successive sessions — is invisible
8//! to that point-in-time view.
9//!
10//! This module persists one effectiveness sample per matched session under
11//! `<root>/.tsift/prompt-cache-history/<key>.jsonl` (newline-delimited JSON,
12//! oldest first) and compares each new sample against the previous recorded
13//! sample for the same session key so cross-run regressions become detectable.
14//! Recording is idempotent: re-running over the same unchanged session does not
15//! append a duplicate sample.
16
17use 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
29/// Minimum cached-input-ratio percentage-point drop between consecutive runs
30/// that counts as a cross-run regression. Small run-to-run jitter is expected;
31/// only a meaningful slide is flagged.
32const CACHED_RATIO_DROP_THRESHOLD_PCT: f64 = 5.0;
33
34/// Cap the on-disk history so a long-lived project does not grow the JSONL file
35/// without bound. Only the trailing window is retained — cross-run comparison
36/// only needs the previous sample, the window is for trend inspection.
37const MAX_HISTORY_SAMPLES: usize = 200;
38
39/// One persisted prompt-cache effectiveness reading for a single session.
40#[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    /// Build a sample from a session's raw token totals, deriving the
59    /// `cached_input_ratio`, `net_cached_input_tokens`, and
60    /// `read_create_regressions` exactly the way the per-fixture effectiveness
61    /// report does so persisted history stays consistent with the live gate.
62    #[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    /// The content identity of a session reading, used to skip re-recording an
97    /// unchanged session. Excludes `recorded_at_unix_secs` so re-running the
98    /// same session at a later time does not append a duplicate row.
99    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/// A single detected cross-run regression between the previous and current
110/// recorded sample for one session key.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct PromptCacheCrossRunRegression {
113    pub kind: String,
114    pub detail: String,
115}
116
117/// The result of recording a new sample: where it landed, the previous reading
118/// it was compared against, and any cross-run regressions detected.
119#[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
138/// Directory holding the per-session prompt-cache history JSONL files.
139pub fn prompt_cache_history_dir(root: &Path) -> PathBuf {
140    root.join(".tsift/prompt-cache-history")
141}
142
143/// Stable, filesystem-safe key for a `(source, path)` session identity. The
144/// sanitized source is kept human-readable as a prefix and a hash of the full
145/// `(source, path)` pair disambiguates collisions after sanitization.
146pub 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
174/// Path to the JSONL history file for a `(source, path)` session identity.
175pub 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
182/// Load the persisted history for a session key, oldest first. Malformed lines
183/// are skipped so a partially-written file does not break analysis.
184pub 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
199/// Record a new effectiveness sample for a session and compare it against the
200/// previous recorded sample for the same key.
201///
202/// Idempotent: if the most recent persisted sample has the same content
203/// identity (session mtime + token totals) the sample is not re-appended, but
204/// the cross-run comparison against the prior reading is still returned so the
205/// regression stays visible on repeat runs. Returns the comparison; the on-disk
206/// write is best-effort and a write failure surfaces as `Err`.
207pub 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    // Compare against the most recent *distinct* prior reading.
214    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
274/// Compare a new sample against the previous recorded sample and flag the
275/// regression classes #avbq tracks: a falling cached-input ratio, net cached
276/// tokens sliding (especially crossing into negative territory), and new
277/// read/create regressions.
278pub 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        // Only report a plain decline when it crosses zero or is a large slide;
316        // otherwise net-token jitter would be noisy. Crossing negative is always
317        // reported; a same-sign decline is reported when it more than halves.
318        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        // cached/creation = 8.0 >= 2.0 -> no regression
371        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        // cached/creation = 0.125 < 2.0 -> regression
376        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        // Persisted across "runs": a fresh load sees both readings oldest-first.
410        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        // Same identity (mtime + tokens), later recorded_at: must not append.
436        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        // 90% -> 88% is a 2-point dip, below the 5-point threshold.
450        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}