Skip to main content

tokenmiser_cache/
l2.rs

1//! L2 semantic cache: prompts embedded with `bge-small-en-v1.5` and matched
2//! per-tenant by cosine similarity against a threshold.
3//!
4//! The index is a flat in-memory scan. At the expected <10k entries per tenant
5//! a full 384-dim pass costs ~3ms; HNSW (`instant-distance`) only pays off
6//! past ~50k.
7
8use 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    /// Canonicalized number literals, precomputed so the lexical guard costs
21    /// nothing at lookup.
22    numbers: Vec<String>,
23    shape: u64,
24    response: ChatResponse,
25    inserted_at: Instant,
26}
27
28/// Hash the request fields that change the shape of a valid answer. Semantic
29/// matching is deliberately fuzzy about wording and sampling params, but a
30/// prose answer must never be served to a JSON-mode or tool-calling caller.
31/// Deterministic within a process, which is all an in-memory cache needs.
32fn 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
51/// Cap on distinct tenants held in memory. The tenant id comes straight from
52/// the caller-supplied `x-tokenmiser-tenant` header, so an unbounded map would
53/// let any client allocate a fresh store per unique header value.
54const MAX_TENANTS: usize = 256;
55
56/// Semantic L2 cache. Constructing this downloads the bge model on first run
57/// (cached by fastembed under `~/.cache/fastembed`).
58pub struct L2Cache {
59    embedder: Mutex<TextEmbedding>,
60    tenants: Mutex<HashMap<String, TenantStore>>,
61    threshold: f32,
62    ttl: Duration,
63    per_tenant_capacity: usize,
64    /// Skip candidates whose prompt carries a different multiset of number
65    /// literals. Kills the instruction-template false-positive class
66    /// ("Multiply 3 by 11" matching a cached "Add 4 and 9" above threshold)
67    /// while leaving same-numbers and number-free paraphrases untouched.
68    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    /// Concatenate every user message into the embedded text. System prompts
103    /// are excluded: they repeat across requests and dilute the signal.
104    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    /// Record a miss. Every `lookup` outcome funnels through this or the hit
135    /// path, so `hits + misses == lookups` always holds.
136    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            // Both guards skip candidates rather than rejecting the final
175            // best, so a correct-but-slightly-farther entry can still win over
176            // a closer-but-wrong one. An L2 hit seeds L1, so a bad match here
177            // would become sticky under the exact key.
178            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
255/// Evict the least-recently-written tenant once `MAX_TENANTS` is reached.
256/// Existing tenants are never evicted by their own writes.
257fn 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
270/// Every number literal in `text` as a canonicalized, sorted multiset:
271/// `"3.50 vs 3.5"` → `["3.5", "3.5"]`.
272///
273/// Embedding models are sloppy about digits — prompts differing only in their
274/// numbers embed nearly identically — so similarity alone cannot separate
275/// "Add 4 and 9" from "Multiply 3 by 11".
276fn 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        // A leading '-' is a sign only when not itself preceded by an
286        // alphanumeric, '.' or '-', so "-5" and "(-5)" keep their sign while
287        // "5-3", "2026-07-30", "555-1234" and "1e-5" treat it as a separator.
288        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        // "12,345,678" is one literal iff the leading group has 1-3 digits
300        // and every comma is followed by exactly three; "25,30" stays two.
301        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        // A '.' is a decimal point only when followed by a digit, so
318        // sentence-final "Add 3." parses as "3".
319        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        // Canonicalize through f64 so "007" == "7"; fall back to the raw
331        // digits for values f64 cannot round-trip.
332        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        // Increasing recency, so tenant-0 is the coldest.
390        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        // Subtraction, dates, phone numbers and exponents: '-' is a separator.
486        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        // Comma lists are not thousands groups.
501        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        // Splitting "1,000" into ["1","0"] would collide with this prompt.
505        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        // Sampling and transport params stay out: L2 is fuzzy there.
531        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        // Guard off: the false positive must actually reproduce at 0.87,
592        // otherwise the guarded half of this test is vacuous.
593        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        // Guard on (the default): the same lookup must miss.
610        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        // This paraphrase scores *higher* than the Multiply-vs-Add false
704        // positive does, so no threshold can separate the two cases — which
705        // is why the numeric guard exists.
706        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}