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