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 crate::tokenizer::{SpecialKind, SpecialTokenTable, SpecialTokens, TextOrSpecial};
34use base64::Engine;
35use fancy_regex::Regex;
36use std::collections::HashMap;
37use thiserror::Error;
38
39/// The real split pattern from Kimi K3's `tokenization_kimi.py`, with
40/// the `&&[^\p{Han}]` set-intersection idiom rewritten as an
41/// equivalent per-character negative lookahead (see module doc
42/// comment).
43const PAT_STR: &str = concat!(
44    r"[\p{Han}]+",
45    "|",
46    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)?",
47    "|",
48    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)?",
49    "|",
50    r"\p{N}{1,3}",
51    "|",
52    r" ?[^\s\p{L}\p{N}]+[\r\n]*",
53    "|",
54    r"\s*[\r\n]+",
55    "|",
56    r"\s+(?!\S)",
57    "|",
58    r"\s+",
59);
60
61#[derive(Debug, Error)]
62pub enum KimiTokenizerError {
63    #[error("invalid tiktoken vocab line {0}: {1:?}")]
64    InvalidVocabLine(usize, String),
65    #[error("split regex failed to compile: {0}")]
66    Regex(#[from] fancy_regex::Error),
67    #[error("failed to parse tokenizer_config.json: {0}")]
68    TokenizerConfigJson(#[from] serde_json::Error),
69}
70
71/// Parses the real `added_tokens_decoder` block of Kimi K3's
72/// `tokenizer_config.json` (`{"163584": {"content": "[BOS]", ...}, ...}`,
73/// real key/value shapes confirmed against the real downloaded file) into
74/// a `name -> id` map suitable for `KimiTokenizer::new`'s
75/// `special_tokens` argument.
76pub fn parse_special_tokens(json_text: &str) -> Result<HashMap<String, u32>, KimiTokenizerError> {
77    #[derive(serde::Deserialize)]
78    struct AddedToken {
79        content: String,
80    }
81    #[derive(serde::Deserialize)]
82    struct TokenizerConfig {
83        #[serde(default)]
84        added_tokens_decoder: HashMap<String, AddedToken>,
85    }
86    let cfg: TokenizerConfig = serde_json::from_str(json_text)?;
87    Ok(cfg
88        .added_tokens_decoder
89        .into_iter()
90        .filter_map(|(id_str, tok)| id_str.parse::<u32>().ok().map(|id| (tok.content, id)))
91        .collect())
92}
93
94/// Parses a real `.tiktoken`-format vocab file: one `<base64-bytes>
95/// <rank>` pair per line, blank lines ignored. Standard OpenAI
96/// tiktoken vocab format (confirmed against a real downloaded Kimi K3
97/// `tiktoken.model`).
98pub fn parse_tiktoken_vocab(text: &str) -> Result<HashMap<Vec<u8>, u32>, KimiTokenizerError> {
99    let mut ranks = HashMap::new();
100    for (i, line) in text.lines().enumerate() {
101        let line = line.trim();
102        if line.is_empty() {
103            continue;
104        }
105        let mut parts = line.splitn(2, ' ');
106        let b64 = parts
107            .next()
108            .ok_or_else(|| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
109        let rank_str = parts
110            .next()
111            .ok_or_else(|| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
112        let bytes = base64::engine::general_purpose::STANDARD
113            .decode(b64)
114            .map_err(|_| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
115        let rank: u32 = rank_str
116            .parse()
117            .map_err(|_| KimiTokenizerError::InvalidVocabLine(i, line.to_string()))?;
118        ranks.insert(bytes, rank);
119    }
120    Ok(ranks)
121}
122
123/// The real tiktoken byte-pair-merge algorithm: given one already-split
124/// text piece's raw bytes, repeatedly merges the adjacent byte-span
125/// pair with the lowest rank until no adjacent pair has a rank in the
126/// table, then returns each final span's rank (its token id).
127///
128/// This is a *rank* merge, not an ordered-merge-rules BPE like
129/// `GgufBpeTokenizer::encode_word` -- every candidate byte span's
130/// mergeability is decided by a single lookup into `ranks`, not by
131/// position in a fixed merge-priority list.
132fn byte_pair_merge(piece: &[u8], ranks: &HashMap<Vec<u8>, u32>) -> Vec<u32> {
133    if piece.is_empty() {
134        return Vec::new();
135    }
136    if piece.len() == 1 {
137        return vec![*ranks.get(piece).unwrap_or(&0)];
138    }
139
140    // `boundaries[i]` is the start byte offset of the i-th part;
141    // `boundaries.len() - 1` parts remain once merging is done.
142    let mut boundaries: Vec<usize> = (0..=piece.len()).collect();
143
144    let rank_of = |boundaries: &[usize], i: usize| -> Option<u32> {
145        if i + 2 >= boundaries.len() {
146            return None;
147        }
148        ranks.get(&piece[boundaries[i]..boundaries[i + 2]]).copied()
149    };
150
151    loop {
152        if boundaries.len() <= 2 {
153            break;
154        }
155        let mut best: Option<(u32, usize)> = None;
156        for i in 0..boundaries.len() - 2 {
157            if let Some(r) = rank_of(&boundaries, i) {
158                if best.is_none_or(|(br, _)| r < br) {
159                    best = Some((r, i));
160                }
161            }
162        }
163        match best {
164            Some((_, i)) => {
165                boundaries.remove(i + 1);
166            }
167            None => break,
168        }
169    }
170
171    boundaries
172        .windows(2)
173        .map(|w| {
174            *ranks
175                .get(&piece[w[0]..w[1]])
176                .expect("every final span must be a real vocab entry")
177        })
178        .collect()
179}
180
181pub struct KimiTokenizer {
182    encoder: HashMap<Vec<u8>, u32>,
183    decoder: HashMap<u32, Vec<u8>>,
184    special_tokens: HashMap<String, u32>,
185    /// The same specials as a carve-out table, for
186    /// [`SpecialTokens::Parse`] -- tiktoken's `allowed_special="all"`.
187    special_table: SpecialTokenTable,
188    split_re: Regex,
189}
190
191impl KimiTokenizer {
192    pub fn new(
193        encoder: HashMap<Vec<u8>, u32>,
194        special_tokens: HashMap<String, u32>,
195    ) -> Result<Self, KimiTokenizerError> {
196        let decoder = encoder.iter().map(|(k, v)| (*v, k.clone())).collect();
197        let split_re = Regex::new(PAT_STR)?;
198        let special_table = SpecialTokenTable::from_entries(
199            special_tokens
200                .iter()
201                .map(|(name, &id)| (name.as_str(), id, SpecialKind::Control)),
202        );
203        Ok(KimiTokenizer {
204            encoder,
205            decoder,
206            special_tokens,
207            special_table,
208            split_re,
209        })
210    }
211
212    pub fn vocab_size(&self) -> usize {
213        self.encoder.len()
214    }
215
216    pub fn special_token_id(&self, name: &str) -> Option<u32> {
217        self.special_tokens.get(name).copied()
218    }
219
220    /// Encodes text. [`SpecialTokens::AsText`] is the real
221    /// `disallowed_special=()` path `tokenization_kimi.py` uses for
222    /// untrusted/user text: a literal `<|...|>` substring is BPE-encoded
223    /// like any other text, never misread as a control token.
224    /// [`SpecialTokens::Parse`] is `allowed_special="all"`: every name
225    /// in `tokenizer_config.json` is carved out as its id.
226    pub fn encode(&self, text: &str, specials: SpecialTokens) -> Vec<u32> {
227        let mut out = Vec::new();
228        for seg in self.special_table.split(text, specials) {
229            match seg {
230                TextOrSpecial::Special(id) => out.push(id),
231                TextOrSpecial::Text(run) => self.encode_text_run(run, &mut out),
232            }
233        }
234        out
235    }
236
237    fn encode_text_run(&self, text: &str, out: &mut Vec<u32>) {
238        for piece in self.split_re.find_iter(text) {
239            let piece = piece.expect("split regex match should not error mid-scan");
240            let bytes = piece.as_str().as_bytes();
241            if let Some(&id) = self.encoder.get(bytes) {
242                out.push(id);
243                continue;
244            }
245            out.extend(byte_pair_merge(bytes, &self.encoder));
246        }
247    }
248
249    pub fn decode(&self, ids: &[u32]) -> String {
250        let mut bytes = Vec::new();
251        for &id in ids {
252            if let Some(b) = self.decoder.get(&id) {
253                bytes.extend_from_slice(b);
254            }
255        }
256        String::from_utf8_lossy(&bytes).into_owned()
257    }
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn tiny_ranks() -> HashMap<Vec<u8>, u32> {
265        // Every single byte 0..=255 gets its own rank equal to its
266        // value (matches real tiktoken vocabs, which always include
267        // every single byte as a base token), plus a few real merges
268        // built up in increasing-rank order (lower rank = merged
269        // first, matching real tiktoken semantics).
270        let mut ranks: HashMap<Vec<u8>, u32> = (0u32..256).map(|b| (vec![b as u8], b)).collect();
271        let mut next = 256u32;
272        let add = |bytes: &[u8], ranks: &mut HashMap<Vec<u8>, u32>, next: &mut u32| {
273            ranks.insert(bytes.to_vec(), *next);
274            *next += 1;
275        };
276        add(b"he", &mut ranks, &mut next); // "h"+"e" -> "he"
277        add(b"ll", &mut ranks, &mut next); // "l"+"l" -> "ll"
278        add(b"hel", &mut ranks, &mut next); // "he"+"l" -> "hel"
279        add(b"hell", &mut ranks, &mut next); // "hel"+"l" -> "hell" (uses "ll"? either path, lowest rank wins)
280        add(b"hello", &mut ranks, &mut next); // "hell"+"o" -> "hello"
281        ranks
282    }
283
284    #[test]
285    fn byte_pair_merge_prefers_the_lowest_rank_pair_first() {
286        let ranks = tiny_ranks();
287        let ids = byte_pair_merge(b"hello", &ranks);
288        // "hello" is itself a vocab entry with the lowest possible
289        // rank among any partition, so the merge should collapse all
290        // the way down to the single "hello" token.
291        let hello_id = *ranks.get(b"hello".as_slice()).unwrap();
292        assert_eq!(ids, vec![hello_id]);
293    }
294
295    #[test]
296    fn byte_pair_merge_falls_back_to_single_bytes_with_no_mergeable_pairs() {
297        let ranks = tiny_ranks();
298        let ids = byte_pair_merge(b"xyz", &ranks);
299        assert_eq!(ids, vec![b'x' as u32, b'y' as u32, b'z' as u32]);
300    }
301
302    #[test]
303    fn byte_pair_merge_merges_a_known_pair_but_not_unknown_neighbors() {
304        let ranks = tiny_ranks();
305        // "he" merges (known pair), "z" stays alone.
306        let ids = byte_pair_merge(b"hez", &ranks);
307        let he_id = *ranks.get(b"he".as_slice()).unwrap();
308        assert_eq!(ids, vec![he_id, b'z' as u32]);
309    }
310
311    #[test]
312    fn encode_decode_roundtrips_on_a_tiny_synthetic_vocab() {
313        let ranks = tiny_ranks();
314        let tok = KimiTokenizer::new(ranks, HashMap::new()).expect("regex must compile");
315        let ids = tok.encode("hello", SpecialTokens::AsText);
316        let back = tok.decode(&ids);
317        assert_eq!(back, "hello");
318    }
319
320    /// tiktoken's two modes: `disallowed_special=()` leaves a written
321    /// marker as bytes, `allowed_special="all"` returns its id.
322    #[test]
323    fn a_written_marker_is_bytes_as_text_and_one_id_when_parsed() {
324        let ranks = tiny_ranks();
325        let specials = HashMap::from([("[EOS]".to_string(), 9_000u32)]);
326        let tok = KimiTokenizer::new(ranks, specials).expect("regex must compile");
327        let as_text = tok.encode("hello[EOS]", SpecialTokens::AsText);
328        assert!(!as_text.contains(&9_000), "got {as_text:?}");
329        let parsed = tok.encode("hello[EOS]", SpecialTokens::Parse);
330        assert_eq!(parsed.last(), Some(&9_000), "got {parsed:?}");
331        assert_eq!(
332            &parsed[..parsed.len() - 1],
333            &tok.encode("hello", SpecialTokens::AsText)[..]
334        );
335    }
336
337    #[test]
338    fn split_regex_separates_words_punctuation_and_whitespace() {
339        let ranks = tiny_ranks();
340        let tok = KimiTokenizer::new(ranks, HashMap::new()).expect("regex must compile");
341        let pieces: Vec<&str> = tok
342            .split_re
343            .find_iter("hello, world!")
344            .map(|m| m.unwrap().as_str())
345            .collect();
346        assert_eq!(pieces, vec!["hello", ",", " world", "!"]);
347    }
348
349    #[test]
350    fn split_regex_keeps_han_script_separate_from_latin_words() {
351        let ranks = tiny_ranks();
352        let tok = KimiTokenizer::new(ranks, HashMap::new()).expect("regex must compile");
353        let pieces: Vec<&str> = tok
354            .split_re
355            .find_iter("Hi你好")
356            .map(|m| m.unwrap().as_str())
357            .collect();
358        // "Hi" (Latin word) and "你好" (Han run) must land in separate
359        // pieces -- this is exactly what the `&&[^\p{Han}]` exclusion
360        // (rewritten as a lookahead here) exists to guarantee.
361        assert_eq!(pieces, vec!["Hi", "你好"]);
362    }
363
364    #[test]
365    fn parses_a_real_tiktoken_format_vocab_file() {
366        // "IQ==" base64-decodes to the single byte 0x21 ('!'), matching
367        // the real Kimi K3 tiktoken.model's first line format exactly.
368        let text = "IQ== 0\nIg== 1\n";
369        let ranks = parse_tiktoken_vocab(text).expect("must parse");
370        assert_eq!(ranks.get(&vec![0x21]), Some(&0));
371        assert_eq!(ranks.get(&vec![0x22]), Some(&1));
372    }
373
374    #[test]
375    fn ignores_blank_lines_in_a_tiktoken_vocab_file() {
376        let text = "IQ== 0\n\nIg== 1\n\n";
377        let ranks = parse_tiktoken_vocab(text).expect("must parse");
378        assert_eq!(ranks.len(), 2);
379    }
380
381    #[test]
382    fn parses_real_shaped_special_tokens_from_tokenizer_config_json() {
383        // Matches the real Kimi K3 tokenizer_config.json's shape
384        // exactly (fetched live): string-encoded integer keys, each
385        // with at least a "content" field.
386        let json = r#"{
387            "added_tokens_decoder": {
388                "163584": {"content": "[BOS]", "special": true},
389                "163585": {"content": "[EOS]", "special": true},
390                "163586": {"content": "<|end_of_msg|>", "special": true}
391            }
392        }"#;
393        let tokens = parse_special_tokens(json).expect("must parse");
394        assert_eq!(tokens.get("[BOS]"), Some(&163584));
395        assert_eq!(tokens.get("[EOS]"), Some(&163585));
396        assert_eq!(tokens.get("<|end_of_msg|>"), Some(&163586));
397        assert_eq!(tokens.len(), 3);
398    }
399}