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}