Skip to main content

kopitiam_tokenizer/
byte_map.rs

1//! The GPT-2 "byte-to-unicode" alphabet.
2//!
3//! # Why this exists
4//!
5//! Byte-level BPE's whole selling point is that the base alphabet is the 256
6//! possible byte values, so *any* input -- valid UTF-8, invalid UTF-8, raw
7//! binary -- is representable with no `<UNK>` token. But a `tokenizer.json`
8//! is itself a JSON document, and JSON strings cannot contain raw control
9//! bytes (0x00-0x1F), the byte 0x22 (`"`) needs escaping, whitespace bytes
10//! collide with JSON's own whitespace handling in careless implementations,
11//! and a lone unpaired continuation byte (0x80-0xBF) is not valid UTF-8 on
12//! its own -- so it cannot appear literally inside a JSON string at all.
13//!
14//! GPT-2's `encoder.py` solves this with a reversible byte <-> `char`
15//! bijection: bytes that are already "nice" printable characters (roughly
16//! ASCII `!`..`~` plus a couple of Latin-1 supplement ranges) map to
17//! themselves, and the remaining ~68 awkward bytes (controls, space, DEL,
18//! and the unassigned/continuation bytes in 0x7F-0xA0/0xAD) are remapped to
19//! otherwise-unused codepoints starting at U+0100. Every one of the 256
20//! resulting characters is then representable as an ordinary JSON string
21//! character, so a `tokenizer.json` vocab can hold `"Ġhello"` (where `Ġ`
22//! stands in for the space byte 0x20) instead of needing binary escapes.
23//!
24//! This mapping is a *serialization* concern only. Once a vocab is loaded,
25//! [`crate::vocab::Vocab`] stores each token's canonical raw bytes, and the
26//! rest of this crate (encode, decode, merges) never looks at mapped
27//! characters again -- see [`crate::loader`] for the one place this map is
28//! actually used.
29//!
30//! Reference: <https://github.com/openai/gpt-2/blob/master/src/encoder.py#L9>
31
32use std::sync::LazyLock;
33
34/// `BYTE_TO_UNICODE[b]` is the character byte `b` is mapped to.
35static BYTE_TO_UNICODE: LazyLock<[char; 256]> = LazyLock::new(|| {
36    let mut printable = [false; 256];
37    for b in 0x21u16..=0x7E {
38        printable[b as usize] = true; // '!'..='~'
39    }
40    for b in 0xA1u16..=0xAC {
41        printable[b as usize] = true;
42    }
43    for b in 0xAEu16..=0xFF {
44        printable[b as usize] = true;
45    }
46
47    let mut table = ['\u{0}'; 256];
48    let mut next_extra: u32 = 0;
49    for b in 0..256usize {
50        table[b] = if printable[b] {
51            // Safety net: every value 0..256 is a valid Unicode scalar
52            // value, so this never hits the `None` branch below.
53            char::from_u32(b as u32).expect("byte value is always a valid scalar value")
54        } else {
55            let c = char::from_u32(256 + next_extra)
56                .expect("256..(256+256) is well within the Basic Multilingual Plane");
57            next_extra += 1;
58            c
59        };
60    }
61    table
62});
63
64/// Reverse of [`BYTE_TO_UNICODE`], built once from it so the two can never
65/// drift out of sync with each other.
66static UNICODE_TO_BYTE: LazyLock<std::collections::HashMap<char, u8>> = LazyLock::new(|| {
67    BYTE_TO_UNICODE
68        .iter()
69        .enumerate()
70        .map(|(b, &c)| (c, b as u8))
71        .collect()
72});
73
74/// Maps a raw byte to its GPT-2 byte-level-alphabet character.
75///
76/// Used only when *writing* a byte-level vocab out in JSON-safe form; the
77/// runtime encode/decode path never calls this (see the module docs).
78pub fn byte_to_unicode(b: u8) -> char {
79    BYTE_TO_UNICODE[b as usize]
80}
81
82/// Inverse of [`byte_to_unicode`]: recovers the raw byte a mapped character
83/// stands for, or `None` if `c` is not one of the 256 characters in the
84/// byte-level alphabet (which means the vocab this came from is not a valid
85/// byte-level BPE vocab).
86pub fn unicode_to_byte(c: char) -> Option<u8> {
87    UNICODE_TO_BYTE.get(&c).copied()
88}
89
90/// Decodes a mapped-alphabet token string (as it appears verbatim in a
91/// `tokenizer.json` vocab key) back into the raw bytes it represents.
92///
93/// Returns `None` if any character in `s` falls outside the 256-character
94/// byte-level alphabet -- that indicates the caller handed this a token
95/// string that was never byte-level-mapped in the first place.
96pub fn decode_mapped_token(s: &str) -> Option<Vec<u8>> {
97    s.chars().map(unicode_to_byte).collect()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    /// The single most important property of this module: the map must be
105    /// a bijection over all 256 byte values, or byte-level BPE silently
106    /// loses information round-tripping through a `tokenizer.json`.
107    #[test]
108    fn byte_to_unicode_round_trips_every_byte() {
109        for b in 0u16..=255 {
110            let b = b as u8;
111            let c = byte_to_unicode(b);
112            assert_eq!(
113                unicode_to_byte(c),
114                Some(b),
115                "byte {b:#04x} did not round-trip through char {c:?}"
116            );
117        }
118    }
119
120    #[test]
121    fn mapped_characters_are_pairwise_distinct() {
122        let mut seen = std::collections::HashSet::new();
123        for b in 0u16..=255 {
124            let c = byte_to_unicode(b as u8);
125            assert!(seen.insert(c), "char {c:?} produced by two different bytes");
126        }
127        assert_eq!(seen.len(), 256);
128    }
129
130    #[test]
131    fn printable_ascii_maps_to_itself() {
132        // Spot-check the identity part of the mapping.
133        assert_eq!(byte_to_unicode(b'A'), 'A');
134        assert_eq!(byte_to_unicode(b'!'), '!');
135        assert_eq!(byte_to_unicode(b'~'), '~');
136    }
137
138    #[test]
139    fn space_maps_to_the_conventional_gpt2_placeholder() {
140        // 0x20 (space) is not in any of the "printable" ranges, so it gets
141        // remapped. GPT-2/GPT-4/Qwen vocabs universally render it as 'Ġ'
142        // (U+0120) -- this is the very first "extra" codepoint assigned,
143        // since space is byte 0 in ascending scan order among the
144        // non-printable bytes.
145        assert_eq!(byte_to_unicode(b' '), 'Ġ');
146    }
147
148    #[test]
149    fn decode_mapped_token_round_trips() {
150        let bytes: Vec<u8> = b" hello\n".to_vec();
151        let mapped: String = bytes.iter().map(|&b| byte_to_unicode(b)).collect();
152        assert_eq!(decode_mapped_token(&mapped), Some(bytes));
153    }
154
155    #[test]
156    fn decode_mapped_token_rejects_foreign_characters() {
157        assert_eq!(decode_mapped_token("not-byte-level-\u{1F600}"), None);
158    }
159}