//! BPE encode/decode.
//!
//! Spec: specs/tokenizer.md
use super::byte_level;
use std::collections::HashMap;
pub struct Bpe {
/// token_id β string
tokens: Vec<String>,
/// string β token_id
reverse: HashMap<String, u32>,
/// (left, right) β priority (lower = higher priority)
merges: HashMap<(String, String), u32>,
/// Byte-level encoding table.
byte_encoder: [char; 256],
/// Byte-level decoding table.
byte_decoder: HashMap<char, u8>,
/// Special tokens (name + id). Matched greedily before BPE.
specials: Vec<(String, u32)>,
/// SentencePiece (Gemma, Llama) tokenizers prefix words with U+2581 β
/// (metaspace) instead of GPT-2 byte-level encoding. Auto-detected by
/// presence of β-prefixed tokens in the vocab.
metaspace: bool,
}
impl Bpe {
pub fn new(tokens: Vec<(u32, String)>, merges: Vec<(String, String)>) -> Self {
let max_id = tokens.iter().map(|(id, _)| *id).max().unwrap_or(0);
let mut tok_vec = vec![String::new(); (max_id as usize) + 1];
let mut reverse = HashMap::new();
for (id, tok) in tokens {
if (id as usize) < tok_vec.len() {
tok_vec[id as usize] = tok.clone();
}
reverse.insert(tok, id);
}
let merge_map: HashMap<(String, String), u32> = merges
.into_iter()
.enumerate()
.map(|(i, pair)| (pair, i as u32))
.collect();
let byte_encoder = byte_level::build_byte_encoder();
let byte_decoder = byte_level::build_byte_decoder(&byte_encoder);
// Detect special tokens: anything that looks like a control token
// β angle-bracketed, no whitespace, short. Catches both ChatML/HF
// style (`<|im_start|>`, `<|eot_id|>`) and Gemma style (`<bos>`,
// `<|turn>`, `<turn|>`, `<end_of_turn>`).
let specials: Vec<(String, u32)> = reverse
.iter()
.filter(|(s, _)| {
s.starts_with('<')
&& s.ends_with('>')
&& s.len() <= 32
&& !s.contains(char::is_whitespace)
})
.map(|(s, &id)| (s.clone(), id))
.collect();
// Auto-detect SentencePiece (metaspace) tokenizer: vocab contains
// tokens prefixed with U+2581 (β). Llama, Mistral, Gemma all use this.
let metaspace = reverse.keys().any(|k| k.starts_with('\u{2581}'));
Self {
tokens: tok_vec,
reverse,
merges: merge_map,
byte_encoder,
byte_decoder,
specials,
metaspace,
}
}
pub fn vocab_size(&self) -> usize {
self.tokens.len()
}
pub fn token(&self, id: u32) -> Option<&str> {
self.tokens.get(id as usize).map(|s| s.as_str())
}
pub fn id(&self, token: &str) -> Option<u32> {
self.reverse.get(token).copied()
}
/// Encode text β token IDs.
///
/// Algorithm:
/// 1. Split input on special tokens (longest-match at each position).
/// 2. For each non-special segment: byte-level encode, split into chars,
/// apply merges until no more apply, look up in vocab.
/// 3. Concatenate all resulting IDs.
pub fn encode(&self, text: &str) -> Vec<u32> {
let mut out = Vec::new();
let segments = self.split_on_specials(text);
for seg in segments {
match seg {
Segment::Special(id) => out.push(id),
Segment::Text(s) => {
if s.is_empty() {
continue;
}
let preprocessed = if self.metaspace {
// SentencePiece: replace literal space with β and
// prepend β at the start of the segment so the first
// word lookup hits a "βword" vocab entry. BPE merges
// operate on UTF-8 chars (the metaspace β is U+2581,
// 3 bytes β chars() splits it into one symbol).
let mut p = String::with_capacity(s.len() + 3);
if !s.starts_with(' ') && !s.starts_with('\u{2581}') {
p.push('\u{2581}');
}
for c in s.chars() {
if c == ' ' {
p.push('\u{2581}');
} else {
p.push(c);
}
}
p
} else {
byte_level::encode_bytes(&self.byte_encoder, &s)
};
self.bpe_encode_segment(&preprocessed, &mut out);
}
}
}
out
}
fn bpe_encode_segment(&self, byte_level_str: &str, out: &mut Vec<u32>) {
let mut symbols: Vec<String> = byte_level_str.chars().map(|c| c.to_string()).collect();
loop {
// Find highest-priority merge among adjacent pairs.
let mut best: Option<(usize, u32)> = None;
for i in 0..symbols.len().saturating_sub(1) {
let pair = (symbols[i].clone(), symbols[i + 1].clone());
if let Some(&prio) = self.merges.get(&pair) {
if best.map_or(true, |(_, p)| prio < p) {
best = Some((i, prio));
}
}
}
let (i, _) = match best {
Some(b) => b,
None => break,
};
let merged = format!("{}{}", symbols[i], symbols[i + 1]);
symbols[i] = merged;
symbols.remove(i + 1);
}
for s in symbols {
if let Some(&id) = self.reverse.get(&s) {
out.push(id);
}
// If a symbol isn't in vocab (shouldn't happen with byte-level), skip.
// A more complete implementation would byte-fall-back here.
}
}
fn split_on_specials<'a>(&self, text: &'a str) -> Vec<Segment<'a>> {
if self.specials.is_empty() {
return vec![Segment::Text(std::borrow::Cow::Borrowed(text))];
}
let mut out = Vec::new();
let mut pos = 0;
while pos < text.len() {
// Find longest special match at pos.
let tail = &text[pos..];
let best: Option<(usize, u32)> = self
.specials
.iter()
.filter_map(|(s, id)| {
if tail.starts_with(s.as_str()) {
Some((s.len(), *id))
} else {
None
}
})
.max_by_key(|(len, _)| *len);
if let Some((len, id)) = best {
out.push(Segment::Special(id));
pos += len;
} else {
// Find next special start.
let next_special = self
.specials
.iter()
.filter_map(|(s, _)| {
tail.find(s.as_str()).map(|i| i)
})
.min()
.unwrap_or(tail.len());
let end = pos + next_special;
out.push(Segment::Text(std::borrow::Cow::Borrowed(&text[pos..end])));
pos = end;
}
}
out
}
/// Decode token IDs β text.
pub fn decode(&self, ids: &[u32], skip_special_tokens: bool) -> String {
let mut buffer = String::new();
let specials_set: std::collections::HashSet<u32> =
self.specials.iter().map(|(_, id)| *id).collect();
for &id in ids {
if specials_set.contains(&id) {
if skip_special_tokens {
continue;
}
// Emit literal β bypass byte-level decoding.
if let Some(tok) = self.token(id) {
buffer.push_str(tok);
}
} else if let Some(tok) = self.token(id) {
buffer.push_str(tok);
}
}
// SentencePiece path: vocab strings are UTF-8 with β for spaces.
// Just replace β β space and we're done.
if self.metaspace {
return buffer.replace('\u{2581}', " ");
}
// GPT-2 byte-level decode for the accumulated buffer.
let bytes = byte_level::decode_bytes(&self.byte_decoder, &buffer);
// Bytes may include the specials as their literal string (they're
// already valid UTF-8 since stored as such). This is actually fine
// because byte-level specials are in vocab as the full literal
// and we don't byte-level-encode them when they're emitted literally.
// For correctness, specials should be extracted separately.
// Simpler correct impl: emit string per-ID and concatenate.
match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => {
// Fallback: decode per-token
let mut s = String::new();
for &id in ids {
if specials_set.contains(&id) {
if skip_special_tokens {
continue;
}
if let Some(tok) = self.token(id) {
s.push_str(tok);
}
} else if let Some(tok) = self.token(id) {
let b = byte_level::decode_bytes(&self.byte_decoder, tok);
s.push_str(&String::from_utf8_lossy(&b));
}
}
s
}
}
}
}
enum Segment<'a> {
Special(u32),
Text(std::borrow::Cow<'a, str>),
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny_bpe() -> Bpe {
// Tokens:
// 0 "!"
// 1 "h"
// 2 "e"
// 3 "l"
// 4 "o"
// 5 "he"
// 6 "llo"
// 7 "hello"
// 8 "<|im_end|>"
let tokens = vec![
(0, "!".to_string()),
(1, "h".to_string()),
(2, "e".to_string()),
(3, "l".to_string()),
(4, "o".to_string()),
(5, "he".to_string()),
(6, "llo".to_string()),
(7, "hello".to_string()),
(8, "<|im_end|>".to_string()),
];
let merges = vec![
("h".to_string(), "e".to_string()),
("l".to_string(), "l".to_string()),
("ll".to_string(), "o".to_string()),
("he".to_string(), "llo".to_string()),
];
Bpe::new(tokens, merges)
}
#[test]
fn merges_to_single_token() {
let bpe = tiny_bpe();
let ids = bpe.encode("hello");
assert_eq!(ids, vec![7]);
}
#[test]
fn special_token_preempts_bpe() {
let bpe = tiny_bpe();
let ids = bpe.encode("hello<|im_end|>");
assert_eq!(ids, vec![7, 8]);
}
#[test]
fn decode_skips_specials_when_requested() {
let bpe = tiny_bpe();
let s = bpe.decode(&[7, 8], true);
assert_eq!(s, "hello");
let s = bpe.decode(&[7, 8], false);
assert_eq!(s, "hello<|im_end|>");
}
#[test]
fn unknown_chars_degrade_gracefully() {
// Unknown byte-level char not in vocab: encode skips. Real tokenizers
// have byte fallback tokens; our tiny test vocab doesn't.
let bpe = tiny_bpe();
let ids = bpe.encode("!");
assert_eq!(ids, vec![0]);
}
}
//! BPE encode/decode.
//!
//! Spec: specs/tokenizer.md
use byte_level;
use HashMap;