Skip to main content

ferrotensor_loader/
tokenizer.rs

1//! Tokenizer for LFM2.5 models
2//!
3//! Loads and uses the tokenizer.json from the model directory.
4
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10
11/// Wraps a Hugging Face `tokenizer.json` with special-token ids.
12#[derive(Debug, Clone)]
13pub struct Tokenizer {
14    /// Token string to id.
15    pub vocab: HashMap<String, u32>,
16    /// Id to token string.
17    pub id_to_token: HashMap<u32, String>,
18    /// Beginning-of-sequence token id.
19    pub bos_token_id: u32,
20    /// End-of-sequence token id.
21    pub eos_token_id: u32,
22    /// Padding token id.
23    pub pad_token_id: u32,
24    /// Underlying Hugging Face tokenizer, if it loaded.
25    pub hf_tokenizer: Option<Arc<tokenizers::Tokenizer>>,
26}
27
28#[derive(Deserialize)]
29struct TokenizerJson {
30    model: TokenizerModel,
31}
32
33#[derive(Deserialize)]
34struct TokenizerModel {
35    vocab: HashMap<String, u32>,
36}
37
38impl Tokenizer {
39    /// Load tokenizer from tokenizer.json
40    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, String> {
41        if let Ok(hf_tokenizer) = tokenizers::Tokenizer::from_file(path.as_ref()) {
42            let vocab = hf_tokenizer.get_vocab(true);
43            let id_to_token: HashMap<u32, String> =
44                vocab.iter().map(|(k, v)| (*v, k.clone())).collect();
45            let bos_token_id = hf_tokenizer
46                .token_to_id("<|im_start|>")
47                .or_else(|| hf_tokenizer.token_to_id("<s>"))
48                .unwrap_or(1);
49            let eos_token_id = hf_tokenizer
50                .token_to_id("<|im_end|>")
51                .or_else(|| hf_tokenizer.token_to_id("</s>"))
52                .unwrap_or(2);
53            let pad_token_id = hf_tokenizer.token_to_id("<pad>").unwrap_or(0);
54
55            return Ok(Self {
56                vocab,
57                id_to_token,
58                bos_token_id,
59                eos_token_id,
60                pad_token_id,
61                hf_tokenizer: Some(Arc::new(hf_tokenizer)),
62            });
63        }
64
65        let content = fs::read_to_string(path)
66            .map_err(|e| format!("Failed to read tokenizer.json: {}", e))?;
67
68        let parsed: TokenizerJson = serde_json::from_str(&content)
69            .map_err(|e| format!("Failed to parse tokenizer.json: {}", e))?;
70
71        let vocab = parsed.model.vocab;
72        let id_to_token: HashMap<u32, String> =
73            vocab.iter().map(|(k, v)| (*v, k.clone())).collect();
74
75        // Find special tokens
76        let bos_token_id = *vocab
77            .get("<|im_start|>")
78            .or_else(|| vocab.get("<s>"))
79            .unwrap_or(&1);
80        let eos_token_id = *vocab
81            .get("<|im_end|>")
82            .or_else(|| vocab.get("</s>"))
83            .unwrap_or(&2);
84        let pad_token_id = *vocab.get("<pad>").unwrap_or(&0);
85
86        Ok(Self {
87            vocab,
88            id_to_token,
89            bos_token_id,
90            eos_token_id,
91            pad_token_id,
92            hf_tokenizer: None,
93        })
94    }
95
96    /// Encode text to token IDs, adding the tokenizer's special tokens
97    /// (e.g. a leading BOS) when it defines any.
98    pub fn encode(&self, text: &str) -> Vec<u32> {
99        self.encode_with(text, true)
100    }
101
102    /// Encode text without adding special tokens; use this for text that
103    /// continues a sequence (an SFT response after its prompt).
104    pub fn encode_continuation(&self, text: &str) -> Vec<u32> {
105        self.encode_with(text, false)
106    }
107
108    fn encode_with(&self, text: &str, add_special_tokens: bool) -> Vec<u32> {
109        if let Some(tokenizer) = &self.hf_tokenizer {
110            if let Ok(encoding) = tokenizer.encode(text, add_special_tokens) {
111                return encoding.get_ids().to_vec();
112            }
113        }
114
115        let mut tokens = if add_special_tokens {
116            vec![self.bos_token_id]
117        } else {
118            Vec::new()
119        };
120
121        // Simple character/byte fallback encoding
122        // Real BPE would be more complex, but this works for basic testing
123        for word in text.split_whitespace() {
124            // Try exact match first
125            if let Some(&id) = self.vocab.get(word) {
126                tokens.push(id);
127            } else {
128                // Fallback: encode character by character with Ġ prefix for space
129                let word_with_space = format!("Ġ{}", word);
130                if let Some(&id) = self.vocab.get(&word_with_space) {
131                    tokens.push(id);
132                } else {
133                    // Character by character
134                    for c in word.chars() {
135                        if let Some(&id) = self.vocab.get(&c.to_string()) {
136                            tokens.push(id);
137                        } else {
138                            // Unknown token - use a common fallback
139                            tokens.push(self.vocab.get("<unk>").copied().unwrap_or(0));
140                        }
141                    }
142                }
143            }
144        }
145
146        tokens
147    }
148
149    /// Decode token IDs back to text
150    pub fn decode(&self, tokens: &[u32]) -> String {
151        if let Some(tokenizer) = &self.hf_tokenizer {
152            if let Ok(decoded) = tokenizer.decode(tokens, true) {
153                return decoded;
154            }
155        }
156
157        let mut result = String::new();
158        for &id in tokens {
159            if id == self.bos_token_id || id == self.eos_token_id || id == self.pad_token_id {
160                continue;
161            }
162            if let Some(token) = self.id_to_token.get(&id) {
163                // Handle Ġ prefix (space before word)
164                if let Some(word) = token.strip_prefix('Ġ') {
165                    if !result.is_empty() {
166                        result.push(' ');
167                    }
168                    result.push_str(word);
169                } else {
170                    result.push_str(token);
171                }
172            }
173        }
174        result
175    }
176
177    /// The ChatML prompt for one user turn (with an optional system turn),
178    /// ending with the assistant header so the model continues as the
179    /// assistant: `<|im_start|>user\n{user}<|im_end|>\n<|im_start|>assistant\n`.
180    pub fn chat_prompt(system: Option<&str>, user: &str) -> String {
181        let mut prompt = String::new();
182        if let Some(sys) = system {
183            prompt.push_str("<|im_start|>system\n");
184            prompt.push_str(sys);
185            prompt.push_str("<|im_end|>\n");
186        }
187        prompt.push_str("<|im_start|>user\n");
188        prompt.push_str(user);
189        prompt.push_str("<|im_end|>\n<|im_start|>assistant\n");
190        prompt
191    }
192
193    /// Encode [`chat_prompt`](Self::chat_prompt).
194    pub fn encode_chat(&self, system: Option<&str>, user: &str) -> Vec<u32> {
195        self.encode(&Self::chat_prompt(system, user))
196    }
197}
198
199/// Next-token sampler: temperature, top-k and nucleus (top-p) filtering.
200///
201/// `temperature <= 0` means greedy decoding. `top_k == 0` and
202/// `top_p >= 1` disable the respective filters.
203pub struct Sampler {
204    /// Softmax temperature.
205    pub temperature: f32,
206    /// Keep only the `top_k` most likely tokens (0 = all).
207    pub top_k: usize,
208    /// Keep the smallest set of tokens whose probability reaches `top_p`.
209    pub top_p: f32,
210    rng: rand::rngs::StdRng,
211}
212
213impl Default for Sampler {
214    fn default() -> Self {
215        Self::new(0.7, 0, 0.9, None)
216    }
217}
218
219impl Sampler {
220    /// A sampler; `seed` makes the draws reproducible.
221    pub fn new(temperature: f32, top_k: usize, top_p: f32, seed: Option<u64>) -> Self {
222        use rand::SeedableRng;
223        let rng = match seed {
224            Some(seed) => rand::rngs::StdRng::seed_from_u64(seed),
225            None => rand::rngs::StdRng::from_entropy(),
226        };
227        Self {
228            temperature,
229            top_k,
230            top_p,
231            rng,
232        }
233    }
234
235    /// Pick a token id from one row of logits.
236    pub fn sample(&mut self, logits: &[f32]) -> u32 {
237        use rand::Rng;
238        if logits.is_empty() {
239            return 0;
240        }
241        if self.temperature <= 0.0 || self.top_k == 1 {
242            return argmax(logits);
243        }
244
245        // Candidates sorted by logit, highest first; NaNs sort last.
246        let mut order: Vec<usize> = (0..logits.len()).collect();
247        order.sort_unstable_by(|&a, &b| logits[b].total_cmp(&logits[a]));
248        if self.top_k > 0 {
249            order.truncate(self.top_k);
250        }
251
252        let max = logits[order[0]];
253        let mut probs: Vec<f32> = order
254            .iter()
255            .map(|&i| ((logits[i] - max) / self.temperature).exp())
256            .collect();
257        let total: f32 = probs.iter().sum();
258        probs.iter_mut().for_each(|p| *p /= total);
259
260        if self.top_p < 1.0 {
261            let mut cumsum = 0.0;
262            let mut keep = probs.len();
263            for (i, p) in probs.iter().enumerate() {
264                cumsum += p;
265                if cumsum >= self.top_p {
266                    keep = i + 1;
267                    break;
268                }
269            }
270            order.truncate(keep);
271            probs.truncate(keep);
272        }
273
274        let total: f32 = probs.iter().sum();
275        let mut r = self.rng.gen::<f32>() * total;
276        for (&idx, &p) in order.iter().zip(&probs) {
277            if r < p {
278                return idx as u32;
279            }
280            r -= p;
281        }
282        order[order.len() - 1] as u32
283    }
284}
285
286/// Index of the largest logit (NaN-safe).
287pub fn argmax(logits: &[f32]) -> u32 {
288    logits
289        .iter()
290        .enumerate()
291        .max_by(|a, b| a.1.total_cmp(b.1))
292        .map(|(i, _)| i as u32)
293        .unwrap_or(0)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn greedy_picks_argmax() {
302        let mut s = Sampler::new(0.0, 0, 1.0, Some(1));
303        assert_eq!(s.sample(&[0.1, 3.0, -1.0, 2.9]), 1);
304    }
305
306    #[test]
307    fn top_k_one_is_greedy() {
308        let mut s = Sampler::new(1.5, 1, 1.0, Some(7));
309        for _ in 0..20 {
310            assert_eq!(s.sample(&[0.0, 0.5, 0.4]), 1);
311        }
312    }
313
314    #[test]
315    fn top_k_never_returns_tokens_outside_the_k_best() {
316        let mut s = Sampler::new(5.0, 2, 1.0, Some(3));
317        for _ in 0..200 {
318            let t = s.sample(&[1.0, 4.0, 3.9, 0.0, -2.0]);
319            assert!(t == 1 || t == 2, "sampled {t}");
320        }
321    }
322
323    #[test]
324    fn top_p_keeps_only_the_nucleus() {
325        // Token 0 holds almost all of the mass, so top_p=0.5 keeps only it.
326        let mut s = Sampler::new(1.0, 0, 0.5, Some(9));
327        for _ in 0..50 {
328            assert_eq!(s.sample(&[10.0, 0.0, 0.0, 0.0]), 0);
329        }
330    }
331
332    #[test]
333    fn seeded_sampling_is_reproducible() {
334        let logits = [0.3, 0.2, 0.1, 0.25, 0.15];
335        let mut a = Sampler::new(1.0, 0, 1.0, Some(42));
336        let mut b = Sampler::new(1.0, 0, 1.0, Some(42));
337        let xs: Vec<u32> = (0..32).map(|_| a.sample(&logits)).collect();
338        let ys: Vec<u32> = (0..32).map(|_| b.sample(&logits)).collect();
339        assert_eq!(xs, ys);
340    }
341
342    #[test]
343    fn chat_prompt_matches_chatml() {
344        assert_eq!(
345            Tokenizer::chat_prompt(Some("Be brief."), "Hi"),
346            "<|im_start|>system\nBe brief.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
347        );
348    }
349}