Skip to main content

lean_ctx_proxy/
cache_aligner.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Cache-aligner (#940 detect, #974 relocate) — Headroom "cache aligner" port.
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. Two opt-in
7//! stages address this, both Anthropic-only:
8//!
9//! 1. **Detect** (`cache_aligner`, #940): a deterministic scan counts the
10//!    volatile fields in an *unanchored* system prompt and surfaces the leak on
11//!    `/status` — pure measurement, the body is never mutated.
12//! 2. **Relocate** (`cache_align_relocate`, #974): rewrites `system` into a
13//!    stable block (volatile values replaced by constant placeholders) carrying
14//!    the cache breakpoint, plus an *uncached* tail block that re-states the
15//!    relocated values. The cacheable prefix then stays byte-stable turn-to-turn
16//!    and finally caches; only the small, reprocessed tail changes. Follows the
17//!    same stable-first ordering as
18//!    the cache-alignment output type.
19//!
20//! ## Determinism (#498) & cache-safety (#448)
21//! Both stages are pure functions of the text: matches come from the
22//! `merged_spans` helper (collected, sorted, overlaps merged), and the
23//! relocate's placeholders + tail
24//! header are byte-constants, so identical input yields byte-identical output and
25//! the rewritten prefix is stable across turns. The relocate is idempotent (a
26//! second pass sees only placeholders) and only ever fires when the client
27//! anchored nothing itself, so it never rewrites a client-cached prefix.
28
29#[cfg(feature = "http-server")]
30use std::sync::LazyLock;
31
32#[cfg(feature = "http-server")]
33use regex::Regex;
34#[cfg(feature = "http-server")]
35use serde_json::{Map, Value};
36
37#[cfg(feature = "http-server")]
38use lean_ctx::core::tokens::count_tokens;
39
40/// A type of text that makes an otherwise-stable provider cache prefix vary.
41///
42/// Positions in [`VolatileReport::volatile_positions`] are UTF-8 byte offsets
43/// into the scanned string. Every finding is disjoint and listed in source
44/// order, so callers can safely use the offsets for diagnostics without
45/// needing to merge overlapping date/timestamp matches themselves.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum VolatileKind {
48    Uuid,
49    Timestamp,
50    Jwt,
51    Hash,
52}
53
54/// Measurement-only cache-alignment report for arbitrary text.
55///
56/// `alignment_score` starts at 100 and loses ten points per finding, floored at
57/// zero because the public wire representation is an unsigned byte. Detection
58/// never alters the supplied text.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct VolatileReport {
61    pub uuid_count: u32,
62    pub timestamp_count: u32,
63    pub jwt_count: u32,
64    pub hash_count: u32,
65    pub total_volatile: u32,
66    pub alignment_score: u8,
67    pub volatile_positions: Vec<(usize, VolatileKind)>,
68}
69
70impl Default for VolatileReport {
71    fn default() -> Self {
72        Self {
73            uuid_count: 0,
74            timestamp_count: 0,
75            jwt_count: 0,
76            hash_count: 0,
77            total_volatile: 0,
78            alignment_score: 100,
79            volatile_positions: Vec::new(),
80        }
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85struct VolatileMatch {
86    start: usize,
87    end: usize,
88    kind: VolatileKind,
89}
90
91/// Detect values that are likely to change between requests and invalidate a
92/// provider's KV-cache prefix. This is deliberately regex-free: the scan works
93/// on ASCII syntax inside UTF-8 text and has no hidden regex-engine state.
94///
95/// The result is diagnostic only; this function never rewrites, redacts, or
96/// otherwise changes `text`.
97pub fn detect_volatile_content(text: &str) -> VolatileReport {
98    let bytes = text.as_bytes();
99    let mut matches = Vec::new();
100    matches.extend(uuid_matches(bytes));
101    matches.extend(timestamp_matches(bytes));
102    matches.extend(jwt_matches(bytes));
103    matches.extend(hash_matches(bytes));
104
105    // Prefer the longest match at one offset. This makes an ISO timestamp win
106    // over its date prefix, then discards every later overlap deterministically.
107    matches.sort_unstable_by(|left, right| {
108        left.start
109            .cmp(&right.start)
110            .then_with(|| right.end.cmp(&left.end))
111            .then_with(|| volatile_kind_rank(left.kind).cmp(&volatile_kind_rank(right.kind)))
112    });
113
114    let mut report = VolatileReport::default();
115    let mut last_end = 0;
116    for found in matches {
117        if found.start < last_end {
118            continue;
119        }
120        last_end = found.end;
121        match found.kind {
122            VolatileKind::Uuid => report.uuid_count += 1,
123            VolatileKind::Timestamp => report.timestamp_count += 1,
124            VolatileKind::Jwt => report.jwt_count += 1,
125            VolatileKind::Hash => report.hash_count += 1,
126        }
127        report.volatile_positions.push((found.start, found.kind));
128    }
129    report.total_volatile = report
130        .uuid_count
131        .saturating_add(report.timestamp_count)
132        .saturating_add(report.jwt_count)
133        .saturating_add(report.hash_count);
134    report.alignment_score = 100u8
135        .saturating_sub(u8::try_from(report.total_volatile.saturating_mul(10)).unwrap_or(u8::MAX));
136    report
137}
138
139fn volatile_kind_rank(kind: VolatileKind) -> u8 {
140    match kind {
141        VolatileKind::Uuid => 0,
142        VolatileKind::Timestamp => 1,
143        VolatileKind::Jwt => 2,
144        VolatileKind::Hash => 3,
145    }
146}
147
148fn is_hex(byte: u8) -> bool {
149    byte.is_ascii_hexdigit()
150}
151
152fn is_token_char(byte: u8) -> bool {
153    byte.is_ascii_alphanumeric() || byte == b'_'
154}
155
156fn bounded_by_non_tokens(bytes: &[u8], start: usize, end: usize) -> bool {
157    (start == 0 || !is_token_char(bytes[start - 1]))
158        && (end == bytes.len() || !is_token_char(bytes[end]))
159}
160
161fn uuid_matches(bytes: &[u8]) -> Vec<VolatileMatch> {
162    let mut matches = Vec::new();
163    if bytes.len() < 36 {
164        return matches;
165    }
166    for start in 0..=bytes.len().saturating_sub(36) {
167        let end = start + 36;
168        let valid = bytes[start..end].iter().enumerate().all(|(offset, byte)| {
169            matches!(offset, 8 | 13 | 18 | 23) && *byte == b'-'
170                || !matches!(offset, 8 | 13 | 18 | 23) && is_hex(*byte)
171        });
172        if valid && bounded_by_non_tokens(bytes, start, end) {
173            matches.push(VolatileMatch {
174                start,
175                end,
176                kind: VolatileKind::Uuid,
177            });
178        }
179    }
180    matches
181}
182
183fn timestamp_matches(bytes: &[u8]) -> Vec<VolatileMatch> {
184    let mut matches = Vec::new();
185    if bytes.len() < 10 {
186        return matches;
187    }
188    for start in 0..=bytes.len().saturating_sub(10) {
189        let Some(date_end) = date_end(bytes, start) else {
190            continue;
191        };
192        let end = datetime_end(bytes, start).unwrap_or(date_end);
193        if bounded_by_non_tokens(bytes, start, end) {
194            matches.push(VolatileMatch {
195                start,
196                end,
197                kind: VolatileKind::Timestamp,
198            });
199        }
200    }
201    matches
202}
203
204fn date_end(bytes: &[u8], start: usize) -> Option<usize> {
205    let end = start.checked_add(10)?;
206    let slice = bytes.get(start..end)?;
207    if !slice[0..4].iter().all(u8::is_ascii_digit)
208        || slice[4] != b'-'
209        || !slice[5..7].iter().all(u8::is_ascii_digit)
210        || slice[7] != b'-'
211        || !slice[8..10].iter().all(u8::is_ascii_digit)
212    {
213        return None;
214    }
215    let month = two_digits(slice[5], slice[6]);
216    let day = two_digits(slice[8], slice[9]);
217    ((1..=12).contains(&month) && (1..=31).contains(&day)).then_some(end)
218}
219
220fn datetime_end(bytes: &[u8], start: usize) -> Option<usize> {
221    date_end(bytes, start)?;
222    let time_start = start.checked_add(11)?;
223    if !matches!(bytes.get(start + 10), Some(b'T' | b' ')) {
224        return None;
225    }
226    let mut end = time_start.checked_add(5)?;
227    let time = bytes.get(time_start..end)?;
228    if !time[0..2].iter().all(u8::is_ascii_digit)
229        || time[2] != b':'
230        || !time[3..5].iter().all(u8::is_ascii_digit)
231        || two_digits(time[0], time[1]) > 23
232        || two_digits(time[3], time[4]) > 59
233    {
234        return None;
235    }
236    if bytes.get(end) == Some(&b':') {
237        let seconds_end = end.checked_add(3)?;
238        let seconds = bytes.get(end + 1..seconds_end)?;
239        if !seconds.iter().all(u8::is_ascii_digit) || two_digits(seconds[0], seconds[1]) > 59 {
240            return None;
241        }
242        end = seconds_end;
243    }
244    if bytes.get(end) == Some(&b'.') {
245        let fraction_start = end + 1;
246        end = fraction_start;
247        while bytes.get(end).is_some_and(u8::is_ascii_digit) {
248            end += 1;
249        }
250        if end == fraction_start {
251            return None;
252        }
253    }
254    match bytes.get(end) {
255        Some(b'Z') => end += 1,
256        Some(b'+' | b'-') => {
257            let zone_start = end + 1;
258            let zone_end = zone_start.checked_add(5)?;
259            let zone = bytes.get(zone_start..zone_end)?;
260            if !zone[0..2].iter().all(u8::is_ascii_digit)
261                || zone[2] != b':'
262                || !zone[3..5].iter().all(u8::is_ascii_digit)
263                || two_digits(zone[0], zone[1]) > 23
264                || two_digits(zone[3], zone[4]) > 59
265            {
266                return None;
267            }
268            end = zone_end;
269        }
270        _ => {}
271    }
272    Some(end)
273}
274
275fn two_digits(tens: u8, units: u8) -> u8 {
276    (tens - b'0') * 10 + (units - b'0')
277}
278
279fn is_base64url(byte: u8) -> bool {
280    byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')
281}
282
283fn jwt_matches(bytes: &[u8]) -> Vec<VolatileMatch> {
284    let mut matches = Vec::new();
285    for start in 0..bytes.len() {
286        if start > 0 && (is_base64url(bytes[start - 1]) || bytes[start - 1] == b'.') {
287            continue;
288        }
289        let Some(end) = jwt_end(bytes, start) else {
290            continue;
291        };
292        if end == bytes.len() || (!is_base64url(bytes[end]) && bytes[end] != b'.') {
293            matches.push(VolatileMatch {
294                start,
295                end,
296                kind: VolatileKind::Jwt,
297            });
298        }
299    }
300    matches
301}
302
303fn jwt_end(bytes: &[u8], start: usize) -> Option<usize> {
304    let mut cursor = start;
305    for segment in 0..3 {
306        let segment_start = cursor;
307        while bytes.get(cursor).is_some_and(|byte| is_base64url(*byte)) {
308            cursor += 1;
309        }
310        if cursor == segment_start {
311            return None;
312        }
313        if segment < 2 {
314            if bytes.get(cursor) != Some(&b'.') {
315                return None;
316            }
317            cursor += 1;
318        }
319    }
320    Some(cursor)
321}
322
323fn hash_matches(bytes: &[u8]) -> Vec<VolatileMatch> {
324    let mut matches = Vec::new();
325    let mut start = 0;
326    while start < bytes.len() {
327        if !is_hex(bytes[start]) {
328            start += 1;
329            continue;
330        }
331        let end = bytes[start..]
332            .iter()
333            .position(|byte| !is_hex(*byte))
334            .map_or(bytes.len(), |offset| start + offset);
335        if matches!(end - start, 32 | 40 | 64) && bounded_by_non_tokens(bytes, start, end) {
336            matches.push(VolatileMatch {
337                start,
338                end,
339                kind: VolatileKind::Hash,
340            });
341        }
342        start = end;
343    }
344    matches
345}
346
347/// Volatile substrings that change turn-to-turn and so bust an otherwise-stable
348/// system-prompt prefix. Deliberately precise (ISO dates/datetimes, UUIDs, full
349/// git SHAs) rather than broad, so a stable identifier is never miscounted as
350/// volatile. Datetimes are matched alongside bare dates; the span merge below
351/// collapses the overlap so a full timestamp counts once.
352#[cfg(feature = "http-server")]
353static VOLATILE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
354    [
355        // ISO-8601 datetime: date + time, optional seconds/fraction/zone.
356        r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
357        // ISO-8601 date.
358        r"\d{4}-\d{2}-\d{2}",
359        // RFC-4122 UUID.
360        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}",
361        // git SHA-1 (40 lowercase hex), a common volatile "current commit" field.
362        r"\b[0-9a-f]{40}\b",
363    ]
364    .iter()
365    .filter_map(|p| Regex::new(p).ok())
366    .collect()
367});
368
369/// Result of scanning a system prompt for volatile, cache-busting fields.
370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
371#[cfg(feature = "http-server")]
372pub(crate) struct VolatileScan {
373    /// Number of distinct (overlap-merged) volatile spans found.
374    pub fields: usize,
375    /// Total bytes covered by those spans — how much of the prefix is volatile.
376    pub volatile_bytes: usize,
377}
378
379/// Deterministically collect the volatile spans in `text`, merging overlapping
380/// matches (e.g. a datetime and the bare date inside it) so each counts once.
381/// Shared by the detector ([`scan_volatile`]) and the relocate
382/// ([`relocate_volatile`]) so both see exactly the same fields.
383#[cfg(feature = "http-server")]
384fn merged_spans(text: &str) -> Vec<(usize, usize)> {
385    let mut spans: Vec<(usize, usize)> = Vec::new();
386    for re in VOLATILE_PATTERNS.iter() {
387        spans.extend(re.find_iter(text).map(|m| (m.start(), m.end())));
388    }
389    if spans.is_empty() {
390        return spans;
391    }
392    spans.sort_unstable();
393    let mut merged: Vec<(usize, usize)> = Vec::with_capacity(spans.len());
394    for (start, end) in spans {
395        match merged.last_mut() {
396            Some(last) if start <= last.1 => last.1 = last.1.max(end),
397            _ => merged.push((start, end)),
398        }
399    }
400    merged
401}
402
403/// Deterministically scan `text` for volatile fields (measurement-only, #940).
404#[cfg(feature = "http-server")]
405pub(crate) fn scan_volatile(text: &str) -> VolatileScan {
406    let merged = merged_spans(text);
407    VolatileScan {
408        fields: merged.len(),
409        volatile_bytes: merged.iter().map(|(s, e)| e - s).sum(),
410    }
411}
412
413/// The plain text of an Anthropic `system` field — a bare string, or every text
414/// block of a block array joined with newlines. `None` for any other shape.
415#[cfg(feature = "http-server")]
416pub(crate) fn system_text(system: &Value) -> Option<String> {
417    match system {
418        Value::String(s) => Some(s.clone()),
419        Value::Array(blocks) => {
420            let joined = blocks
421                .iter()
422                .filter_map(|b| b.get("text").and_then(Value::as_str))
423                .collect::<Vec<_>>()
424                .join("\n");
425            (!joined.is_empty()).then_some(joined)
426        }
427        _ => None,
428    }
429}
430
431/// Anthropic ignores a cache breakpoint whose prefix is under its minimum
432/// cacheable size; relocating below it just churns bytes for no cache win, so the
433/// relocate is gated on the same floor as `cache_breakpoint` (#939).
434#[cfg(feature = "http-server")]
435const MIN_STABLE_TOKENS: usize = 1024;
436
437/// Constant header introducing the relocated tail block. Byte-constant so it
438/// never perturbs the prefix (#498).
439#[cfg(feature = "http-server")]
440const TAIL_HEADER: &str = "Volatile context (relocated to keep the prompt-cache prefix stable):";
441
442/// The constant placeholder that replaces the `n`-th relocated value in the
443/// stable block. Numbered by appearance so the model can map it to the tail and
444/// so the rewrite is deterministic; carries no volatile pattern, which is what
445/// makes [`relocate_volatile`] idempotent.
446#[cfg(feature = "http-server")]
447fn placeholder(n: usize) -> String {
448    format!("[ctx#{n}]")
449}
450
451/// A system prompt split for cache alignment.
452#[derive(Debug, Clone, PartialEq, Eq)]
453#[cfg(feature = "http-server")]
454pub(crate) struct RelocateResult {
455    /// System text with every volatile value replaced by a constant placeholder
456    /// — byte-stable turn-to-turn, so it is the part that caches.
457    pub stable: String,
458    /// The relocated volatile values, re-stated in order under a constant header.
459    /// Belongs in an *uncached* trailing block.
460    pub tail: String,
461    /// Number of volatile fields relocated.
462    pub fields: usize,
463}
464
465/// Split `text` into a byte-stable `stable` part (volatile values → placeholders)
466/// and a `tail` that re-states those values. `None` when there is nothing
467/// volatile to move, so callers stay a strict no-op.
468#[cfg(feature = "http-server")]
469pub(crate) fn relocate_volatile(text: &str) -> Option<RelocateResult> {
470    let spans = merged_spans(text);
471    if spans.is_empty() {
472        return None;
473    }
474    let mut stable = String::with_capacity(text.len());
475    let mut values: Vec<&str> = Vec::with_capacity(spans.len());
476    let mut cursor = 0usize;
477    for (start, end) in &spans {
478        stable.push_str(&text[cursor..*start]);
479        stable.push_str(&placeholder(values.len() + 1));
480        values.push(&text[*start..*end]);
481        cursor = *end;
482    }
483    stable.push_str(&text[cursor..]);
484
485    let mut tail = String::from(TAIL_HEADER);
486    for (i, value) in values.iter().enumerate() {
487        tail.push('\n');
488        tail.push_str(&placeholder(i + 1));
489        tail.push_str(" = ");
490        tail.push_str(value);
491    }
492    Some(RelocateResult {
493        stable,
494        tail,
495        fields: values.len(),
496    })
497}
498
499/// A plain `{"type":"text","text":…}` system block.
500#[cfg(feature = "http-server")]
501fn text_block(text: String) -> Map<String, Value> {
502    let mut block = Map::new();
503    block.insert("type".into(), Value::String("text".into()));
504    block.insert("text".into(), Value::String(text));
505    block
506}
507
508/// The stable block plus the ephemeral cache breakpoint that anchors the prefix.
509#[cfg(feature = "http-server")]
510fn stable_block(text: String) -> Value {
511    let mut block = text_block(text);
512    block.insert(
513        "cache_control".into(),
514        serde_json::json!({ "type": "ephemeral" }),
515    );
516    Value::Object(block)
517}
518
519/// Rewrite the Anthropic `system` field in place so volatile values live in an
520/// uncached tail block and the stable prefix carries the cache breakpoint.
521/// Returns the number of fields relocated (`0` = left untouched).
522///
523/// Handles a plain string or an array of pure text blocks that carry no
524/// `cache_control` of their own (the caller already guards anchored prefixes).
525/// Any other shape, or a stable part below [`MIN_STABLE_TOKENS`], is a no-op.
526#[cfg(feature = "http-server")]
527pub(crate) fn apply_anthropic_relocate(doc: &mut Value) -> usize {
528    let Some(system) = doc.get_mut("system") else {
529        return 0;
530    };
531    let text = match system {
532        Value::String(s) => s.clone(),
533        Value::Array(blocks) => {
534            let all_plain_text = !blocks.is_empty()
535                && blocks.iter().all(|b| {
536                    b.get("type").and_then(Value::as_str) == Some("text")
537                        && b.get("text").is_some_and(Value::is_string)
538                        && b.get("cache_control").is_none()
539                });
540            if !all_plain_text {
541                return 0;
542            }
543            blocks
544                .iter()
545                .filter_map(|b| b.get("text").and_then(Value::as_str))
546                .collect::<Vec<_>>()
547                .join("\n")
548        }
549        _ => return 0,
550    };
551    let Some(result) = relocate_volatile(&text) else {
552        return 0;
553    };
554    if count_tokens(&result.stable) < MIN_STABLE_TOKENS {
555        return 0;
556    }
557    *system = Value::Array(vec![
558        stable_block(result.stable),
559        Value::Object(text_block(result.tail)),
560    ]);
561    result.fields
562}
563
564#[cfg(all(test, feature = "http-server"))]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn counts_each_volatile_kind_once() {
570        let text = "Today is 2026-06-22. Session 550e8400-e29b-41d4-a716-446655440000 \
571                    at commit da39a3ee5e6b4b0d3255bfef95601890afd80709.";
572        let scan = scan_volatile(text);
573        assert_eq!(scan.fields, 3, "one date, one UUID, one git SHA");
574        assert!(scan.volatile_bytes > 0);
575    }
576
577    #[test]
578    fn datetime_and_inner_date_merge_to_one_span() {
579        // The datetime pattern and the bare-date pattern both match the date part;
580        // the merge must collapse them so a full timestamp counts exactly once.
581        let scan = scan_volatile("Generated at 2026-06-22T15:04:05Z by the agent.");
582        assert_eq!(
583            scan.fields, 1,
584            "overlapping datetime/date spans merge to one"
585        );
586    }
587
588    #[test]
589    fn stable_prompt_has_no_volatile_fields() {
590        let scan = scan_volatile("You are a careful senior engineer. Prefer small diffs.");
591        assert_eq!(scan, VolatileScan::default());
592    }
593
594    #[test]
595    fn scan_is_deterministic() {
596        let text = "v1 2026-06-22 id 550e8400-e29b-41d4-a716-446655440000 and 2025-01-01";
597        assert_eq!(scan_volatile(text), scan_volatile(text));
598    }
599
600    #[test]
601    fn report_detects_uuid_at_its_byte_position() {
602        let text = "run 550e8400-e29b-41d4-a716-446655440000 now";
603        assert_eq!(
604            detect_volatile_content(text),
605            VolatileReport {
606                uuid_count: 1,
607                total_volatile: 1,
608                alignment_score: 90,
609                volatile_positions: vec![(4, VolatileKind::Uuid)],
610                ..VolatileReport::default()
611            }
612        );
613    }
614
615    #[test]
616    fn report_detects_iso_timestamp_once() {
617        let text = "built 2026-06-22T15:04:05Z";
618        assert_eq!(
619            detect_volatile_content(text),
620            VolatileReport {
621                timestamp_count: 1,
622                total_volatile: 1,
623                alignment_score: 90,
624                volatile_positions: vec![(6, VolatileKind::Timestamp)],
625                ..VolatileReport::default()
626            }
627        );
628    }
629
630    #[test]
631    fn report_detects_timestamp_fraction_and_zone() {
632        let text = "built 2026-06-22T15:04:05.123+02:00";
633        let report = detect_volatile_content(text);
634        assert_eq!(report.timestamp_count, 1);
635        assert_eq!(report.alignment_score, 90);
636    }
637
638    #[test]
639    fn report_detects_jwt() {
640        let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJl";
641        assert_eq!(
642            detect_volatile_content(jwt),
643            VolatileReport {
644                jwt_count: 1,
645                total_volatile: 1,
646                alignment_score: 90,
647                volatile_positions: vec![(0, VolatileKind::Jwt)],
648                ..VolatileReport::default()
649            }
650        );
651    }
652
653    #[test]
654    fn report_scores_mixed_volatile_content() {
655        let text = concat!(
656            "2026-06-22 ",
657            "550e8400-e29b-41d4-a716-446655440000 ",
658            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJl ",
659            "da39a3ee5e6b4b0d3255bfef95601890afd80709"
660        );
661        let report = detect_volatile_content(text);
662        assert_eq!(report.timestamp_count, 1);
663        assert_eq!(report.uuid_count, 1);
664        assert_eq!(report.jwt_count, 1);
665        assert_eq!(report.hash_count, 1);
666        assert_eq!(report.total_volatile, 4);
667        assert_eq!(report.alignment_score, 60);
668        assert_eq!(report.volatile_positions.len(), 4);
669    }
670
671    #[test]
672    fn system_text_reads_string_and_block_array() {
673        assert_eq!(
674            system_text(&Value::String("hi".into())).as_deref(),
675            Some("hi")
676        );
677        let arr = serde_json::json!([
678            {"type": "text", "text": "alpha"},
679            {"type": "text", "text": "beta"}
680        ]);
681        assert_eq!(system_text(&arr).as_deref(), Some("alpha\nbeta"));
682        assert_eq!(system_text(&serde_json::json!(42)), None);
683    }
684
685    // A system prompt comfortably over MIN_STABLE_TOKENS, with one volatile date.
686    fn big_system_with_date() -> String {
687        format!(
688            "You are a meticulous senior engineer. Today is 2026-06-27. {}",
689            "Prefer small, well-tested diffs. ".repeat(400)
690        )
691    }
692
693    #[test]
694    fn relocate_moves_volatiles_to_tail_and_leaves_placeholders() {
695        let result =
696            relocate_volatile("Date 2026-06-27, id 550e8400-e29b-41d4-a716-446655440000.").unwrap();
697        assert_eq!(result.fields, 2);
698        assert!(!result.stable.contains("2026-06-27"), "value left prefix");
699        assert!(result.stable.contains("[ctx#1]") && result.stable.contains("[ctx#2]"));
700        assert!(result.tail.contains("[ctx#1] = 2026-06-27"));
701        assert!(
702            result
703                .tail
704                .contains("[ctx#2] = 550e8400-e29b-41d4-a716-446655440000")
705        );
706    }
707
708    #[test]
709    fn relocate_is_noop_without_volatile_fields() {
710        assert!(relocate_volatile("You are a careful engineer.").is_none());
711    }
712
713    #[test]
714    fn relocate_is_idempotent() {
715        let once = relocate_volatile("Built at 2026-06-27 ok").unwrap();
716        assert!(
717            relocate_volatile(&once.stable).is_none(),
718            "placeholders carry no volatile pattern, so a second pass is a no-op"
719        );
720    }
721
722    #[test]
723    fn relocate_is_deterministic() {
724        let text = "v 2026-06-27 id 550e8400-e29b-41d4-a716-446655440000 sha \
725                    da39a3ee5e6b4b0d3255bfef95601890afd80709";
726        assert_eq!(relocate_volatile(text), relocate_volatile(text));
727    }
728
729    #[test]
730    fn apply_rewrites_string_system_into_stable_plus_tail() {
731        let mut doc = serde_json::json!({ "system": big_system_with_date(), "messages": [] });
732        assert_eq!(apply_anthropic_relocate(&mut doc), 1);
733        let system = &doc["system"];
734        assert!(system.is_array(), "string system becomes a block array");
735        assert_eq!(system[0]["cache_control"]["type"], "ephemeral");
736        assert!(
737            !system[0]["text"].as_str().unwrap().contains("2026-06-27"),
738            "the date left the cacheable prefix"
739        );
740        assert!(
741            system[1].get("cache_control").is_none(),
742            "the tail block stays uncached"
743        );
744        assert!(
745            system[1]["text"].as_str().unwrap().contains("2026-06-27"),
746            "the date was relocated to the tail"
747        );
748    }
749
750    #[test]
751    fn apply_skips_small_system_and_clean_system() {
752        let mut small = serde_json::json!({ "system": "Today is 2026-06-27", "messages": [] });
753        assert_eq!(
754            apply_anthropic_relocate(&mut small),
755            0,
756            "below the cacheable floor → no churn"
757        );
758        let mut clean =
759            serde_json::json!({ "system": "You are precise. ".repeat(400), "messages": [] });
760        assert_eq!(
761            apply_anthropic_relocate(&mut clean),
762            0,
763            "no volatile fields → strict no-op"
764        );
765    }
766
767    #[test]
768    fn apply_skips_array_with_existing_breakpoint() {
769        let mut doc = serde_json::json!({
770            "system": [{
771                "type": "text",
772                "text": big_system_with_date(),
773                "cache_control": { "type": "ephemeral" }
774            }],
775            "messages": []
776        });
777        assert_eq!(
778            apply_anthropic_relocate(&mut doc),
779            0,
780            "a client-anchored array must be left untouched"
781        );
782    }
783
784    #[test]
785    fn apply_is_deterministic() {
786        let mk = || serde_json::json!({ "system": big_system_with_date(), "messages": [] });
787        let (mut a, mut b) = (mk(), mk());
788        assert_eq!(apply_anthropic_relocate(&mut a), 1);
789        assert_eq!(apply_anthropic_relocate(&mut b), 1);
790        assert_eq!(a, b, "identical input → byte-identical output (#498)");
791    }
792}