Skip to main content

lean_ctx/proxy/
cache_aligner.rs

1//! Cache-aligner volatile-field detection (#940, Headroom "cache aligner" stage
2//! 1) — **telemetry-first**.
3//!
4//! A stable system prompt is the largest prefix a provider can cache, but a
5//! single turn-to-turn-varying token inside it (today's date, a fresh UUID, a
6//! git SHA) shifts the bytes and busts the cache on every request. Headroom's
7//! cache aligner *relocates* those volatile fields to the tail so the prefix
8//! stays byte-stable. Relocating provider-visible system content is risky, so
9//! this phase ships only the **measurement** half: a deterministic detector that
10//! counts the volatile fields in an unanchored system prompt, surfaced on
11//! `/status` so a user can see how much cache their prompt is leaking before any
12//! opt-in relocate is enabled.
13//!
14//! ## Why measure first
15//! The honest, low-risk order is: detect → quantify (telemetry) → only then offer
16//! an opt-in tail-relocate behind its own flag, once the data shows it pays. The
17//! relocate, when added, will reuse the stable-first ordering of
18//! [`crate::core::neural::cache_alignment::CacheAlignedOutput`] (today only
19//! exercised by the doctor self-test) as its building block.
20//!
21//! ## Determinism (#498)
22//! The scan is a pure function of the text: every pattern's matches are collected,
23//! sorted, and overlapping spans merged, so the field count and covered-byte total
24//! are stable across runs and never depend on hash-map order. It mutates nothing —
25//! the request body is byte-identical whether the scan runs or not.
26
27use std::sync::LazyLock;
28
29use regex::Regex;
30use serde_json::Value;
31
32/// Volatile substrings that change turn-to-turn and so bust an otherwise-stable
33/// system-prompt prefix. Deliberately precise (ISO dates/datetimes, UUIDs, full
34/// git SHAs) rather than broad, so a stable identifier is never miscounted as
35/// volatile. Datetimes are matched alongside bare dates; the span merge below
36/// collapses the overlap so a full timestamp counts once.
37static VOLATILE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
38    [
39        // ISO-8601 datetime: date + time, optional seconds/fraction/zone.
40        r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
41        // ISO-8601 date.
42        r"\d{4}-\d{2}-\d{2}",
43        // RFC-4122 UUID.
44        r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
45        // git SHA-1 (40 lowercase hex), a common volatile "current commit" field.
46        r"\b[0-9a-f]{40}\b",
47    ]
48    .iter()
49    .filter_map(|p| Regex::new(p).ok())
50    .collect()
51});
52
53/// Result of scanning a system prompt for volatile, cache-busting fields.
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub(crate) struct VolatileScan {
56    /// Number of distinct (overlap-merged) volatile spans found.
57    pub fields: usize,
58    /// Total bytes covered by those spans — how much of the prefix is volatile.
59    pub volatile_bytes: usize,
60}
61
62/// Deterministically scan `text` for volatile fields, merging overlapping matches
63/// (e.g. a datetime and the bare date inside it) so each is counted once.
64pub(crate) fn scan_volatile(text: &str) -> VolatileScan {
65    let mut spans: Vec<(usize, usize)> = Vec::new();
66    for re in VOLATILE_PATTERNS.iter() {
67        spans.extend(re.find_iter(text).map(|m| (m.start(), m.end())));
68    }
69    if spans.is_empty() {
70        return VolatileScan::default();
71    }
72    spans.sort_unstable();
73    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(spans.len());
74    for (start, end) in spans {
75        match merged.last_mut() {
76            Some(last) if start <= last.1 => last.1 = last.1.max(end),
77            _ => merged.push((start, end)),
78        }
79    }
80    VolatileScan {
81        fields: merged.len(),
82        volatile_bytes: merged.iter().map(|(s, e)| e - s).sum(),
83    }
84}
85
86/// The plain text of an Anthropic `system` field — a bare string, or every text
87/// block of a block array joined with newlines. `None` for any other shape.
88pub(crate) fn system_text(system: &Value) -> Option<String> {
89    match system {
90        Value::String(s) => Some(s.clone()),
91        Value::Array(blocks) => {
92            let joined = blocks
93                .iter()
94                .filter_map(|b| b.get("text").and_then(Value::as_str))
95                .collect::<Vec<_>>()
96                .join("\n");
97            (!joined.is_empty()).then_some(joined)
98        }
99        _ => None,
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn counts_each_volatile_kind_once() {
109        let text = "Today is 2026-06-22. Session 550e8400-e29b-41d4-a716-446655440000 \
110                    at commit da39a3ee5e6b4b0d3255bfef95601890afd80709.";
111        let scan = scan_volatile(text);
112        assert_eq!(scan.fields, 3, "one date, one UUID, one git SHA");
113        assert!(scan.volatile_bytes > 0);
114    }
115
116    #[test]
117    fn datetime_and_inner_date_merge_to_one_span() {
118        // The datetime pattern and the bare-date pattern both match the date part;
119        // the merge must collapse them so a full timestamp counts exactly once.
120        let scan = scan_volatile("Generated at 2026-06-22T15:04:05Z by the agent.");
121        assert_eq!(
122            scan.fields, 1,
123            "overlapping datetime/date spans merge to one"
124        );
125    }
126
127    #[test]
128    fn stable_prompt_has_no_volatile_fields() {
129        let scan = scan_volatile("You are a careful senior engineer. Prefer small diffs.");
130        assert_eq!(scan, VolatileScan::default());
131    }
132
133    #[test]
134    fn scan_is_deterministic() {
135        let text = "v1 2026-06-22 id 550e8400-e29b-41d4-a716-446655440000 and 2025-01-01";
136        assert_eq!(scan_volatile(text), scan_volatile(text));
137    }
138
139    #[test]
140    fn system_text_reads_string_and_block_array() {
141        assert_eq!(
142            system_text(&Value::String("hi".into())).as_deref(),
143            Some("hi")
144        );
145        let arr = serde_json::json!([
146            {"type": "text", "text": "alpha"},
147            {"type": "text", "text": "beta"}
148        ]);
149        assert_eq!(system_text(&arr).as_deref(), Some("alpha\nbeta"));
150        assert_eq!(system_text(&serde_json::json!(42)), None);
151    }
152}