1use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use anyhow::{anyhow, Result};
13use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
14use parking_lot::Mutex;
15use tokenmiser_providers::{ChatRequest, ChatResponse};
16use tracing::{info, warn};
17
18struct Entry {
19 embedding: Vec<f32>,
20 numbers: Vec<String>,
23 shape: u64,
24 response: ChatResponse,
25 inserted_at: Instant,
26}
27
28fn shape_fingerprint(req: &ChatRequest) -> u64 {
33 use std::hash::{Hash, Hasher};
34 let mut h = std::collections::hash_map::DefaultHasher::new();
35 for field in ["tools", "tool_choice", "response_format"] {
36 field.hash(&mut h);
37 match req.extra.get(field) {
38 Some(v) => serde_json::to_string(v).unwrap_or_default().hash(&mut h),
39 None => "".hash(&mut h),
40 }
41 }
42 h.finish()
43}
44
45#[derive(Default)]
46struct TenantStore {
47 entries: Vec<Entry>,
48 last_used: Option<Instant>,
49}
50
51const MAX_TENANTS: usize = 256;
55
56pub struct L2Cache {
59 embedder: Mutex<TextEmbedding>,
60 tenants: Mutex<HashMap<String, TenantStore>>,
61 threshold: f32,
62 ttl: Duration,
63 per_tenant_capacity: usize,
64 numeric_guard: bool,
69 hits: std::sync::atomic::AtomicU64,
70 misses: std::sync::atomic::AtomicU64,
71}
72
73impl L2Cache {
74 pub fn new(
75 threshold: f32,
76 ttl: Duration,
77 per_tenant_capacity: usize,
78 numeric_guard: bool,
79 ) -> Result<Arc<Self>> {
80 let opts = InitOptions::new(EmbeddingModel::BGESmallENV15);
81 let embedder = TextEmbedding::try_new(opts)
82 .map_err(|e| anyhow!("bge-small-en-v1.5 init failed: {e}"))?;
83 info!(
84 model = "bge-small-en-v1.5",
85 threshold,
86 ttl_secs = ttl.as_secs(),
87 numeric_guard,
88 "L2 semantic cache initialized"
89 );
90 Ok(Arc::new(Self {
91 embedder: Mutex::new(embedder),
92 tenants: Mutex::new(HashMap::new()),
93 threshold,
94 ttl,
95 per_tenant_capacity,
96 numeric_guard,
97 hits: Default::default(),
98 misses: Default::default(),
99 }))
100 }
101
102 fn extract_text(req: &ChatRequest) -> String {
105 req.messages
106 .iter()
107 .filter(|m| m.role == "user")
108 .filter_map(|m| match &m.content {
109 serde_json::Value::String(s) => Some(s.clone()),
110 serde_json::Value::Array(arr) => {
111 let mut buf = String::new();
112 for item in arr {
113 if let Some(t) = item.get("text").and_then(|v| v.as_str()) {
114 buf.push_str(t);
115 buf.push(' ');
116 }
117 }
118 Some(buf)
119 }
120 _ => None,
121 })
122 .collect::<Vec<_>>()
123 .join("\n")
124 }
125
126 fn embed(&self, text: &str) -> Result<Vec<f32>> {
127 let mut e = self.embedder.lock();
128 let mut out = e
129 .embed(vec![text], None)
130 .map_err(|e| anyhow!("embed failed: {e}"))?;
131 out.pop().ok_or_else(|| anyhow!("no embedding returned"))
132 }
133
134 fn miss(&self) -> Option<ChatResponse> {
137 self.misses
138 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
139 None
140 }
141
142 pub fn lookup(&self, req: &ChatRequest, tenant: &str) -> Option<ChatResponse> {
143 let text = Self::extract_text(req);
144 if text.trim().is_empty() {
145 return self.miss();
146 }
147 let q = match self.embed(&text) {
148 Ok(v) => v,
149 Err(e) => {
150 warn!(error = %e, "L2 embed failed; falling through");
151 return self.miss();
152 }
153 };
154
155 let mut tenants = self.tenants.lock();
156 let store = match tenants.get_mut(tenant) {
157 Some(s) => s,
158 None => {
159 drop(tenants);
160 return self.miss();
161 }
162 };
163
164 let q_numbers = self.numeric_guard.then(|| extract_numbers(&text));
165 let q_shape = shape_fingerprint(req);
166
167 let mut best: Option<(f32, usize)> = None;
168 let now = Instant::now();
169 let ttl = self.ttl;
170 store
171 .entries
172 .retain(|e| now.duration_since(e.inserted_at) < ttl);
173 for (i, entry) in store.entries.iter().enumerate() {
174 if entry.shape != q_shape {
179 continue;
180 }
181 if let Some(qn) = &q_numbers {
182 if entry.numbers != *qn {
183 continue;
184 }
185 }
186 let sim = cosine(&q, &entry.embedding);
187 if best.map(|(b, _)| sim > b).unwrap_or(true) {
188 best = Some((sim, i));
189 }
190 }
191
192 match best {
193 Some((sim, idx)) if sim >= self.threshold => {
194 self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
195 Some(store.entries[idx].response.clone())
196 }
197 _ => {
198 self.misses
199 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
200 None
201 }
202 }
203 }
204
205 pub fn insert(&self, req: &ChatRequest, tenant: &str, resp: &ChatResponse) {
206 let text = Self::extract_text(req);
207 if text.trim().is_empty() {
208 return;
209 }
210 let emb = match self.embed(&text) {
211 Ok(v) => v,
212 Err(e) => {
213 warn!(error = %e, "L2 embed failed on insert; skip");
214 return;
215 }
216 };
217 let mut tenants = self.tenants.lock();
218 evict_coldest_tenant_if_full(&mut tenants, tenant);
219 let store = tenants.entry(tenant.to_string()).or_default();
220 store.last_used = Some(Instant::now());
221 if store.entries.len() >= self.per_tenant_capacity {
222 store.entries.remove(0);
223 }
224 store.entries.push(Entry {
225 embedding: emb,
226 numbers: extract_numbers(&text),
227 shape: shape_fingerprint(req),
228 response: resp.clone(),
229 inserted_at: Instant::now(),
230 });
231 }
232
233 pub fn stats(&self) -> SemanticStats {
234 let total: u64 = self
235 .tenants
236 .lock()
237 .values()
238 .map(|t| t.entries.len() as u64)
239 .sum();
240 SemanticStats {
241 hits: self.hits.load(std::sync::atomic::Ordering::Relaxed),
242 misses: self.misses.load(std::sync::atomic::Ordering::Relaxed),
243 size: total,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
249pub struct SemanticStats {
250 pub hits: u64,
251 pub misses: u64,
252 pub size: u64,
253}
254
255fn evict_coldest_tenant_if_full(tenants: &mut HashMap<String, TenantStore>, incoming: &str) {
258 if tenants.len() < MAX_TENANTS || tenants.contains_key(incoming) {
259 return;
260 }
261 if let Some(coldest) = tenants
262 .iter()
263 .min_by_key(|(_, s)| s.last_used)
264 .map(|(k, _)| k.clone())
265 {
266 tenants.remove(&coldest);
267 }
268}
269
270fn extract_numbers(text: &str) -> Vec<String> {
277 let bytes = text.as_bytes();
278 let mut out = Vec::new();
279 let mut i = 0;
280 while i < bytes.len() {
281 if !bytes[i].is_ascii_digit() {
282 i += 1;
283 continue;
284 }
285 let negative = i > 0
289 && bytes[i - 1] == b'-'
290 && (i == 1 || {
291 let p = bytes[i - 2];
292 !(p.is_ascii_alphanumeric() || p == b'.' || p == b'-')
293 });
294 let start = i;
295 while i < bytes.len() && bytes[i].is_ascii_digit() {
296 i += 1;
297 }
298 let mut num = text[start..i].to_string();
299 if num.len() <= 3 {
302 let mut j = i;
303 while j + 3 < bytes.len()
304 && bytes[j] == b','
305 && bytes[j + 1].is_ascii_digit()
306 && bytes[j + 2].is_ascii_digit()
307 && bytes[j + 3].is_ascii_digit()
308 && !(j + 4 < bytes.len() && bytes[j + 4].is_ascii_digit())
309 {
310 j += 4;
311 }
312 if j > i {
313 num = text[start..j].replace(',', "");
314 i = j;
315 }
316 }
317 if i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1].is_ascii_digit() {
320 let frac_start = i;
321 i += 1;
322 while i < bytes.len() && bytes[i].is_ascii_digit() {
323 i += 1;
324 }
325 num.push_str(&text[frac_start..i]);
326 }
327 if negative {
328 num.insert(0, '-');
329 }
330 let canon = num
333 .parse::<f64>()
334 .ok()
335 .filter(|v| v.is_finite())
336 .map(|v| format!("{v}"))
337 .unwrap_or(num);
338 out.push(canon);
339 }
340 out.sort_unstable();
341 out
342}
343
344fn cosine(a: &[f32], b: &[f32]) -> f32 {
345 let mut dot = 0.0;
346 let mut na = 0.0;
347 let mut nb = 0.0;
348 for i in 0..a.len().min(b.len()) {
349 dot += a[i] * b[i];
350 na += a[i] * a[i];
351 nb += b[i] * b[i];
352 }
353 if na == 0.0 || nb == 0.0 {
354 return 0.0;
355 }
356 dot / (na.sqrt() * nb.sqrt())
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use tokenmiser_providers::{ChatChoice, ChatMessage, ChatRequest, Usage};
363
364 #[test]
365 fn cosine_identical_is_one() {
366 let v = vec![0.1, 0.2, 0.3, 0.4];
367 let s = cosine(&v, &v);
368 assert!((s - 1.0).abs() < 1e-6);
369 }
370
371 #[test]
372 fn cosine_orthogonal_is_zero() {
373 let a = vec![1.0, 0.0];
374 let b = vec![0.0, 1.0];
375 assert!(cosine(&a, &b).abs() < 1e-6);
376 }
377
378 fn store_at(t: Instant) -> TenantStore {
379 TenantStore {
380 entries: Vec::new(),
381 last_used: Some(t),
382 }
383 }
384
385 #[test]
386 fn tenant_map_is_bounded_and_evicts_the_coldest() {
387 let mut tenants: HashMap<String, TenantStore> = HashMap::new();
388 let base = Instant::now();
389 for i in 0..MAX_TENANTS {
391 tenants.insert(
392 format!("tenant-{i}"),
393 store_at(base + Duration::from_millis(i as u64)),
394 );
395 }
396 assert_eq!(tenants.len(), MAX_TENANTS);
397
398 evict_coldest_tenant_if_full(&mut tenants, "attacker-new");
399 assert_eq!(
400 tenants.len(),
401 MAX_TENANTS - 1,
402 "a new tenant at capacity must evict exactly one"
403 );
404 assert!(
405 !tenants.contains_key("tenant-0"),
406 "the least-recently-written tenant must be the one evicted"
407 );
408 assert!(tenants.contains_key(&format!("tenant-{}", MAX_TENANTS - 1)));
409
410 for i in 0..1000 {
411 let name = format!("attacker-{i}");
412 evict_coldest_tenant_if_full(&mut tenants, &name);
413 tenants.insert(name, store_at(Instant::now()));
414 assert!(
415 tenants.len() <= MAX_TENANTS,
416 "tenant map exceeded its cap at iteration {i}"
417 );
418 }
419 }
420
421 #[test]
422 fn existing_tenant_write_does_not_evict() {
423 let mut tenants: HashMap<String, TenantStore> = HashMap::new();
424 let base = Instant::now();
425 for i in 0..MAX_TENANTS {
426 tenants.insert(
427 format!("tenant-{i}"),
428 store_at(base + Duration::from_millis(i as u64)),
429 );
430 }
431 evict_coldest_tenant_if_full(&mut tenants, "tenant-0");
432 assert_eq!(tenants.len(), MAX_TENANTS);
433 assert!(tenants.contains_key("tenant-0"));
434 }
435
436 #[test]
437 fn extract_numbers_basic() {
438 assert_eq!(
439 extract_numbers("Multiply 3 by 11. Reply with the number only."),
440 vec!["11".to_string(), "3".to_string()]
441 );
442 assert_eq!(
443 extract_numbers("Add 25 and 30. Reply with the number only."),
444 vec!["25".to_string(), "30".to_string()]
445 );
446 assert!(extract_numbers("What is the capital of France? One word only.").is_empty());
447 }
448
449 #[test]
450 fn extract_numbers_canonicalizes() {
451 assert_eq!(extract_numbers("007 and 7"), vec!["7", "7"]);
452 assert_eq!(extract_numbers("3.50 vs 3.5"), vec!["3.5", "3.5"]);
453 assert_eq!(extract_numbers("Add 3."), vec!["3"]);
454 assert_eq!(
455 extract_numbers("30 plus 12"),
456 extract_numbers("What is 12 plus 30?")
457 );
458 }
459
460 #[test]
461 fn guard_rejects_multiply_vs_add_template_case() {
462 let query = extract_numbers("Multiply 3 by 11. Reply with the number only.");
463 for i in 0..40u32 {
464 let cached = extract_numbers(&format!(
465 "Add {} and {}. Reply with the number only.",
466 i * 3 + 1,
467 i * 7 + 2
468 ));
469 assert_ne!(
470 query, cached,
471 "guard must reject every Add-template entry for the Multiply query"
472 );
473 }
474 }
475
476 #[test]
477 fn guard_distinguishes_negative_numbers() {
478 assert_ne!(
479 extract_numbers("What is -5 plus 3? Number only."),
480 extract_numbers("What is 5 plus 3? Number only.")
481 );
482 assert_eq!(extract_numbers("What is -5 plus 3?"), vec!["-5", "3"]);
483 assert_eq!(extract_numbers("(-5) times 2"), vec!["-5", "2"]);
484 assert_eq!(extract_numbers("-40 degrees"), vec!["-40"]);
485 assert_eq!(extract_numbers("compute 10-4"), vec!["10", "4"]);
487 assert_eq!(extract_numbers("on 2026-07-30"), vec!["2026", "30", "7"]);
488 assert_eq!(extract_numbers("call 555-1234"), vec!["1234", "555"]);
489 assert_eq!(extract_numbers("about 1e-5 units"), vec!["1", "5"]);
490 }
491
492 #[test]
493 fn guard_groups_thousands_separators() {
494 assert_eq!(
495 extract_numbers("Add 1,000 and 5."),
496 extract_numbers("Add 1000 and 5.")
497 );
498 assert_eq!(extract_numbers("population 12,345,678"), vec!["12345678"]);
499 assert_eq!(extract_numbers("costs 1,234.56 dollars"), vec!["1234.56"]);
500 assert_eq!(extract_numbers("pick 25,30"), vec!["25", "30"]);
502 assert_eq!(extract_numbers("pick 1,2 or 3"), vec!["1", "2", "3"]);
503 assert_eq!(extract_numbers("ids 1,2345"), vec!["1", "2345"]);
504 assert_ne!(
506 extract_numbers("Add 1,000 and 5. Reply with the number only."),
507 extract_numbers("Add 1 and 0 and 5. Reply with the number only.")
508 );
509 }
510
511 #[test]
512 fn shape_fingerprint_tracks_answer_shaping_params() {
513 let plain = req("hello");
514 let mut json_mode = req("hello");
515 json_mode.extra.insert(
516 "response_format".into(),
517 serde_json::json!({"type": "json_object"}),
518 );
519 let mut with_tools = req("hello");
520 with_tools.extra.insert(
521 "tools".into(),
522 serde_json::json!([{"type": "function", "function": {"name": "f"}}]),
523 );
524 assert_ne!(shape_fingerprint(&plain), shape_fingerprint(&json_mode));
525 assert_ne!(shape_fingerprint(&plain), shape_fingerprint(&with_tools));
526 assert_ne!(
527 shape_fingerprint(&json_mode),
528 shape_fingerprint(&with_tools)
529 );
530 let mut sampled = req("hello");
532 sampled.temperature = Some(0.9);
533 sampled.max_tokens = Some(5);
534 sampled.stream = Some(true);
535 sampled.extra.insert("seed".into(), serde_json::json!(1234));
536 assert_eq!(shape_fingerprint(&plain), shape_fingerprint(&sampled));
537 }
538
539 #[test]
540 fn guard_accepts_numeric_paraphrase() {
541 assert_eq!(
542 extract_numbers("What is 12 plus 30? Number only."),
543 extract_numbers("Compute 12 + 30 and reply with just the number.")
544 );
545 assert_eq!(
546 extract_numbers("What is the capital of France? One word only."),
547 extract_numbers("Tell me the capital city of France, answer in a single word.")
548 );
549 }
550
551 fn req(text: &str) -> ChatRequest {
552 ChatRequest {
553 model: "ollama:qwen2.5:7b".into(),
554 messages: vec![ChatMessage {
555 role: "user".into(),
556 content: serde_json::Value::String(text.into()),
557 extra: Default::default(),
558 }],
559 temperature: Some(0.0),
560 max_tokens: Some(20),
561 top_p: None,
562 stream: None,
563 extra: Default::default(),
564 }
565 }
566
567 fn resp(text: &str) -> ChatResponse {
568 ChatResponse {
569 id: "x".into(),
570 object: "chat.completion".into(),
571 created: 0,
572 model: "x".into(),
573 choices: vec![ChatChoice {
574 index: 0,
575 message: ChatMessage {
576 role: "assistant".into(),
577 content: serde_json::Value::String(text.into()),
578 extra: Default::default(),
579 },
580 finish_reason: Some("stop".into()),
581 logprobs: None,
582 }],
583 usage: Usage::default(),
584 extra: Default::default(),
585 }
586 }
587
588 #[test]
589 #[ignore = "needs the bge-small model on disk (~seconds); run with --ignored"]
590 fn live_template_false_positive_is_killed_by_guard() {
591 let unguarded = L2Cache::new(0.87, Duration::from_secs(3600), 1024, false).unwrap();
594 for i in 0..40u32 {
595 let p = format!(
596 "Add {} and {}. Reply with the number only.",
597 i * 3 + 1,
598 i * 7 + 2
599 );
600 unguarded.insert(&req(&p), "t", &resp("wrong"));
601 }
602 let q = req("Multiply 3 by 11. Reply with the number only.");
603 assert!(
604 unguarded.lookup(&q, "t").is_some(),
605 "expected the unguarded cache to reproduce the false positive \
606 (if this stops reproducing, the guard test below is vacuous)"
607 );
608
609 let guarded = L2Cache::new(0.87, Duration::from_secs(3600), 1024, true).unwrap();
611 for i in 0..40u32 {
612 let p = format!(
613 "Add {} and {}. Reply with the number only.",
614 i * 3 + 1,
615 i * 7 + 2
616 );
617 guarded.insert(&req(&p), "t", &resp("wrong"));
618 }
619 assert!(
620 guarded.lookup(&q, "t").is_none(),
621 "numeric guard must reject the Multiply-vs-Add template hit"
622 );
623 }
624
625 #[test]
626 #[ignore = "diagnostic: prints cosine sims for candidate pairs"]
627 fn live_sim_probe() {
628 let opts = InitOptions::new(EmbeddingModel::BGESmallENV15);
629 let mut e = TextEmbedding::try_new(opts).unwrap();
630 let pairs = [
631 (
632 "What is -5 plus 3? Number only.",
633 "What is 5 plus 3? Number only.",
634 ),
635 (
636 "Add 1,000 and 5. Reply with the number only.",
637 "Add 1000 and 5. Reply with the number only.",
638 ),
639 (
640 "What is 12 plus 30? Number only.",
641 "Compute 12 + 30 and reply with just the number.",
642 ),
643 (
644 "What is 12 plus 30? Number only.",
645 "What is 12 plus 30? Reply with the number only.",
646 ),
647 (
648 "What is 12 plus 30? Number only.",
649 "12 plus 30 equals what? Number only.",
650 ),
651 (
652 "Add 12 and 30. Reply with the number only.",
653 "Compute 12 + 30 and reply with just the number.",
654 ),
655 (
656 "What is the capital of France? One word only.",
657 "Tell me the capital city of France, answer in a single word.",
658 ),
659 (
660 "Multiply 3 by 11. Reply with the number only.",
661 "Add 25 and 30. Reply with the number only.",
662 ),
663 ];
664 for (a, b) in pairs {
665 let v = e.embed(vec![a, b], None).unwrap();
666 println!("{:.4} {a:?} vs {b:?}", cosine(&v[0], &v[1]));
667 }
668 }
669
670 #[test]
671 #[ignore = "needs the bge-small model on disk (~seconds); run with --ignored"]
672 fn live_shape_mismatch_never_hits() {
673 let cache = L2Cache::new(0.87, Duration::from_secs(3600), 1024, true).unwrap();
674 cache.insert(&req("List three colors."), "t", &resp("red, green, blue"));
675 assert!(cache.lookup(&req("List three colors."), "t").is_some());
676 let mut json_mode = req("List three colors.");
677 json_mode.extra.insert(
678 "response_format".into(),
679 serde_json::json!({"type": "json_object"}),
680 );
681 assert!(
682 cache.lookup(&json_mode, "t").is_none(),
683 "L2 must not serve a prose entry to a JSON-mode request"
684 );
685 }
686
687 #[test]
688 #[ignore = "needs the bge-small model on disk (~seconds); run with --ignored"]
689 fn live_paraphrases_still_hit_with_guard() {
690 let cache = L2Cache::new(0.87, Duration::from_secs(3600), 1024, true).unwrap();
691
692 cache.insert(
693 &req("What is the capital of France? One word only."),
694 "t",
695 &resp("Paris"),
696 );
697 let hit = cache.lookup(
698 &req("Tell me the capital city of France, answer in a single word."),
699 "t",
700 );
701 assert!(hit.is_some(), "number-free paraphrase must still hit");
702
703 cache.insert(
707 &req("Add 12 and 30. Reply with the number only."),
708 "t2",
709 &resp("42"),
710 );
711 let hit = cache.lookup(
712 &req("Compute 12 + 30 and reply with just the number."),
713 "t2",
714 );
715 assert!(hit.is_some(), "same-numbers paraphrase must still hit");
716 }
717}