Skip to main content

tpt_tokenizer_core/
bpe.rs

1//! Byte-Pair Encoding (BPE) tokenizer.
2
3use alloc::collections::{BTreeMap, BinaryHeap};
4use alloc::{
5    format,
6    string::{String, ToString},
7    vec,
8    vec::Vec,
9};
10use core::cmp::Reverse;
11
12use crate::error::TokenizerError;
13use crate::pretokenize::{decode_byte_level, encode_byte_level, gpt2_split};
14use crate::tokenizer::{split_words, TokenId, Tokenizer};
15
16/// The fallback token name used when a sub-word is not present in the vocab.
17const UNK: &str = "<unk>";
18
19/// A Byte-Pair Encoding tokenizer built from a vocabulary and an ordered list
20/// of merge rules.
21///
22/// The merge list is applied greedily: at each step the adjacent symbol pair
23/// with the lowest rank (earliest in the list) is merged, until no ranked pair
24/// remains.
25///
26/// # Byte-level mode
27///
28/// Enable [`with_byte_level`](Self::with_byte_level) for GPT-2 / RoBERTa /
29/// Llama style tokenizers. In this mode the input is pre-tokenized with a
30/// GPT-2 word-splitting pass and each piece's raw bytes are mapped into the
31/// reversible byte-level unicode alphabet before BPE is applied, so encoding
32/// **never fails** on arbitrary UTF-8 and `decode(encode(s)) == s`.
33///
34/// # Special tokens
35///
36/// Register atomic markers (e.g. `<|endoftext|>`, `<s>`) with
37/// [`with_special_tokens`](Self::with_special_tokens). They are matched
38/// verbatim (longest-match-wins) before pre-tokenization and never split.
39#[derive(Debug, Clone)]
40pub struct BpeTokenizer {
41    vocab: BTreeMap<String, TokenId>,
42    id_to_token: BTreeMap<TokenId, String>,
43    merge_ranks: BTreeMap<(String, String), usize>,
44    byte_level: bool,
45    special_tokens: BTreeMap<String, TokenId>,
46    special_id_to_token: BTreeMap<TokenId, String>,
47    #[cfg(feature = "normalization")]
48    normalize: Option<crate::normalize::NormalizationForm>,
49}
50
51impl BpeTokenizer {
52    /// Builds a tokenizer from a vocabulary (token string → id) and an ordered
53    /// list of merge rules. The index of a rule in `merges` is its rank, so
54    /// earlier rules win ties.
55    #[must_use]
56    pub fn from_vocab_merges(
57        vocab: BTreeMap<String, TokenId>,
58        merges: Vec<(String, String)>,
59    ) -> Self {
60        let merge_ranks = merges
61            .into_iter()
62            .enumerate()
63            .map(|(rank, pair)| (pair, rank))
64            .collect();
65        let id_to_token = vocab
66            .iter()
67            .map(|(token, &id)| (id, token.clone()))
68            .collect();
69        Self {
70            vocab,
71            id_to_token,
72            merge_ranks,
73            byte_level: false,
74            special_tokens: BTreeMap::new(),
75            special_id_to_token: BTreeMap::new(),
76            #[cfg(feature = "normalization")]
77            normalize: None,
78        }
79    }
80
81    /// Enables GPT-2 byte-level pre-tokenization and byte-fallback encoding.
82    #[must_use]
83    pub fn with_byte_level(mut self) -> Self {
84        self.byte_level = true;
85        self
86    }
87
88    /// Applies the given Unicode normalization form to text before encoding.
89    ///
90    /// Requires the `normalization` Cargo feature.
91    #[cfg(feature = "normalization")]
92    #[must_use]
93    pub fn with_normalization(mut self, form: crate::normalize::NormalizationForm) -> Self {
94        self.normalize = Some(form);
95        self
96    }
97
98    /// Registers special tokens (token string → id) that are matched atomically
99    /// before pre-tokenization and never split by BPE.
100    #[must_use]
101    pub fn with_special_tokens(mut self, specials: BTreeMap<String, TokenId>) -> Self {
102        self.special_id_to_token = specials.iter().map(|(t, &id)| (id, t.clone())).collect();
103        self.special_tokens = specials;
104        self
105    }
106
107    /// Number of tokens in the vocabulary (excluding special tokens that are
108    /// not also present in the base vocabulary).
109    #[must_use]
110    pub fn vocab_size(&self) -> usize {
111        self.vocab.len()
112    }
113
114    /// Applies the BPE merge algorithm to a single pre-token, returning the
115    /// resulting sub-word symbols (before id lookup).
116    ///
117    /// Symbols are held in an intrusive doubly-linked list and candidate merges
118    /// in a binary min-heap keyed by merge rank, so the whole reduction runs in
119    /// `O(n log n)` instead of the naive `O(n²)` rescan-every-pair loop. Heap
120    /// entries can go stale after a merge rewrites a neighbour; those are
121    /// discarded lazily by re-checking the live pair's rank on pop.
122    fn tokenize_word(&self, word: &str) -> Vec<String> {
123        let mut symbols: Vec<String> = word.chars().map(|c| c.to_string()).collect();
124        if symbols.len() < 2 {
125            return symbols;
126        }
127        let n = symbols.len();
128
129        // Intrusive doubly-linked list over `symbols` indices.
130        let mut prev: Vec<Option<usize>> = (0..n).map(|i| i.checked_sub(1)).collect();
131        let mut next: Vec<Option<usize>> = (0..n).map(|i| (i + 1 < n).then_some(i + 1)).collect();
132        let mut alive = vec![true; n];
133
134        // Min-heap of `(rank, left_index)`; `Reverse` makes the lowest rank
135        // (earliest merge rule) pop first, matching greedy BPE.
136        let mut heap: BinaryHeap<Reverse<(usize, usize)>> = BinaryHeap::new();
137        for i in 0..n - 1 {
138            if let Some(&rank) = self
139                .merge_ranks
140                .get(&(symbols[i].clone(), symbols[i + 1].clone()))
141            {
142                heap.push(Reverse((rank, i)));
143            }
144        }
145
146        while let Some(Reverse((rank, i))) = heap.pop() {
147            // Skip entries invalidated by an earlier merge.
148            if !alive[i] {
149                continue;
150            }
151            let Some(j) = next[i] else { continue };
152            if !alive[j] {
153                continue;
154            }
155            match self
156                .merge_ranks
157                .get(&(symbols[i].clone(), symbols[j].clone()))
158            {
159                Some(&cur) if cur == rank => {}
160                _ => continue, // the live pair no longer matches this heap entry
161            }
162
163            // Merge `j` into `i`, unlinking `j`.
164            symbols[i] = format!("{}{}", symbols[i], symbols[j]);
165            alive[j] = false;
166            next[i] = next[j];
167            if let Some(k) = next[j] {
168                prev[k] = Some(i);
169            }
170
171            // Newly-formed pairs (prev[i], i) and (i, next[i]) may now merge.
172            if let Some(p) = prev[i] {
173                if let Some(&r) = self
174                    .merge_ranks
175                    .get(&(symbols[p].clone(), symbols[i].clone()))
176                {
177                    heap.push(Reverse((r, p)));
178                }
179            }
180            if let Some(k) = next[i] {
181                if let Some(&r) = self
182                    .merge_ranks
183                    .get(&(symbols[i].clone(), symbols[k].clone()))
184                {
185                    heap.push(Reverse((r, i)));
186                }
187            }
188        }
189
190        // Walk the surviving symbols in list order.
191        let mut out = Vec::new();
192        let mut cur = Some(0usize);
193        while let Some(i) = cur {
194            if alive[i] {
195                out.push(core::mem::take(&mut symbols[i]));
196            }
197            cur = next[i];
198        }
199        out
200    }
201
202    /// Looks up a sub-word symbol, falling back to `<unk>` when configured.
203    fn push_symbol(&self, sub: String, ids: &mut Vec<TokenId>) -> Result<(), TokenizerError> {
204        match self.vocab.get(&sub) {
205            Some(&id) => ids.push(id),
206            None => match self.vocab.get(UNK) {
207                Some(&unk) => ids.push(unk),
208                None => return Err(TokenizerError::UnknownToken(sub)),
209            },
210        }
211        Ok(())
212    }
213
214    /// Encodes a run of ordinary (non-special) text into `ids`.
215    fn encode_text(&self, text: &str, ids: &mut Vec<TokenId>) -> Result<(), TokenizerError> {
216        if self.byte_level {
217            for piece in gpt2_split(text) {
218                let encoded = encode_byte_level(&piece);
219                for sub in self.tokenize_word(&encoded) {
220                    self.push_symbol(sub, ids)?;
221                }
222            }
223        } else {
224            for word in split_words(text) {
225                for sub in self.tokenize_word(word) {
226                    self.push_symbol(sub, ids)?;
227                }
228            }
229        }
230        Ok(())
231    }
232
233    /// Splits `text` into `(special_id?, text_run)` segments, matching the
234    /// longest registered special token starting at each position.
235    fn split_specials<'a>(&self, text: &'a str) -> Vec<Segment<'a>> {
236        if self.special_tokens.is_empty() {
237            return vec![Segment::Text(text)];
238        }
239        // Longest tokens first so overlapping markers prefer the longest match.
240        let mut specials: Vec<(&String, &TokenId)> = self.special_tokens.iter().collect();
241        specials.sort_by_key(|s| core::cmp::Reverse(s.0.len()));
242
243        let mut segments = Vec::new();
244        let mut cursor = 0usize;
245        let mut run_start = 0usize;
246        while cursor < text.len() {
247            let mut matched = None;
248            for (tok, &id) in &specials {
249                if text[cursor..].starts_with(tok.as_str()) {
250                    matched = Some((tok.len(), id));
251                    break;
252                }
253            }
254            if let Some((len, id)) = matched {
255                if run_start < cursor {
256                    segments.push(Segment::Text(&text[run_start..cursor]));
257                }
258                segments.push(Segment::Special(id));
259                cursor += len;
260                run_start = cursor;
261            } else {
262                // Advance by one full char to keep byte offsets on boundaries.
263                cursor += text[cursor..].chars().next().map_or(1, char::len_utf8);
264            }
265        }
266        if run_start < text.len() {
267            segments.push(Segment::Text(&text[run_start..]));
268        }
269        segments
270    }
271
272    /// Loads a GPT-2 style tokenizer from disk: `vocab_path` is one token per
273    /// line (line number = id), `merges_path` is one `A B` pair per line
274    /// (optionally prefixed by a single count header line).
275    ///
276    /// # Errors
277    /// Returns [`TokenizerError::Io`] on a read failure, or
278    /// [`TokenizerError::MalformedFile`] if a line cannot be parsed.
279    #[cfg(feature = "std")]
280    pub fn from_files(vocab_path: &str, merges_path: &str) -> Result<Self, TokenizerError> {
281        let vocab_text = std::fs::read_to_string(vocab_path)?;
282        let merges_text = std::fs::read_to_string(merges_path)?;
283
284        let vocab = crate::tokenizer::parse_vocab_lines(&vocab_text);
285
286        let mut merges = Vec::new();
287        for line in merges_text.lines() {
288            if line.starts_with("#version") || line.trim().is_empty() {
289                continue;
290            }
291            let parts: Vec<&str> = line.split_whitespace().collect();
292            if parts.len() != 2 {
293                continue; // skip the optional count header line
294            }
295            merges.push((parts[0].to_string(), parts[1].to_string()));
296        }
297
298        Ok(Self::from_vocab_merges(vocab, merges))
299    }
300}
301
302/// A run of input classified during special-token splitting.
303enum Segment<'a> {
304    /// Ordinary text to be pre-tokenized and BPE-encoded.
305    Text(&'a str),
306    /// A registered special token, emitted atomically.
307    Special(TokenId),
308}
309
310impl Tokenizer for BpeTokenizer {
311    fn encode(&self, text: &str) -> Result<Vec<TokenId>, TokenizerError> {
312        #[cfg(feature = "normalization")]
313        let normalized;
314        #[cfg(feature = "normalization")]
315        let text = if let Some(form) = self.normalize {
316            normalized = crate::normalize::normalize(text, form);
317            normalized.as_str()
318        } else {
319            text
320        };
321
322        let mut ids = Vec::new();
323        for segment in self.split_specials(text) {
324            match segment {
325                Segment::Special(id) => ids.push(id),
326                Segment::Text(run) => self.encode_text(run, &mut ids)?,
327            }
328        }
329        Ok(ids)
330    }
331
332    /// Decode `ids` back into a string.
333    ///
334    /// In byte-level mode this is exact: `decode(encode(s)) == s`. In plain
335    /// mode `encode` discards whitespace, so decoding is **lossy** for
336    /// multi-word input.
337    fn decode(&self, ids: &[TokenId]) -> Result<String, TokenizerError> {
338        let mut out = String::new();
339        // Byte-level tokens must be decoded together (a single UTF-8 char may
340        // span several tokens), so buffer them until interrupted by a special.
341        let mut buf = String::new();
342        let flush = |buf: &mut String, out: &mut String| -> Result<(), TokenizerError> {
343            if buf.is_empty() {
344                return Ok(());
345            }
346            if self.byte_level {
347                let decoded = decode_byte_level(buf).ok_or_else(|| {
348                    TokenizerError::MalformedFile("invalid byte-level sequence".to_string())
349                })?;
350                out.push_str(&decoded);
351            } else {
352                out.push_str(buf);
353            }
354            buf.clear();
355            Ok(())
356        };
357
358        for &id in ids {
359            if let Some(special) = self.special_id_to_token.get(&id) {
360                flush(&mut buf, &mut out)?;
361                out.push_str(special);
362                continue;
363            }
364            match self.id_to_token.get(&id) {
365                Some(token) => buf.push_str(token),
366                None => return Err(TokenizerError::UnknownToken(id.to_string())),
367            }
368        }
369        flush(&mut buf, &mut out)?;
370        Ok(out)
371    }
372}