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