Skip to main content

dynamo_tokenizers/cache/
l1.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3//
4// SPDX-FileCopyrightText: Copyright (c) 2024 Simo Lin, Chang Su, Keyang Ru (llm-tokenizer authors)
5//
6// Portions adapted from sgl-project/llm-tokenizer v1.3.2 (Apache-2.0).
7// Upstream: https://github.com/lightseekorg/smg
8// Modifications: removed `add_special_tokens` plumbing (Dynamo's Encoder has no such
9// flag), bound `insert_at_boundaries` on `Encoder` rather than `Tokenizer`, retargeted
10// imports onto `crate::traits`.
11
12//! L1 Cache: Special-token boundary prefix cache
13//!
14//! Caches tokenization results at ALL special token boundaries.
15//! Special tokens (like `<|im_start|>`, `<|im_end|>`) are atomic in BPE tokenizers
16//! (`special: true, normalized: false`), making them the ONLY safe split points that
17//! guarantee correctness: `tokenize(prefix) + tokenize(suffix) == tokenize(prefix + suffix)`.
18//!
19//! No fallback to whitespace/punctuation — better to not cache than risk corruption.
20//!
21//! Storage and eviction are delegated to a weighted [`moka`] `sync::Cache` (W-TinyLFU):
22//! entries are keyed by the blake3 digest of `input[0..boundary]` and weighed by their
23//! resident token-vector bytes, so the byte budget is enforced — and recency/frequency
24//! tracked — by moka rather than by hand.
25
26use std::{
27    hash::BuildHasherDefault,
28    mem::size_of_val,
29    sync::{
30        Arc,
31        atomic::{AtomicU64, Ordering},
32    },
33};
34
35use aho_corasick::AhoCorasick;
36use moka::sync::Cache;
37use rustc_hash::FxHasher;
38
39use crate::{TokenIdType, traits::Encoder};
40
41/// Hash type for cache keys
42type Blake3Hash = [u8; 32];
43
44/// Keys are blake3 digests (already uniformly distributed), so a fast non-DoS-resistant
45/// hasher suffices — no need for the default SipHash.
46type PrefixHasher = BuildHasherDefault<FxHasher>;
47
48/// Weighted W-TinyLFU cache mapping a prefix's blake3 digest to its cumulative tokens.
49type PrefixCache = Cache<Blake3Hash, Arc<[TokenIdType]>, PrefixHasher>;
50
51/// Positions immediately after each special-token occurrence in `text`.
52///
53/// Callers supply token strings that the inner tokenizer treats as atomic, so a boundary
54/// immediately after a selected occurrence is a safe split point:
55/// `tokenize(prefix) + tokenize(suffix) == tokenize(prefix + suffix)`. The overlapping scan
56/// is safe only when registered special-token occurrences cannot overlap; construction
57/// screens out other sets with [`first_unsafe_overlap`]. A boundary at the end of the input
58/// is omitted because there is no suffix to encode.
59fn boundaries_with(text: &str, matcher: &AhoCorasick) -> Vec<usize> {
60    let mut boundaries: Vec<usize> = matcher
61        .find_overlapping_iter(text)
62        .map(|m| m.end())
63        .filter(|&end| end < text.len())
64        .collect();
65    boundaries.sort_unstable();
66    boundaries.dedup();
67    boundaries
68}
69
70fn has_nontrivial_self_overlap(token: &str) -> bool {
71    let bytes = token.as_bytes();
72    (1..bytes.len()).any(|overlap| bytes[bytes.len() - overlap..] == bytes[..overlap])
73}
74
75fn tokens_can_overlap(a: &str, b: &str) -> bool {
76    if a.contains(b) || b.contains(a) {
77        return true;
78    }
79
80    let a = a.as_bytes();
81    let b = b.as_bytes();
82    let max_overlap = a.len().min(b.len());
83    (1..max_overlap).any(|overlap| {
84        a[a.len() - overlap..] == b[..overlap] || b[b.len() - overlap..] == a[..overlap]
85    })
86}
87
88/// Returns the first pair of special tokens whose occurrences can overlap.
89///
90/// [`boundaries_with`] reports the end of *every* occurrence of *every* special token.
91/// That equals the tokenizer's own segmentation only when occurrences cannot overlap;
92/// otherwise a reported boundary can land strictly inside the span the tokenizer actually
93/// consumed, and splitting there breaks the module invariant
94/// `tokenize(prefix) + tokenize(suffix) == tokenize(prefix + suffix)`.
95pub(super) fn first_unsafe_overlap(special_tokens: &[String]) -> Option<(&str, &str)> {
96    for (index, token) in special_tokens.iter().enumerate() {
97        if token.is_empty() {
98            continue;
99        }
100        if has_nontrivial_self_overlap(token) {
101            return Some((token, token));
102        }
103        for other in &special_tokens[index + 1..] {
104            if !other.is_empty() && token != other && tokens_can_overlap(token, other) {
105                return Some((token, other));
106            }
107        }
108    }
109
110    None
111}
112
113/// Test-only reference: build a one-off automaton and find boundaries. Production goes
114/// through [`L1Cache::boundaries`], which reuses a process-once automaton.
115#[cfg(test)]
116fn find_special_token_boundaries(text: &str, special_tokens: &[&str]) -> Vec<usize> {
117    if special_tokens.is_empty() {
118        return Vec::new();
119    }
120    let matcher = AhoCorasick::new(special_tokens)
121        .expect("special tokens form a valid Aho-Corasick automaton");
122    boundaries_with(text, &matcher)
123}
124
125/// Optional per-event observer. `on_hit` runs after each cache hit, `on_miss`
126/// after each miss — wired by `CachedTokenizer::with_observer` to push events
127/// straight into Prometheus counters without a periodic sampling step.
128pub type CacheEventFn = Arc<dyn Fn() + Send + Sync>;
129
130/// L1 cache: prefix matching at special-token boundaries, backed by a weighted W-TinyLFU
131/// [`moka`] cache that owns storage, recency/frequency tracking, and eviction. Hit/miss
132/// counts (our notion of a *prefix* hit) are tracked separately for metrics.
133pub struct L1Cache {
134    /// Prefix entries keyed by the blake3 digest of `input[0..boundary]`.
135    cache: PrefixCache,
136    /// Aho-Corasick automaton over the special tokens, built once at construction (`None`
137    /// when there are no special tokens). Lets boundary detection be a single pass.
138    matcher: Option<AhoCorasick>,
139    hits: AtomicU64,
140    misses: AtomicU64,
141    on_hit: Option<CacheEventFn>,
142    on_miss: Option<CacheEventFn>,
143}
144
145impl L1Cache {
146    /// `special_tokens` is the atomic special-token set whose boundaries the cache splits
147    /// at; an empty set leaves L1 inert (no boundaries, no entries).
148    pub fn new(max_memory: usize, mut special_tokens: Vec<String>) -> Self {
149        special_tokens.retain(|token| !token.is_empty());
150
151        // Capacity is the byte budget; each entry weighs its resident token-vector bytes
152        // (the prefix text is hashed and discarded, never stored). moka's W-TinyLFU policy
153        // admits/evicts to keep the weighted size within budget.
154        let cache = Cache::builder()
155            .max_capacity(max_memory as u64)
156            .weigher(|_k: &Blake3Hash, tokens: &Arc<[TokenIdType]>| -> u32 {
157                size_of_val(tokens.as_ref()).min(u32::MAX as usize) as u32
158            })
159            .build_with_hasher(PrefixHasher::default());
160
161        // Build the boundary automaton once; `None` when there are no special tokens.
162        let matcher = (!special_tokens.is_empty()).then(|| {
163            AhoCorasick::new(&special_tokens)
164                .expect("special tokens form a valid Aho-Corasick automaton")
165        });
166
167        Self {
168            cache,
169            matcher,
170            hits: AtomicU64::new(0),
171            misses: AtomicU64::new(0),
172            on_hit: None,
173            on_miss: None,
174        }
175    }
176
177    /// Install hit/miss callbacks. Replaces any previously-set observers.
178    pub fn set_observer(&mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) {
179        self.on_hit = Some(on_hit);
180        self.on_miss = Some(on_miss);
181    }
182
183    /// Special-token boundaries in `text` via the process-once Aho-Corasick automaton built
184    /// at construction — a single pass over the input rather than one `str::find` sweep per
185    /// token. Empty when the cache has no special tokens.
186    fn boundaries(&self, text: &str) -> Vec<usize> {
187        match &self.matcher {
188            Some(matcher) => boundaries_with(text, matcher),
189            None => Vec::new(),
190        }
191    }
192
193    /// Try to find the longest prefix match at a special-token boundary.
194    ///
195    /// Returns `(cached_tokens, byte_offset, deepest_boundary)` if found. The caller
196    /// extends the cached tokens with a fresh encode of `input[byte_offset..]`;
197    /// `deepest_boundary` is the deepest special-token boundary in `input` (end-exclusive),
198    /// handed back so [`extend_after_match`] need not rescan the input for it.
199    pub fn longest_prefix_match(&self, input: &str) -> Option<(Arc<[TokenIdType]>, usize, usize)> {
200        let boundaries = self.boundaries(input);
201
202        if boundaries.is_empty() {
203            self.misses.fetch_add(1, Ordering::Relaxed);
204            if let Some(cb) = &self.on_miss {
205                cb();
206            }
207            return None;
208        }
209
210        // Deepest boundary in the input — returned on a hit so the extend path can split
211        // there without recomputing `find_special_token_boundaries`.
212        let deepest_boundary = *boundaries.last().expect("boundaries is non-empty here");
213
214        // Build all prefix hashes incrementally — O(N).
215        let mut hasher = blake3::Hasher::new();
216        let mut prefix_hashes = Vec::with_capacity(boundaries.len());
217        let mut last_pos = 0;
218        let bytes = input.as_bytes();
219        for &boundary_pos in &boundaries {
220            hasher.update(&bytes[last_pos..boundary_pos]);
221            // `finalize(&self)` borrows — no need to clone the hasher to keep updating it.
222            prefix_hashes.push((boundary_pos, *hasher.finalize().as_bytes()));
223            last_pos = boundary_pos;
224        }
225
226        // Search from the longest boundary down — return first hit. moka updates recency
227        // and frequency on `get`, so no manual timestamp bookkeeping is needed.
228        for (boundary_pos, hash_bytes) in prefix_hashes.into_iter().rev() {
229            if let Some(tokens) = self.cache.get(&hash_bytes) {
230                self.hits.fetch_add(1, Ordering::Relaxed);
231                if let Some(cb) = &self.on_hit {
232                    cb();
233                }
234                // Return the shared `Arc` directly — the caller decides whether to
235                // materialize a `Vec` (and reserves exact capacity when it does),
236                // avoiding a clone of the (large) cached prefix on every hit.
237                return Some((tokens, boundary_pos, deepest_boundary));
238            }
239        }
240
241        self.misses.fetch_add(1, Ordering::Relaxed);
242        if let Some(cb) = &self.on_miss {
243            cb();
244        }
245        None
246    }
247
248    /// Insert prefix entries at every special-token boundary (e.g. to pre-seed the cache).
249    ///
250    /// Uses incremental hashing and incremental tokenization (per-segment encode of the
251    /// delta text between adjacent boundaries) so populating N entries costs one full
252    /// re-tokenize total, split across the segments. The miss path uses
253    /// [`Self::populate_and_encode`] instead, which reuses this same work to *also* return
254    /// the full token vector (avoiding a redundant second tokenization).
255    pub fn insert_at_boundaries<E: Encoder + ?Sized>(
256        &self,
257        input: &str,
258        tokenizer: &E,
259    ) -> anyhow::Result<()> {
260        let boundaries = self.boundaries(input);
261        if boundaries.is_empty() {
262            return Ok(());
263        }
264        self.populate_boundaries(input, &boundaries, tokenizer)?;
265        Ok(())
266    }
267
268    /// Miss-path encode: tokenize `input` exactly once, caching the cumulative prefix at
269    /// every special-token boundary as we go, and return the full token-id vector. This
270    /// replaces a separate full `encode` + [`Self::insert_at_boundaries`], which together
271    /// tokenized the input ~twice (once for the result, once split across segments).
272    ///
273    /// The concatenation of the per-segment encodes equals an uncached `encode(input)`
274    /// because special tokens are atomic in BPE — the same invariant the hit path relies
275    /// on. Returns token-ids only; the caller wraps them in [`crate::Encoding::Sp`].
276    pub fn populate_and_encode<E: Encoder + ?Sized>(
277        &self,
278        input: &str,
279        tokenizer: &E,
280    ) -> anyhow::Result<Vec<TokenIdType>> {
281        let boundaries = self.boundaries(input);
282        if boundaries.is_empty() {
283            // No special tokens present — nothing cacheable; a single plain encode.
284            return Ok(tokenizer.encode(input)?.token_ids().to_vec());
285        }
286
287        // Tokenize + cache every boundary prefix; `running` covers input[0..last boundary].
288        let mut running = self.populate_boundaries(input, &boundaries, tokenizer)?;
289
290        // The trailing segment after the last boundary is not a cache key (boundaries
291        // exclude input.len()); encoding it completes the full tokenization.
292        let tail_start = *boundaries.last().expect("boundaries is non-empty here");
293        let tail = tokenizer.encode(&input[tail_start..])?;
294        running.extend_from_slice(tail.token_ids());
295        Ok(running)
296    }
297
298    /// Shared core of the miss path: walk `boundaries`, hashing and tokenizing each
299    /// inter-boundary segment, caching the cumulative prefix at each boundary, and return
300    /// the running token vector (covering `input[0..boundaries.last()]`).
301    fn populate_boundaries<E: Encoder + ?Sized>(
302        &self,
303        input: &str,
304        boundaries: &[usize],
305        tokenizer: &E,
306    ) -> anyhow::Result<Vec<TokenIdType>> {
307        let mut hasher = blake3::Hasher::new();
308        let mut running_tokens: Vec<TokenIdType> = Vec::new();
309        let mut last_pos = 0;
310        let bytes = input.as_bytes();
311
312        for &boundary_pos in boundaries {
313            // 1. Incremental hash. `finalize(&self)` borrows, so no clone is needed.
314            hasher.update(&bytes[last_pos..boundary_pos]);
315            let hash_bytes: Blake3Hash = *hasher.finalize().as_bytes();
316
317            // 2. Incremental tokenization. Dynamo's Encoder has no `add_special_tokens`
318            //    parameter — equivalent to upstream always passing `false` past the first
319            //    segment (which is also what Dynamo's HF impl always does for the first).
320            let seg = tokenizer.encode(&input[last_pos..boundary_pos])?;
321            running_tokens.extend_from_slice(seg.token_ids());
322
323            // 3. Snapshot the cumulative prefix as Arc<[T]> and hand it to moka (the weigher
324            //    charges its token bytes against the budget; eviction is moka's job).
325            let prefix_tokens: Arc<[TokenIdType]> = running_tokens.as_slice().into();
326            self.cache.insert(hash_bytes, prefix_tokens);
327
328            last_pos = boundary_pos;
329        }
330
331        Ok(running_tokens)
332    }
333
334    /// Extend the cache on a *partial* hit so the next turn of a growing conversation
335    /// hits deeper. Given the `(prefix_tokens, prefix_len, deepest_boundary)` returned by
336    /// [`longest_prefix_match`], tokenize the remaining suffix and cache the cumulative
337    /// prefix at the suffix's **deepest** special-token boundary, then return the full
338    /// merged token vector.
339    ///
340    /// Deepest-only is intentional: in an append-only conversation the next turn always
341    /// reaches the deepest boundary, so caching it bounds per-turn work to the newest
342    /// exchange; shallow/branching coverage already comes from the miss path's
343    /// [`insert_at_boundaries`]. Splitting at special-token boundaries is correctness-safe
344    /// because special tokens are atomic in BPE
345    /// (`tokenize(a) + tokenize(b) == tokenize(a + b)`).
346    ///
347    /// Note: unlike the read-only fast path, this **writes** to the cache on a hit
348    /// (one insert + possible eviction). It relies on the same best-effort memory
349    /// accounting as [`insert_at_boundaries`].
350    pub fn extend_after_match<E: Encoder + ?Sized>(
351        &self,
352        input: &str,
353        prefix_tokens: Arc<[TokenIdType]>,
354        prefix_len: usize,
355        deepest_boundary: usize,
356        tokenizer: &E,
357    ) -> anyhow::Result<Vec<TokenIdType>> {
358        // `deepest_boundary` (from `longest_prefix_match`) is the deepest special-token
359        // boundary in `input`; split there only if it lies strictly past the matched
360        // prefix. Strict `>` avoids re-inserting the entry we just matched. Boundaries
361        // exclude any position == input.len(), so `deepest < input.len()` and the trailing
362        // segment below is always non-empty.
363        let deepest = (deepest_boundary > prefix_len).then_some(deepest_boundary);
364
365        let Some(deepest) = deepest else {
366            // No new boundary in the suffix — nothing worth caching. Encode the suffix
367            // once and merge, identical to the non-extend hit path. Reserve exact capacity
368            // so the prefix isn't re-copied by a Vec grow-realloc.
369            let suffix_enc = tokenizer.encode(&input[prefix_len..])?;
370            let mut merged = Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
371            merged.extend_from_slice(&prefix_tokens);
372            merged.extend_from_slice(suffix_enc.token_ids());
373            return Ok(merged);
374        };
375
376        // Cumulative tokens up to `deepest` = matched prefix + the spanning segment.
377        // Both `prefix_len` and `deepest` are special-token boundaries, so encoding the
378        // span as one chunk and concatenating preserves the merge invariant.
379        // Encode both segments up front so `cumulative` can be reserved to its final
380        // size (prefix + seg_a + seg_b) — this eliminates the two grow-reallocs (each of
381        // which re-copied the whole large prefix) the previous Vec-append path incurred.
382        let seg_a = tokenizer.encode(&input[prefix_len..deepest])?;
383        let seg_b = tokenizer.encode(&input[deepest..])?;
384        let mut cumulative = Vec::with_capacity(
385            prefix_tokens.len() + seg_a.token_ids().len() + seg_b.token_ids().len(),
386        );
387        cumulative.extend_from_slice(&prefix_tokens);
388        cumulative.extend_from_slice(seg_a.token_ids());
389
390        // Key is blake3 of input[0..deepest]. Built with the same streaming idiom as
391        // `longest_prefix_match`/`insert_at_boundaries` so the digest is byte-for-byte
392        // identical to the incremental one a future lookup computes for this prefix.
393        let mut hasher = blake3::Hasher::new();
394        hasher.update(&input.as_bytes()[..deepest]);
395        let hash_bytes: Blake3Hash = *hasher.finalize().as_bytes();
396
397        // Snapshot prefix+seg_a (`as_slice().into()` copies only the populated len, not the
398        // reserved capacity) and cache it.
399        let tokens: Arc<[TokenIdType]> = cumulative.as_slice().into();
400        self.cache.insert(hash_bytes, tokens);
401
402        // Append the trailing segment for the returned result — no realloc, capacity was
403        // reserved above.
404        cumulative.extend_from_slice(seg_b.token_ids());
405        Ok(cumulative)
406    }
407
408    /// Number of live entries. Flushes moka's deferred maintenance first so the count is
409    /// exact rather than lagging behind pending inserts/evictions.
410    pub fn len(&self) -> usize {
411        self.cache.run_pending_tasks();
412        self.cache.entry_count() as usize
413    }
414
415    pub fn is_empty(&self) -> bool {
416        self.len() == 0
417    }
418
419    pub fn stats(&self) -> L1CacheStats {
420        // Flush moka's deferred maintenance so entry_count / weighted_size are accurate.
421        self.cache.run_pending_tasks();
422        let hits = self.hits.load(Ordering::Relaxed);
423        let misses = self.misses.load(Ordering::Relaxed);
424        let total_requests = hits + misses;
425
426        L1CacheStats {
427            hits,
428            misses,
429            entries: self.cache.entry_count() as usize,
430            memory_bytes: self.cache.weighted_size() as usize,
431            hit_rate: if total_requests > 0 {
432                hits as f64 / total_requests as f64
433            } else {
434                0.0
435            },
436        }
437    }
438
439    pub fn clear(&self) {
440        self.cache.invalidate_all();
441        self.cache.run_pending_tasks();
442        self.hits.store(0, Ordering::Relaxed);
443        self.misses.store(0, Ordering::Relaxed);
444    }
445}
446
447#[derive(Debug, Clone)]
448pub struct L1CacheStats {
449    pub hits: u64,
450    pub misses: u64,
451    pub entries: usize,
452    pub memory_bytes: usize,
453    pub hit_rate: f64,
454}
455
456#[cfg(test)]
457mod tests {
458    use std::sync::Arc;
459
460    use super::*;
461    use crate::{HuggingFaceTokenizer, traits::Tokenizer};
462
463    // TinyLlama: real Llama BPE with `<s>` and `</s>` as added tokens with
464    // `special: true, normalized: false` — atomic in BPE, safe boundary points.
465    const TINYLLAMA_PATH: &str = concat!(
466        env!("CARGO_MANIFEST_DIR"),
467        "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
468    );
469
470    const SPECIALS: &[&str] = &["<s>", "</s>"];
471
472    fn load_tokenizer() -> Arc<dyn Tokenizer> {
473        Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
474    }
475
476    /// An `L1Cache` over the TinyLlama [`SPECIALS`] with the given byte budget.
477    fn test_cache(max_memory: usize) -> L1Cache {
478        L1Cache::new(
479            max_memory,
480            SPECIALS.iter().map(|s| (*s).to_string()).collect(),
481        )
482    }
483
484    #[test]
485    fn boundaries_are_after_each_special_token_occurrence() {
486        let input = "<s>system\nHi</s><s>user\nHello</s>";
487        let bounds = find_special_token_boundaries(input, SPECIALS);
488        // Drop the trailing boundary (==text.len()), so 3 not 4 boundaries.
489        assert_eq!(bounds.len(), 3);
490        for w in bounds.windows(2) {
491            assert!(w[0] < w[1], "boundaries must be strictly increasing");
492        }
493        assert!(bounds.iter().all(|&b| b < input.len()));
494    }
495
496    #[test]
497    fn no_special_tokens_yields_no_boundaries() {
498        assert!(find_special_token_boundaries("plain text", &[]).is_empty());
499    }
500
501    #[test]
502    fn unsafe_overlap_detects_containment_crossing_and_self_overlap() {
503        let cases = [
504            (vec!["〈|", "〈|EOS|〉"], Some(("〈|", "〈|EOS|〉"))),
505            (vec!["ab", "bc"], Some(("ab", "bc"))),
506            (vec!["|◊|"], Some(("|◊|", "|◊|"))),
507            (vec!["<s>", "<s>"], None),
508        ];
509
510        for (tokens, expected) in cases {
511            let tokens: Vec<String> = tokens.into_iter().map(String::from).collect();
512            assert_eq!(first_unsafe_overlap(&tokens), expected);
513        }
514    }
515
516    #[test]
517    fn llama_numbered_special_tokens_do_not_trigger_overlap_guard() {
518        let mut llama: Vec<String> = [
519            "<|begin_of_text|>",
520            "<|end_of_text|>",
521            "<|start_header_id|>",
522            "<|end_header_id|>",
523            "<|eot_id|>",
524        ]
525        .into_iter()
526        .map(String::from)
527        .collect();
528        llama.extend((0..251).map(|id| format!("<|reserved_special_token_{id}|>")));
529
530        assert_eq!(first_unsafe_overlap(&llama), None);
531    }
532
533    #[test]
534    fn insert_then_lookup_finds_shared_prefix() {
535        let cache = test_cache(1024 * 1024);
536        let tokenizer = load_tokenizer();
537
538        let warm = "<s>system\nYou are helpful.</s><s>user\nHi</s>";
539        cache
540            .insert_at_boundaries(warm, tokenizer.as_ref())
541            .unwrap();
542        assert!(!cache.is_empty());
543
544        let target = "<s>system\nYou are helpful.</s><s>user\nDifferent question</s>";
545        let (tokens, offset, _deepest) = cache
546            .longest_prefix_match(target)
547            .expect("shared prefix should match");
548        assert!(offset > 0);
549        assert!(!tokens.is_empty());
550    }
551
552    #[test]
553    fn miss_increments_misses_counter() {
554        let cache = test_cache(1024 * 1024);
555        assert!(
556            cache
557                .longest_prefix_match("plain text no specials")
558                .is_none()
559        );
560        assert_eq!(cache.stats().misses, 1);
561    }
562
563    #[test]
564    fn hit_increments_hits_counter() {
565        let cache = test_cache(1024 * 1024);
566        let tokenizer = load_tokenizer();
567        let warm = "<s>system\nA.</s><s>user\nB</s>";
568        cache
569            .insert_at_boundaries(warm, tokenizer.as_ref())
570            .unwrap();
571        let _ = cache.longest_prefix_match(warm);
572        assert!(cache.stats().hits >= 1);
573    }
574
575    #[test]
576    fn merge_invariant_holds_against_uncached_encode() {
577        // Load-bearing correctness check: cached prefix + fresh suffix encode must
578        // equal plain encode of the full input. Relies on `<s>`/`</s>` being atomic
579        // in TinyLlama's BPE (they are).
580        let cache = test_cache(1024 * 1024);
581        let tokenizer = load_tokenizer();
582
583        let template = "<s>system\nYou are helpful.</s><s>user\n";
584        let warm = format!("{template}First.</s>");
585        cache
586            .insert_at_boundaries(&warm, tokenizer.as_ref())
587            .unwrap();
588
589        let target = format!("{template}A completely different second question.</s>");
590        let (prefix_tokens, prefix_len, _deepest) = cache
591            .longest_prefix_match(&target)
592            .expect("should find prefix");
593
594        let suffix = &target[prefix_len..];
595        let suffix_enc = tokenizer.encode(suffix).unwrap();
596        // longest_prefix_match returns the shared `Arc<[u32]>`; copy into a Vec to append the suffix.
597        let mut merged = prefix_tokens.to_vec();
598        merged.extend_from_slice(suffix_enc.token_ids());
599
600        let plain = tokenizer.encode(&target).unwrap();
601        assert_eq!(
602            merged,
603            plain.token_ids(),
604            "merged tokens must equal plain encode"
605        );
606    }
607
608    #[test]
609    fn eviction_respects_memory_budget() {
610        // 4 KB budget — tight enough to force eviction after a few inserts.
611        let cache = test_cache(4 * 1024);
612        let tokenizer = load_tokenizer();
613        for i in 0..50 {
614            let input =
615                format!("<s>system\nPersona {i} chatty.</s><s>user\nTurn {i} content here.</s>");
616            cache
617                .insert_at_boundaries(&input, tokenizer.as_ref())
618                .unwrap();
619        }
620        let stats = cache.stats();
621        assert!(
622            stats.memory_bytes <= 4 * 1024,
623            "memory_bytes={} exceeds budget",
624            stats.memory_bytes
625        );
626    }
627
628    #[test]
629    fn concurrent_inserts_and_lookups_do_not_corrupt() {
630        use std::thread;
631
632        let cache = Arc::new(test_cache(1024 * 1024));
633        let tokenizer = load_tokenizer();
634
635        let mut handles = vec![];
636        for i in 0..10 {
637            let cache_c = cache.clone();
638            let tok = tokenizer.clone();
639            handles.push(thread::spawn(move || {
640                let input = format!("<s>system\nThread {i}.</s><s>user\nThread {i} body.</s>");
641                cache_c.insert_at_boundaries(&input, tok.as_ref()).unwrap();
642                let r = cache_c.longest_prefix_match(&input);
643                assert!(r.is_some(), "thread {i} expected match after insert");
644            }));
645        }
646        for h in handles {
647            h.join().unwrap();
648        }
649        assert!(cache.stats().memory_bytes > 0);
650        assert!(cache.stats().hits >= 10);
651    }
652
653    /// Build an append-only multi-turn conversation. `turns[i]` is the full prompt at
654    /// turn `i`: the system prompt, `i + 1` completed user/assistant exchanges, and a
655    /// diverging open user turn (no trailing special, so the deepest boundary is the
656    /// `<s>` that opens it). Each `turns[i]` shares a strictly longer `</s>`-bounded
657    /// prefix with `turns[i + 1]`.
658    fn growing_chat_turns(n: usize) -> Vec<String> {
659        let mut convo = String::from("<s>system\nYou are a helpful assistant.</s>");
660        let mut turns = Vec::with_capacity(n);
661        for i in 0..n {
662            convo.push_str(&format!(
663                "<s>user\nQuestion {i} please answer it.</s><s>assistant\nDetailed answer {i} follows here.</s>"
664            ));
665            turns.push(format!("{convo}<s>user\nFollow-up {i}"));
666        }
667        turns
668    }
669
670    #[test]
671    fn extend_on_hit_advances_match_depth_each_turn() {
672        // The load-bearing behavioral proof. Without extension the match offset is
673        // pinned at turn-1 depth (hits never insert); with extension it advances every
674        // turn, so the suffix re-tokenized per turn shrinks instead of growing.
675        let tok = load_tokenizer();
676        let turns = growing_chat_turns(5);
677
678        // EXTEND OFF: seed turn 0 via the miss path, then only look up (never insert).
679        let off = test_cache(8 * 1024 * 1024);
680        off.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
681        let pinned = off.longest_prefix_match(&turns[1]).expect("hit").1;
682        for t in &turns[1..] {
683            let (_toks, offset, _deepest) = off.longest_prefix_match(t).expect("hit");
684            assert_eq!(
685                offset, pinned,
686                "extend-off offset must stay pinned at turn-1 depth"
687            );
688        }
689
690        // EXTEND ON: each hit caches the deepest boundary, so the next turn hits deeper.
691        let on = test_cache(8 * 1024 * 1024);
692        on.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
693        let mut prev = 0usize;
694        for (i, t) in turns.iter().enumerate().skip(1) {
695            let (prefix_tokens, offset, deepest) = on.longest_prefix_match(t).expect("hit");
696            assert!(
697                offset > prev,
698                "turn {i}: extend-on offset {offset} must exceed previous {prev}"
699            );
700            prev = offset;
701
702            // Extending must also preserve byte-exact correctness vs an uncached encode.
703            let merged = on
704                .extend_after_match(t, prefix_tokens, offset, deepest, tok.as_ref())
705                .unwrap();
706            let plain = tok.encode(t).unwrap();
707            assert_eq!(
708                merged,
709                plain.token_ids(),
710                "turn {i}: extend merge must equal plain encode"
711            );
712        }
713
714        assert!(
715            prev > pinned,
716            "extend-on frontier ({prev}) must reach deeper than pinned extend-off depth ({pinned})"
717        );
718    }
719
720    #[test]
721    fn extend_on_hit_respects_budget_and_stays_correct() {
722        // Tiny budget forces eviction (and over-budget skips) while extending; every
723        // turn's encode must stay correct and memory must stay within budget.
724        let tok = load_tokenizer();
725        let cache = test_cache(4 * 1024);
726        let turns = growing_chat_turns(20);
727        cache.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
728
729        for t in &turns[1..] {
730            let merged = match cache.longest_prefix_match(t) {
731                Some((prefix_tokens, offset, deepest)) => cache
732                    .extend_after_match(t, prefix_tokens, offset, deepest, tok.as_ref())
733                    .unwrap(),
734                None => {
735                    // Full miss under eviction pressure — mirror the miss path.
736                    let enc = tok.encode(t).unwrap();
737                    cache.insert_at_boundaries(t, tok.as_ref()).unwrap();
738                    enc.token_ids().to_vec()
739                }
740            };
741            let plain = tok.encode(t).unwrap();
742            assert_eq!(
743                merged,
744                plain.token_ids(),
745                "encode must stay correct under eviction pressure"
746            );
747            assert!(
748                cache.stats().memory_bytes <= 4 * 1024,
749                "memory_bytes={} exceeds budget",
750                cache.stats().memory_bytes
751            );
752        }
753    }
754
755    #[test]
756    fn concurrent_extend_on_hit_does_not_corrupt() {
757        use std::thread;
758
759        let tok = load_tokenizer();
760        let cache = Arc::new(test_cache(8 * 1024 * 1024));
761        let turns = growing_chat_turns(8);
762        // Seed turn 0 so every thread gets at least a partial hit.
763        cache.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
764
765        let mut handles = vec![];
766        for _ in 0..8 {
767            let cache_c = cache.clone();
768            let tok_c = tok.clone();
769            let turns_c = turns.clone();
770            handles.push(thread::spawn(move || {
771                for t in &turns_c[1..] {
772                    if let Some((prefix_tokens, offset, deepest)) = cache_c.longest_prefix_match(t)
773                    {
774                        let merged = cache_c
775                            .extend_after_match(t, prefix_tokens, offset, deepest, tok_c.as_ref())
776                            .unwrap();
777                        let plain = tok_c.encode(t).unwrap();
778                        assert_eq!(
779                            merged,
780                            plain.token_ids(),
781                            "concurrent extend must stay correct"
782                        );
783                    }
784                }
785            }));
786        }
787        for h in handles {
788            h.join().unwrap();
789        }
790        assert!(cache.stats().memory_bytes > 0);
791    }
792
793    #[test]
794    fn extend_after_match_persists_correct_deepest_entry() {
795        // The *saved* entry on a partial hit — not just the returned merge — must be
796        // byte-exact and retrievable: a fresh lookup hits at the just-cached deepest
797        // boundary and returns exactly `encode(input[0..deepest])`, so the next turn
798        // reuses a correct prefix. Also proves the deepest-only invariant: extend
799        // persists exactly one new entry.
800        let tok = load_tokenizer();
801        let turns = growing_chat_turns(3);
802
803        let cache = test_cache(8 * 1024 * 1024);
804        cache.insert_at_boundaries(&turns[0], tok.as_ref()).unwrap();
805
806        let (prefix_tokens, prefix_len, deepest_boundary) = cache
807            .longest_prefix_match(&turns[1])
808            .expect("partial hit on turns[1]");
809        let entries_before = cache.stats().entries;
810
811        let _merged = cache
812            .extend_after_match(
813                &turns[1],
814                prefix_tokens,
815                prefix_len,
816                deepest_boundary,
817                tok.as_ref(),
818            )
819            .unwrap();
820
821        assert_eq!(
822            cache.stats().entries,
823            entries_before + 1,
824            "extend must persist exactly one (deepest) entry"
825        );
826
827        // The deepest boundary strictly past the matched prefix is what extend cached, and
828        // `longest_prefix_match` must have handed back exactly that boundary (no rescan).
829        let deepest = find_special_token_boundaries(&turns[1], SPECIALS)
830            .into_iter()
831            .rev()
832            .find(|&b| b > prefix_len)
833            .expect("a deeper boundary must exist in the appended turn");
834        assert_eq!(
835            deepest_boundary, deepest,
836            "longest_prefix_match must return the deepest boundary used by extend"
837        );
838
839        // A fresh lookup must now hit AT that deepest boundary, and the stored tokens must
840        // equal the uncached encode of exactly that prefix.
841        let (saved_tokens, saved_offset, _deepest) = cache
842            .longest_prefix_match(&turns[1])
843            .expect("hit after extend");
844        assert_eq!(
845            saved_offset, deepest,
846            "lookup must now hit at the just-saved deepest boundary"
847        );
848        let expected = tok.encode(&turns[1][..deepest]).unwrap();
849        assert_eq!(
850            &*saved_tokens,
851            expected.token_ids(),
852            "persisted entry tokens must equal the uncached encode of the cached prefix"
853        );
854    }
855
856    #[test]
857    fn boundaries_detected_for_multibyte_deepseek_tool_tokens() {
858        // `find_special_token_boundaries` keys off byte offsets; DeepSeek's tool tokens use
859        // multibyte code points (| = U+FF5C, ▁ = U+2581, 3 bytes each). A boundary must
860        // land immediately after each occurrence at a valid char boundary, so the cache can
861        // split a tool-call block at its special tokens without panicking on a slice.
862        let specials = &["<|tool▁calls▁begin|>", "<|tool▁call▁end|>"];
863        let text = "<|tool▁calls▁begin|>payload<|tool▁call▁end|>tail";
864        let bounds = find_special_token_boundaries(text, specials);
865
866        let after_begin = "<|tool▁calls▁begin|>".len();
867        let after_end = text.find("<|tool▁call▁end|>").unwrap() + "<|tool▁call▁end|>".len();
868        assert_eq!(bounds, vec![after_begin, after_end]);
869        for &b in &bounds {
870            assert!(
871                text.is_char_boundary(b),
872                "boundary {b} is not a char boundary"
873            );
874            let _ = &text[..b]; // must not panic
875        }
876    }
877
878    #[test]
879    fn populate_and_encode_matches_uncached_and_seeds_cache() {
880        // The fused miss path must (a) return ids byte-exact to an uncached encode and
881        // (b) leave the cache populated at the boundaries, so a follow-up lookup hits.
882        let tok = load_tokenizer();
883        let cache = test_cache(8 * 1024 * 1024);
884        let input = "<s>system\nYou are helpful.</s><s>user\nHello there, friend.</s>";
885
886        let got = cache.populate_and_encode(input, tok.as_ref()).unwrap();
887        let plain = tok.encode(input).unwrap();
888        assert_eq!(
889            got,
890            plain.token_ids(),
891            "fused miss encode must equal uncached encode"
892        );
893
894        // It also seeded the cache: a follow-up lookup hits at a boundary.
895        assert!(
896            !cache.is_empty(),
897            "miss path must populate boundary entries"
898        );
899        let (_t, offset, _d) = cache
900            .longest_prefix_match(input)
901            .expect("hit after populate");
902        assert!(offset > 0, "follow-up lookup should hit a cached boundary");
903    }
904
905    #[test]
906    fn populate_and_encode_handles_inputs_without_special_tokens() {
907        // No registered special appears in the input → no boundaries → one plain encode,
908        // nothing cached, still byte-exact.
909        let tok = load_tokenizer();
910        let cache = test_cache(8 * 1024 * 1024);
911        let input = "plain text with no special tokens at all";
912
913        let got = cache.populate_and_encode(input, tok.as_ref()).unwrap();
914        let plain = tok.encode(input).unwrap();
915        assert_eq!(got, plain.token_ids());
916        assert!(cache.is_empty(), "nothing cacheable without boundaries");
917    }
918
919    #[test]
920    fn populate_and_encode_handles_trailing_special_token() {
921        // Input ending in a special token: the final boundary == input.len() is excluded,
922        // so the trailing `</s>` lands in the tail segment. The assembled ids must still
923        // equal an uncached encode.
924        let tok = load_tokenizer();
925        let cache = test_cache(8 * 1024 * 1024);
926        let input = "<s>system\nDone.</s>";
927
928        let got = cache.populate_and_encode(input, tok.as_ref()).unwrap();
929        let plain = tok.encode(input).unwrap();
930        assert_eq!(
931            got,
932            plain.token_ids(),
933            "tail-segment assembly must be exact"
934        );
935    }
936}