Skip to main content

lean_ctx/proxy/
cache_aligner.rs

1//! Cache-aligner (#940 detect, #974 relocate) — Headroom "cache aligner" port.
2//!
3//! A stable system prompt is the largest prefix a provider can cache, but a
4//! single turn-to-turn-varying token inside it (today's date, a fresh UUID, a
5//! git SHA) shifts the bytes and busts the cache on every request. Two opt-in
6//! stages address this, both Anthropic-only:
7//!
8//! 1. **Detect** (`cache_aligner`, #940): a deterministic scan counts the
9//!    volatile fields in an *unanchored* system prompt and surfaces the leak on
10//!    `/status` — pure measurement, the body is never mutated.
11//! 2. **Relocate** (`cache_align_relocate`, #974): rewrites `system` into a
12//!    stable block (volatile values replaced by constant placeholders) carrying
13//!    the cache breakpoint, plus an *uncached* tail block that re-states the
14//!    relocated values. The cacheable prefix then stays byte-stable turn-to-turn
15//!    and finally caches; only the small, reprocessed tail changes. Follows the
16//!    same stable-first ordering as
17//!    `crate::core::neural::cache_alignment::CacheAlignedOutput`.
18//!
19//! ## Determinism (#498) & cache-safety (#448)
20//! Both stages are pure functions of the text: matches come from the
21//! `merged_spans` helper (collected, sorted, overlaps merged), and the
22//! relocate's placeholders + tail
23//! header are byte-constants, so identical input yields byte-identical output and
24//! the rewritten prefix is stable across turns. The relocate is idempotent (a
25//! second pass sees only placeholders) and only ever fires when the client
26//! anchored nothing itself, so it never rewrites a client-cached prefix.
27
28use std::sync::LazyLock;
29
30use regex::Regex;
31use serde_json::{Map, Value};
32
33use crate::core::tokens::count_tokens;
34
35/// Volatile substrings that change turn-to-turn and so bust an otherwise-stable
36/// system-prompt prefix. Deliberately precise (ISO dates/datetimes, UUIDs, full
37/// git SHAs) rather than broad, so a stable identifier is never miscounted as
38/// volatile. Datetimes are matched alongside bare dates; the span merge below
39/// collapses the overlap so a full timestamp counts once.
40static VOLATILE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
41    [
42        // ISO-8601 datetime: date + time, optional seconds/fraction/zone.
43        r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
44        // ISO-8601 date.
45        r"\d{4}-\d{2}-\d{2}",
46        // RFC-4122 UUID.
47        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}",
48        // git SHA-1 (40 lowercase hex), a common volatile "current commit" field.
49        r"\b[0-9a-f]{40}\b",
50    ]
51    .iter()
52    .filter_map(|p| Regex::new(p).ok())
53    .collect()
54});
55
56/// Result of scanning a system prompt for volatile, cache-busting fields.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub(crate) struct VolatileScan {
59    /// Number of distinct (overlap-merged) volatile spans found.
60    pub fields: usize,
61    /// Total bytes covered by those spans — how much of the prefix is volatile.
62    pub volatile_bytes: usize,
63}
64
65/// Deterministically collect the volatile spans in `text`, merging overlapping
66/// matches (e.g. a datetime and the bare date inside it) so each counts once.
67/// Shared by the detector ([`scan_volatile`]) and the relocate
68/// ([`relocate_volatile`]) so both see exactly the same fields.
69fn merged_spans(text: &str) -> Vec<(usize, usize)> {
70    let mut spans: Vec<(usize, usize)> = Vec::new();
71    for re in VOLATILE_PATTERNS.iter() {
72        spans.extend(re.find_iter(text).map(|m| (m.start(), m.end())));
73    }
74    if spans.is_empty() {
75        return spans;
76    }
77    spans.sort_unstable();
78    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(spans.len());
79    for (start, end) in spans {
80        match merged.last_mut() {
81            Some(last) if start <= last.1 => last.1 = last.1.max(end),
82            _ => merged.push((start, end)),
83        }
84    }
85    merged
86}
87
88/// Deterministically scan `text` for volatile fields (measurement-only, #940).
89pub(crate) fn scan_volatile(text: &str) -> VolatileScan {
90    let merged = merged_spans(text);
91    VolatileScan {
92        fields: merged.len(),
93        volatile_bytes: merged.iter().map(|(s, e)| e - s).sum(),
94    }
95}
96
97/// The plain text of an Anthropic `system` field — a bare string, or every text
98/// block of a block array joined with newlines. `None` for any other shape.
99pub(crate) fn system_text(system: &Value) -> Option<String> {
100    match system {
101        Value::String(s) => Some(s.clone()),
102        Value::Array(blocks) => {
103            let joined = blocks
104                .iter()
105                .filter_map(|b| b.get("text").and_then(Value::as_str))
106                .collect::<Vec<_>>()
107                .join("\n");
108            (!joined.is_empty()).then_some(joined)
109        }
110        _ => None,
111    }
112}
113
114/// Anthropic ignores a cache breakpoint whose prefix is under its minimum
115/// cacheable size; relocating below it just churns bytes for no cache win, so the
116/// relocate is gated on the same floor as `cache_breakpoint` (#939).
117const MIN_STABLE_TOKENS: usize = 1024;
118
119/// Constant header introducing the relocated tail block. Byte-constant so it
120/// never perturbs the prefix (#498).
121const TAIL_HEADER: &str = "Volatile context (relocated to keep the prompt-cache prefix stable):";
122
123/// The constant placeholder that replaces the `n`-th relocated value in the
124/// stable block. Numbered by appearance so the model can map it to the tail and
125/// so the rewrite is deterministic; carries no volatile pattern, which is what
126/// makes [`relocate_volatile`] idempotent.
127fn placeholder(n: usize) -> String {
128    format!("[ctx#{n}]")
129}
130
131/// A system prompt split for cache alignment.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct RelocateResult {
134    /// System text with every volatile value replaced by a constant placeholder
135    /// — byte-stable turn-to-turn, so it is the part that caches.
136    pub stable: String,
137    /// The relocated volatile values, re-stated in order under a constant header.
138    /// Belongs in an *uncached* trailing block.
139    pub tail: String,
140    /// Number of volatile fields relocated.
141    pub fields: usize,
142}
143
144/// Split `text` into a byte-stable `stable` part (volatile values → placeholders)
145/// and a `tail` that re-states those values. `None` when there is nothing
146/// volatile to move, so callers stay a strict no-op.
147pub(crate) fn relocate_volatile(text: &str) -> Option<RelocateResult> {
148    let spans = merged_spans(text);
149    if spans.is_empty() {
150        return None;
151    }
152    let mut stable = String::with_capacity(text.len());
153    let mut values: Vec<&str> = Vec::with_capacity(spans.len());
154    let mut cursor = 0usize;
155    for (start, end) in &spans {
156        stable.push_str(&text[cursor..*start]);
157        stable.push_str(&placeholder(values.len() + 1));
158        values.push(&text[*start..*end]);
159        cursor = *end;
160    }
161    stable.push_str(&text[cursor..]);
162
163    let mut tail = String::from(TAIL_HEADER);
164    for (i, value) in values.iter().enumerate() {
165        tail.push('\n');
166        tail.push_str(&placeholder(i + 1));
167        tail.push_str(" = ");
168        tail.push_str(value);
169    }
170    Some(RelocateResult {
171        stable,
172        tail,
173        fields: values.len(),
174    })
175}
176
177/// A plain `{"type":"text","text":…}` system block.
178fn text_block(text: String) -> Map<String, Value> {
179    let mut block = Map::new();
180    block.insert("type".into(), Value::String("text".into()));
181    block.insert("text".into(), Value::String(text));
182    block
183}
184
185/// The stable block plus the ephemeral cache breakpoint that anchors the prefix.
186fn stable_block(text: String) -> Value {
187    let mut block = text_block(text);
188    block.insert(
189        "cache_control".into(),
190        serde_json::json!({ "type": "ephemeral" }),
191    );
192    Value::Object(block)
193}
194
195/// Rewrite the Anthropic `system` field in place so volatile values live in an
196/// uncached tail block and the stable prefix carries the cache breakpoint.
197/// Returns the number of fields relocated (`0` = left untouched).
198///
199/// Handles a plain string or an array of pure text blocks that carry no
200/// `cache_control` of their own (the caller already guards anchored prefixes).
201/// Any other shape, or a stable part below [`MIN_STABLE_TOKENS`], is a no-op.
202pub(crate) fn apply_anthropic_relocate(doc: &mut Value) -> usize {
203    let Some(system) = doc.get_mut("system") else {
204        return 0;
205    };
206    let text = match system {
207        Value::String(s) => s.clone(),
208        Value::Array(blocks) => {
209            let all_plain_text = !blocks.is_empty()
210                && blocks.iter().all(|b| {
211                    b.get("type").and_then(Value::as_str) == Some("text")
212                        && b.get("text").is_some_and(Value::is_string)
213                        && b.get("cache_control").is_none()
214                });
215            if !all_plain_text {
216                return 0;
217            }
218            blocks
219                .iter()
220                .filter_map(|b| b.get("text").and_then(Value::as_str))
221                .collect::<Vec<_>>()
222                .join("\n")
223        }
224        _ => return 0,
225    };
226    let Some(result) = relocate_volatile(&text) else {
227        return 0;
228    };
229    if count_tokens(&result.stable) < MIN_STABLE_TOKENS {
230        return 0;
231    }
232    *system = Value::Array(vec![
233        stable_block(result.stable),
234        Value::Object(text_block(result.tail)),
235    ]);
236    result.fields
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn counts_each_volatile_kind_once() {
245        let text = "Today is 2026-06-22. Session 550e8400-e29b-41d4-a716-446655440000 \
246                    at commit da39a3ee5e6b4b0d3255bfef95601890afd80709.";
247        let scan = scan_volatile(text);
248        assert_eq!(scan.fields, 3, "one date, one UUID, one git SHA");
249        assert!(scan.volatile_bytes > 0);
250    }
251
252    #[test]
253    fn datetime_and_inner_date_merge_to_one_span() {
254        // The datetime pattern and the bare-date pattern both match the date part;
255        // the merge must collapse them so a full timestamp counts exactly once.
256        let scan = scan_volatile("Generated at 2026-06-22T15:04:05Z by the agent.");
257        assert_eq!(
258            scan.fields, 1,
259            "overlapping datetime/date spans merge to one"
260        );
261    }
262
263    #[test]
264    fn stable_prompt_has_no_volatile_fields() {
265        let scan = scan_volatile("You are a careful senior engineer. Prefer small diffs.");
266        assert_eq!(scan, VolatileScan::default());
267    }
268
269    #[test]
270    fn scan_is_deterministic() {
271        let text = "v1 2026-06-22 id 550e8400-e29b-41d4-a716-446655440000 and 2025-01-01";
272        assert_eq!(scan_volatile(text), scan_volatile(text));
273    }
274
275    #[test]
276    fn system_text_reads_string_and_block_array() {
277        assert_eq!(
278            system_text(&Value::String("hi".into())).as_deref(),
279            Some("hi")
280        );
281        let arr = serde_json::json!([
282            {"type": "text", "text": "alpha"},
283            {"type": "text", "text": "beta"}
284        ]);
285        assert_eq!(system_text(&arr).as_deref(), Some("alpha\nbeta"));
286        assert_eq!(system_text(&serde_json::json!(42)), None);
287    }
288
289    // A system prompt comfortably over MIN_STABLE_TOKENS, with one volatile date.
290    fn big_system_with_date() -> String {
291        format!(
292            "You are a meticulous senior engineer. Today is 2026-06-27. {}",
293            "Prefer small, well-tested diffs. ".repeat(400)
294        )
295    }
296
297    #[test]
298    fn relocate_moves_volatiles_to_tail_and_leaves_placeholders() {
299        let result =
300            relocate_volatile("Date 2026-06-27, id 550e8400-e29b-41d4-a716-446655440000.").unwrap();
301        assert_eq!(result.fields, 2);
302        assert!(!result.stable.contains("2026-06-27"), "value left prefix");
303        assert!(result.stable.contains("[ctx#1]") && result.stable.contains("[ctx#2]"));
304        assert!(result.tail.contains("[ctx#1] = 2026-06-27"));
305        assert!(
306            result
307                .tail
308                .contains("[ctx#2] = 550e8400-e29b-41d4-a716-446655440000")
309        );
310    }
311
312    #[test]
313    fn relocate_is_noop_without_volatile_fields() {
314        assert!(relocate_volatile("You are a careful engineer.").is_none());
315    }
316
317    #[test]
318    fn relocate_is_idempotent() {
319        let once = relocate_volatile("Built at 2026-06-27 ok").unwrap();
320        assert!(
321            relocate_volatile(&once.stable).is_none(),
322            "placeholders carry no volatile pattern, so a second pass is a no-op"
323        );
324    }
325
326    #[test]
327    fn relocate_is_deterministic() {
328        let text = "v 2026-06-27 id 550e8400-e29b-41d4-a716-446655440000 sha \
329                    da39a3ee5e6b4b0d3255bfef95601890afd80709";
330        assert_eq!(relocate_volatile(text), relocate_volatile(text));
331    }
332
333    #[test]
334    fn apply_rewrites_string_system_into_stable_plus_tail() {
335        let mut doc = serde_json::json!({ "system": big_system_with_date(), "messages": [] });
336        assert_eq!(apply_anthropic_relocate(&mut doc), 1);
337        let system = &doc["system"];
338        assert!(system.is_array(), "string system becomes a block array");
339        assert_eq!(system[0]["cache_control"]["type"], "ephemeral");
340        assert!(
341            !system[0]["text"].as_str().unwrap().contains("2026-06-27"),
342            "the date left the cacheable prefix"
343        );
344        assert!(
345            system[1].get("cache_control").is_none(),
346            "the tail block stays uncached"
347        );
348        assert!(
349            system[1]["text"].as_str().unwrap().contains("2026-06-27"),
350            "the date was relocated to the tail"
351        );
352    }
353
354    #[test]
355    fn apply_skips_small_system_and_clean_system() {
356        let mut small = serde_json::json!({ "system": "Today is 2026-06-27", "messages": [] });
357        assert_eq!(
358            apply_anthropic_relocate(&mut small),
359            0,
360            "below the cacheable floor → no churn"
361        );
362        let mut clean =
363            serde_json::json!({ "system": "You are precise. ".repeat(400), "messages": [] });
364        assert_eq!(
365            apply_anthropic_relocate(&mut clean),
366            0,
367            "no volatile fields → strict no-op"
368        );
369    }
370
371    #[test]
372    fn apply_skips_array_with_existing_breakpoint() {
373        let mut doc = serde_json::json!({
374            "system": [{
375                "type": "text",
376                "text": big_system_with_date(),
377                "cache_control": { "type": "ephemeral" }
378            }],
379            "messages": []
380        });
381        assert_eq!(
382            apply_anthropic_relocate(&mut doc),
383            0,
384            "a client-anchored array must be left untouched"
385        );
386    }
387
388    #[test]
389    fn apply_is_deterministic() {
390        let mk = || serde_json::json!({ "system": big_system_with_date(), "messages": [] });
391        let (mut a, mut b) = (mk(), mk());
392        assert_eq!(apply_anthropic_relocate(&mut a), 1);
393        assert_eq!(apply_anthropic_relocate(&mut b), 1);
394        assert_eq!(a, b, "identical input → byte-identical output (#498)");
395    }
396}