Skip to main content

ferrox_models/tokenizer/
special.rs

1//! Special tokens: which vocabulary entries are one, and whether a
2//! literal marker in the input is parsed as the token it names.
3//!
4//! Shared by every GGUF tokenizer in this crate (BPE, SPM, Unigram,
5//! WordPiece), so the four cannot come to disagree about what a special
6//! token is. Transcribed from llama.cpp's `src/llama-vocab.cpp`, and
7//! every rule below cites the line it came from.
8//!
9//! # The two questions
10//!
11//! **Which entries are special?** llama.cpp's `cache_special_tokens`
12//! (`llama-vocab.cpp:2948-2952`): every token whose attribute is
13//! `CONTROL`, `USER_DEFINED` or `UNKNOWN`. The attribute starts as the
14//! file's `tokenizer.ggml.token_type` (`:2447-2458`) and is then
15//! adjusted by name: every text in [`EOG_TOKEN_TEXTS`] is promoted to
16//! `CONTROL` whatever the file said (`:2800-2832`, "control-looking
17//! token ... its type will be overridden"), and two family-specific
18//! demotions follow (`:2880-2937`).
19//!
20//! That is the WHOLE rule. This module used to add one of its own -- any
21//! vocabulary entry shaped like `<...>` was treated as special -- and
22//! that made Qwen2.5's `<s>`, which its vocabulary carries as an ordinary
23//! NORMAL entry, tokenize as one id where llama.cpp gives three (`<`,
24//! `s`, `>`), under BOTH `parse_special` settings. A document that
25//! mentions `<s>` in prose was off by one token per mention.
26//!
27//! **Is a marker in the text parsed?** llama.cpp's `parse_special`
28//! (`llama.h`, `llama_tokenize`: "Allow tokenizing special and/or
29//! control tokens which otherwise are not exposed and treated as
30//! plaintext"). Its `tokenizer_st_partition` (`:3163-3175`) skips
31//! `CONTROL` and `UNKNOWN` entries when it is false and still carves
32//! out `USER_DEFINED` ones, because HuggingFace's tokenizers do. The
33//! library default is `false` (`common/common.h:1015`), and this crate
34//! used to behave as if it were always `true`: prose that mentioned
35//! `<|im_end|>` inside backticks became the end-of-turn token. Every
36//! `encode` now takes a [`SpecialTokens`] so a caller says which it
37//! wants, and the callers are matched to llama.cpp's one by one -- the
38//! table is in the PR that introduced this module.
39//!
40//! Not ported: the `LSTRIP`/`RSTRIP` attributes llama.cpp sets by model
41//! name for jina-v2, phi-3 and modern-bert (`:3020-3047`), which strip
42//! whitespace next to a special. No local checkpoint carries them, so
43//! there is no fixture to hold an implementation to.
44
45use super::EOG_TOKEN_TEXTS;
46
47/// Whether a literal special-token marker in the input (`<s>`,
48/// `<|im_end|>`, `[SEP]`) is carved out as the token it names or
49/// tokenized as the characters it is written with.
50///
51/// llama.cpp's `parse_special`. Callers choose per site, because the
52/// right answer differs: a rendered chat prompt needs its template's own
53/// markers parsed, while a stop string, a DRY sequence breaker or a
54/// rerank document is plain text whatever it happens to mention.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum SpecialTokens {
57    /// `parse_special = false`, llama.cpp's library default. `CONTROL`
58    /// and `UNKNOWN` entries are ordinary text; `USER_DEFINED` ones are
59    /// still carved out (`llama-vocab.cpp:3169-3174`).
60    AsText,
61    /// `parse_special = true`: every special entry is matched as an
62    /// atomic substring before normal tokenization runs.
63    Parse,
64}
65
66/// The attribute that makes an entry special, from llama.cpp's
67/// `llama_token_attr` (`include/llama.h`).
68#[derive(Clone, Copy, Debug, PartialEq, Eq)]
69pub(crate) enum SpecialKind {
70    /// `LLAMA_TOKEN_ATTR_CONTROL`: parsed only under [`SpecialTokens::Parse`].
71    Control,
72    /// `LLAMA_TOKEN_ATTR_USER_DEFINED`: parsed under both settings.
73    UserDefined,
74    /// `LLAMA_TOKEN_ATTR_UNKNOWN`: parsed only under [`SpecialTokens::Parse`].
75    Unknown,
76}
77
78impl SpecialKind {
79    /// `tokenizer_st_partition`'s gate (`llama-vocab.cpp:3169`): with
80    /// `parse_special == false`, control and unknown tokens are skipped.
81    fn is_parsed(self, mode: SpecialTokens) -> bool {
82        match mode {
83            SpecialTokens::Parse => true,
84            SpecialTokens::AsText => self == SpecialKind::UserDefined,
85        }
86    }
87}
88
89/// GGUF's `tokenizer.ggml.token_type` per-token integer tag, matching
90/// llama.cpp's `llama_token_type` enum (`include/llama.h`) and its
91/// GGUF-loading switch (`llama-vocab.cpp:2447-2458`). A plain sequential
92/// enum on disk (`1=NORMAL, 2=UNKNOWN, 3=CONTROL, 4=USER_DEFINED,
93/// 5=UNUSED, 6=BYTE`), which is a different and simpler representation
94/// than llama.cpp's internal bit-flag `llama_token_attr`.
95const GGML_TOKEN_TYPE_UNKNOWN: i64 = 2;
96const GGML_TOKEN_TYPE_CONTROL: i64 = 3;
97const GGML_TOKEN_TYPE_USER_DEFINED: i64 = 4;
98
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub(crate) struct SpecialToken {
101    pub text: String,
102    pub id: u32,
103    pub kind: SpecialKind,
104}
105
106/// One chunk of [`SpecialTokenTable::split`]'s output: either a raw
107/// text run to tokenize normally, or an already-resolved special id.
108pub(crate) enum TextOrSpecial<'a> {
109    Text(&'a str),
110    Special(u32),
111}
112
113/// The special entries of one vocabulary, in the order llama.cpp
114/// partitions on them.
115#[derive(Clone, Debug, Default, PartialEq, Eq)]
116pub(crate) struct SpecialTokenTable {
117    /// Longest text first (`llama-vocab.cpp:2954-2958`); ties keep id
118    /// order, which llama.cpp's unstable sort leaves unspecified.
119    entries: Vec<SpecialToken>,
120}
121
122impl SpecialTokenTable {
123    /// Reads `tokenizer.ggml.token_type` and applies llama.cpp's by-name
124    /// adjustments. `id_to_token` is the vocabulary in id order.
125    pub fn from_gguf(file: &impl ferrox_gguf::TensorSource, id_to_token: &[String]) -> Self {
126        let mut kinds: Vec<Option<SpecialKind>> = vec![None; id_to_token.len()];
127
128        if let Some(ferrox_gguf::GgufValue::Array(items)) =
129            file.metadata("tokenizer.ggml.token_type")
130        {
131            for (kind, v) in kinds.iter_mut().zip(items) {
132                let ty = match v {
133                    ferrox_gguf::GgufValue::I32(t) => *t as i64,
134                    ferrox_gguf::GgufValue::U32(t) => *t as i64,
135                    _ => continue,
136                };
137                *kind = match ty {
138                    GGML_TOKEN_TYPE_CONTROL => Some(SpecialKind::Control),
139                    GGML_TOKEN_TYPE_USER_DEFINED => Some(SpecialKind::UserDefined),
140                    GGML_TOKEN_TYPE_UNKNOWN => Some(SpecialKind::Unknown),
141                    _ => None,
142                };
143            }
144        }
145
146        // `llama-vocab.cpp:2800-2832`: every end-of-generation text is
147        // CONTROL whatever the file said. Yi-1.5-6B-Chat ships
148        // `<|im_end|>` as NORMAL; this is what makes it one token there
149        // (and `<|im_start|>`, which is not on the list, stays
150        // shattered -- on llama.cpp too).
151        for (id, text) in id_to_token.iter().enumerate() {
152            if EOG_TOKEN_TEXTS.contains(&text.as_str()) {
153                kinds[id] = Some(SpecialKind::Control);
154            }
155        }
156
157        // `llama-vocab.cpp:2880-2912`: gpt-oss / solar-open render
158        // `<|end|>` as USER_DEFINED so it is parsed even as plain text.
159        let has = |t: &str| id_to_token.iter().any(|x| x == t);
160        if has("<|end|>")
161            && ((has("<|return|>") && has("<|call|>")) || (has("<|calls|>") && has("<|flush|>")))
162        {
163            for (id, text) in id_to_token.iter().enumerate() {
164                if text == "<|end|>" {
165                    kinds[id] = Some(SpecialKind::UserDefined);
166                }
167            }
168        }
169        // `llama-vocab.cpp:2914-2937`: gemma-4 / paddleocr carry `</s>`
170        // as an ordinary word once `<|tool_response>` is present.
171        if has("<|tool_response>") && has("</s>") {
172            for (id, text) in id_to_token.iter().enumerate() {
173                if text == "</s>" {
174                    kinds[id] = None;
175                }
176            }
177        }
178
179        Self::from_entries(
180            kinds
181                .into_iter()
182                .enumerate()
183                .filter_map(|(id, kind)| Some((id_to_token[id].as_str(), id as u32, kind?))),
184        )
185    }
186
187    /// A table from `(text, id, kind)` triples: for a vocabulary that
188    /// carries its specials outside GGUF metadata (Kimi's
189    /// `tokenizer_config.json`), and for tests. The GGUF constructor
190    /// above ends here too, so there is one ordering rule.
191    pub fn from_entries<'a>(
192        entries: impl IntoIterator<Item = (&'a str, u32, SpecialKind)>,
193    ) -> Self {
194        let mut entries: Vec<SpecialToken> = entries
195            .into_iter()
196            // An empty special would match everywhere and nowhere.
197            .filter(|(text, _, _)| !text.is_empty())
198            .map(|(text, id, kind)| SpecialToken {
199                text: text.to_string(),
200                id,
201                kind,
202            })
203            .collect();
204        // Stable, so equal lengths keep id order.
205        entries.sort_by_key(|e| std::cmp::Reverse(e.text.len()));
206        SpecialTokenTable { entries }
207    }
208
209    /// Splits `text` around every literal occurrence of every special
210    /// entry `mode` lets through, leaving the text runs between them
211    /// for the caller's normal tokenization pass.
212    ///
213    /// A port of `tokenizer_st_partition` (`llama-vocab.cpp:3163-3268`):
214    /// specials are taken longest first, and each one splits every raw
215    /// fragment left by the ones before it. Order matters when two
216    /// specials overlap, and this is llama.cpp's order.
217    pub fn split<'a>(&self, text: &'a str, mode: SpecialTokens) -> Vec<TextOrSpecial<'a>> {
218        let mut fragments = vec![TextOrSpecial::Text(text)];
219        for special in self.entries.iter().filter(|s| s.kind.is_parsed(mode)) {
220            let mut next = Vec::with_capacity(fragments.len());
221            for fragment in fragments {
222                match fragment {
223                    TextOrSpecial::Special(id) => next.push(TextOrSpecial::Special(id)),
224                    TextOrSpecial::Text(run) => {
225                        let mut rest = run;
226                        while let Some(at) = rest.find(special.text.as_str()) {
227                            if at > 0 {
228                                next.push(TextOrSpecial::Text(&rest[..at]));
229                            }
230                            next.push(TextOrSpecial::Special(special.id));
231                            rest = &rest[at + special.text.len()..];
232                        }
233                        if !rest.is_empty() {
234                            next.push(TextOrSpecial::Text(rest));
235                        }
236                    }
237                }
238            }
239            fragments = next;
240        }
241        fragments
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use ferrox_gguf::{GgufError, GgufValue, TensorInfo, TensorSource};
249
250    fn text_of<'a>(seg: &TextOrSpecial<'a>) -> Option<&'a str> {
251        match seg {
252            TextOrSpecial::Text(t) => Some(t),
253            TextOrSpecial::Special(_) => None,
254        }
255    }
256
257    fn table(specials: &[(&str, u32)]) -> SpecialTokenTable {
258        SpecialTokenTable::from_entries(
259            specials
260                .iter()
261                .map(|&(t, id)| (t, id, SpecialKind::Control)),
262        )
263    }
264
265    #[test]
266    fn an_empty_table_returns_the_whole_text_unsplit() {
267        let segs = table(&[]).split("hello world", SpecialTokens::Parse);
268        assert_eq!(segs.len(), 1);
269        assert_eq!(text_of(&segs[0]), Some("hello world"));
270    }
271
272    #[test]
273    fn splits_around_a_single_special_token_in_the_middle() {
274        let segs = table(&[("<|sep|>", 99)]).split("before<|sep|>after", SpecialTokens::Parse);
275        assert_eq!(segs.len(), 3);
276        assert_eq!(text_of(&segs[0]), Some("before"));
277        assert!(matches!(segs[1], TextOrSpecial::Special(99)));
278        assert_eq!(text_of(&segs[2]), Some("after"));
279    }
280
281    #[test]
282    fn multiple_occurrences_and_multiple_distinct_specials_all_split() {
283        let segs = table(&[("<a>", 1), ("<b>", 2)]).split("<a>x<b>y<a>", SpecialTokens::Parse);
284        let ids: Vec<u32> = segs
285            .iter()
286            .filter_map(|s| match s {
287                TextOrSpecial::Special(id) => Some(*id),
288                _ => None,
289            })
290            .collect();
291        assert_eq!(ids, vec![1, 2, 1]);
292        let texts: Vec<&str> = segs.iter().filter_map(text_of).collect();
293        assert_eq!(texts, vec!["x", "y"]);
294    }
295
296    /// llama.cpp partitions longest-first, so a marker that is a prefix
297    /// of a longer one never steals the longer one's match.
298    #[test]
299    fn the_longest_special_is_carved_out_before_a_prefix_of_it() {
300        let segs = table(&[("<s>", 1), ("<s>x", 2)]).split("<s>x", SpecialTokens::Parse);
301        assert_eq!(segs.len(), 1);
302        assert!(matches!(segs[0], TextOrSpecial::Special(2)));
303    }
304
305    #[test]
306    fn no_match_at_all_returns_the_whole_text_as_one_segment() {
307        let segs = table(&[("<|zzz|>", 5)]).split("nothing here", SpecialTokens::Parse);
308        assert_eq!(segs.len(), 1);
309        assert_eq!(text_of(&segs[0]), Some("nothing here"));
310    }
311
312    /// The gate this module exists for. `tokenizer_st_partition` skips
313    /// CONTROL and UNKNOWN entries when `parse_special` is false and
314    /// still carves out USER_DEFINED ones (`llama-vocab.cpp:3169-3174`).
315    #[test]
316    fn as_text_leaves_control_and_unknown_markers_as_prose_but_still_parses_user_defined() {
317        let t = SpecialTokenTable::from_entries(vec![
318            ("<|im_end|>", 7, SpecialKind::Control),
319            ("<unk>", 0, SpecialKind::Unknown),
320            ("<|user|>", 9, SpecialKind::UserDefined),
321        ]);
322        let segs = t.split("a<|im_end|>b<unk>c<|user|>d", SpecialTokens::AsText);
323        let texts: Vec<&str> = segs.iter().filter_map(text_of).collect();
324        assert_eq!(texts, vec!["a<|im_end|>b<unk>c", "d"]);
325        assert!(matches!(segs[1], TextOrSpecial::Special(9)));
326
327        let segs = t.split("a<|im_end|>b<unk>c<|user|>d", SpecialTokens::Parse);
328        let ids: Vec<u32> = segs
329            .iter()
330            .filter_map(|s| match s {
331                TextOrSpecial::Special(id) => Some(*id),
332                _ => None,
333            })
334            .collect();
335        assert_eq!(ids, vec![7, 0, 9]);
336    }
337
338    struct MetaOnly(std::collections::HashMap<String, GgufValue>);
339
340    impl TensorSource for MetaOnly {
341        fn metadata(&self, key: &str) -> Option<&GgufValue> {
342            self.0.get(key)
343        }
344        fn find_tensor(&self, _name: &str) -> Option<&TensorInfo> {
345            None
346        }
347        fn tensor_bytes(&self, name: &str) -> Result<&[u8], GgufError> {
348            Err(GgufError::TensorNotFound(name.to_string()))
349        }
350        fn tensor_mapped_range(
351            &self,
352            name: &str,
353        ) -> Result<
354            (
355                std::sync::Arc<ferrox_gguf::MmapHandle>,
356                std::ops::Range<usize>,
357            ),
358            GgufError,
359        > {
360            Err(GgufError::TensorNotFound(name.to_string()))
361        }
362    }
363
364    fn vocab(tokens: &[(&str, i32)]) -> (MetaOnly, Vec<String>) {
365        let mut m = std::collections::HashMap::new();
366        m.insert(
367            "tokenizer.ggml.token_type".to_string(),
368            GgufValue::Array(tokens.iter().map(|&(_, ty)| GgufValue::I32(ty)).collect()),
369        );
370        let id_to_token = tokens.iter().map(|&(t, _)| t.to_string()).collect();
371        (MetaOnly(m), id_to_token)
372    }
373
374    /// Qwen2.5-1.5B's vocabulary carries `<s>` (id 128245) as NORMAL,
375    /// and llama.cpp tokenizes it as `<`, `s`, `>` under both settings.
376    /// A shape-based promotion made it one token here; that is the
377    /// off-by-one `ferrox imatrix` measured on `docs/CLI.md`.
378    #[test]
379    fn a_normal_typed_entry_shaped_like_a_marker_is_not_special() {
380        let (file, ids) = vocab(&[("<", 1), ("s", 1), (">", 1), ("<s>", 1), ("<|im_end|>", 3)]);
381        let t = SpecialTokenTable::from_gguf(&file, &ids);
382        let segs = t.split("<s><|im_end|>", SpecialTokens::Parse);
383        let texts: Vec<&str> = segs.iter().filter_map(text_of).collect();
384        assert_eq!(texts, vec!["<s>"]);
385        assert!(matches!(segs[1], TextOrSpecial::Special(4)));
386    }
387
388    /// `llama-vocab.cpp:2800-2832`: an end-of-generation text is CONTROL
389    /// whatever the file says. Yi-1.5-6B-Chat is the checkpoint that
390    /// needs it -- `<|im_end|>` is NORMAL in its file.
391    #[test]
392    fn an_end_of_generation_text_is_control_even_when_the_file_says_normal() {
393        let (file, ids) = vocab(&[("<|im_start|>", 1), ("<|im_end|>", 1)]);
394        let t = SpecialTokenTable::from_gguf(&file, &ids);
395        assert_eq!(
396            t.entries,
397            vec![SpecialToken {
398                text: "<|im_end|>".to_string(),
399                id: 1,
400                kind: SpecialKind::Control
401            }]
402        );
403    }
404
405    #[test]
406    fn user_defined_and_unknown_types_are_special_and_normal_byte_and_unused_are_not() {
407        let (file, ids) = vocab(&[
408            ("<unk>", 2),
409            ("<ctl>", 3),
410            ("<usr>", 4),
411            ("<unused>", 5),
412            ("<0x00>", 6),
413            ("word", 1),
414        ]);
415        let t = SpecialTokenTable::from_gguf(&file, &ids);
416        let kinds: Vec<(u32, SpecialKind)> = t.entries.iter().map(|e| (e.id, e.kind)).collect();
417        assert_eq!(
418            kinds,
419            vec![
420                (0, SpecialKind::Unknown),
421                (1, SpecialKind::Control),
422                (2, SpecialKind::UserDefined)
423            ]
424        );
425    }
426
427    /// `llama-vocab.cpp:2914-2937`: gemma-4 ships `</s>` beside
428    /// `<|tool_response>`, and there it is an ordinary word.
429    #[test]
430    fn gemma4_style_end_of_sentence_is_demoted_beside_tool_response() {
431        let (file, ids) = vocab(&[("</s>", 3), ("<|tool_response>", 3)]);
432        let t = SpecialTokenTable::from_gguf(&file, &ids);
433        assert_eq!(t.entries.iter().map(|e| e.id).collect::<Vec<_>>(), vec![1]);
434    }
435
436    /// `llama-vocab.cpp:2880-2912`: gpt-oss's `<|end|>` becomes
437    /// USER_DEFINED, so it is parsed even as plain text.
438    #[test]
439    fn harmony_end_is_user_defined_when_return_and_call_are_present() {
440        let (file, ids) = vocab(&[("<|end|>", 3), ("<|return|>", 3), ("<|call|>", 3)]);
441        let t = SpecialTokenTable::from_gguf(&file, &ids);
442        let end = t.entries.iter().find(|e| e.text == "<|end|>").unwrap();
443        assert_eq!(end.kind, SpecialKind::UserDefined);
444        let segs = t.split("x<|end|>y", SpecialTokens::AsText);
445        assert!(matches!(segs[1], TextOrSpecial::Special(0)));
446    }
447
448    #[test]
449    fn a_file_without_token_types_has_only_the_by_name_specials() {
450        let file = MetaOnly(std::collections::HashMap::new());
451        let ids: Vec<String> = ["a", "<|eot_id|>", "b"]
452            .iter()
453            .map(|s| s.to_string())
454            .collect();
455        let t = SpecialTokenTable::from_gguf(&file, &ids);
456        assert_eq!(t.entries.iter().map(|e| e.id).collect::<Vec<_>>(), vec![1]);
457    }
458}