Skip to main content

ferrox_models/
kimi_tokenizer.rs

1//! Kimi K3's real tokenizer: tiktoken-style rank-based BPE, loaded from
2//! a real `tiktoken.model` file (base64-encoded byte sequence + rank
3//! per line -- the standard OpenAI tiktoken vocab format, confirmed
4//! against a real downloaded Kimi K3 `tiktoken.model` and its real
5//! `tokenization_kimi.py`/`tokenizer_config.json`), not from GGUF
6//! metadata like `GgufBpeTokenizer`/`GgufSpmTokenizer` -- Kimi K3 ships
7//! as safetensors, with its own real tokenizer format, distinct from
8//! both existing tokenizers in this module.
9//!
10//! The real split pattern (`pat_str` in `tokenization_kimi.py`) uses
11//! Unicode script/general-category properties (`\p{Han}`, `\p{Lu}`,
12//! ...) plus a negative lookahead (`\s+(?!\S)`) that Rust's `regex`
13//! crate deliberately doesn't support (no backtracking, for linear-time
14//! guarantees) -- `fancy-regex` is used instead, since it supports
15//! exactly this class of pattern while still being a generic,
16//! model-agnostic regex engine (same category of tool as `regex`
17//! itself), not tiktoken-specific code. One real translation was
18//! needed: the real pattern uses `[A&&[^B]]` (character-class set
19//! intersection), valid in Python's third-party `regex` module (which
20//! `tiktoken`'s reference implementation depends on) but not in Rust's
21//! regex syntax. Rewritten as `(?:(?!B)[A])` -- a per-character
22//! negative lookahead guarding the class -- which is semantically
23//! equivalent under repetition (`*`/`+`) since each repeated character
24//! is independently re-checked. Verified byte-for-byte against the
25//! real `tiktoken` Python library (see this module's tests).
26//!
27//! The core merge algorithm (given a UTF-8 text piece already isolated
28//! by the split regex, repeatedly merge the lowest-rank adjacent byte
29//! span until no mergeable pair remains) is transcribed from OpenAI's
30//! publicly documented tiktoken algorithm description, not copied from
31//! any source file.
32
33use base64::Engine;
34use fancy_regex::Regex;
35use std::collections::HashMap;
36use thiserror::Error;
37
38/// The real split pattern from Kimi K3's `tokenization_kimi.py`, with
39/// the `&&[^\p{Han}]` set-intersection idiom rewritten as an
40/// equivalent per-character negative lookahead (see module doc
41/// comment).
42const PAT_STR: &str = concat!(
43    r"[\p{Han}]+",
44    "|",
45    r"[^\r\n\p{L}\p{N}]?(?:(?!\p{Han})[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}])*(?:(?!\p{Han})[\p{Ll}\p{Lm}\p{Lo}\p{M}])+(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
46    "|",
47    r"[^\r\n\p{L}\p{N}]?(?:(?!\p{Han})[\p{Lu}\p{Lt}\p{Lm}\p{Lo}\p{M}])+(?:(?!\p{Han})[\p{Ll}\p{Lm}\p{Lo}\p{M}])*(?i:'s|'t|'re|'ve|'m|'ll|'d)?",
48    "|",
49    r"\p{N}{1,3}",
50    "|",
51    r" ?[^\s\p{L}\p{N}]+[\r\n]*",
52    "|",
53    r"\s*[\r\n]+",
54    "|",
55    r"\s+(?!\S)",
56    "|",
57    r"\s+",
58);
59
60#[derive(Debug, Error)]
61pub enum KimiTokenizerError {
62    #[error("invalid tiktoken vocab line {0}: {1:?}")]
63    InvalidVocabLine(usize, String),
64    #[error("split regex failed to compile: {0}")]
65    Regex(#[from] fancy_regex::Error),
66    #[error("failed to parse tokenizer_config.json: {0}")]
67    TokenizerConfigJson(#[from] serde_json::Error),
68}
69
70/// Parses the real `added_tokens_decoder` block of Kimi K3's
71/// `tokenizer_config.json` (`{"163584": {"content": "[BOS]", ...}, ...}`,
72/// real key/value shapes confirmed against the real downloaded file) into
73/// a `name -> id` map suitable for `KimiTokenizer::new`'s
74/// `special_tokens` argument.
75pub fn parse_special_tokens(json_text: &str) -> Result<HashMap<String, u32>, KimiTokenizerError> {
76    #[derive(serde::Deserialize)]
77    struct AddedToken {
78        content: String,
79    }
80    #[derive(serde::Deserialize)]
81    struct TokenizerConfig {
82        #[serde(default)]
83        added_tokens_decoder: HashMap<String, AddedToken>,
84    }
85    let cfg: TokenizerConfig = serde_json::from_str(json_text)?;
86    Ok(cfg
87        .added_tokens_decoder
88        .into_iter()
89        .filter_map(|(id_str, tok)| id_str.parse::<u32>().ok().map(|id| (tok.content, id)))
90        .collect())
91}
92
93/// Parses a real `.tiktoken`-format vocab file: one `<base64-bytes>
94/// <rank>` pair per line, blank lines ignored. Standard OpenAI
95/// tiktoken vocab format (confirmed against a real downloaded Kimi K3
96/// `tiktoken.model`).
97pub fn parse_tiktoken_vocab(text: &str) -> Result<HashMap<Vec<u8>, u32>, KimiTokenizerError> {
98    let mut ranks = HashMap::new();
99    for (i, line) in text.lines().enumerate() {
100        let line = line.trim();
101        if line.is_empty() {
102            continue;
103        }
104        let mut parts = line.splitn(2, ' ');
105        let b64 = parts
106            .next()
107            .ok_or_else(|| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
108        let rank_str = parts
109            .next()
110            .ok_or_else(|| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
111        let bytes = base64::engine::general_purpose::STANDARD
112            .decode(b64)
113            .map_err(|_| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
114        let rank: u32 = rank_str
115            .parse()
116            .map_err(|_| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
117        ranks.insert(bytes, rank);
118    }
119    Ok(ranks)
120}
121
122/// The real tiktoken byte-pair-merge algorithm: given one already-split
123/// text piece's raw bytes, repeatedly merges the adjacent byte-span
124/// pair with the lowest rank until no adjacent pair has a rank in the
125/// table, then returns each final span's rank (its token id).
126///
127/// This is a *rank* merge, not an ordered-merge-rules BPE like
128/// `GgufBpeTokenizer::encode_word` -- every candidate byte span's
129/// mergeability is decided by a single lookup into `ranks`, not by
130/// position in a fixed merge-priority list.
131fn byte_pair_merge(piece: &[u8], ranks: &HashMap<Vec<u8>, u32>) -> Vec<u32> {
132    if piece.is_empty() {
133        return Vec::new();
134    }
135    if piece.len() == 1 {
136        return vec![*ranks.get(piece).unwrap_or(&0)];
137    }
138
139    // `boundaries[i]` is the start byte offset of the i-th part;
140    // `boundaries.len() - 1` parts remain once merging is done.
141    let mut boundaries: Vec<usize> = (0..=piece.len()).collect();
142
143    let rank_of = |boundaries: &[usize], i: usize| -> Option<u32> {
144        if i + 2 >= boundaries.len() {
145            return None;
146        }
147        ranks.get(&piece[boundaries[i]..boundaries[i + 2]]).copied()
148    };
149
150    loop {
151        if boundaries.len() <= 2 {
152            break;
153        }
154        let mut best: Option<(u32, usize)> = None;
155        for i in 0..boundaries.len() - 2 {
156            if let Some(r) = rank_of(&boundaries, i) {
157                if best.is_none_or(|(br, _)| r < br) {
158                    best = Some((r, i));
159                }
160            }
161        }
162        match best {
163            Some((_, i)) => {
164                boundaries.remove(i + 1);
165            }
166            None => break,
167        }
168    }
169
170    boundaries
171        .windows(2)
172        .map(|w| {
173            *ranks
174                .get(&piece[w[0]..w[1]])
175                .expect("every final span must be a real vocab entry")
176        })
177        .collect()
178}
179
180pub struct KimiTokenizer {
181    encoder: HashMap<Vec<u8>, u32>,
182    decoder: HashMap<u32, Vec<u8>>,
183    special_tokens: HashMap<String, u32>,
184    split_re: Regex,
185}
186
187impl KimiTokenizer {
188    pub fn new(
189        encoder: HashMap<Vec<u8>, u32>,
190        special_tokens: HashMap<String, u32>,
191    ) -> Result<Self, KimiTokenizerError> {
192        let decoder = encoder.iter().map(|(k, v)| (*v, k.clone())).collect();
193        let split_re = Regex::new(PAT_STR)?;
194        Ok(KimiTokenizer {
195            encoder,
196            decoder,
197            special_tokens,
198            split_re,
199        })
200    }
201
202    pub fn vocab_size(&self) -> usize {
203        self.encoder.len()
204    }
205
206    pub fn special_token_id(&self, name: &str) -> Option<u32> {
207        self.special_tokens.get(name).copied()
208    }
209
210    /// Encodes ordinary text (no inline special-token recognition --
211    /// matches the real `disallowed_special=()` path used for
212    /// untrusted/user text in `tokenization_kimi.py`, so a literal
213    /// `<|...|>` substring in the input is BPE-encoded like any other
214    /// text, never misread as a control token).
215    pub fn encode(&self, text: &str) -> Vec<u32> {
216        let mut out = Vec::new();
217        for piece in self.split_re.find_iter(text) {
218            let piece = piece.expect("split regex match should not error mid-scan");
219            let bytes = piece.as_str().as_bytes();
220            if let Some(&id) = self.encoder.get(bytes) {
221                out.push(id);
222                continue;
223            }
224            out.extend(byte_pair_merge(bytes, &self.encoder));
225        }
226        out
227    }
228
229    pub fn decode(&self, ids: &[u32]) -> String {
230        let mut bytes = Vec::new();
231        for &id in ids {
232            if let Some(b) = self.decoder.get(&id) {
233                bytes.extend_from_slice(b);
234            }
235        }
236        String::from_utf8_lossy(&bytes).into_owned()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn tiny_ranks() -> HashMap<Vec<u8>, u32> {
245        // Every single byte 0..=255 gets its own rank equal to its
246        // value (matches real tiktoken vocabs, which always include
247        // every single byte as a base token), plus a few real merges
248        // built up in increasing-rank order (lower rank = merged
249        // first, matching real tiktoken semantics).
250        let mut ranks: HashMap<Vec<u8>, u32> = (0u32..256).map(|b| (vec![b as u8], b)).collect();
251        let mut next = 256u32;
252        let add = |bytes: &[u8], ranks: &mut HashMap<Vec<u8>, u32>, next: &mut u32| {
253            ranks.insert(bytes.to_vec(), *next);
254            *next += 1;
255        };
256        add(b"he", &mut ranks, &mut next); // "h"+"e" -> "he"
257        add(b"ll", &mut ranks, &mut next); // "l"+"l" -> "ll"
258        add(b"hel", &mut ranks, &mut next); // "he"+"l" -> "hel"
259        add(b"hell", &mut ranks, &mut next); // "hel"+"l" -> "hell" (uses "ll"? either path, lowest rank wins)
260        add(b"hello", &mut ranks, &mut next); // "hell"+"o" -> "hello"
261        ranks
262    }
263
264    #[test]
265    fn byte_pair_merge_prefers_the_lowest_rank_pair_first() {
266        let ranks = tiny_ranks();
267        let ids = byte_pair_merge(b"hello", &ranks);
268        // "hello" is itself a vocab entry with the lowest possible
269        // rank among any partition, so the merge should collapse all
270        // the way down to the single "hello" token.
271        let hello_id = *ranks.get(b"hello".as_slice()).unwrap();
272        assert_eq!(ids, vec![hello_id]);
273    }
274
275    #[test]
276    fn byte_pair_merge_falls_back_to_single_bytes_with_no_mergeable_pairs() {
277        let ranks = tiny_ranks();
278        let ids = byte_pair_merge(b"xyz", &ranks);
279        assert_eq!(ids, vec![b'x' as u32, b'y' as u32, b'z' as u32]);
280    }
281
282    #[test]
283    fn byte_pair_merge_merges_a_known_pair_but_not_unknown_neighbors() {
284        let ranks = tiny_ranks();
285        // "he" merges (known pair), "z" stays alone.
286        let ids = byte_pair_merge(b"hez", &ranks);
287        let he_id = *ranks.get(b"he".as_slice()).unwrap();
288        assert_eq!(ids, vec![he_id, b'z' as u32]);
289    }
290
291    #[test]
292    fn encode_decode_roundtrips_on_a_tiny_synthetic_vocab() {
293        let ranks = tiny_ranks();
294        let tok = KimiTokenizer::new(ranks, HashMap::new()).expect("regex must compile");
295        let ids = tok.encode("hello");
296        let back = tok.decode(&ids);
297        assert_eq!(back, "hello");
298    }
299
300    #[test]
301    fn split_regex_separates_words_punctuation_and_whitespace() {
302        let ranks = tiny_ranks();
303        let tok = KimiTokenizer::new(ranks, HashMap::new()).expect("regex must compile");
304        let pieces: Vec<&str> = tok
305            .split_re
306            .find_iter("hello, world!")
307            .map(|m| m.unwrap().as_str())
308            .collect();
309        assert_eq!(pieces, vec!["hello", ",", " world", "!"]);
310    }
311
312    #[test]
313    fn split_regex_keeps_han_script_separate_from_latin_words() {
314        let ranks = tiny_ranks();
315        let tok = KimiTokenizer::new(ranks, HashMap::new()).expect("regex must compile");
316        let pieces: Vec<&str> = tok
317            .split_re
318            .find_iter("Hi你好")
319            .map(|m| m.unwrap().as_str())
320            .collect();
321        // "Hi" (Latin word) and "你好" (Han run) must land in separate
322        // pieces -- this is exactly what the `&&[^\p{Han}]` exclusion
323        // (rewritten as a lookahead here) exists to guarantee.
324        assert_eq!(pieces, vec!["Hi", "你好"]);
325    }
326
327    #[test]
328    fn parses_a_real_tiktoken_format_vocab_file() {
329        // "IQ==" base64-decodes to the single byte 0x21 ('!'), matching
330        // the real Kimi K3 tiktoken.model's first line format exactly.
331        let text = "IQ== 0\nIg== 1\n";
332        let ranks = parse_tiktoken_vocab(text).expect("must parse");
333        assert_eq!(ranks.get(&vec![0x21]), Some(&0));
334        assert_eq!(ranks.get(&vec![0x22]), Some(&1));
335    }
336
337    #[test]
338    fn ignores_blank_lines_in_a_tiktoken_vocab_file() {
339        let text = "IQ== 0\n\nIg== 1\n\n";
340        let ranks = parse_tiktoken_vocab(text).expect("must parse");
341        assert_eq!(ranks.len(), 2);
342    }
343
344    #[test]
345    fn parses_real_shaped_special_tokens_from_tokenizer_config_json() {
346        // Matches the real Kimi K3 tokenizer_config.json's shape
347        // exactly (fetched live): string-encoded integer keys, each
348        // with at least a "content" field.
349        let json = r#"{
350            "added_tokens_decoder": {
351                "163584": {"content": "[BOS]", "special": true},
352                "163585": {"content": "[EOS]", "special": true},
353                "163586": {"content": "<|end_of_msg|>", "special": true}
354            }
355        }"#;
356        let tokens = parse_special_tokens(json).expect("must parse");
357        assert_eq!(tokens.get("[BOS]"), Some(&163584));
358        assert_eq!(tokens.get("[EOS]"), Some(&163585));
359        assert_eq!(tokens.get("<|end_of_msg|>"), Some(&163586));
360        assert_eq!(tokens.len(), 3);
361    }
362}