Skip to main content

qualia_core_db/inference/gguf_sharder/
tokenizer.rs

1//! `GgufTokenizer` — vocabulary + BOS/EOS + BPE merges parsed from a GGUF KV
2//! section (or a compact P64 section), with encode/decode, chat-template handling,
3//! and the generation stop-token set.
4
5use super::gguf_skip_value;
6use std::collections::HashMap;
7use std::sync::OnceLock;
8
9mod decode;
10mod pretokenizer;
11pub use pretokenizer::{PretokenError, PretokenSpan};
12
13/// GPT-2 `bytes_to_unicode` table — maps raw bytes to BPE merge symbols.
14fn gpt2_byte_to_unicode(byte: u8) -> char {
15    static TABLE: OnceLock<[char; 256]> = OnceLock::new();
16    TABLE.get_or_init(|| {
17        let mut bs: Vec<u32> = (b'!'..=b'~')
18            .chain(b'\xA1'..=b'\xAC')
19            .chain(b'\xAE'..=b'\xFF')
20            .map(|b| b as u32)
21            .collect();
22        let mut cs = bs.clone();
23        let mut n = 0u32;
24        for b in 0u32..256 {
25            if !bs.contains(&b) {
26                bs.push(b);
27                cs.push(256 + n);
28                n += 1;
29            }
30        }
31        let mut out = ['\0'; 256];
32        for (b, c) in bs.into_iter().zip(cs) {
33            out[b as usize] = char::from_u32(c).unwrap_or('\u{FFFD}');
34        }
35        out
36    })[byte as usize]
37}
38
39fn gpt2_unicode_to_byte(symbol: char) -> Option<u8> {
40    (0u16..=255)
41        .find(|byte| gpt2_byte_to_unicode(*byte as u8) == symbol)
42        .map(|byte| byte as u8)
43}
44
45/// Max stop-token ids kept on the tokenizer (eos + chat-end family + extras).
46pub const MAX_STOP_TOKEN_IDS: usize = 8;
47
48/// Vocabulary and BOS/EOS metadata extracted from a GGUF KV section.
49/// Used by `infer_local_model()` to encode prompts and decode output token IDs.
50pub struct GgufTokenizer {
51    /// Token ID → string (index = token ID).
52    pub vocab: Vec<String>,
53    pub bos_token_id: u32,
54    pub eos_token_id: u32,
55    /// `tokenizer.ggml.add_bos_token` — prepend BOS before prompt tokens when true.
56    pub add_bos_token: bool,
57    /// `tokenizer.ggml.pre` — e.g. `smollm`, `gpt2`; drives pretokenization.
58    pub pre_type: String,
59    /// BPE merge ranks: `(left_symbol, right_symbol)` in ascending rank order.
60    merge_pairs: Vec<(String, String)>,
61    /// Cold-built pair fingerprint -> rank index. A detected fingerprint collision disables the
62    /// index and preserves the exact linear oracle.
63    merge_rank_index: HashMap<u64, usize>,
64    merge_rank_collision: bool,
65    /// Fast vocab lookup for BPE tail + legacy greedy path.
66    pub(super) token_to_id_map: HashMap<String, u32>,
67    /// Special tokens (`<|…|>`, etc.) sorted longest-first for atomic matching.
68    special_tokens: Vec<(String, u32)>,
69    /// (token_string, token_id) sorted by descending byte length — legacy greedy fallback.
70    pub(super) token_to_id: Vec<(String, u32)>,
71    /// Decode stop set: always includes `eos_token_id`, plus chat-end specials when
72    /// present in vocab (`<|eot_id|>`, `<|im_end|>`, `<end_of_turn>`, …). Fixed array
73    /// so the hot path does not allocate.
74    stop_token_ids: [u32; MAX_STOP_TOKEN_IDS],
75    stop_token_count: u8,
76}
77
78/// Chat-template family, detected from the special tokens a model's vocab carries. Instruct models
79/// must have their prompt wrapped in this template (with an assistant-turn cue) or they degenerate
80/// (emit EOS immediately, or repeat) — a raw prompt gives the model no "your turn to answer" signal.
81#[derive(Clone, Copy, PartialEq, Eq, Debug)]
82pub enum ChatFamily {
83    /// `<|im_start|>role\n…<|im_end|>` — Qwen2 / SmolLM2 / many instruct models.
84    ChatMl,
85    /// `<|start_header_id|>role<|end_header_id|>\n\n…<|eot_id|>` — Llama-3.x.
86    Llama3,
87    /// `<start_of_turn>role\n…<end_of_turn>` — Gemma 1/2/3 (no system role).
88    Gemma,
89    /// `<|turn>role\n…<turn|>` — Gemma 4 instruct (also uses `<|channel>` tool channel).
90    Gemma4,
91    /// No recognised chat specials — the raw prompt is used unchanged.
92    None,
93}
94
95impl Default for GgufTokenizer {
96    /// 256-entry byte-level fallback tokenizer — used when no GGUF is loaded.
97    fn default() -> Self {
98        let vocab: Vec<String> = (0u32..256)
99            .map(|b| {
100                let c = b as u8;
101                if c.is_ascii_graphic() || c == b' ' {
102                    (c as char).to_string()
103                } else {
104                    format!("<0x{:02X}>", b)
105                }
106            })
107            .collect();
108        let mut t2id: Vec<(String, u32)> = vocab
109            .iter()
110            .enumerate()
111            .map(|(i, s)| (s.clone(), i as u32))
112            .collect();
113        t2id.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
114        let token_to_id_map: HashMap<String, u32> =
115            t2id.iter().map(|(s, id)| (s.clone(), *id)).collect();
116        let mut tok = Self {
117            vocab,
118            bos_token_id: 1,
119            eos_token_id: 2,
120            add_bos_token: true,
121            pre_type: String::new(),
122            merge_pairs: Vec::new(),
123            merge_rank_index: HashMap::new(),
124            merge_rank_collision: false,
125            token_to_id_map,
126            special_tokens: Vec::new(),
127            token_to_id: t2id,
128            stop_token_ids: [0; MAX_STOP_TOKEN_IDS],
129            stop_token_count: 0,
130        };
131        tok.rebuild_stop_token_ids();
132        tok
133    }
134}
135
136impl GgufTokenizer {
137    /// Parse vocab + BOS/EOS from a memory-mapped GGUF v2/v3 file.
138    /// Falls back to `Default` (byte-level) on any parse error.
139    pub fn from_gguf(mmap: &[u8]) -> Self {
140        Self::try_parse(mmap).unwrap_or_default()
141    }
142
143    fn try_parse(mmap: &[u8]) -> Option<Self> {
144        if mmap.len() < 24 || &mmap[0..4] != b"GGUF" {
145            return None;
146        }
147        let version = u32::from_le_bytes(mmap[4..8].try_into().ok()?);
148        if version < 2 {
149            return None;
150        } // only v2/v3 have u64 string lengths
151        let kv_count = u64::from_le_bytes(mmap[16..24].try_into().ok()?);
152        let mut pos = 24usize;
153        let mut vocab: Option<Vec<String>> = None;
154        let mut merges_raw: Option<Vec<String>> = None;
155        let mut bos_id: Option<u32> = None;
156        let mut eos_id: Option<u32> = None;
157        let mut add_bos: Option<bool> = None;
158        let mut pre_type: Option<String> = None;
159
160        for _ in 0..kv_count {
161            if pos + 8 > mmap.len() {
162                break;
163            }
164            let klen = u64::from_le_bytes(mmap[pos..pos + 8].try_into().ok()?) as usize;
165            pos += 8;
166            if pos + klen > mmap.len() {
167                break;
168            }
169            let key = std::str::from_utf8(&mmap[pos..pos + klen]).unwrap_or("");
170            pos += klen;
171            if pos + 4 > mmap.len() {
172                break;
173            }
174            let vtype = u32::from_le_bytes(mmap[pos..pos + 4].try_into().ok()?);
175            pos += 4;
176            match key {
177                "tokenizer.ggml.tokens" => {
178                    vocab = Self::read_string_array(mmap, &mut pos, vtype);
179                }
180                "tokenizer.ggml.merges" => {
181                    merges_raw = Self::read_string_array(mmap, &mut pos, vtype);
182                }
183                "tokenizer.ggml.bos_token_id" => {
184                    bos_id = Self::read_u32_val(mmap, &mut pos, vtype);
185                }
186                "tokenizer.ggml.eos_token_id" => {
187                    eos_id = Self::read_u32_val(mmap, &mut pos, vtype);
188                }
189                "tokenizer.ggml.add_bos_token" => {
190                    add_bos = Self::read_bool_val(mmap, &mut pos, vtype);
191                }
192                "tokenizer.ggml.pre" => {
193                    pre_type = Self::read_string_val(mmap, &mut pos, vtype);
194                }
195                _ => {
196                    if Self::skip_value(mmap, &mut pos, vtype).is_none() {
197                        break;
198                    }
199                }
200            }
201        }
202
203        let v = vocab?;
204        let bos = bos_id.unwrap_or(1);
205        let eos = eos_id.unwrap_or(2);
206        let mut t2id: Vec<(String, u32)> = v
207            .iter()
208            .enumerate()
209            .map(|(i, s)| (s.clone(), i as u32))
210            .collect();
211        t2id.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
212        let token_to_id_map: HashMap<String, u32> =
213            t2id.iter().map(|(s, id)| (s.clone(), *id)).collect();
214        let mut special_tokens: Vec<(String, u32)> = v
215            .iter()
216            .enumerate()
217            .filter(|(_, s)| s.starts_with('<') && s.ends_with('>'))
218            .map(|(i, s)| (s.clone(), i as u32))
219            .collect();
220        special_tokens.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
221        let merge_pairs = Self::parse_merge_pairs(merges_raw.as_deref());
222        let (merge_rank_index, merge_rank_collision) = Self::build_merge_rank_index(&merge_pairs);
223        let mut tok = Self {
224            vocab: v,
225            bos_token_id: bos,
226            eos_token_id: eos,
227            add_bos_token: add_bos.unwrap_or(true),
228            pre_type: pre_type.unwrap_or_default(),
229            merge_pairs,
230            merge_rank_index,
231            merge_rank_collision,
232            token_to_id_map,
233            special_tokens,
234            token_to_id: t2id,
235            stop_token_ids: [0; MAX_STOP_TOKEN_IDS],
236            stop_token_count: 0,
237        };
238        tok.rebuild_stop_token_ids();
239        Some(tok)
240    }
241
242    /// Phase 4 v3 / v2: serialize the tokenizer into a compact, contiguous P64 section (no page
243    /// alignment needed). Fields: vocab / merges / bos / eos / add_bos / pre, plus (v2) the
244    /// stop-token set so decode does not re-guess chat ends. Derived maps are rebuilt by
245    /// [`from_p64_section`]. Heap use here is load-time only.
246    pub fn to_p64_section(&self) -> Vec<u8> {
247        let mut out = Vec::with_capacity(1 << 20);
248        out.extend_from_slice(b"Q42T");
249        out.extend_from_slice(&2u16.to_le_bytes()); // section version (v2 = stop tokens)
250        out.extend_from_slice(&0u16.to_le_bytes()); // flags
251        out.extend_from_slice(&self.bos_token_id.to_le_bytes());
252        out.extend_from_slice(&self.eos_token_id.to_le_bytes());
253        out.push(self.add_bos_token as u8);
254        out.extend_from_slice(&[0u8; 3]);
255        let put_str = |o: &mut Vec<u8>, s: &str| {
256            o.extend_from_slice(&(s.len() as u32).to_le_bytes());
257            o.extend_from_slice(s.as_bytes());
258        };
259        put_str(&mut out, &self.pre_type);
260        out.extend_from_slice(&(self.vocab.len() as u32).to_le_bytes());
261        for t in &self.vocab {
262            put_str(&mut out, t);
263        }
264        out.extend_from_slice(&(self.merge_pairs.len() as u32).to_le_bytes());
265        for (l, r) in &self.merge_pairs {
266            put_str(&mut out, l);
267            put_str(&mut out, r);
268        }
269        // v2: stop-token set (eos + chat ends). Fixed 8 slots, count in first byte.
270        out.push(self.stop_token_count);
271        out.extend_from_slice(&[0u8; 3]);
272        for id in &self.stop_token_ids {
273            out.extend_from_slice(&id.to_le_bytes());
274        }
275        out
276    }
277
278    /// Compatibility alias for the historical pre-P64 method name.
279    #[deprecated(note = "use to_p64_section")]
280    pub fn to_q42_section(&self) -> Vec<u8> {
281        self.to_p64_section()
282    }
283
284    /// Phase 4 v3: rebuild a tokenizer from a P64 tokenizer section — bypasses GGUF KV string-key
285    /// parsing entirely. Fully bounds-checked (the section is untrusted input). Returns `None` on any
286    /// malformed field.
287    pub fn from_p64_section(data: &[u8]) -> Option<Self> {
288        let mut p = 0usize;
289        let take = |p: &mut usize, n: usize| -> Option<&[u8]> {
290            let end = p.checked_add(n)?;
291            if end > data.len() {
292                return None;
293            }
294            let s = &data[*p..end];
295            *p = end;
296            Some(s)
297        };
298        let take_u32 = |p: &mut usize| -> Option<u32> {
299            Some(u32::from_le_bytes(take(p, 4)?.try_into().ok()?))
300        };
301        let take_str = |p: &mut usize| -> Option<String> {
302            let len = take_u32(p)? as usize;
303            Some(String::from_utf8_lossy(take(p, len)?).into_owned())
304        };
305        if take(&mut p, 4)? != b"Q42T" {
306            return None;
307        }
308        let ver = u16::from_le_bytes(take(&mut p, 2)?.try_into().ok()?);
309        let _flags = take(&mut p, 2)?;
310        let bos = take_u32(&mut p)?;
311        let eos = take_u32(&mut p)?;
312        let add_bos = take(&mut p, 1)?[0] != 0;
313        let _pad = take(&mut p, 3)?;
314        let pre_type = take_str(&mut p)?;
315        let n_vocab = take_u32(&mut p)? as usize;
316        if n_vocab > 1_000_000 {
317            return None;
318        }
319        let mut vocab = Vec::with_capacity(n_vocab);
320        for _ in 0..n_vocab {
321            vocab.push(take_str(&mut p)?);
322        }
323        let n_merges = take_u32(&mut p)? as usize;
324        if n_merges > 5_000_000 {
325            return None;
326        }
327        let mut merge_pairs = Vec::with_capacity(n_merges);
328        for _ in 0..n_merges {
329            let l = take_str(&mut p)?;
330            let r = take_str(&mut p)?;
331            merge_pairs.push((l, r));
332        }
333        // v2 optional trailer: stop_count + pad3 + 8×u32. v1 rebuilds from vocab specials.
334        let mut stored_stops: Option<([u32; MAX_STOP_TOKEN_IDS], u8)> = None;
335        if ver >= 2 && p + 4 + MAX_STOP_TOKEN_IDS * 4 <= data.len() {
336            let count = take(&mut p, 1)?[0].min(MAX_STOP_TOKEN_IDS as u8);
337            let _ = take(&mut p, 3)?;
338            let mut ids = [0u32; MAX_STOP_TOKEN_IDS];
339            for i in 0..MAX_STOP_TOKEN_IDS {
340                ids[i] = take_u32(&mut p)?;
341            }
342            stored_stops = Some((ids, count));
343        }
344        // Rebuild the derived maps exactly as `try_parse` does, so encode/decode are identical.
345        let mut t2id: Vec<(String, u32)> = vocab
346            .iter()
347            .enumerate()
348            .map(|(i, s)| (s.clone(), i as u32))
349            .collect();
350        t2id.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
351        let token_to_id_map: HashMap<String, u32> =
352            t2id.iter().map(|(s, id)| (s.clone(), *id)).collect();
353        let mut special_tokens: Vec<(String, u32)> = vocab
354            .iter()
355            .enumerate()
356            .filter(|(_, s)| s.starts_with('<') && s.ends_with('>'))
357            .map(|(i, s)| (s.clone(), i as u32))
358            .collect();
359        special_tokens.sort_by(|a, b| b.0.len().cmp(&a.0.len()));
360        let mut tok = Self {
361            vocab,
362            bos_token_id: bos,
363            eos_token_id: eos,
364            add_bos_token: add_bos,
365            pre_type,
366            merge_pairs,
367            merge_rank_index: HashMap::new(),
368            merge_rank_collision: false,
369            token_to_id_map,
370            special_tokens,
371            token_to_id: t2id,
372            stop_token_ids: [0; MAX_STOP_TOKEN_IDS],
373            stop_token_count: 0,
374        };
375        (tok.merge_rank_index, tok.merge_rank_collision) =
376            Self::build_merge_rank_index(&tok.merge_pairs);
377        if let Some((ids, count)) = stored_stops {
378            tok.stop_token_ids = ids;
379            tok.stop_token_count = count;
380            // Always ensure eos is present even if a stale helper omitted it.
381            if !tok.is_stop_token(eos) {
382                tok.rebuild_stop_token_ids();
383            }
384        } else {
385            tok.rebuild_stop_token_ids();
386        }
387        Some(tok)
388    }
389
390    /// Rebuild the decode stop set from `eos_token_id` + known chat-end specials in vocab.
391    /// Call after any mutation of eos / token_to_id_map (load paths do this automatically).
392    pub fn rebuild_stop_token_ids(&mut self) {
393        let mut ids = [0u32; MAX_STOP_TOKEN_IDS];
394        let mut n = 0usize;
395        let mut push = |id: u32| {
396            if n >= MAX_STOP_TOKEN_IDS {
397                return;
398            }
399            if ids[..n].contains(&id) {
400                return;
401            }
402            ids[n] = id;
403            n += 1;
404        };
405        push(self.eos_token_id);
406        // Chat / instruct end-of-turn tokens. Missing from vocab → no-op.
407        // Without these, Llama-3 keeps past <|eot_id|> into pretraining-style continuation.
408        const CHAT_ENDS: &[&str] = &[
409            "<|endoftext|>",
410            "<|eot_id|>",
411            "<|im_end|>",
412            "<end_of_turn>",
413            "<turn|>", // Gemma 4 turn close
414            "<|end_of_text|>",
415            "</s>",
416            "<|end|>",
417        ];
418        for s in CHAT_ENDS {
419            if let Some(&id) = self.token_to_id_map.get(*s) {
420                push(id);
421            }
422        }
423        // Also scan special_tokens + full token_to_id_map for end-of-turn markers
424        // (SentencePiece / BPE may store them with leading space or ▁ prefixes).
425        let looks_like_chat_end = |n: &str| -> bool {
426            let l = n.to_ascii_lowercase();
427            l == "<|im_end|>"
428                || l == "<|eot_id|>"
429                || l == "<|endoftext|>"
430                || l == "<end_of_turn>"
431                || l == "</s>"
432                || l.ends_with("im_end|>")
433                || l.ends_with("eot_id|>")
434                || l.contains("end_of_turn")
435                || l.contains("im_end")
436                || l.contains("eot_id")
437        };
438        for (name, id) in &self.special_tokens {
439            if looks_like_chat_end(name) {
440                push(*id);
441            }
442        }
443        for (name, id) in &self.token_to_id_map {
444            if looks_like_chat_end(name) {
445                push(*id);
446            }
447        }
448        self.stop_token_ids = ids;
449        self.stop_token_count = n as u8;
450    }
451
452    /// Whether `id` is a generation stop token (eos and/or chat end-of-turn).
453    #[inline]
454    pub fn is_stop_token(&self, id: u32) -> bool {
455        let n = self.stop_token_count as usize;
456        self.stop_token_ids[..n].contains(&id)
457    }
458
459    /// Slice of active stop-token ids (for logging / q42 export).
460    pub fn stop_tokens(&self) -> &[u32] {
461        &self.stop_token_ids[..self.stop_token_count as usize]
462    }
463
464    /// Merge extra stop ids (e.g. from a model's canonical `.q42` metadata) into the stop set.
465    /// Does not allocate; drops overflow past [`MAX_STOP_TOKEN_IDS`].
466    pub fn merge_stop_token_ids(&mut self, extra: &[u32]) {
467        let mut n = self.stop_token_count as usize;
468        for &id in extra {
469            if n >= MAX_STOP_TOKEN_IDS {
470                break;
471            }
472            if self.stop_token_ids[..n].contains(&id) {
473                continue;
474            }
475            self.stop_token_ids[n] = id;
476            n += 1;
477        }
478        // Always keep eos.
479        if !self.stop_token_ids[..n].contains(&self.eos_token_id) && n < MAX_STOP_TOKEN_IDS {
480            self.stop_token_ids[n] = self.eos_token_id;
481            n += 1;
482        }
483        self.stop_token_count = n as u8;
484    }
485
486    /// Tokenize `text`, prepending [`bos_token_id`] when [`add_bos_token`] is set and absent.
487    pub fn encode_prompt(&self, text: &str) -> Vec<u32> {
488        let mut ids = self.encode(text);
489        if self.add_bos_token && ids.first().copied() != Some(self.bos_token_id) {
490            let mut with_bos = Vec::with_capacity(ids.len().saturating_add(1));
491            with_bos.push(self.bos_token_id);
492            with_bos.append(&mut ids);
493            with_bos
494        } else {
495            ids
496        }
497    }
498
499    /// Detect this model's chat-template family from the special tokens present in its vocab.
500    pub fn chat_family(&self) -> ChatFamily {
501        if self.token_to_id_map.contains_key("<|im_start|>") {
502            ChatFamily::ChatMl
503        } else if self.token_to_id_map.contains_key("<|start_header_id|>") {
504            ChatFamily::Llama3
505        } else if self.token_to_id_map.contains_key("<|turn>")
506            || self.token_to_id_map.contains_key("<turn|>")
507        {
508            // Gemma 4 (before classic Gemma — classic uses <start_of_turn>).
509            ChatFamily::Gemma4
510        } else if self.token_to_id_map.contains_key("<start_of_turn>") {
511            ChatFamily::Gemma
512        } else {
513            ChatFamily::None
514        }
515    }
516
517    /// Wrap a user prompt (and optional system message) in the model's chat template, cueing the
518    /// assistant turn so an instruct model answers instead of degenerating. The tokenizer BOS is
519    /// still prepended by [`encode_prompt`]; it is NOT embedded here (avoids a double BOS). Returns
520    /// the raw prompt unchanged when no chat family is recognised.
521    pub fn apply_chat_template(&self, system: Option<&str>, user: &str) -> String {
522        match self.chat_family() {
523            ChatFamily::ChatMl => {
524                let mut s = String::new();
525                if let Some(sys) = system {
526                    s.push_str("<|im_start|>system\n");
527                    s.push_str(sys);
528                    s.push_str("<|im_end|>\n");
529                }
530                s.push_str("<|im_start|>user\n");
531                s.push_str(user);
532                s.push_str("<|im_end|>\n<|im_start|>assistant\n");
533                s
534            }
535            ChatFamily::Llama3 => {
536                let mut s = String::new();
537                if let Some(sys) = system {
538                    s.push_str("<|start_header_id|>system<|end_header_id|>\n\n");
539                    s.push_str(sys);
540                    s.push_str("<|eot_id|>");
541                }
542                s.push_str("<|start_header_id|>user<|end_header_id|>\n\n");
543                s.push_str(user);
544                s.push_str("<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n");
545                s
546            }
547            ChatFamily::Gemma => {
548                // Gemma has no system role; fold any system text into the user turn.
549                let mut s = String::from("<start_of_turn>user\n");
550                if let Some(sys) = system {
551                    s.push_str(sys);
552                    s.push_str("\n\n");
553                }
554                s.push_str(user);
555                s.push_str("<end_of_turn>\n<start_of_turn>model\n");
556                s
557            }
558            ChatFamily::Gemma4 => {
559                // Gemma 4 instruct: paired <|turn>…<turn|> markers (see GGUF chat_template).
560                // No dedicated system role — fold system into the user turn. BOS is added by encode.
561                let mut s = String::from("<|turn>user\n");
562                if let Some(sys) = system {
563                    s.push_str(sys);
564                    s.push_str("\n\n");
565                }
566                s.push_str(user);
567                s.push_str("<turn|><|turn>model\n");
568                s
569            }
570            ChatFamily::None => user.to_string(),
571        }
572    }
573
574    /// Apply the model's chat template (if any), then tokenize (+BOS per `add_bos_token`). This is
575    /// the path for interactive chat/instruct inference; [`encode_prompt`] stays the raw-completion
576    /// path. Chat models without a recognised family fall back to the raw prompt.
577    pub fn encode_chat_prompt(&self, user: &str) -> Vec<u32> {
578        let templated = self.apply_chat_template(None, user);
579        self.encode_prompt(&templated)
580    }
581
582    /// Format token IDs for diagnostic logging (MC3f).
583    pub fn format_ids_for_log(ids: &[u32]) -> String {
584        let mut s = String::from("[");
585        for (i, &id) in ids.iter().enumerate() {
586            if i > 0 {
587                s.push_str(", ");
588            }
589            if i >= 64 {
590                s.push_str("…");
591                break;
592            }
593            s.push_str(&id.to_string());
594        }
595        s.push(']');
596        s
597    }
598
599    /// Greedy longest-match tokenisation; falls back to single-byte encoding.
600    pub fn encode(&self, text: &str) -> Vec<u32> {
601        if self.uses_bpe() {
602            return self.encode_bpe(text);
603        }
604        self.encode_greedy(text)
605    }
606
607    fn uses_bpe(&self) -> bool {
608        !self.merge_pairs.is_empty()
609            || matches!(
610                self.pre_type.as_str(),
611                "smollm" | "gpt2" | "mpt" | "olmo" | "jais" | "llama3"
612            )
613    }
614
615    fn encode_greedy(&self, text: &str) -> Vec<u32> {
616        let mut ids = Vec::new();
617        let mut remaining = text;
618        while !remaining.is_empty() {
619            let mut matched = false;
620            for (token, id) in &self.token_to_id {
621                if remaining.starts_with(token.as_str()) {
622                    ids.push(*id);
623                    remaining = &remaining[token.len()..];
624                    matched = true;
625                    break;
626                }
627            }
628            if !matched {
629                let b = remaining.as_bytes()[0];
630                ids.push(b as u32);
631                let step = remaining.chars().next().map(|c| c.len_utf8()).unwrap_or(1);
632                remaining = &remaining[step..];
633            }
634        }
635        ids
636    }
637
638    /// BPE encode with special-token atomicity + smollm/gpt2 pretokenization.
639    fn encode_bpe(&self, text: &str) -> Vec<u32> {
640        let mut ids = Vec::new();
641        // One bounded span workspace replaces regex captures and one String per piece.
642        let mut spans = vec![PretokenSpan::default(); text.len().max(1)];
643        let mut remaining = text;
644        while !remaining.is_empty() {
645            let mut matched_special = false;
646            for (tok, id) in &self.special_tokens {
647                if remaining.starts_with(tok.as_str()) {
648                    ids.push(*id);
649                    remaining = &remaining[tok.len()..];
650                    matched_special = true;
651                    break;
652                }
653            }
654            if matched_special {
655                continue;
656            }
657            let mut next_special = remaining.len();
658            for (tok, _) in &self.special_tokens {
659                if let Some(pos) = remaining.find(tok.as_str()) {
660                    next_special = next_special.min(pos);
661                }
662            }
663            let segment = &remaining[..next_special];
664            if !segment.is_empty() {
665                let count = pretokenizer::pretokenize_into(segment, &mut spans)
666                    .expect("span workspace covers the segment byte length");
667                for span in &spans[..count] {
668                    ids.extend(self.bpe_piece(span.get(segment).unwrap()));
669                }
670            }
671            remaining = &remaining[next_special..];
672        }
673        ids
674    }
675
676    /// Regex-free llama.cpp `LLAMA_VOCAB_PRE_TYPE_SMOLLM`-compatible borrowed-span split.
677    pub fn pretokenize_into(
678        &self,
679        text: &str,
680        out: &mut [PretokenSpan],
681    ) -> Result<usize, PretokenError> {
682        pretokenizer::pretokenize_into(text, out)
683    }
684
685    fn bpe_piece(&self, piece: &str) -> Vec<u32> {
686        if piece.is_empty() {
687            return Vec::new();
688        }
689        if let Some(&id) = self.token_to_id_map.get(piece) {
690            return vec![id];
691        }
692        let word: String = piece.bytes().map(gpt2_byte_to_unicode).collect();
693        if let Some(&id) = self.token_to_id_map.get(word.as_str()) {
694            return vec![id];
695        }
696        let mut symbols: Vec<String> = word.chars().map(|c| c.to_string()).collect();
697        if symbols.is_empty() {
698            return Vec::new();
699        }
700        loop {
701            let mut best_rank: Option<usize> = None;
702            let mut best_idx = 0usize;
703            for i in 0..symbols.len().saturating_sub(1) {
704                if let Some(rank) = self.merge_rank_str(&symbols[i], &symbols[i + 1]) {
705                    if best_rank.is_none() || rank < best_rank.unwrap() {
706                        best_rank = Some(rank);
707                        best_idx = i;
708                    }
709                }
710            }
711            let Some(_rank) = best_rank else { break };
712            let merged = format!("{}{}", symbols[best_idx], symbols[best_idx + 1]);
713            symbols[best_idx] = merged;
714            symbols.remove(best_idx + 1);
715        }
716        let mut ids = Vec::with_capacity(symbols.len());
717        for sym in symbols {
718            if let Some(&id) = self.token_to_id_map.get(sym.as_str()) {
719                ids.push(id);
720            } else {
721                for ch in sym.chars() {
722                    let s = ch.to_string();
723                    if let Some(&id) = self.token_to_id_map.get(s.as_str()) {
724                        ids.push(id);
725                    }
726                }
727            }
728        }
729        ids
730    }
731
732    fn merge_rank_str(&self, left: &str, right: &str) -> Option<usize> {
733        if !self.merge_rank_collision {
734            let fingerprint = Self::merge_pair_fingerprint(left, right);
735            if let Some(&rank) = self.merge_rank_index.get(&fingerprint) {
736                let pair = self.merge_pairs.get(rank)?;
737                if pair.0 == left && pair.1 == right {
738                    return Some(rank);
739                }
740                // Defensive exactness if an index built by an older serialized source collides.
741                return self
742                    .merge_pairs
743                    .iter()
744                    .position(|(l, r)| l == left && r == right);
745            }
746            return None;
747        }
748        self.merge_pairs
749            .iter()
750            .position(|(l, r)| l == left && r == right)
751    }
752
753    fn merge_pair_fingerprint(left: &str, right: &str) -> u64 {
754        let mut hash = 0xcbf29ce484222325u64;
755        for byte in (left.len() as u64)
756            .to_le_bytes()
757            .into_iter()
758            .chain(left.bytes())
759            .chain((right.len() as u64).to_le_bytes())
760            .chain(right.bytes())
761        {
762            hash ^= byte as u64;
763            hash = hash.wrapping_mul(0x100000001b3);
764        }
765        hash
766    }
767
768    fn build_merge_rank_index(pairs: &[(String, String)]) -> (HashMap<u64, usize>, bool) {
769        let mut index: HashMap<u64, usize> = HashMap::with_capacity(pairs.len());
770        let mut collision = false;
771        for (rank, (left, right)) in pairs.iter().enumerate() {
772            let fingerprint = Self::merge_pair_fingerprint(left, right);
773            if let Some(&existing) = index.get(&fingerprint) {
774                if pairs[existing].0 != *left || pairs[existing].1 != *right {
775                    collision = true;
776                }
777                continue;
778            }
779            index.insert(fingerprint, rank);
780        }
781        (index, collision)
782    }
783
784    fn parse_merge_pairs(merges: Option<&[String]>) -> Vec<(String, String)> {
785        let Some(merges) = merges else {
786            return Vec::new();
787        };
788        let mut pairs = Vec::with_capacity(merges.len());
789        for merge in merges {
790            if let Some((a, b)) = merge.split_once(' ') {
791                pairs.push((a.to_string(), b.to_string()));
792            }
793        }
794        pairs
795    }
796
797    fn uses_gpt2_byte_decoder(&self) -> bool {
798        matches!(
799            self.pre_type.to_ascii_lowercase().as_str(),
800            "gpt2"
801                | "smollm"
802                | "qwen2"
803                | "llama-bpe"
804                | "deepseek-llm"
805                | "deepseek-coder"
806                | "falcon"
807                | "starcoder"
808        )
809    }
810
811    fn append_decoded_token_bytes(&self, out: &mut Vec<u8>, s: &str) {
812        if s.len() == 6 && s.starts_with("<0x") && s.ends_with('>') {
813            if let Ok(byte) = u8::from_str_radix(&s[3..5], 16) {
814                out.push(byte);
815            }
816        } else if self.uses_gpt2_byte_decoder() {
817            for symbol in s.chars() {
818                if let Some(byte) = gpt2_unicode_to_byte(symbol) {
819                    out.push(byte);
820                } else {
821                    let mut encoded = [0u8; 4];
822                    out.extend_from_slice(symbol.encode_utf8(&mut encoded).as_bytes());
823                }
824            }
825        } else if let Some(rest) = s.strip_prefix('\u{2581}') {
826            out.push(b' ');
827            out.extend_from_slice(rest.as_bytes());
828        } else if let Some(rest) = s.strip_prefix('\u{0120}') {
829            out.push(b' ');
830            out.extend_from_slice(rest.as_bytes());
831        } else {
832            out.extend_from_slice(s.as_bytes());
833        }
834    }
835
836    /// Append one vocabulary token to `out` with BPE / SentencePiece space normalization.
837    #[allow(dead_code)]
838    fn append_decoded_token(out: &mut String, s: &str) {
839        if let Some(rest) = s.strip_prefix('\u{2581}') {
840            out.push(' ');
841            out.push_str(rest);
842        } else if let Some(rest) = s.strip_prefix('\u{0120}') {
843            // GPT-2 / Llama / SmolLM BPE space marker (Ġ).
844            out.push(' ');
845            out.push_str(rest);
846        } else if s.len() == 6 && s.starts_with("<0x") && s.ends_with('>') {
847            if let Ok(b) = u8::from_str_radix(&s[3..5], 16) {
848                out.push(b as char);
849            }
850        } else {
851            out.push_str(s);
852        }
853    }
854
855    /// Map token IDs → strings, joining without separator.
856    /// Converts SentencePiece `▁` and GPT-2 BPE `Ġ` → space; `<0x##>` → raw byte.
857    pub fn decode(&self, ids: &[u32]) -> String {
858        let mut out = Vec::new();
859        for &id in ids {
860            let s = self
861                .vocab
862                .get(id as usize)
863                .map(|s| s.as_str())
864                .unwrap_or("");
865            self.append_decoded_token_bytes(&mut out, s);
866        }
867        String::from_utf8_lossy(&out).into_owned()
868    }
869
870    /// Decode one token into the exact byte piece used by comparator APIs.
871    ///
872    /// This allocates and is intended for cold diagnostics, receipts, and corpus comparison,
873    /// never for the token-forward hot path.
874    pub fn decode_token_bytes_cold(&self, id: u32) -> Vec<u8> {
875        let mut out = Vec::new();
876        if let Some(token) = self.vocab.get(id as usize) {
877            self.append_decoded_token_bytes(&mut out, token);
878        }
879        out
880    }
881
882    pub fn vocab_len(&self) -> u32 {
883        self.vocab.len() as u32
884    }
885
886    /// Number of BPE merges loaded from GGUF (diagnostic).
887    pub fn merge_count(&self) -> usize {
888        self.merge_pairs.len()
889    }
890
891    // ── internal KV parsers ──────────────────────────────────────────────────
892
893    fn read_string_array(mmap: &[u8], pos: &mut usize, vtype: u32) -> Option<Vec<String>> {
894        if vtype != 9 {
895            Self::skip_value(mmap, pos, vtype)?;
896            return None;
897        }
898        if *pos + 12 > mmap.len() {
899            return None;
900        }
901        let etype = u32::from_le_bytes(mmap[*pos..*pos + 4].try_into().ok()?);
902        *pos += 4;
903        let count = u64::from_le_bytes(mmap[*pos..*pos + 8].try_into().ok()?) as usize;
904        *pos += 8;
905        if etype != 8 {
906            return None;
907        } // must be STRING array
908        let mut result = Vec::with_capacity(count.min(256_000));
909        for _ in 0..count {
910            if *pos + 8 > mmap.len() {
911                break;
912            }
913            let slen = u64::from_le_bytes(mmap[*pos..*pos + 8].try_into().ok()?) as usize;
914            *pos += 8;
915            if *pos + slen > mmap.len() {
916                break;
917            }
918            let s = std::str::from_utf8(&mmap[*pos..*pos + slen])
919                .unwrap_or("<?>")
920                .to_string();
921            *pos += slen;
922            result.push(s);
923        }
924        Some(result)
925    }
926
927    fn read_u32_val(mmap: &[u8], pos: &mut usize, vtype: u32) -> Option<u32> {
928        if vtype == 4 {
929            if *pos + 4 > mmap.len() {
930                return None;
931            }
932            let v = u32::from_le_bytes(mmap[*pos..*pos + 4].try_into().ok()?);
933            *pos += 4;
934            Some(v)
935        } else {
936            Self::skip_value(mmap, pos, vtype)?;
937            None
938        }
939    }
940
941    fn read_string_val(mmap: &[u8], pos: &mut usize, vtype: u32) -> Option<String> {
942        if vtype == 8 {
943            if *pos + 8 > mmap.len() {
944                return None;
945            }
946            let slen = u64::from_le_bytes(mmap[*pos..*pos + 8].try_into().ok()?) as usize;
947            *pos += 8;
948            if *pos + slen > mmap.len() {
949                return None;
950            }
951            let s = std::str::from_utf8(&mmap[*pos..*pos + slen])
952                .unwrap_or("")
953                .to_string();
954            *pos += slen;
955            Some(s)
956        } else {
957            Self::skip_value(mmap, pos, vtype)?;
958            None
959        }
960    }
961
962    fn read_bool_val(mmap: &[u8], pos: &mut usize, vtype: u32) -> Option<bool> {
963        if vtype == 7 {
964            if *pos + 1 > mmap.len() {
965                return None;
966            }
967            let b = mmap[*pos];
968            *pos += 1;
969            Some(b != 0)
970        } else {
971            Self::skip_value(mmap, pos, vtype)?;
972            None
973        }
974    }
975
976    fn skip_value(mmap: &[u8], pos: &mut usize, vtype: u32) -> Option<()> {
977        gguf_skip_value(mmap, pos, vtype)
978    }
979}