Skip to main content

kopitiam_tokenizer/
bpe.rs

1//! [`BpeTokenizer`]: the byte-level BPE [`crate::Tokenizer`] implementation.
2//!
3//! This ties together the other modules in this crate into the pipeline a
4//! GPT-2/Qwen-family tokenizer actually runs:
5//!
6//! ```text
7//! text
8//!   -> split off special tokens atomically      (specials::SpecialTokens)
9//!   -> pre-tokenize the remaining text spans     (pretokenize::split)
10//!   -> map each chunk's raw UTF-8 bytes to base-vocab ids
11//!   -> repeatedly apply the highest-priority merge until none apply
12//! ```
13//!
14//! Two design choices are worth calling out because they are easy to get
15//! wrong by copying GPT-2's Python reference too literally:
16//!
17//! * **The byte-to-unicode alphabet never appears here.** [`crate::vocab::Vocab`]
18//!   stores every token's *canonical raw bytes*, decoded once at load time
19//!   by [`crate::loader`] (or supplied directly by [`BpeTokenizer::from_vocab_and_merges`]).
20//!   So encoding a chunk is simply "look up the vocab id for each of its
21//!   raw UTF-8 bytes" -- no per-token mapped-character round trip, at
22//!   encode or decode time.
23//! * **Decoding needs no special-token branch.** A special token's id maps
24//!   to the raw UTF-8 bytes of its own content (see [`crate::specials`]),
25//!   so plain byte concatenation already reconstructs it.
26
27use crate::Tokenizer;
28use crate::merges::MergeTable;
29use crate::pretokenize;
30use crate::specials::{Segment, SpecialTokens};
31use crate::vocab::Vocab;
32use kopitiam_core::{Error, Result};
33
34/// A byte-level BPE tokenizer: the scheme used by the GPT-2/GPT-3/GPT-4 and
35/// Qwen model families.
36///
37/// Build one via [`BpeTokenizer::from_vocab_and_merges`] (plain data --
38/// what tests and a future GGUF loader use) or
39/// [`crate::loader::from_tokenizer_json`] (HuggingFace `tokenizer.json`).
40#[derive(Debug, Clone)]
41pub struct BpeTokenizer {
42    vocab: Vocab,
43    /// `byte_ids[b as usize]` is the vocab id of the single-byte token for
44    /// raw byte `b`. Precomputed once at construction (rather than doing a
45    /// `vocab.id_of(&[b])` hash lookup per input byte) since every encode
46    /// call starts by mapping raw bytes to ids.
47    byte_ids: [u32; 256],
48    merges: MergeTable,
49    /// Whether to prepend a single space to the input before
50    /// pre-tokenizing, if it does not already start with one. See
51    /// [`BpeTokenizer::with_add_prefix_space`] for why this exists and
52    /// its (documented) scope limitation.
53    add_prefix_space: bool,
54    specials: SpecialTokens,
55}
56
57impl BpeTokenizer {
58    /// Builds a tokenizer directly from a dense, id-ordered vocab and an
59    /// ordered merge list -- no JSON, no byte-to-unicode mapping involved.
60    ///
61    /// `vocab_entries[i]` is the raw-byte token for id `i`. Every one of
62    /// the 256 possible single bytes must appear somewhere in
63    /// `vocab_entries` as its own one-byte token, or this returns
64    /// [`Error::MalformedModel`] -- that completeness is what makes
65    /// byte-level BPE total (every input is encodable, no `<UNK>`).
66    pub fn from_vocab_and_merges(
67        vocab_entries: Vec<Vec<u8>>,
68        merge_pairs: Vec<(Vec<u8>, Vec<u8>)>,
69    ) -> Result<Self> {
70        Self::from_vocab(Vocab::from_entries(vocab_entries)?, merge_pairs)
71    }
72
73    /// Shared construction path for an already-built [`Vocab`]. Used by
74    /// [`BpeTokenizer::from_vocab_and_merges`] (dense array input) and by
75    /// [`crate::loader`] (sparse, id-keyed JSON input, which cannot go
76    /// through `Vocab::from_entries`'s "index is the id" assumption).
77    pub(crate) fn from_vocab(vocab: Vocab, merge_pairs: Vec<(Vec<u8>, Vec<u8>)>) -> Result<Self> {
78        let merges = MergeTable::build(&merge_pairs, |b| vocab.id_of(b))?;
79
80        let mut byte_ids = [0u32; 256];
81        for b in 0u16..=255 {
82            let b = b as u8;
83            byte_ids[b as usize] = vocab.id_of(&[b]).ok_or_else(|| Error::MalformedModel {
84                format: "bpe-vocab",
85                reason: format!(
86                    "byte-level vocab has no single-byte token for byte {b:#04x}; \
87                     byte-level BPE requires all 256 bytes as base tokens"
88                ),
89            })?;
90        }
91
92        Ok(Self {
93            vocab,
94            byte_ids,
95            merges,
96            add_prefix_space: false,
97            specials: SpecialTokens::new(),
98        })
99    }
100
101    /// Sets whether a leading space is prepended before pre-tokenizing an
102    /// input that does not already start with one.
103    ///
104    /// GPT-2 treats a word at the very start of a sequence differently
105    /// from the same word later on (the pre-tokenization pattern's
106    /// `" ?"` prefix means "dog" and " dog" are different tokens), so
107    /// most fast tokenizers add this synthetic leading space to make the
108    /// first word behave like every other word. Whether it defaults to on
109    /// varies by model family; a loaded `tokenizer.json`'s
110    /// `pre_tokenizer.add_prefix_space` field (see [`crate::loader`])
111    /// takes precedence over this default when present.
112    ///
113    /// Scope limitation: this crate applies the prefix space to the very
114    /// start of the whole input passed to [`BpeTokenizer::encode`], before
115    /// special-token splitting. The reference implementation technically
116    /// scopes it to the first *non-special* span, which only differs when
117    /// a special token is the very first thing in the input -- a rare
118    /// enough case that documenting it here beats the added complexity of
119    /// tracking it precisely.
120    #[must_use]
121    pub fn with_add_prefix_space(mut self, add_prefix_space: bool) -> Self {
122        self.add_prefix_space = add_prefix_space;
123        self
124    }
125
126    /// Registers a token that must always be matched atomically, never
127    /// split by pre-tokenization or BPE -- e.g. Qwen's `<|endoftext|>`,
128    /// `<|im_start|>`, `<|im_end|>`.
129    ///
130    /// `id` becomes the token's vocab id; if it falls outside the current
131    /// vocab it grows the vocab to fit (special tokens are conventionally
132    /// appended after the base vocab). Errors if `id` is already assigned
133    /// to different content.
134    pub fn add_special_token(&mut self, content: impl Into<String>, id: u32) -> Result<()> {
135        let content = content.into();
136        self.vocab.insert(id, content.clone().into_bytes())?;
137        self.specials.register(content, id);
138        Ok(())
139    }
140
141    /// The id of a registered special token by its exact content, if any.
142    pub fn special_token_id(&self, content: &str) -> Option<u32> {
143        self.specials.id_of(content)
144    }
145
146    /// The id of any exact token (special or ordinary) by its raw bytes.
147    pub fn token_id(&self, token: &[u8]) -> Option<u32> {
148        self.vocab.id_of(token)
149    }
150
151    /// Maps a pre-tokenized chunk's raw UTF-8 bytes to their base
152    /// (single-byte) vocab ids.
153    fn chunk_to_symbols(&self, chunk: &str) -> Vec<u32> {
154        chunk.bytes().map(|b| self.byte_ids[b as usize]).collect()
155    }
156
157    /// Repeatedly merges the highest-priority (lowest-rank) adjacent pair
158    /// until no pair in `symbols` has a merge rule.
159    ///
160    /// This rescans every adjacent pair from scratch on every merge, so it
161    /// is O(merges x symbols) per chunk -- quadratic in the chunk length
162    /// in the worst case. That is the "naive but correct first"
163    /// implementation named in this crate's design brief. The standard
164    /// production technique (see e.g. the vendored reference's
165    /// `models/bpe/word.rs`) keeps a doubly-linked list of symbols plus a
166    /// binary heap of candidate merges keyed by rank, so each merge is
167    /// found in O(log symbols) and applying it only re-examines the two
168    /// new neighboring pairs instead of rescanning everything. That
169    /// optimization is a pure performance change with no effect on the
170    /// output and belongs here, behind this same function signature, once
171    /// tokenizer throughput actually matters for the runtime.
172    fn merge(&self, mut symbols: Vec<u32>) -> Vec<u32> {
173        loop {
174            let mut best: Option<(usize, u32, u32)> = None; // (position, rank, merged_id)
175            for i in 0..symbols.len().saturating_sub(1) {
176                if let Some(rule) = self.merges.get(symbols[i], symbols[i + 1])
177                    && best.is_none_or(|(_, best_rank, _)| rule.rank < best_rank)
178                {
179                    best = Some((i, rule.rank, rule.merged_id));
180                }
181            }
182            let Some((i, _, merged_id)) = best else {
183                return symbols;
184            };
185            symbols[i] = merged_id;
186            symbols.remove(i + 1);
187        }
188    }
189}
190
191impl Tokenizer for BpeTokenizer {
192    fn encode(&self, text: &str) -> Result<Vec<u32>> {
193        if text.is_empty() {
194            return Ok(Vec::new());
195        }
196
197        let prefixed;
198        let text = if self.add_prefix_space && !text.starts_with(' ') {
199            prefixed = format!(" {text}");
200            prefixed.as_str()
201        } else {
202            text
203        };
204
205        let mut ids = Vec::new();
206        for segment in self.specials.split(text) {
207            match segment {
208                Segment::Special(id) => ids.push(id),
209                Segment::Text(span) => {
210                    for (start, end) in pretokenize::split(span) {
211                        let symbols = self.chunk_to_symbols(&span[start..end]);
212                        ids.extend(self.merge(symbols));
213                    }
214                }
215            }
216        }
217        Ok(ids)
218    }
219
220    fn decode(&self, ids: &[u32]) -> Result<String> {
221        let mut bytes = Vec::new();
222        for &id in ids {
223            let token_bytes = self.vocab.bytes_of(id).ok_or(Error::IndexOutOfBounds {
224                dim: 0,
225                index: id as usize,
226                len: self.vocab.len(),
227            })?;
228            bytes.extend_from_slice(token_bytes);
229        }
230        // Lossy, not `from_utf8`: for any id sequence produced by this
231        // tokenizer's own `encode`, the concatenated bytes are always
232        // valid UTF-8 (they reconstruct the original valid `&str`), so the
233        // lossy path never actually triggers there. But `decode` is also
234        // called on model output during generation, where a caller may
235        // reasonably decode a prefix of ids that splits a multi-byte
236        // character mid-sequence (e.g. streaming token-by-token before a
237        // multi-token emoji is complete); erroring on that would make
238        // streaming decode unusable, so this matches the universal
239        // tokenizer convention of never failing decode on well-formed ids.
240        Ok(String::from_utf8_lossy(&bytes).into_owned())
241    }
242
243    fn vocab_size(&self) -> usize {
244        self.vocab.len()
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// A tiny hand-built vocab/merges pair standing in for a trained BPE
253    /// model. Base bytes 'a', 'b', 'c' (plus every other byte, since
254    /// byte-level BPE requires all 256), and two merges: `("a","b")` ->
255    /// `"ab"` (rank 0, highest priority) and `("ab","c")` -> `"abc"`
256    /// (rank 1).
257    fn tiny_tokenizer() -> BpeTokenizer {
258        let mut vocab: Vec<Vec<u8>> = (0u16..=255).map(|b| vec![b as u8]).collect();
259        vocab.push(b"ab".to_vec()); // id 256
260        vocab.push(b"abc".to_vec()); // id 257
261        let merges = vec![
262            (b"a".to_vec(), b"b".to_vec()),
263            (b"ab".to_vec(), b"c".to_vec()),
264        ];
265        BpeTokenizer::from_vocab_and_merges(vocab, merges).expect("valid tiny tokenizer")
266    }
267
268    fn byte_id(tok: &BpeTokenizer, b: u8) -> u32 {
269        tok.token_id(&[b]).unwrap()
270    }
271
272    #[test]
273    fn a_plus_b_then_ab_plus_c_merges_produce_the_single_token_abc() {
274        let tok = tiny_tokenizer();
275        let ids = tok.encode("abc").unwrap();
276        assert_eq!(ids, vec![tok.token_id(b"abc").unwrap()]);
277    }
278
279    /// The property that actually needs the rank comparison, not just the
280    /// existence check: build a vocab where the *leftmost* adjacent pair
281    /// has the *lower priority* (higher rank number) merge, and a
282    /// non-leftmost pair has the higher-priority (rank 0) merge. A
283    /// tokenizer that greedily merges "the first pair it happens to find"
284    /// while scanning left to right -- instead of comparing ranks across
285    /// every candidate pair before merging any of them -- would merge
286    /// `(a, b)` first because it is encountered first, producing `["ab",
287    /// "c"]` with no further merges possible. The correct, rank-ordered
288    /// result instead prefers `(b, c)` (rank 0, taught first during BPE
289    /// training) even though it is not the leftmost pair, producing `["a",
290    /// "bc"]`.
291    #[test]
292    fn merges_apply_in_rank_order_not_leftmost_scan_order() {
293        let mut vocab: Vec<Vec<u8>> = (0u16..=255).map(|b| vec![b as u8]).collect();
294        vocab.push(b"bc".to_vec()); // id 256
295        vocab.push(b"ab".to_vec()); // id 257
296        let merges = vec![
297            (b"b".to_vec(), b"c".to_vec()), // rank 0: highest priority
298            (b"a".to_vec(), b"b".to_vec()), // rank 1: lower priority
299        ];
300        let tok = BpeTokenizer::from_vocab_and_merges(vocab, merges).unwrap();
301
302        let ids = tok.encode("abc").unwrap();
303        let expected = vec![tok.token_id(b"a").unwrap(), tok.token_id(b"bc").unwrap()];
304        assert_eq!(
305            ids, expected,
306            "expected rank-0 (b,c) to win over the leftmost-but-lower-priority (a,b) pair"
307        );
308
309        // Pin down exactly what the *wrong* leftmost-greedy answer would
310        // have been, so this test fails loudly (not just "doesn't match
311        // expected") if the implementation regresses to scan order.
312        let wrong_leftmost_greedy_answer =
313            vec![tok.token_id(b"ab").unwrap(), tok.token_id(b"c").unwrap()];
314        assert_ne!(ids, wrong_leftmost_greedy_answer);
315    }
316
317    #[test]
318    fn vocab_size_counts_every_registered_id() {
319        let tok = tiny_tokenizer();
320        assert_eq!(tok.vocab_size(), 258);
321    }
322
323    #[test]
324    fn empty_string_encodes_to_empty() {
325        let tok = tiny_tokenizer();
326        assert_eq!(tok.encode("").unwrap(), Vec::<u32>::new());
327    }
328
329    #[test]
330    fn decode_of_empty_is_empty() {
331        let tok = tiny_tokenizer();
332        assert_eq!(tok.decode(&[]).unwrap(), "");
333    }
334
335    #[test]
336    fn decode_rejects_unknown_id_gracefully() {
337        let tok = tiny_tokenizer();
338        let err = tok.decode(&[99_999]).unwrap_err();
339        assert!(matches!(err, Error::IndexOutOfBounds { .. }));
340    }
341
342    #[test]
343    fn special_tokens_are_never_split_by_bpe() {
344        let mut tok = tiny_tokenizer();
345        tok.add_special_token("<|endoftext|>", 300).unwrap();
346        let ids = tok.encode("ab<|endoftext|>c").unwrap();
347        assert_eq!(ids.last().copied(), Some(byte_id(&tok, b'c')));
348        assert!(ids.contains(&300));
349        // The special token contributes exactly one id, not one id per
350        // byte of "<|endoftext|>".
351        assert_eq!(ids.iter().filter(|&&id| id == 300).count(), 1);
352    }
353
354    #[test]
355    fn decode_of_encode_is_byte_exact_for_ascii() {
356        let tok = tiny_tokenizer();
357        let s = "abcabc hello world";
358        assert_eq!(tok.decode(&tok.encode(s).unwrap()).unwrap(), s);
359    }
360}