Skip to main content

dynamo_tokenizers/cache/
mod.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 L0 layer, removed `add_special_tokens` plumbing (Dynamo's
9// `Encoder::encode` has no such flag), dropped fingerprinting, retargeted onto
10// `crate::traits::Tokenizer`.
11
12//! Tokenizer caching layer (L1: prefix matching at special-token boundaries).
13//!
14//! Wraps a cache-compatible [`Tokenizer`] in a cache that records prefix
15//! tokenizations at every special-token boundary. On a hit, the cached prefix
16//! tokens are merged with a fresh encode of the trailing suffix only — turning
17//! O(N) tokenization work into O(suffix_len) when prompts share a system prefix.
18//!
19//! # Correctness
20//!
21//! Boundaries are taken **only** at positions immediately following a registered
22//! special token (e.g. `<|im_start|>`, `<|im_end|>`, `<s>`, `</s>`). Special tokens
23//! are atomic in BPE (`special: true, normalized: false`), so splitting there
24//! preserves the invariant `tokenize(prefix) + tokenize(suffix) == tokenize(prefix + suffix)`.
25//! No fallback to whitespace or punctuation — better to miss than to corrupt.
26//!
27//! Atomicity alone is insufficient when registered special-token strings can overlap.
28//! [`CachedTokenizer::new`] disables L1 for such sets because the boundary scanner could
29//! otherwise split inside the token selected by the underlying tokenizer.
30//!
31//! # Storage normalization
32//!
33//! When L1 is enabled, **every** `encode` returns [`Encoding::Sp`] (token-ids only) —
34//! hits merge cached prefix ids with a fresh suffix encode, and misses assemble the ids
35//! from the per-boundary segment encodes (see [`L1Cache::populate_and_encode`]) — even
36//! when the inner tokenizer would have produced [`Encoding::Hf`] (rich offsets/attention/
37//! etc). All current downstream consumers in Dynamo only call [`Encoding::token_ids`], so
38//! this lossy normalization is safe; revisit if a caller starts reading offsets or
39//! attention masks from encodings produced through the cache.
40//!
41//! # Configuration
42//!
43//! - `special_tokens: Vec<String>` — must be supplied at construction (the
44//!   [`Tokenizer`] trait is intentionally minimal and does not expose them).
45//!   An empty list disables L1: `encode`/`encode_batch` short-circuit straight
46//!   to the inner tokenizer with no lookup, no miss-counter bump, and no
47//!   insert attempt. A list whose members can overlap disables L1 identically.
48//! - `encode_segments` always passes through to the inner tokenizer without
49//!   caching. Flattening segments for L1 would discard their special-token
50//!   trust boundaries.
51//! - `max_memory_bytes` — L1 byte budget; entries evicted via approximate LRU.
52//!
53//! # Provenance
54//!
55//! Adapted from `llm-tokenizer` v1.3.2 (`cache/l1.rs`, `cache/mod.rs`). L0 and
56//! fingerprinting were dropped; L1 alone covers the headline multi-turn-chat
57//! workload, and the in-memory cache lifetime is bound to a single tokenizer
58//! instance so fingerprint-based invalidation is unnecessary.
59
60mod l1;
61
62use std::sync::Arc;
63
64pub use l1::{CacheEventFn, L1Cache, L1CacheStats};
65
66use crate::{
67    EncodeSegment, Encoding, Result, TokenIdType,
68    traits::{DecodeResult, Decoder, Encoder, Tokenizer},
69};
70
71/// Token-level cache usage for one successful encode.
72///
73/// A partial cache hit reports both cached prefix tokens and uncached suffix tokens.
74/// Their sum always equals the number of tokens returned by the encode operation.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct CacheTokenUsage {
77    /// Tokens returned from the cached prefix.
78    pub cached_tokens: usize,
79    /// Tokens freshly encoded from the uncached suffix.
80    pub uncached_tokens: usize,
81}
82
83/// Optional observer for token-level cache usage.
84pub type CacheTokenUsageFn = Arc<dyn Fn(CacheTokenUsage) + Send + Sync>;
85
86/// Caching wrapper around an inner tokenizer.
87///
88/// Implements [`Encoder`], [`Decoder`], and [`Tokenizer`]; decode calls pass
89/// through to the inner tokenizer (decoding is fast and rarely repeated).
90pub struct CachedTokenizer {
91    inner: Arc<dyn Tokenizer>,
92    l1: L1Cache,
93    l1_enabled: bool,
94    /// When true, cache the newly-tokenized suffix on a partial hit so the next turn
95    /// of a growing conversation hits deeper (see [`L1Cache::extend_after_match`]).
96    extend_on_hit: bool,
97    /// Called once after every successful encode while L1 is active.
98    token_observer: Option<CacheTokenUsageFn>,
99}
100
101impl CachedTokenizer {
102    /// Construct a cached tokenizer.
103    ///
104    /// `special_tokens` is the list of atomic special-token strings the inner
105    /// tokenizer recognizes (typically extracted via the HuggingFace tokenizer's
106    /// `get_added_tokens_decoder()` filtering by `special == true`). An empty list
107    /// disables L1 — `encode`/`encode_batch` short-circuit to the inner tokenizer
108    /// without touching the cache or its counters. An overlapping token set also disables
109    /// L1, with a warning, because its boundaries are ambiguous.
110    ///
111    /// `max_memory_bytes` is the L1 cache byte budget.
112    ///
113    /// # Errors
114    ///
115    /// Returns the inner tokenizer's compatibility error when it cannot be
116    /// safely wrapped in the prefix cache.
117    pub fn new(
118        inner: Arc<dyn Tokenizer>,
119        mut special_tokens: Vec<String>,
120        max_memory_bytes: usize,
121    ) -> Result<Self> {
122        inner.validate_prefix_cache()?;
123        special_tokens.retain(|token| !token.is_empty());
124
125        // Overlapping matches can create a cache boundary inside a token selected by the
126        // inner tokenizer. Preserve correctness by bypassing this optional optimization.
127        let overlapping_specials = match l1::first_unsafe_overlap(&special_tokens) {
128            Some((first, second)) => {
129                tracing::warn!(
130                    target: "tokenizer",
131                    first_token = first,
132                    second_token = second,
133                    special_token_count = special_tokens.len(),
134                    "special tokens can overlap; tokenizer prefix cache disabled"
135                );
136                true
137            }
138            None => false,
139        };
140
141        let l1_enabled = !special_tokens.is_empty() && !overlapping_specials;
142        let cache_tokens = if l1_enabled {
143            special_tokens
144        } else {
145            Vec::new()
146        };
147        Ok(Self {
148            inner,
149            l1: L1Cache::new(max_memory_bytes, cache_tokens),
150            l1_enabled,
151            extend_on_hit: false,
152            token_observer: None,
153        })
154    }
155
156    /// Enable partial-hit extension. When on, a partial cache hit also caches the
157    /// freshly-tokenized suffix at its deepest special-token boundary, so each turn of
158    /// a growing multi-turn conversation hits deeper than the last and per-turn
159    /// tokenization cost stops growing with conversation length. Default off.
160    pub fn with_extend(mut self, enabled: bool) -> Self {
161        self.extend_on_hit = enabled;
162        self
163    }
164
165    /// Install hit/miss callbacks so each L1 lookup pushes an event into the
166    /// supplied closures (e.g. `Prometheus::Counter::inc`). Replaces any
167    /// previously-set observer.
168    pub fn with_observer(mut self, on_hit: CacheEventFn, on_miss: CacheEventFn) -> Self {
169        self.l1.set_observer(on_hit, on_miss);
170        self
171    }
172
173    /// Install a callback that receives exact cached and uncached token counts after each
174    /// successful encode while L1 is active. A partial hit reports both categories, which
175    /// lets consumers maintain token-level cache totals and derive a reuse ratio. Replaces
176    /// any previously-set token observer.
177    ///
178    /// This observer is not called when the special-token set is empty (and L1 is therefore
179    /// disabled) or when encoding returns an error.
180    pub fn with_token_observer(mut self, observer: CacheTokenUsageFn) -> Self {
181        self.token_observer = Some(observer);
182        self
183    }
184
185    fn observe_token_usage(&self, cached_tokens: usize, total_tokens: usize) {
186        if let Some(observer) = &self.token_observer {
187            let uncached_tokens = total_tokens
188                .checked_sub(cached_tokens)
189                .expect("cached token count cannot exceed total token count");
190            observer(CacheTokenUsage {
191                cached_tokens,
192                uncached_tokens,
193            });
194        }
195    }
196
197    /// Snapshot of L1 cache statistics (cumulative hits/misses/entries/memory).
198    pub fn cache_stats(&self) -> L1CacheStats {
199        self.l1.stats()
200    }
201
202    /// Clear all cached entries and reset counters.
203    pub fn clear_cache(&self) {
204        self.l1.clear();
205    }
206
207    /// Access the underlying tokenizer (e.g. for downcasting to a concrete type).
208    pub fn inner(&self) -> &Arc<dyn Tokenizer> {
209        &self.inner
210    }
211}
212
213impl Encoder for CachedTokenizer {
214    fn encode(&self, input: &str) -> Result<Encoding> {
215        // No specials => no boundaries are ever produced. Skip the lookup, miss-counter
216        // bump, and insert attempt entirely — otherwise the tiktoken wrapping path (which
217        // deliberately passes an empty list) pays the cost on every call with no chance
218        // of a hit.
219        if !self.l1_enabled {
220            return self.inner.encode(input);
221        }
222
223        if let Some((prefix_tokens, prefix_len, deepest_boundary)) =
224            self.l1.longest_prefix_match(input)
225        {
226            let cached_tokens = prefix_tokens.len();
227            let suffix = &input[prefix_len..];
228            let encoding = if suffix.is_empty() {
229                Encoding::Sp(prefix_tokens.to_vec())
230            } else if self.extend_on_hit {
231                // Cache the new suffix at its deepest boundary so the next turn hits
232                // deeper, then return the full merged tokens. The deepest boundary was
233                // already found by `longest_prefix_match`, so no rescan is needed here.
234                Encoding::Sp(self.l1.extend_after_match(
235                    input,
236                    prefix_tokens,
237                    prefix_len,
238                    deepest_boundary,
239                    self.inner.as_ref(),
240                )?)
241            } else {
242                let suffix_enc = self.inner.encode(suffix)?;
243                // Reserve exact capacity so appending the suffix doesn't grow-realloc and
244                // re-copy the (large) cached prefix.
245                let mut merged: Vec<TokenIdType> =
246                    Vec::with_capacity(prefix_tokens.len() + suffix_enc.token_ids().len());
247                merged.extend_from_slice(&prefix_tokens);
248                merged.extend_from_slice(suffix_enc.token_ids());
249                Encoding::Sp(merged)
250            };
251            self.observe_token_usage(cached_tokens, encoding.token_ids().len());
252            return Ok(encoding);
253        }
254
255        // Miss path: tokenize once, caching the cumulative prefix at every boundary as we
256        // go. The returned ids equal an uncached encode (special tokens are atomic), so we
257        // avoid the redundant second tokenization a separate full-encode + insert would
258        // cost. Returns Encoding::Sp — consistent with the hit path (see the storage-
259        // normalization note in the module docs).
260        let encoding = Encoding::Sp(self.l1.populate_and_encode(input, self.inner.as_ref())?);
261        self.observe_token_usage(0, encoding.token_ids().len());
262        Ok(encoding)
263    }
264
265    fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
266        // True passthrough when L1 is disabled — delegate to the inner's native
267        // batch path (which may be rayon-parallel for HF) instead of falling
268        // through per-item.
269        if !self.l1_enabled {
270            return self.inner.encode_batch(inputs);
271        }
272
273        // Per-item cache lookup — do NOT delegate to inner.encode_batch, which would
274        // bypass the cache. Sequential iteration is fine; if rayon is added later it
275        // belongs here, not inside `encode`.
276        inputs.iter().map(|&i| self.encode(i)).collect()
277    }
278
279    fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
280        // L1 indexes flattened string offsets and cannot preserve each
281        // segment's allow_special boundary. Keep the operation correct by
282        // delegating without populating or consulting the cache.
283        let encoding = self.inner.encode_segments(segments)?;
284        if self.l1_enabled {
285            self.observe_token_usage(0, encoding.token_ids().len());
286        }
287        Ok(encoding)
288    }
289}
290
291impl Decoder for CachedTokenizer {
292    fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
293        // Decode is not cached — passthrough to inner.
294        self.inner.decode(token_ids, skip_special_tokens)
295    }
296}
297
298impl Tokenizer for CachedTokenizer {
299    fn vocab_size(&self) -> Option<usize> {
300        self.inner.vocab_size()
301    }
302
303    fn token_to_id(&self, token: &str) -> Result<Option<TokenIdType>> {
304        self.inner.token_to_id(token)
305    }
306
307    fn special_token_ids(&self) -> Result<Vec<TokenIdType>> {
308        self.inner.special_token_ids()
309    }
310
311    fn num_special_tokens_added(&self) -> Result<usize> {
312        Ok(0)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::HuggingFaceTokenizer;
320    use std::sync::{Mutex, atomic::AtomicU64, atomic::Ordering};
321    use tokenizers::Tokenizer as HfTokenizer;
322
323    struct FailingTokenizer;
324
325    struct SegmentTokenizer;
326
327    impl Encoder for SegmentTokenizer {
328        fn encode(&self, input: &str) -> Result<Encoding> {
329            Ok(Encoding::Sp(vec![input.len() as u32]))
330        }
331
332        fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
333            inputs.iter().map(|input| self.encode(input)).collect()
334        }
335
336        fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
337            let ids = segments
338                .iter()
339                .flat_map(|segment| [segment.allow_special as u32, segment.text.len() as u32])
340                .collect();
341            Ok(Encoding::Sp(ids))
342        }
343    }
344
345    impl Decoder for SegmentTokenizer {
346        fn decode(
347            &self,
348            _token_ids: &[TokenIdType],
349            _skip_special_tokens: bool,
350        ) -> Result<DecodeResult> {
351            Ok(DecodeResult::Complete(String::new()))
352        }
353    }
354
355    impl Tokenizer for SegmentTokenizer {
356        fn validate_prefix_cache(&self) -> Result<()> {
357            Ok(())
358        }
359    }
360
361    impl Encoder for FailingTokenizer {
362        fn encode(&self, _input: &str) -> Result<Encoding> {
363            Err(anyhow::anyhow!("intentional encode failure"))
364        }
365
366        fn encode_batch(&self, _inputs: &[&str]) -> Result<Vec<Encoding>> {
367            Err(anyhow::anyhow!("intentional encode failure"))
368        }
369    }
370
371    impl Decoder for FailingTokenizer {
372        fn decode(
373            &self,
374            _token_ids: &[TokenIdType],
375            _skip_special_tokens: bool,
376        ) -> Result<DecodeResult> {
377            Err(anyhow::anyhow!("intentional decode failure"))
378        }
379    }
380
381    impl Tokenizer for FailingTokenizer {
382        fn validate_prefix_cache(&self) -> Result<()> {
383            Ok(())
384        }
385
386        fn vocab_size(&self) -> Option<usize> {
387            None
388        }
389    }
390
391    const TINYLLAMA_PATH: &str = concat!(
392        env!("CARGO_MANIFEST_DIR"),
393        "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
394    );
395
396    fn inner() -> Arc<dyn Tokenizer> {
397        Arc::new(HuggingFaceTokenizer::from_file(TINYLLAMA_PATH).expect("load TinyLlama"))
398    }
399
400    fn specials() -> Vec<String> {
401        vec!["<s>".into(), "</s>".into()]
402    }
403
404    fn collect_token_usage(
405        tokenizer: CachedTokenizer,
406    ) -> (CachedTokenizer, Arc<Mutex<Vec<CacheTokenUsage>>>) {
407        let events = Arc::new(Mutex::new(Vec::new()));
408        let observed = events.clone();
409        let tokenizer = tokenizer.with_token_observer(Arc::new(move |usage| {
410            observed.lock().unwrap().push(usage);
411        }));
412        (tokenizer, events)
413    }
414
415    #[test]
416    fn rejects_hf_tokenizer_that_adds_special_tokens() {
417        let tokenizer: Arc<dyn Tokenizer> = Arc::new(
418            HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
419                .expect("load TinyLlama")
420                .with_options(crate::TokenizerOptions {
421                    add_special_tokens: true,
422                }),
423        );
424
425        let result = CachedTokenizer::new(tokenizer, specials(), 4096);
426        let Err(error) = result else {
427            panic!("add_special_tokens=true must be rejected");
428        };
429        assert_eq!(
430            error.to_string(),
431            "HuggingFace tokenizers configured with add_special_tokens=true must remain uncached"
432        );
433    }
434
435    #[test]
436    fn empty_specials_passes_through_correctly() {
437        // Empty token strings carry no boundary information and must not make L1 active.
438        let tok = inner();
439        let (cached, events) = collect_token_usage(
440            CachedTokenizer::new(tok.clone(), vec![String::new()], 4096)
441                .expect("TinyLlama must support prefix caching"),
442        );
443        let s = "<s>hello world</s>";
444        let a = cached.encode(s).unwrap();
445        let b = tok.encode(s).unwrap();
446        assert_eq!(a.token_ids(), b.token_ids());
447        let stats = cached.cache_stats();
448        assert_eq!(stats.entries, 0);
449        assert_eq!(stats.misses, 0, "empty specials must not increment misses");
450        assert_eq!(stats.hits, 0);
451        assert!(
452            events.lock().unwrap().is_empty(),
453            "empty specials must not emit token usage"
454        );
455    }
456
457    #[test]
458    fn laguna_overlapping_specials_bypass_cache() {
459        const TOKENIZER_JSON: &str = r#"{
460            "version": "1.0",
461            "truncation": null,
462            "padding": null,
463            "added_tokens": [
464                {"id": 0, "content": "<unk>", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false},
465                {"id": 2, "content": "〈|EOS|〉", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false},
466                {"id": 14, "content": "〈|", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false},
467                {"id": 15, "content": "|〉", "special": true, "single_word": false, "lstrip": false, "rstrip": false, "normalized": false}
468            ],
469            "normalizer": null,
470            "pre_tokenizer": null,
471            "post_processor": null,
472            "decoder": null,
473            "model": {
474                "type": "WordLevel",
475                "vocab": {"<unk>": 0, "〈|EOS|〉": 2, "〈|": 14, "|〉": 15, "tail": 16},
476                "unk_token": "<unk>"
477            }
478        }"#;
479
480        let hf = HfTokenizer::from_bytes(TOKENIZER_JSON).expect("load test tokenizer");
481        let tok: Arc<dyn Tokenizer> = Arc::new(HuggingFaceTokenizer::from_tokenizer(hf));
482        let overlapping = vec!["〈|EOS|〉".into(), "〈|".into(), "|〉".into()];
483        let (cached, events) = collect_token_usage(
484            CachedTokenizer::new(tok.clone(), overlapping, 4096)
485                .expect("HuggingFace tokenizer must support prefix caching"),
486        );
487
488        let expected = tok.encode("〈|EOS|〉").unwrap();
489        assert_eq!(expected.token_ids(), &[2]);
490        assert_eq!(
491            cached.encode("〈|EOS|〉").unwrap().token_ids(),
492            expected.token_ids()
493        );
494        let stats = cached.cache_stats();
495        assert_eq!(stats.entries, 0);
496        assert_eq!(
497            stats.misses, 0,
498            "overlapping specials must not increment misses"
499        );
500        assert_eq!(stats.hits, 0);
501        assert!(
502            events.lock().unwrap().is_empty(),
503            "overlapping specials must not emit token usage"
504        );
505    }
506
507    #[test]
508    fn segmented_encoding_passes_through_without_caching() {
509        let inner: Arc<dyn Tokenizer> = Arc::new(SegmentTokenizer);
510        let segments = [
511            EncodeSegment::new("<ctl>", true),
512            EncodeSegment::new("user content", false),
513        ];
514        let expected = inner.encode_segments(&segments).unwrap();
515
516        for special_tokens in [Vec::new(), vec!["<ctl>".to_string()]] {
517            let l1_enabled = !special_tokens.is_empty();
518            let (cached, events) = collect_token_usage(
519                CachedTokenizer::new(inner.clone(), special_tokens, 4096)
520                    .expect("test tokenizer supports prefix caching"),
521            );
522            let actual = cached.encode_segments(&segments).unwrap();
523
524            assert_eq!(actual.token_ids(), expected.token_ids());
525            let stats = cached.cache_stats();
526            assert_eq!(stats.entries, 0);
527            assert_eq!(stats.hits, 0);
528            assert_eq!(stats.misses, 0);
529            let events = events.lock().unwrap();
530            if l1_enabled {
531                assert_eq!(
532                    events.as_slice(),
533                    &[CacheTokenUsage {
534                        cached_tokens: 0,
535                        uncached_tokens: expected.token_ids().len(),
536                    }]
537                );
538            } else {
539                assert!(events.is_empty());
540            }
541        }
542    }
543
544    #[test]
545    fn token_observer_reports_full_miss_and_partial_hit_with_and_without_extension() {
546        for extend_on_hit in [false, true] {
547            let tok = inner();
548            let hits = Arc::new(AtomicU64::new(0));
549            let misses = Arc::new(AtomicU64::new(0));
550            let hit_counter = hits.clone();
551            let miss_counter = misses.clone();
552            let cached = CachedTokenizer::new(tok, specials(), 64 * 1024)
553                .expect("TinyLlama must support prefix caching")
554                .with_extend(extend_on_hit)
555                .with_observer(
556                    Arc::new(move || {
557                        hit_counter.fetch_add(1, Ordering::Relaxed);
558                    }),
559                    Arc::new(move || {
560                        miss_counter.fetch_add(1, Ordering::Relaxed);
561                    }),
562                );
563            let (cached, events) = collect_token_usage(cached);
564
565            let shared = "<s>system\nYou are helpful.</s><s>user\n";
566            let first = format!("{shared}First question?</s>");
567            let second = format!("{shared}Second different prompt entirely.</s>");
568
569            let first_encoding = cached.encode(&first).unwrap();
570            let second_encoding = cached.encode(&second).unwrap();
571
572            let events = events.lock().unwrap();
573            assert_eq!(events.len(), 2);
574            assert_eq!(
575                events[0],
576                CacheTokenUsage {
577                    cached_tokens: 0,
578                    uncached_tokens: first_encoding.token_ids().len(),
579                }
580            );
581            assert!(events[1].cached_tokens > 0);
582            assert!(events[1].uncached_tokens > 0);
583            assert_eq!(
584                events[1].cached_tokens + events[1].uncached_tokens,
585                second_encoding.token_ids().len()
586            );
587            assert_eq!(hits.load(Ordering::Relaxed), 1);
588            assert_eq!(misses.load(Ordering::Relaxed), 1);
589        }
590    }
591
592    #[test]
593    fn token_observer_does_not_report_failed_encodes() {
594        let tokenizer: Arc<dyn Tokenizer> = Arc::new(FailingTokenizer);
595        let (cached, events) = collect_token_usage(
596            CachedTokenizer::new(tokenizer, specials(), 4096)
597                .expect("test tokenizer explicitly supports prefix caching"),
598        );
599
600        assert!(cached.encode("<s>this fails</s>").is_err());
601        assert!(events.lock().unwrap().is_empty());
602    }
603
604    #[test]
605    fn two_turn_chat_correctness_and_hit() {
606        let tok = inner();
607        let cached = CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
608            .expect("TinyLlama must support prefix caching");
609
610        let template = "<s>system\nYou are helpful.</s><s>user\n";
611        let first = format!("{template}First question?</s>");
612        let second = format!("{template}Second different prompt entirely.</s>");
613
614        // Warm the cache.
615        let _ = cached.encode(&first).unwrap();
616
617        // Second request: shared prefix → L1 hit, suffix-only fresh encode.
618        let cached_second = cached.encode(&second).unwrap();
619        let plain_second = tok.encode(&second).unwrap();
620        assert_eq!(
621            cached_second.token_ids(),
622            plain_second.token_ids(),
623            "cached encode must equal plain encode for second turn"
624        );
625
626        let stats = cached.cache_stats();
627        assert!(stats.hits >= 1, "expected L1 hit on second request");
628    }
629
630    #[test]
631    fn decode_passes_through() {
632        let tok = inner();
633        let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
634            .expect("TinyLlama must support prefix caching");
635        let enc = cached.encode("<s>hello</s>").unwrap();
636        let direct = tok.decode(enc.token_ids(), false).unwrap();
637        let through = cached.decode(enc.token_ids(), false).unwrap();
638        assert_eq!(direct, through);
639    }
640
641    #[test]
642    fn encode_batch_uses_cache() {
643        let tok = inner();
644        let (cached, events) = collect_token_usage(
645            CachedTokenizer::new(tok.clone(), specials(), 64 * 1024)
646                .expect("TinyLlama must support prefix caching"),
647        );
648        let shared = "<s>system\nShared persona.</s><s>user\n";
649        let inputs = [
650            format!("{shared}q1</s>"),
651            format!("{shared}q2</s>"),
652            format!("{shared}q3</s>"),
653        ];
654        let refs: Vec<&str> = inputs.iter().map(String::as_str).collect();
655        let outs = cached.encode_batch(&refs).unwrap();
656        assert_eq!(outs.len(), 3);
657        let events = events.lock().unwrap();
658        assert_eq!(events.len(), outs.len());
659        for (event, output) in events.iter().zip(&outs) {
660            assert_eq!(
661                event.cached_tokens + event.uncached_tokens,
662                output.token_ids().len()
663            );
664        }
665        assert_eq!(events[0].cached_tokens, 0);
666        assert!(events[1..].iter().all(|event| event.cached_tokens > 0));
667        // First call populates, second/third hit.
668        assert!(cached.cache_stats().hits >= 2, "expected hits on q2 and q3");
669    }
670
671    #[test]
672    fn vocab_introspection_forwards_to_inner() {
673        let tok = inner();
674        let cached = CachedTokenizer::new(tok.clone(), specials(), 4096)
675            .expect("TinyLlama must support prefix caching");
676        assert_eq!(cached.vocab_size(), tok.vocab_size());
677        assert_eq!(
678            cached.token_to_id("<s>").unwrap(),
679            tok.token_to_id("<s>").unwrap()
680        );
681        assert_eq!(
682            cached.special_token_ids().unwrap(),
683            tok.special_token_ids().unwrap()
684        );
685    }
686
687    #[test]
688    fn special_token_accounting_matches_cached_encoder_behavior() {
689        let cached = CachedTokenizer::new(inner(), specials(), 4096)
690            .expect("TinyLlama must support prefix caching")
691            .with_options(crate::TokenizerOptions {
692                add_special_tokens: true,
693            });
694        let cached_ids = cached.encode("hello").unwrap();
695        let hf_ids = HuggingFaceTokenizer::from_file(TINYLLAMA_PATH)
696            .expect("load TinyLlama")
697            .with_options(crate::TokenizerOptions {
698                add_special_tokens: true,
699            })
700            .encode("hello")
701            .unwrap();
702
703        assert_eq!(cached.num_special_tokens_added().unwrap(), 0);
704        assert_eq!(hf_ids.token_ids().len(), cached_ids.token_ids().len() + 1);
705        assert_eq!(&hf_ids.token_ids()[1..], cached_ids.token_ids());
706    }
707
708    #[test]
709    fn unoverridden_introspection_methods_use_defaults() {
710        let tokenizer = SegmentTokenizer;
711        assert_eq!(tokenizer.vocab_size(), None);
712        assert!(tokenizer.token_to_id("anything").is_err());
713        assert!(tokenizer.special_token_ids().is_err());
714        assert!(tokenizer.num_special_tokens_added().is_err());
715    }
716}