dynamo_tokenizers/
fastokens.rs1use std::path::Path;
11
12use rayon::prelude::*;
13
14use super::{
15 EncodeSegment, Encoding, Error, Result, TokenIdType,
16 hf::HuggingFaceTokenizer,
17 traits::{DecodeResult, Decoder, Encoder, Tokenizer},
18};
19
20pub struct FastTokenizer {
24 fast_encoder: fastokens::Tokenizer,
25 hf_decoder: HuggingFaceTokenizer,
26}
27
28impl FastTokenizer {
29 pub fn from_file(path: &str) -> Result<Self> {
30 let fast_encoder = fastokens::Tokenizer::from_file(Path::new(path))
31 .map_err(|e| Error::msg(format!("Error loading fastokens tokenizer: {e}")))?;
32 let hf_decoder = HuggingFaceTokenizer::from_file(path)?;
33 Ok(Self {
34 fast_encoder,
35 hf_decoder,
36 })
37 }
38}
39
40impl Encoder for FastTokenizer {
41 fn encode(&self, input: &str) -> Result<Encoding> {
42 let ids = self
43 .fast_encoder
44 .encode(input)
45 .map_err(|e| Error::msg(format!("Fastokens encode error: {e}")))?;
46 Ok(Encoding::Sp(ids))
47 }
48
49 fn encode_batch(&self, inputs: &[&str]) -> Result<Vec<Encoding>> {
50 inputs.par_iter().map(|input| self.encode(input)).collect()
51 }
52
53 fn encode_segments(&self, segments: &[EncodeSegment<'_>]) -> Result<Encoding> {
54 let segments: Vec<fastokens::EncodeSegment<'_>> = segments
55 .iter()
56 .map(|segment| fastokens::EncodeSegment {
57 text: segment.text,
58 allow_special: segment.allow_special,
59 })
60 .collect();
61 let ids = self
62 .fast_encoder
63 .encode_segments(&segments)
64 .map_err(|e| Error::msg(format!("Fastokens segmented encode error: {e}")))?;
65 Ok(Encoding::Sp(ids))
66 }
67}
68
69impl Decoder for FastTokenizer {
70 fn decode(&self, token_ids: &[TokenIdType], skip_special_tokens: bool) -> Result<DecodeResult> {
71 self.hf_decoder.decode(token_ids, skip_special_tokens)
72 }
73}
74
75impl Tokenizer for FastTokenizer {
76 fn validate_prefix_cache(&self) -> Result<()> {
77 Ok(())
78 }
79
80 fn vocab_size(&self) -> Option<usize> {
83 self.hf_decoder.vocab_size()
84 }
85
86 fn token_to_id(&self, token: &str) -> Result<Option<TokenIdType>> {
87 self.hf_decoder.token_to_id(token)
88 }
89
90 fn special_token_ids(&self) -> Result<Vec<TokenIdType>> {
91 self.hf_decoder.special_token_ids()
92 }
93
94 fn num_special_tokens_added(&self) -> Result<usize> {
95 Ok(0)
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::{HuggingFaceTokenizer, TokenizerOptions};
103
104 const TOKENIZER_PATH: &str = concat!(
107 env!("CARGO_MANIFEST_DIR"),
108 "/tests/data/minimal-bpe/tokenizer.json"
109 );
110 const SEGMENTED_TOKENIZER_PATH: &str = concat!(
111 env!("CARGO_MANIFEST_DIR"),
112 "/tests/data/sample-models/TinyLlama_v1.1/tokenizer.json"
113 );
114
115 #[test]
116 fn test_fast_encode_decode_roundtrip() {
117 let tokenizer = FastTokenizer::from_file(TOKENIZER_PATH).unwrap();
118 let text = "Hello, world!";
123 let encoding = tokenizer.encode(text).unwrap();
124 assert!(!encoding.token_ids().is_empty());
125 let decoded: String = tokenizer.decode(encoding.token_ids(), true).unwrap().into();
126 assert!(!decoded.is_empty());
127 let enc_chars: String = text.chars().filter(|c| !c.is_whitespace()).collect();
129 let dec_chars: String = decoded.chars().filter(|c| !c.is_whitespace()).collect();
130 assert_eq!(
131 enc_chars, dec_chars,
132 "non-space characters must be preserved"
133 );
134 }
135
136 #[test]
137 fn test_fast_matches_hf_encoding() {
138 let fast = FastTokenizer::from_file(TOKENIZER_PATH).unwrap();
139 let hf = HuggingFaceTokenizer::from_file(TOKENIZER_PATH).unwrap();
140
141 for text in &["Hello, world!", "Hello", " world", "He llo"] {
142 let fast_ids = fast.encode(text).unwrap();
143 let hf_ids = hf.encode(text).unwrap();
144 assert_eq!(
145 fast_ids.token_ids(),
146 hf_ids.token_ids(),
147 "fastokens and HuggingFace must produce identical token IDs for '{text}'"
148 );
149 }
150 }
151
152 #[test]
153 fn test_fast_batch_encode() {
154 let tokenizer = FastTokenizer::from_file(TOKENIZER_PATH).unwrap();
155 let inputs = &["Hello", " world", "Hello, world!"];
156 let encodings = tokenizer.encode_batch(inputs).unwrap();
157 assert_eq!(encodings.len(), inputs.len());
158 for (enc, input) in encodings.iter().zip(inputs.iter()) {
159 assert!(
160 !enc.token_ids().is_empty(),
161 "encoding for '{input}' must be non-empty"
162 );
163 }
164 }
165
166 #[test]
167 fn test_fast_segmented_encoding_preserves_trust_boundaries() {
168 let tokenizer = FastTokenizer::from_file(SEGMENTED_TOKENIZER_PATH).unwrap();
169 let upstream =
170 fastokens::Tokenizer::from_file(std::path::Path::new(SEGMENTED_TOKENIZER_PATH))
171 .unwrap();
172 let marker = "<s>";
173
174 let trusted = tokenizer
175 .encode_segments(&[EncodeSegment::control(marker)])
176 .unwrap();
177 assert_eq!(
178 trusted.token_ids(),
179 &[upstream.token_to_id(marker).unwrap()],
180 "trusted renderer output must recognize the control token"
181 );
182
183 let ordinary = tokenizer
184 .encode_segments(&[EncodeSegment::ordinary(marker)])
185 .unwrap();
186 assert_ne!(
187 ordinary.token_ids(),
188 trusted.token_ids(),
189 "untrusted content must encode the control-token spelling as ordinary text"
190 );
191
192 let segments = [
193 EncodeSegment::ordinary("hello "),
194 EncodeSegment::control(marker),
195 EncodeSegment::ordinary(marker),
196 ];
197 let upstream_segments = [
198 fastokens::EncodeSegment::ordinary("hello "),
199 fastokens::EncodeSegment::special(marker),
200 fastokens::EncodeSegment::ordinary(marker),
201 ];
202 let actual = tokenizer.encode_segments(&segments).unwrap();
203 let expected = upstream.encode_segments(&upstream_segments).unwrap();
204 assert_eq!(actual.token_ids(), expected);
205
206 assert!(
207 tokenizer
208 .encode_segments(&[])
209 .unwrap()
210 .token_ids()
211 .is_empty()
212 );
213 }
214
215 #[test]
216 fn test_fast_with_decode_stream() {
217 use crate::Tokenizer as TokenizerWrapper;
218 use std::sync::Arc;
219
220 let tokenizer = Arc::new(FastTokenizer::from_file(TOKENIZER_PATH).unwrap());
221 let wrapper = TokenizerWrapper::from(tokenizer);
222
223 let prompt_ids = wrapper.encode("Hello").unwrap().token_ids().to_vec();
225 let continuation = ", world!";
226 let cont_ids = wrapper.encode(continuation).unwrap().token_ids().to_vec();
227
228 let mut stream = wrapper.decode_stream(&prompt_ids, true);
229 let mut accumulated = String::new();
231 for id in &cont_ids {
232 if let Some(chunk) = stream.step(*id).unwrap() {
233 accumulated.push_str(&chunk);
234 }
235 }
236
237 let mut all_ids = prompt_ids.clone();
241 all_ids.extend_from_slice(&cont_ids);
242 let full_text: String = wrapper.decode(&all_ids, true).unwrap().into();
243 let prompt_text: String = wrapper.decode(&prompt_ids, true).unwrap().into();
244 let expected = &full_text[prompt_text.len()..];
245 assert_eq!(
246 accumulated, expected,
247 "streamed chunks must equal context-aware decoded continuation"
248 );
249 }
250
251 #[test]
252 fn vocabulary_metadata_forwards_to_hf_decoder() {
253 let fast = FastTokenizer::from_file(TOKENIZER_PATH).unwrap();
254 let hf = HuggingFaceTokenizer::from_file(TOKENIZER_PATH).unwrap();
255 assert_eq!(fast.vocab_size(), hf.vocab_size());
256 assert_eq!(
257 fast.token_to_id("Hello").unwrap(),
258 hf.token_to_id("Hello").unwrap()
259 );
260 assert_eq!(
261 fast.special_token_ids().unwrap(),
262 hf.special_token_ids().unwrap()
263 );
264 }
265
266 #[test]
267 fn special_token_accounting_matches_fast_encoder() {
268 let fast = FastTokenizer::from_file(SEGMENTED_TOKENIZER_PATH).unwrap();
269 let upstream =
270 fastokens::Tokenizer::from_file(std::path::Path::new(SEGMENTED_TOKENIZER_PATH))
271 .unwrap();
272 let hf = HuggingFaceTokenizer::from_file(SEGMENTED_TOKENIZER_PATH).unwrap();
273
274 assert_eq!(hf.num_special_tokens_added().unwrap(), 1);
275 assert_eq!(fast.num_special_tokens_added().unwrap(), 0);
276 let hf_with_special_tokens = hf.with_options(TokenizerOptions {
277 add_special_tokens: true,
278 });
279
280 for text in ["hello", "hello there"] {
281 let fast_ids = fast.encode(text).unwrap();
282 assert_eq!(
283 fast_ids.token_ids(),
284 upstream.encode(text).unwrap(),
285 "FastTokenizer must match the encoder that omits the HF post-processor"
286 );
287 assert_eq!(
288 hf_with_special_tokens
289 .encode(text)
290 .unwrap()
291 .token_ids()
292 .len(),
293 fast_ids.token_ids().len() + 1,
294 "the HF post-processor must add the BOS token FastTokenizer omits"
295 );
296 }
297 }
298}