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