Skip to main content

memra_tokenizer/
lib.rs

1//! memra-tokenizer — host-only GPT-2/BPE tokenizer (encode + decode + chat template).
2//!
3//! Algorithm TAKEn ~1:1 from llama.cpp's GPT-2 BPE path (`src/llama-vocab.cpp`,
4//! `src/unicode.cpp`), Rust glue hand-rolled. Built from the model's own GGUF
5//! tokenizer metadata (`tokenizer.ggml.*`) so it is integer-exact for that model.
6//!
7//! Scope: the `gpt2` vocab model with the `qwen35` pre-tokenizer (Qwen3.5). Other
8//! pre-tokenizers are not ported (we only need this model's).
9
10pub mod chat;
11mod json;
12mod unicode;
13mod unicode_data;
14
15pub use chat::apply_chat_template_str;
16
17use memra_gguf::{GgufFile, MetaValue};
18use std::cmp::Ordering;
19use std::collections::{BinaryHeap, HashMap};
20
21/// ggml token_type values (llama.cpp `LLAMA_TOKEN_TYPE_*`).
22const TT_UNKNOWN: i64 = 2;
23const TT_CONTROL: i64 = 3;
24const TT_USER_DEFINED: i64 = 4;
25const TT_BYTE: i64 = 6;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum TokAttr {
29    Normal,
30    Unknown,
31    Control,
32    UserDefined,
33    Byte,
34    Other,
35}
36
37impl TokAttr {
38    fn from_toktype(t: i64) -> Self {
39        match t {
40            TT_UNKNOWN => TokAttr::Unknown,
41            TT_CONTROL => TokAttr::Control,
42            TT_USER_DEFINED => TokAttr::UserDefined,
43            TT_BYTE => TokAttr::Byte,
44            1 => TokAttr::Normal,
45            _ => TokAttr::Other,
46        }
47    }
48    /// Tokens that participate in `tokenizer_st_partition` (special-token splitting):
49    /// CONTROL | USER_DEFINED | UNKNOWN.
50    fn is_special(self) -> bool {
51        matches!(self, TokAttr::Control | TokAttr::UserDefined | TokAttr::Unknown)
52    }
53}
54
55pub struct Tokenizer {
56    /// id -> raw vocab piece string (byte-encoded GPT-2 form, e.g. "Ġworld").
57    id_to_token: Vec<String>,
58    /// piece string -> id.
59    token_to_id: HashMap<String, u32>,
60    /// per-token attribute.
61    attrs: Vec<TokAttr>,
62    /// (left, right) merge pair -> rank (lower = higher priority).
63    bpe_ranks: HashMap<(String, String), i32>,
64    /// special-token ids, sorted by descending piece length (llama's cache order).
65    special_tokens: Vec<u32>,
66    eos_id: u32,
67    bos_id: Option<u32>,
68    add_bos: bool,
69    pre: String,
70    chat_template: Option<String>,
71    /// SPM-style BPE (gemma4): \u2581 whitespace escaping, raw-UTF-8 merges, <0xXX> byte fallback.
72    spm_style: bool,
73}
74
75/// A bigram in the BPE work queue. Ordering matches llama.cpp's comparator:
76/// the priority_queue pops the *smallest* (rank, left) under the std comparator
77/// `l.rank > r.rank || (l.rank == r.rank && l.left > r.left)`. We implement `Ord`
78/// so a max-heap pops that same element (min rank, then min left).
79#[derive(Clone, Eq, PartialEq)]
80struct Bigram {
81    left: i32,
82    right: i32,
83    rank: i32,
84    text: String,
85}
86
87impl Ord for Bigram {
88    fn cmp(&self, other: &Self) -> Ordering {
89        // BinaryHeap is a max-heap; we want the element with the lowest rank
90        // (ties: lowest left index) to be "greatest" so it pops first.
91        match other.rank.cmp(&self.rank) {
92            Ordering::Equal => other.left.cmp(&self.left),
93            o => o,
94        }
95    }
96}
97impl PartialOrd for Bigram {
98    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
99        Some(self.cmp(other))
100    }
101}
102
103/// A symbol (one or more codepoints) in the BPE chain. Mirrors `llm_symbol`.
104struct Symbol {
105    text: String,
106    prev: i32,
107    next: i32,
108    n: usize, // codepoint count (0 == merged away)
109}
110
111impl Tokenizer {
112    /// Build a tokenizer from a model's GGUF tokenizer metadata.
113    pub fn from_gguf(g: &GgufFile) -> Result<Self, String> {
114        let model = g
115            .metadata
116            .get("tokenizer.ggml.model")
117            .and_then(|v| v.as_str())
118            .ok_or("missing tokenizer.ggml.model")?;
119        if model != "gpt2" && model != "gemma4" {
120            return Err(format!("unsupported tokenizer model '{model}' (only gpt2/gemma4)"));
121        }
122        // gemma4 = SPM-style BPE (llama-vocab.cpp): spaces escaped to \u2581 by the normalizer,
123        // merges over raw UTF-8 (NO gpt2 byte-encoding), whole-line pre-split, <0xXX> byte
124        // fallback tokens, add_bos force-true (PR #21500 workaround).
125        let spm_style = model == "gemma4";
126        let pre = g
127            .metadata
128            .get("tokenizer.ggml.pre")
129            .and_then(|v| v.as_str())
130            .unwrap_or(if spm_style { "gemma4" } else { "default" })
131            .to_string();
132
133        // tokens[]
134        let tokens = match g.metadata.get("tokenizer.ggml.tokens") {
135            Some(MetaValue::Array(a)) => a,
136            _ => return Err("missing tokenizer.ggml.tokens array".into()),
137        };
138        let n = tokens.len();
139        let mut id_to_token = Vec::with_capacity(n);
140        let mut token_to_id = HashMap::with_capacity(n);
141        for (i, t) in tokens.iter().enumerate() {
142            let s = t.as_str().ok_or("non-string in tokens[]")?.to_string();
143            // first-id-wins on duplicates (llama keeps the map's first insert)
144            token_to_id.entry(s.clone()).or_insert(i as u32);
145            id_to_token.push(s);
146        }
147
148        // token_type[] -> attrs
149        let mut attrs = vec![TokAttr::Normal; n];
150        if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.token_type") {
151            for (i, v) in a.iter().enumerate().take(n) {
152                if let Some(t) = v.as_u64() {
153                    attrs[i] = TokAttr::from_toktype(t as i64);
154                } else if let MetaValue::I32(t) = v {
155                    attrs[i] = TokAttr::from_toktype(*t as i64);
156                }
157            }
158        }
159
160        // merges[] -> ranks. Each entry is "first second" (split on first space at idx>=1).
161        let mut bpe_ranks = HashMap::new();
162        if let Some(MetaValue::Array(a)) = g.metadata.get("tokenizer.ggml.merges") {
163            for (i, v) in a.iter().enumerate() {
164                let word = v.as_str().ok_or("non-string in merges[]")?;
165                // llama: pos = word.find(' ', 1) — a *byte* search starting at byte 1.
166                // (The space separating the two pieces is always single-byte ASCII; the
167                // pieces themselves may contain multibyte chars like 'Ġ', so we search bytes.)
168                let bytes = word.as_bytes();
169                if let Some(pos) = bytes.iter().skip(1).position(|&b| b == b' ').map(|p| p + 1) {
170                    let first = word[..pos].to_string();
171                    let second = word[pos + 1..].to_string();
172                    bpe_ranks.insert((first, second), i as i32);
173                }
174            }
175        } else {
176            return Err("missing tokenizer.ggml.merges array".into());
177        }
178
179        // special-token cache: CONTROL|USER_DEFINED|UNKNOWN, sorted by descending text length.
180        let mut special_tokens: Vec<u32> = (0..n as u32)
181            .filter(|&id| attrs[id as usize].is_special())
182            .collect();
183        special_tokens.sort_by(|&a, &b| {
184            id_to_token[b as usize]
185                .len()
186                .cmp(&id_to_token[a as usize].len())
187        });
188
189        let eos_id = g
190            .metadata
191            .get("tokenizer.ggml.eos_token_id")
192            .and_then(|v| v.as_u64())
193            .map(|v| v as u32)
194            .ok_or("missing tokenizer.ggml.eos_token_id")?;
195        let bos_id = g
196            .metadata
197            .get("tokenizer.ggml.bos_token_id")
198            .and_then(|v| v.as_u64())
199            .map(|v| v as u32);
200        let add_bos = g
201            .metadata
202            .get("tokenizer.ggml.add_bos_token")
203            .and_then(|v| match v {
204                MetaValue::Bool(b) => Some(*b),
205                _ => v.as_u64().map(|x| x != 0),
206            })
207            .unwrap_or(false);
208        let add_bos = add_bos || spm_style;
209
210        let chat_template = g
211            .metadata
212            .get("tokenizer.chat_template")
213            .and_then(|v| v.as_str())
214            .map(|s| s.to_string());
215
216        Ok(Tokenizer {
217            id_to_token,
218            token_to_id,
219            attrs,
220            bpe_ranks,
221            special_tokens,
222            eos_id,
223            bos_id,
224            add_bos,
225            pre,
226            chat_template,
227            spm_style,
228        })
229    }
230
231    /// Build a tokenizer from an HF fast-tokenizer checkpoint directory
232    /// (`tokenizer.json` + optional `tokenizer_config.json` / `generation_config.json` /
233    /// `chat_template.jinja`). Only byte-level BPE (the gpt2 class — MiniMax-M3, Qwen,
234    /// Llama-3 style) is supported: `model.type == "BPE"` with a ByteLevel pre-tokenizer.
235    ///
236    /// Mapping to the GGUF-built struct:
237    ///   - model.vocab (token -> id map)             -> id_to_token / token_to_id
238    ///   - model.merges ("a b" strings OR [a,b] pairs; both HF serializations) -> bpe_ranks
239    ///   - added_tokens special=true -> Control class (split before BPE + hidden on decode);
240    ///     non-special added tokens stay Normal.
241    ///   - eos/bos: tokenizer_config eos_token/bos_token (string or {content} object),
242    ///     generation_config eos_token_id (int or array) as the eos fallback.
243    ///   - add_bos: tokenizer_config add_bos_token (default false).
244    ///   - chat template: tokenizer_config chat_template, else chat_template.jinja.
245    ///   - pre = "default": every `pre` class routes to the gpt2-style byte-level split
246    ///     in bpe_tokenize (see the match there) — correct for the M3 tokenizer class.
247    pub fn from_hf_dir(dir: &std::path::Path) -> Result<Self, String> {
248        let tj_path = dir.join("tokenizer.json");
249        let text = std::fs::read_to_string(&tj_path)
250            .map_err(|e| format!("read {}: {e}", tj_path.display()))?;
251        let tj = json::parse(&text).map_err(|e| format!("{}: {e}", tj_path.display()))?;
252
253        let model = tj.get("model").ok_or("tokenizer.json: missing model")?;
254        if let Some(t) = model.get("type").and_then(|v| v.as_str()) {
255            if t != "BPE" {
256                return Err(format!("unsupported tokenizer.json model type '{t}' (only BPE)"));
257            }
258        }
259        // byte-level check: pre_tokenizer.type == ByteLevel (possibly inside a Sequence).
260        let pre_tok = tj.get("pre_tokenizer").ok_or("tokenizer.json: missing pre_tokenizer")?;
261        if !pre_tokenizer_is_byte_level(pre_tok) {
262            return Err("tokenizer.json: pre_tokenizer is not ByteLevel — only byte-level \
263                        BPE is supported"
264                .into());
265        }
266
267        // ---- vocab (token -> id). ids may exceed the map len (added_tokens append). ----
268        let vocab = model
269            .get("vocab")
270            .and_then(|v| v.as_obj())
271            .ok_or("tokenizer.json: missing model.vocab")?;
272        let empty: Vec<json::Value> = Vec::new();
273        let added = tj
274            .get("added_tokens")
275            .and_then(|v| v.as_arr())
276            .unwrap_or(&empty);
277        let mut max_id = 0u32;
278        for v in vocab.values() {
279            let id = v.as_u64().ok_or("tokenizer.json: non-integer id in model.vocab")? as u32;
280            max_id = max_id.max(id);
281        }
282        for a in added {
283            if let Some(id) = a.get("id").and_then(|v| v.as_u64()) {
284                max_id = max_id.max(id as u32);
285            }
286        }
287        let n = max_id as usize + 1;
288        let mut id_to_token = vec![String::new(); n];
289        let mut token_to_id: HashMap<String, u32> = HashMap::with_capacity(n);
290        let mut attrs = vec![TokAttr::Normal; n];
291        for (tok, v) in vocab {
292            let id = v.as_u64().unwrap() as u32;
293            id_to_token[id as usize] = tok.clone();
294            token_to_id.entry(tok.clone()).or_insert(id);
295        }
296        // added_tokens: register content + special flag. special=true -> Control (the class
297        // that is split out before BPE and hidden by decode_special(.., false)).
298        for a in added {
299            let id = a
300                .get("id")
301                .and_then(|v| v.as_u64())
302                .ok_or("tokenizer.json: added_tokens entry missing id")? as u32;
303            let content = a
304                .get("content")
305                .and_then(|v| v.as_str())
306                .ok_or("tokenizer.json: added_tokens entry missing content")?;
307            if id_to_token[id as usize].is_empty() {
308                id_to_token[id as usize] = content.to_string();
309            }
310            token_to_id.entry(content.to_string()).or_insert(id);
311            if a.get("special").and_then(|v| v.as_bool()).unwrap_or(false) {
312                attrs[id as usize] = TokAttr::Control;
313            } else {
314                // HF's AddedVocabulary matches EVERY added token whole (special or not) before
315                // the BPE model runs; `special` only controls skip_special_tokens on decode.
316                // UserDefined = split whole before BPE but NOT hidden on decode — exactly the
317                // HF non-special class (Hy3's `<think:opensource>`/`<|reasoning_mode…|>` chat
318                // tokens are special=false and MUST encode as single ids, 2026-07-09).
319                attrs[id as usize] = TokAttr::UserDefined;
320            }
321        }
322
323        // ---- merges: array of "a b" strings OR [a, b] pairs (HF emits both). ----
324        let merges = model
325            .get("merges")
326            .and_then(|v| v.as_arr())
327            .ok_or("tokenizer.json: missing model.merges")?;
328        let mut bpe_ranks = HashMap::with_capacity(merges.len());
329        for (i, m) in merges.iter().enumerate() {
330            let (first, second) = match m {
331                json::Value::Str(s) => {
332                    // byte search for the separating space from byte 1 (same as the GGUF
333                    // path: pieces may contain multibyte chars like 'Ġ', the space is ASCII).
334                    let bytes = s.as_bytes();
335                    let pos = bytes
336                        .iter()
337                        .skip(1)
338                        .position(|&b| b == b' ')
339                        .map(|p| p + 1)
340                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] has no space"))?;
341                    (s[..pos].to_string(), s[pos + 1..].to_string())
342                }
343                json::Value::Arr(a) if a.len() == 2 => {
344                    let f = a[0]
345                        .as_str()
346                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
347                    let s2 = a[1]
348                        .as_str()
349                        .ok_or_else(|| format!("tokenizer.json: merges[{i}] non-string pair"))?;
350                    (f.to_string(), s2.to_string())
351                }
352                _ => {
353                    return Err(format!(
354                        "tokenizer.json: merges[{i}] is neither \"a b\" string nor [a, b] pair"
355                    ));
356                }
357            };
358            bpe_ranks.insert((first, second), i as i32);
359        }
360
361        // special-token cache: same construction as from_gguf.
362        let mut special_tokens: Vec<u32> = (0..n as u32)
363            .filter(|&id| attrs[id as usize].is_special())
364            .collect();
365        special_tokens.sort_by(|&a, &b| {
366            id_to_token[b as usize]
367                .len()
368                .cmp(&id_to_token[a as usize].len())
369        });
370
371        // ---- sidecars: tokenizer_config.json + generation_config.json ----
372        let tc = std::fs::read_to_string(dir.join("tokenizer_config.json"))
373            .ok()
374            .and_then(|t| json::parse(&t).ok());
375        let gc = std::fs::read_to_string(dir.join("generation_config.json"))
376            .ok()
377            .and_then(|t| json::parse(&t).ok());
378
379        // eos_token/bos_token: plain string OR {"content": "..."} AddedToken object.
380        let tok_content = |v: &json::Value| -> Option<String> {
381            v.as_str()
382                .map(|s| s.to_string())
383                .or_else(|| v.get("content").and_then(|c| c.as_str()).map(|s| s.to_string()))
384        };
385        let eos_from_cfg = tc
386            .as_ref()
387            .and_then(|c| c.get("eos_token"))
388            .and_then(&tok_content)
389            .and_then(|s| token_to_id.get(&s).copied());
390        // generation_config eos_token_id: int or array of ints (first entry wins).
391        let eos_from_gen = gc
392            .as_ref()
393            .and_then(|c| c.get("eos_token_id"))
394            .and_then(|v| match v {
395                json::Value::Num(_) => v.as_u64(),
396                json::Value::Arr(a) => a.first().and_then(|x| x.as_u64()),
397                _ => None,
398            })
399            .map(|v| v as u32);
400        let eos_id = eos_from_cfg.or(eos_from_gen).ok_or(
401            "no eos token: need tokenizer_config.json eos_token or \
402             generation_config.json eos_token_id",
403        )?;
404        let bos_id = tc
405            .as_ref()
406            .and_then(|c| c.get("bos_token"))
407            .and_then(&tok_content)
408            .and_then(|s| token_to_id.get(&s).copied());
409        let add_bos = tc
410            .as_ref()
411            .and_then(|c| c.get("add_bos_token"))
412            .and_then(|v| v.as_bool())
413            .unwrap_or(false);
414
415        // chat template: tokenizer_config chat_template string, else chat_template.jinja file.
416        let chat_template = tc
417            .as_ref()
418            .and_then(|c| c.get("chat_template"))
419            .and_then(|v| v.as_str())
420            .map(|s| s.to_string())
421            .or_else(|| std::fs::read_to_string(dir.join("chat_template.jinja")).ok());
422
423        Ok(Tokenizer {
424            id_to_token,
425            token_to_id,
426            attrs,
427            bpe_ranks,
428            special_tokens,
429            eos_id,
430            bos_id,
431            add_bos,
432            pre: "default".to_string(),
433            chat_template,
434            spm_style: false,
435        })
436    }
437
438    pub fn eos_id(&self) -> u32 {
439        self.eos_id
440    }
441    /// End-of-generation ids: eos + the common turn-end control tokens present in the vocab
442    /// (llama's special_eog set — <|im_end|> chatml, <turn|>/<end_of_turn> gemma).
443    pub fn eog_ids(&self) -> Vec<u32> {
444        let mut ids = vec![self.eos_id];
445        for t in ["<|im_end|>", "<turn|>", "<end_of_turn>"] {
446            if let Some(&id) = self.token_to_id.get(t) {
447                if !ids.contains(&id) { ids.push(id); }
448            }
449        }
450        ids
451    }
452    pub fn bos_id(&self) -> Option<u32> {
453        self.bos_id
454    }
455    pub fn vocab_size(&self) -> usize {
456        self.id_to_token.len()
457    }
458    pub fn pre(&self) -> &str {
459        &self.pre
460    }
461    pub fn chat_template(&self) -> Option<&str> {
462        self.chat_template.as_deref()
463    }
464
465    #[inline]
466    fn text_to_token(&self, s: &str) -> Option<u32> {
467        self.token_to_id.get(s).copied()
468    }
469
470    fn find_bpe_rank(&self, left: &str, right: &str) -> i32 {
471        self.bpe_ranks
472            .get(&(left.to_string(), right.to_string()))
473            .copied()
474            .unwrap_or(-1)
475    }
476
477    /// Encode text -> token ids.
478    ///
479    /// `add_special` controls whether a BOS is prepended when the model asks for it.
480    /// `parse_special` (always true here) splits control/user-defined/unknown tokens
481    /// (e.g. `<|im_start|>`) out before BPE — matching llama's default tokenize().
482    pub fn encode(&self, text: &str, add_special: bool) -> Vec<u32> {
483        self.encode_special(text, add_special, true)
484    }
485
486    pub fn encode_special(&self, text: &str, add_special: bool, parse_special: bool) -> Vec<u32> {
487        let mut output: Vec<u32> = Vec::new();
488        if add_special && self.add_bos {
489            if let Some(b) = self.bos_id {
490                output.push(b);
491            }
492        }
493        if text.is_empty() {
494            return output;
495        }
496
497        // fragment buffer: alternate raw-text spans and resolved special-token ids.
498        for frag in self.st_partition(text, parse_special) {
499            match frag {
500                Fragment::Token(id) => output.push(id),
501                Fragment::Text(span) => self.bpe_tokenize(&span, &mut output),
502            }
503        }
504        output
505    }
506
507    /// `tokenizer_st_partition` — split out special tokens (longest first) before BPE.
508    fn st_partition(&self, text: &str, parse_special: bool) -> Vec<Fragment> {
509        let mut frags = vec![Fragment::Text(text.to_string())];
510        for &sid in &self.special_tokens {
511            let attr = self.attrs[sid as usize];
512            // when parse_special is false, skip CONTROL/UNKNOWN (user-defined still split).
513            if !parse_special && matches!(attr, TokAttr::Control | TokAttr::Unknown) {
514                continue;
515            }
516            let needle = &self.id_to_token[sid as usize];
517            if needle.is_empty() {
518                continue;
519            }
520            let mut next: Vec<Fragment> = Vec::with_capacity(frags.len());
521            for f in frags.drain(..) {
522                match f {
523                    Fragment::Token(id) => next.push(Fragment::Token(id)),
524                    Fragment::Text(s) => {
525                        let mut rest: &str = &s;
526                        let mut acc = String::new();
527                        while let Some(m) = rest.find(needle.as_str()) {
528                            acc.push_str(&rest[..m]);
529                            if !acc.is_empty() {
530                                next.push(Fragment::Text(std::mem::take(&mut acc)));
531                            }
532                            next.push(Fragment::Token(sid));
533                            rest = &rest[m + needle.len()..];
534                        }
535                        acc.push_str(rest);
536                        if !acc.is_empty() {
537                            next.push(Fragment::Text(acc));
538                        }
539                    }
540                }
541            }
542            frags = next;
543        }
544        frags
545    }
546
547    /// Core BPE over one raw-text fragment (`llm_tokenizer_bpe_session::tokenize`).
548    fn bpe_tokenize(&self, text: &str, output: &mut Vec<u32>) {
549        if self.spm_style {
550            // gemma4 (llama PRE_TYPE_GEMMA4): escape spaces to \u2581 on the raw fragment,
551            // split whole lines ([^\n]+|[\n]+), run BPE on raw UTF-8 chars.
552            let escaped: String = text.chars().map(|c| if c == ' ' { '\u{2581}' } else { c }).collect();
553            let mut words: Vec<String> = Vec::new();
554            let mut cur = String::new();
555            let mut cur_nl: Option<bool> = None;
556            for c in escaped.chars() {
557                let nl = c == '\n';
558                if cur_nl != Some(nl) && !cur.is_empty() {
559                    words.push(std::mem::take(&mut cur));
560                }
561                cur_nl = Some(nl);
562                cur.push(c);
563            }
564            if !cur.is_empty() { words.push(cur); }
565            for word in &words {
566                // newline-run fix (llama PR #21343): whole-word vocab hit short-circuits BPE.
567                if word.chars().all(|c| c == '\n') {
568                    if let Some(tok) = self.text_to_token(word) {
569                        output.push(tok);
570                        continue;
571                    }
572                }
573                self.bpe_merge_word(word, output);
574            }
575            return;
576        }
577        // 1) pre-tokenizer split (qwen35), then 2) GPT-2 byte-encode each word.
578        let words: Vec<String> = match self.pre.as_str() {
579            "qwen35" => unicode::split_qwen35(text),
580            other => {
581                // Fall back to qwen35 split for the closely-related qwen2 family;
582                // anything else is unsupported and would not be integer-exact.
583                if other == "qwen2" {
584                    unicode::split_qwen35(text)
585                } else {
586                    unicode::split_qwen35(text)
587                }
588            }
589        };
590
591        for word in &words {
592            let word = unicode::byte_encode(word);
593            self.bpe_merge_word(&word, output);
594        }
595    }
596
597    /// BPE merge over one pre-split word (symbols = unicode chars), emitting token ids with
598    /// byte fallback (gpt2 single-char byte tokens, or SPM <0xXX> tokens when spm_style).
599    fn bpe_merge_word(&self, word: &str, output: &mut Vec<u32>) {
600        {
601            let word = word.to_string();
602
603            // build the symbol chain, one symbol per unicode char initially.
604            let chars: Vec<char> = word.chars().collect();
605            let mut symbols: Vec<Symbol> = Vec::with_capacity(chars.len());
606            for (i, &c) in chars.iter().enumerate() {
607                symbols.push(Symbol {
608                    text: c.to_string(),
609                    prev: i as i32 - 1,
610                    next: if i + 1 == chars.len() { -1 } else { i as i32 + 1 },
611                    n: 1,
612                });
613            }
614
615            // seed the work queue with adjacent bigrams.
616            let mut queue: BinaryHeap<Bigram> = BinaryHeap::new();
617            for i in 1..symbols.len() {
618                self.add_bigram(&symbols, i as i32 - 1, i as i32, &mut queue);
619            }
620
621            // merge by rank.
622            while let Some(bigram) = queue.pop() {
623                let li = bigram.left as usize;
624                let ri = bigram.right as usize;
625                if symbols[li].n == 0 || symbols[ri].n == 0 {
626                    continue;
627                }
628                let combined = format!("{}{}", symbols[li].text, symbols[ri].text);
629                if combined != bigram.text {
630                    continue; // outdated bigram
631                }
632                // merge right into left
633                symbols[li].text = combined;
634                symbols[li].n += symbols[ri].n;
635                symbols[ri].n = 0;
636                let r_next = symbols[ri].next;
637                symbols[li].next = r_next;
638                if r_next >= 0 {
639                    symbols[r_next as usize].prev = bigram.left;
640                }
641                let l_prev = symbols[li].prev;
642                let l_next = symbols[li].next;
643                self.add_bigram(&symbols, l_prev, bigram.left, &mut queue);
644                self.add_bigram(&symbols, bigram.left, l_next, &mut queue);
645            }
646
647            // emit final symbols in chain order, with byte-level fallback.
648            for sym in &symbols {
649                if sym.n == 0 {
650                    continue;
651                }
652                match self.text_to_token(&sym.text) {
653                    Some(tok) => output.push(tok),
654                    None => {
655                        // byte fallback: each *byte* of the piece must be its own token.
656                        for b in sym.text.bytes() {
657                            let bs = if self.spm_style {
658                                format!("<0x{b:02X}>")   // SPM-style byte tokens (gemma4)
659                            } else {
660                                (b as char).to_string()
661                            };
662                            if let Some(t) = self.text_to_token(&bs) {
663                                output.push(t);
664                            }
665                        }
666                    }
667                }
668            }
669        }
670    }
671
672    fn add_bigram(&self, symbols: &[Symbol], left: i32, right: i32, queue: &mut BinaryHeap<Bigram>) {
673        if left == -1 || right == -1 {
674            return;
675        }
676        let lt = &symbols[left as usize].text;
677        let rt = &symbols[right as usize].text;
678        let rank = self.find_bpe_rank(lt, rt);
679        if rank < 0 {
680            return;
681        }
682        queue.push(Bigram {
683            left,
684            right,
685            rank,
686            text: format!("{lt}{rt}"),
687        });
688    }
689
690    /// Decode token ids -> String. `special=false` drops control tokens (chat tags);
691    /// `special=true` renders them as their literal text.
692    pub fn decode(&self, ids: &[u32]) -> String {
693        self.decode_special(ids, true)
694    }
695
696    /// True for Control/Unknown tokens — vocab entries that are protocol markers, not text.
697    /// External vocab consumers (llguidance's toktrie, constrained decoding) must not let a
698    /// grammar match these as literal bytes (a JSON string could otherwise smuggle
699    /// `<|im_start|>`); they substitute a non-text marker form instead.
700    pub fn token_is_control(&self, id: u32) -> bool {
701        match self.attrs.get(id as usize) {
702            Some(TokAttr::Control) | Some(TokAttr::Unknown) => true,
703            _ => false,
704        }
705    }
706
707    pub fn decode_special(&self, ids: &[u32], special: bool) -> String {
708        String::from_utf8_lossy(&self.decode_bytes_special(ids, special)).into_owned()
709    }
710
711    /// Decode token ids to their exact byte stream. Streaming callers must retain incomplete
712    /// UTF-8 suffixes across token boundaries instead of replacing them prematurely.
713    pub fn decode_bytes_special(&self, ids: &[u32], special: bool) -> Vec<u8> {
714        let mut bytes: Vec<u8> = Vec::new();
715        for &id in ids {
716            let i = id as usize;
717            if i >= self.id_to_token.len() {
718                continue;
719            }
720            let attr = self.attrs[i];
721            let piece = &self.id_to_token[i];
722            match attr {
723                TokAttr::Normal | TokAttr::Byte => {
724                    if self.spm_style {
725                        // gemma4: <0xXX> byte tokens -> raw byte; else unescape \u2581 -> space.
726                        if matches!(attr, TokAttr::Byte)
727                            || (piece.len() == 6 && piece.starts_with("<0x") && piece.ends_with('>')) {
728                            if let Ok(b) = u8::from_str_radix(&piece[3..5], 16) {
729                                bytes.push(b);
730                                continue;
731                            }
732                        }
733                        for c in piece.chars() {
734                            if c == '\u{2581}' { bytes.push(b' '); }
735                            else {
736                                let mut buf = [0u8; 4];
737                                bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
738                            }
739                        }
740                    } else {
741                        // undo GPT-2 byte encoding: each char -> one raw byte.
742                        self.piece_to_bytes(piece, &mut bytes);
743                    }
744                }
745                TokAttr::UserDefined => {
746                    // user-defined tokens are literal text (not byte-encoded).
747                    bytes.extend_from_slice(piece.as_bytes());
748                }
749                TokAttr::Control | TokAttr::Unknown => {
750                    if special {
751                        bytes.extend_from_slice(piece.as_bytes());
752                    }
753                    // else: render nothing
754                }
755                TokAttr::Other => {}
756            }
757        }
758        bytes
759    }
760
761    fn piece_to_bytes(&self, piece: &str, out: &mut Vec<u8>) {
762        for c in piece.chars() {
763            match unicode::unicode_to_byte(c) {
764                Some(b) => out.push(b),
765                None => {
766                    // not in the byte map — emit the char's utf-8 bytes verbatim.
767                    let mut buf = [0u8; 4];
768                    out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
769                }
770            }
771        }
772    }
773
774    /// Apply the chat template (from GGUF, or a chatml fallback) to a list of
775    /// (role, content) turns, producing the prompt string. Then `encode` it.
776    pub fn apply_chat_template(
777        &self,
778        messages: &[(&str, &str)],
779        add_generation_prompt: bool,
780    ) -> String {
781        chat::apply_chat_template_str(self.chat_template.as_deref(), messages, add_generation_prompt)
782    }
783
784    /// Tools-capable chat rendering (OpenAI `tools` / `tool_calls` / role:"tool" surface +
785    /// the think-tail switch). Plain requests render byte-identically to
786    /// `apply_chat_template`; see `chat::apply_chat_template_tools`.
787    pub fn apply_chat_template_tools(
788        &self,
789        turns: &[chat::Turn],
790        add_generation_prompt: bool,
791        tools_json: &[String],
792        think: chat::ThinkMode,
793    ) -> Result<String, String> {
794        chat::apply_chat_template_tools(
795            self.chat_template.as_deref(), turns, add_generation_prompt, tools_json, think)
796    }
797}
798
799enum Fragment {
800    Text(String),
801    Token(u32),
802}
803
804/// True when an HF `pre_tokenizer` object is byte-level BPE: type == "ByteLevel", or a
805/// "Sequence" whose pretokenizers include a ByteLevel step (the common Split+ByteLevel combo).
806fn pre_tokenizer_is_byte_level(pt: &json::Value) -> bool {
807    match pt.get("type").and_then(|v| v.as_str()) {
808        Some("ByteLevel") => true,
809        Some("Sequence") => pt
810            .get("pretokenizers")
811            .and_then(|v| v.as_arr())
812            .map(|arr| arr.iter().any(pre_tokenizer_is_byte_level))
813            .unwrap_or(false),
814        _ => false,
815    }
816}
817
818#[cfg(test)]
819mod hf_tests {
820    use super::*;
821
822    /// Inline tokenizer.json fixture: byte-level BPE, ~20 tokens incl one special added
823    /// token, merges deliberately MIXED between the "a b" string format and the [a, b]
824    /// pair format (HF emits both across tokenizers versions).
825    const TOKENIZER_JSON: &str = r#"{
826      "version": "1.0",
827      "added_tokens": [
828        {"id": 15, "content": "<|end|>", "special": true},
829        {"id": 16, "content": "<think>", "special": false}
830      ],
831      "pre_tokenizer": {
832        "type": "Sequence",
833        "pretokenizers": [
834          {"type": "Split", "pattern": {"Regex": ""}, "behavior": "Isolated"},
835          {"type": "ByteLevel", "add_prefix_space": false, "trim_offsets": false}
836        ]
837      },
838      "model": {
839        "type": "BPE",
840        "vocab": {
841          "h": 0, "e": 1, "l": 2, "o": 3, "Ġ": 4, "w": 5, "r": 6, "d": 7,
842          "he": 8, "ll": 9, "hell": 10, "hello": 11, "Ġw": 12, "or": 13, "!": 14
843        },
844        "merges": [
845          "h e",
846          ["l", "l"],
847          "he ll",
848          ["hell", "o"],
849          ["Ġ", "w"],
850          "o r"
851        ]
852      }
853    }"#;
854
855    fn write_fixture(name: &str, tokenizer_config: Option<&str>, generation_config: Option<&str>,
856                     jinja: Option<&str>) -> std::path::PathBuf {
857        let dir = std::env::temp_dir().join(format!("memra-tok-hf-{name}-{}", std::process::id()));
858        let _ = std::fs::remove_dir_all(&dir);
859        std::fs::create_dir_all(&dir).unwrap();
860        std::fs::write(dir.join("tokenizer.json"), TOKENIZER_JSON).unwrap();
861        if let Some(tc) = tokenizer_config {
862            std::fs::write(dir.join("tokenizer_config.json"), tc).unwrap();
863        }
864        if let Some(gc) = generation_config {
865            std::fs::write(dir.join("generation_config.json"), gc).unwrap();
866        }
867        if let Some(j) = jinja {
868            std::fs::write(dir.join("chat_template.jinja"), j).unwrap();
869        }
870        dir
871    }
872
873    #[test]
874    fn hf_dir_encode_decode_roundtrip_and_specials() {
875        // eos as an AddedToken OBJECT + chat_template string in tokenizer_config.
876        let tc = r#"{
877          "eos_token": {"content": "<|end|>", "lstrip": false},
878          "add_bos_token": false,
879          "chat_template": "{{ messages }}<|end|>"
880        }"#;
881        let dir = write_fixture("full", Some(tc), None, None);
882        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
883
884        assert_eq!(tok.eos_id(), 15);
885        assert_eq!(tok.bos_id(), None);
886        assert_eq!(tok.pre(), "default");
887        assert_eq!(tok.vocab_size(), 17); // ids 0..16 (added tokens extend the table)
888        assert_eq!(tok.chat_template(), Some("{{ messages }}<|end|>"));
889
890        // BPE over both merge formats: "hello world" -> hello(11) Ġw(12) or(13) l(2) d(7).
891        // The 'hello' chain exercises string merges (h e / he ll), the pair merges
892        // ([l,l] / [hell,o] / [Ġ,w]) fire inside the same words -> both formats load.
893        let ids = tok.encode("hello world", true);
894        assert_eq!(ids, vec![11, 12, 13, 2, 7]);
895        assert_eq!(tok.decode(&ids), "hello world");
896
897        // special handling: <|end|> (Control) is split out BEFORE BPE and never byte-merged.
898        let ids = tok.encode("hello<|end|> world", true);
899        assert_eq!(ids, vec![11, 15, 12, 13, 2, 7]);
900        // decode with specials rendered vs dropped
901        assert_eq!(tok.decode_special(&ids, true), "hello<|end|> world");
902        assert_eq!(tok.decode_special(&ids, false), "hello world");
903
904        // non-special added token stays Normal: decodes as literal text.
905        assert_eq!(tok.decode(&[16]), "<think>");
906        let _ = std::fs::remove_dir_all(&dir);
907    }
908
909    #[test]
910    fn hf_dir_generation_config_eos_fallback_and_jinja() {
911        // no tokenizer_config eos -> generation_config eos_token_id (array form) must win;
912        // chat template comes from chat_template.jinja.
913        let gc = r#"{"eos_token_id": [15, 14]}"#;
914        let dir = write_fixture("genconf", None, Some(gc), Some("JINJA {{ messages }}"));
915        let tok = Tokenizer::from_hf_dir(&dir).expect("from_hf_dir");
916        assert_eq!(tok.eos_id(), 15);
917        assert!(!tok.encode("hello", true).is_empty());
918        assert_eq!(tok.chat_template(), Some("JINJA {{ messages }}"));
919        let _ = std::fs::remove_dir_all(&dir);
920    }
921
922    #[test]
923    fn hf_dir_rejects_non_byte_level() {
924        let dir = std::env::temp_dir()
925            .join(format!("memra-tok-hf-nonbl-{}", std::process::id()));
926        let _ = std::fs::remove_dir_all(&dir);
927        std::fs::create_dir_all(&dir).unwrap();
928        let bad = TOKENIZER_JSON.replace("\"ByteLevel\"", "\"Metaspace\"");
929        std::fs::write(dir.join("tokenizer.json"), bad).unwrap();
930        assert!(Tokenizer::from_hf_dir(&dir).is_err());
931        let _ = std::fs::remove_dir_all(&dir);
932    }
933}