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