metrale_grammar/tokenizer/
info.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//
3// TokenizerInfo — port of `TokenizerInfo` / `TokenizerInfo::Impl` in
4// `cpp/tokenizer_info.{cc,h}` + `tokenizer_info_impl.h`.
5//
6// Holds the decoded vocabulary, vocab type, stop tokens, and special
7// tokens used by the grammar matcher to mask the logit bitmask.
8
9use std::collections::HashSet;
10
11use crate::tokenizer::decoder::decode_token;
12use crate::tokenizer::hf_metadata::detect_metadata_from_hf;
13use crate::tokenizer::vocab_type::VocabType;
14
15/// Tokens used to auto-detect stop tokens when the caller does not
16/// supply explicit `stop_token_ids`. Faithful to the C++
17/// `DETECTION_STOP_TOKENS` set (LLaMA2/3, Phi-2, Gemma, DeepSeek-V2).
18const DETECTION_STOP_TOKENS: &[&str] = &[
19    "</s>",
20    "<|end_of_text|>",
21    "<|eot_id|>",
22    "<|endoftext|>",
23    "<eos>",
24    "<|eos|>",
25    "<end_of_turn>",
26    "<\u{ff5c}end\u{2581}of\u{2581}sentence\u{ff5c}>",
27];
28
29/// Decoded-vocabulary view of a tokenizer for grammar-constrained
30/// decoding.
31///
32/// Construct with [`TokenizerInfo::new`] from a raw vocabulary, or with
33/// [`TokenizerInfo::from_huggingface`] to additionally infer the vocab
34/// type / prefix-space from a `tokenizer.json`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct TokenizerInfo {
37    vocab_type: VocabType,
38    vocab_size: usize,
39    add_prefix_space: bool,
40    /// Decoded token bytes for every id `0..vocab_size`. Ids beyond the
41    /// supplied raw vocab are padding and decode to an empty sequence.
42    decoded_vocab: Vec<Vec<u8>>,
43    /// `(id, token)` pairs sorted lexicographically by token bytes.
44    /// Excludes special tokens and stop tokens — maximizes prefix reuse
45    /// during trie matching.
46    sorted_decoded_vocab: Vec<(i32, Vec<u8>)>,
47    /// Pseudo-trie subtree ranges: `trie_subtree_nodes_range[i]` is the
48    /// exclusive end index of the subtree rooted at sorted entry `i`.
49    trie_subtree_nodes_range: Vec<i32>,
50    /// Ids accepted as end-of-generation by the grammar matcher.
51    stop_token_ids: Vec<i32>,
52    /// Ids masked out (ignored) during grammar-guided generation.
53    special_token_ids: Vec<i32>,
54}
55
56impl TokenizerInfo {
57    /// Build a `TokenizerInfo` from a raw (encoded) vocabulary.
58    ///
59    /// Mirrors the C++ `TokenizerInfo` constructor:
60    /// * `encoded_vocab` — raw vocabulary strings, indexed by token id.
61    /// * `vocab_type` — controls token decoding (see [`VocabType`]).
62    /// * `vocab_size` — total id space; `None` means `encoded_vocab.len()`.
63    ///   When larger, the trailing ids become padding special tokens.
64    /// * `stop_token_ids` — explicit stop ids; `None` triggers
65    ///   detection via `DETECTION_STOP_TOKENS`.
66    /// * `add_prefix_space` — recorded for metadata, no decoding effect.
67    pub fn new(
68        encoded_vocab: &[String],
69        vocab_type: VocabType,
70        vocab_size: Option<usize>,
71        stop_token_ids: Option<Vec<i32>>,
72        add_prefix_space: bool,
73    ) -> Self {
74        let vocab_size = vocab_size.unwrap_or(encoded_vocab.len());
75
76        let mut decoded_vocab: Vec<Vec<u8>> = Vec::with_capacity(encoded_vocab.len());
77        let mut sorted_decoded_vocab: Vec<(i32, Vec<u8>)> = Vec::new();
78        let mut stop_ids: Vec<i32> = Vec::new();
79        let mut special_ids: Vec<i32> = Vec::new();
80
81        // The explicit stop set, if provided, is consulted per id.
82        let explicit_stop: Option<HashSet<i32>> =
83            stop_token_ids.as_ref().map(|v| v.iter().copied().collect());
84
85        for (i, raw) in encoded_vocab.iter().enumerate() {
86            let id = i as i32;
87            let token = decode_token(raw, vocab_type);
88
89            let is_stop = match &explicit_stop {
90                // Explicit ids: this id is a stop iff it is listed.
91                Some(set) => set.contains(&id),
92                // Auto-detection: a stop iff the decoded token text is
93                // one of the well-known end-of-text markers.
94                None => token_str_in_detection_set(&token),
95            };
96
97            if is_stop {
98                stop_ids.push(id);
99            } else if is_special_token(&token) {
100                special_ids.push(id);
101            } else {
102                sorted_decoded_vocab.push((id, token.clone()));
103            }
104            decoded_vocab.push(token);
105        }
106
107        // Ids beyond the raw vocab are padding — pure special tokens.
108        for i in encoded_vocab.len()..vocab_size {
109            special_ids.push(i as i32);
110        }
111        // Keep `decoded_vocab` length == vocab_size so callers can index
112        // the full id space (padding ids decode to empty bytes).
113        decoded_vocab.resize(vocab_size, Vec::new());
114
115        // Lexicographic sort by token bytes — stable on (bytes, id).
116        sorted_decoded_vocab.sort_by(|a, b| a.1.cmp(&b.1));
117
118        let trie_subtree_nodes_range = build_trie_ranges(&sorted_decoded_vocab);
119
120        TokenizerInfo {
121            vocab_type,
122            vocab_size,
123            add_prefix_space,
124            decoded_vocab,
125            sorted_decoded_vocab,
126            trie_subtree_nodes_range,
127            stop_token_ids: stop_ids,
128            special_token_ids: special_ids,
129        }
130    }
131
132    /// Build a `TokenizerInfo` from the raw text of a HuggingFace
133    /// `tokenizer.json`, inferring `vocab_type` and `add_prefix_space`.
134    ///
135    /// Returns `Err` when `tokenizer_json` is not a parseable JSON
136    /// object. The caller still supplies the raw vocabulary, optional
137    /// `vocab_size`, and optional `stop_token_ids`.
138    pub fn from_huggingface(
139        encoded_vocab: &[String],
140        tokenizer_json: &str,
141        vocab_size: Option<usize>,
142        stop_token_ids: Option<Vec<i32>>,
143    ) -> Result<Self, String> {
144        let meta = detect_metadata_from_hf(tokenizer_json)?;
145        Ok(Self::new(
146            encoded_vocab,
147            meta.vocab_type,
148            vocab_size,
149            stop_token_ids,
150            meta.add_prefix_space,
151        ))
152    }
153
154    /// The vocabulary type.
155    pub fn vocab_type(&self) -> VocabType {
156        self.vocab_type
157    }
158
159    /// The total id space size (raw vocab length, or the override).
160    pub fn vocab_size(&self) -> usize {
161        self.vocab_size
162    }
163
164    /// Whether the tokenizer prepends a space to the first token.
165    pub fn add_prefix_space(&self) -> bool {
166        self.add_prefix_space
167    }
168
169    /// Decoded token bytes, indexed by token id (length `vocab_size`).
170    pub fn decoded_vocab(&self) -> &[Vec<u8>] {
171        &self.decoded_vocab
172    }
173
174    /// Ids accepted as end-of-generation by the grammar matcher.
175    pub fn stop_token_ids(&self) -> &[i32] {
176        &self.stop_token_ids
177    }
178
179    /// Ids masked out during grammar-guided generation.
180    pub fn special_token_ids(&self) -> &[i32] {
181        &self.special_token_ids
182    }
183
184    /// `(id, token)` pairs sorted lexicographically by token bytes,
185    /// excluding stop and special tokens.
186    pub fn sorted_decoded_vocab(&self) -> &[(i32, Vec<u8>)] {
187        &self.sorted_decoded_vocab
188    }
189
190    /// Pseudo-trie subtree end-indices over [`Self::sorted_decoded_vocab`].
191    pub fn trie_subtree_nodes_range(&self) -> &[i32] {
192        &self.trie_subtree_nodes_range
193    }
194
195    /// A PROCESS-STABLE digest of everything an adaptive token mask
196    /// depends on: the vocab type / size / prefix-space flag, the
197    /// sorted decoded vocabulary (ids *and* bytes, in order), and the
198    /// stop / special id sets.
199    ///
200    /// #918: the on-disk mask snapshot
201    /// ([`crate::compiler::mask_snapshot`]) is only valid for the exact
202    /// tokenizer it was computed against — a mask is a partition of
203    /// [`Self::sorted_decoded_vocab`] by index. This is the key that
204    /// decides hit vs miss across processes, so it is FNV-1a with a
205    /// fixed basis rather than `ahash`/`DefaultHasher`, whose seeds are
206    /// randomized per process.
207    pub fn fingerprint(&self) -> u64 {
208        use crate::compiler::mask_snapshot::{fnv1a, fnv1a_of};
209        let mut h = fnv1a_of(b"xgrammar.TokenizerInfo.v1");
210        h = fnv1a(h, &(self.vocab_type as u8).to_le_bytes());
211        h = fnv1a(h, &(self.vocab_size as u64).to_le_bytes());
212        h = fnv1a(h, &[self.add_prefix_space as u8]);
213        h = fnv1a(h, &(self.sorted_decoded_vocab.len() as u64).to_le_bytes());
214        for (id, token) in &self.sorted_decoded_vocab {
215            h = fnv1a(h, &id.to_le_bytes());
216            h = fnv1a(h, &(token.len() as u32).to_le_bytes());
217            h = fnv1a(h, token);
218        }
219        for ids in [&self.stop_token_ids, &self.special_token_ids] {
220            h = fnv1a(h, &(ids.len() as u64).to_le_bytes());
221            for id in ids {
222                h = fnv1a(h, &id.to_le_bytes());
223            }
224        }
225        h
226    }
227
228    /// Serialize the metadata to the compact JSON form C++
229    /// `DumpMetadata` emits:
230    /// `{"vocab_type":N,"vocab_size":N,"add_prefix_space":b,"stop_token_ids":[...]}`.
231    pub fn dump_metadata(&self) -> String {
232        let ids: Vec<String> = self.stop_token_ids.iter().map(|i| i.to_string()).collect();
233        format!(
234            "{{\"vocab_type\":{},\"vocab_size\":{},\"add_prefix_space\":{},\"stop_token_ids\":[{}]}}",
235            self.vocab_type.as_int(),
236            self.vocab_size,
237            self.add_prefix_space,
238            ids.join(",")
239        )
240    }
241}
242
243/// A decoded token is a "special token" iff it is empty.
244///
245/// Faithful to C++ `IsSpecialToken`, whose comment notes that *only* the
246/// empty string is treated as special.
247fn is_special_token(token: &[u8]) -> bool {
248    token.is_empty()
249}
250
251/// True if the decoded token, interpreted as UTF-8, equals one of the
252/// auto-detection stop markers. Tokens that are not valid UTF-8 can
253/// never match (the C++ stores `std::string` and compares directly,
254/// which for our purposes is equivalent to a byte comparison).
255fn token_str_in_detection_set(token: &[u8]) -> bool {
256    match std::str::from_utf8(token) {
257        Ok(s) => DETECTION_STOP_TOKENS.contains(&s),
258        Err(_) => false,
259    }
260}
261
262/// Build the pseudo-trie subtree ranges over the sorted vocabulary.
263///
264/// Faithful to the C++ prefix-stack algorithm: `trie[i]` is the
265/// exclusive end of the subtree rooted at sorted entry `i`. The stack
266/// is unwound whenever the current token does *not* contain the stack
267/// top as a substring (C++ uses `token.find(top) == npos`).
268fn build_trie_ranges(sorted: &[(i32, Vec<u8>)]) -> Vec<i32> {
269    let mut ranges = vec![0i32; sorted.len()];
270    // Stack of (token_bytes_index_into_sorted, stack_slot).
271    let mut prefix_stack: Vec<usize> = Vec::new();
272
273    for (i, (_, token)) in sorted.iter().enumerate() {
274        while let Some(&top) = prefix_stack.last() {
275            let top_token = &sorted[top].1;
276            if contains_subslice(token, top_token) {
277                break;
278            }
279            ranges[top] = i as i32;
280            prefix_stack.pop();
281        }
282        prefix_stack.push(i);
283    }
284    let end = sorted.len() as i32;
285    while let Some(top) = prefix_stack.pop() {
286        ranges[top] = end;
287    }
288    ranges
289}
290
291/// True if `needle` occurs as a contiguous subslice of `haystack`
292/// (the empty needle always matches), mirroring `std::string::find`.
293fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
294    if needle.is_empty() {
295        return true;
296    }
297    if needle.len() > haystack.len() {
298        return false;
299    }
300    haystack.windows(needle.len()).any(|w| w == needle)
301}
302
303#[cfg(test)]
304#[path = "tests.rs"]
305mod tests;