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;