1#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum VolatileKind {
48 Uuid,
49 Timestamp,
50 Jwt,
51 Hash,
52}
53
54#[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
91pub 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 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#[cfg(feature = "http-server")]
353static VOLATILE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
354 [
355 r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
357 r"\d{4}-\d{2}-\d{2}",
359 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 r"\b[0-9a-f]{40}\b",
363 ]
364 .iter()
365 .filter_map(|p| Regex::new(p).ok())
366 .collect()
367});
368
369#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
371#[cfg(feature = "http-server")]
372pub(crate) struct VolatileScan {
373 pub fields: usize,
375 pub volatile_bytes: usize,
377}
378
379#[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#[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#[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#[cfg(feature = "http-server")]
435const MIN_STABLE_TOKENS: usize = 1024;
436
437#[cfg(feature = "http-server")]
440const TAIL_HEADER: &str = "Volatile context (relocated to keep the prompt-cache prefix stable):";
441
442#[cfg(feature = "http-server")]
447fn placeholder(n: usize) -> String {
448 format!("[ctx#{n}]")
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453#[cfg(feature = "http-server")]
454pub(crate) struct RelocateResult {
455 pub stable: String,
458 pub tail: String,
461 pub fields: usize,
463}
464
465#[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#[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#[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#[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 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 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}