Injective, surjective, bijective; the GPT-2 byte map
Overview
Section titled “Overview”| Module | M05.2 · build · Python · Pass 3 · 2 to 3 h |
| You build | python/tinyllm/tok/bytes_unicode.py: is_injective, is_surjective, inverse, bytes_to_unicode, unicode_to_bytes, encode_bytes, decode_chars |
| Contract | course/contracts/py/tinyllm/tok/bytes_unicode.pyi |
| Tests | course/tests/M05.2/ (what they check: section 4) |
| Needs | no code from earlier modules · reading: S-M05 (functions and bijections problems) |
| Used by | L1.1 the tokenizer protocol · L1.2 byte-level BPE stores its vocabulary and merges in this alphabet · later: L1.5 re-implements the table in Rust · later: L8.7 |
| Milestone | MS-P3 (tokens and data) |
| Optional depth | Rosen, Discrete Mathematics and Its Applications, section 2.3 “Functions”; Radford et al., “Language Models are Unsupervised Multitask Learners” (2019), section 2.2 |
Key Takeaways
Section titled “Key Takeaways”- A finite function is injective when no two inputs share an output, surjective onto a set when every element of that set is hit, and bijective when both; only a bijection has an inverse (
test_hand_example_small_maps,test_inverse_rejects_a_collision). - Between two finite sets of the same size, injective, surjective, and bijective are the same thing, so 256 distinct outputs from 256 bytes is enough to prove the byte map is invertible (
test_is_a_bijection_onto_256_characters). - GPT-2 maps every byte to a visible, non-whitespace character so a vocabulary of byte strings can live in JSON and a merges file can split on spaces (
test_every_character_is_printable_and_not_whitespace). - Your table equals the one every GPT-2 style vocabulary uses, byte for byte (
test_matches_gpt2_table), soL1.2can load GPT-2 and SmolLM2 tokenizers.
How to work this chapter
Section titled “How to work this chapter”ol start M05.2 # stubs bytes_unicode.py into your repool tests M05.2 # read the test catalog firstol check M05.2 # exit code is the verdictol diff M05.2 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Your system’s tokenizer is still the tracer’s: one token per byte, vocabulary 256 (D32). Pass 3 replaces it with byte-level BPE (L1.2), and the first thing L1.2 does is load a real vocabulary, SmolLM2’s tokenizer.json. Open it and the tokens look wrong: "Ġthe", "Ċ", "ĠĠĠ". There is no " the" anywhere. A loader that reads those keys as text finds no token that starts with a space, so every word after the first gets split into single bytes and your ids stop matching the model’s. The fix is a fixed bijection between the 256 byte values and 256 visible characters, the one GPT-2 introduced. This module builds that bijection, and the three definitions (injective, surjective, bijective) that prove it can be inverted.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| , | finite sets: the domain (inputs) and the codomain (allowed outputs) | set |
| a function: exactly one output for every | Mapping (keys ) | |
| the image: the outputs that actually occur | set | |
| the number of elements of | int | |
| the inverse of a bijection: and | dict | |
| a byte value | int | |
| how many bytes before were remapped (the running count) | int | |
| , | code point to its one-character string, and back | str, int |
Functions as tables. A function assigns one output to every input. When is finite you can write it down as a table, and a Python dict is exactly that: its keys are and its values are the outputs. A dict cannot give one key two values, so every dict is a function from its key set. The codomain is not stored anywhere; it is part of the question you ask.
Injective (one to one). is injective when different inputs give different outputs: . Equivalently, no value appears twice. Note what is checked: the values. The keys of a dict are always distinct, so checking them proves nothing.
Surjective (onto ). is surjective onto when every is some , that is . This is a statement about , so it needs . If some lies outside , then is not a function into at all, and is_surjective raises instead of answering.
Bijective. Both at once. Then every has exactly one preimage, and = that preimage is a function . If is not injective, two keys compete for ; a dict comprehension {v: k for k, v in f.items()} silently keeps the last one, which is why inverse must check and raise.
Counting settles it for equal sizes. If is injective, its image has exactly elements, one per input. So when , an injective hits all of and is also surjective. Conversely a surjective needs at least distinct outputs, which uses up all inputs with no repeats (the pigeonhole principle, S-M05). For finite sets of equal size the three properties coincide: 256 bytes with 256 distinct outputs is a bijection onto those 256 outputs.
Why GPT-2 needs a byte map. Byte-level BPE works on bytes, so its tokens are byte strings: b" the", b"\n", and halves of UTF-8 characters such as b"\xc3". Three places need them as text:
vocab.jsonandtokenizer.jsonare JSON, whose keys must be valid Unicode text. A lone byte0xC3is not valid UTF-8, so a raw byte string cannot be a key.merges.txtstores one merge per line as two tokens separated by a space (Ġ t). A token that contains a space would split the line in the wrong place.- Control characters (
0x00to0x1F) and the soft hyphen0xADare invisible, so a vocabulary full of them cannot be read or diffed.
So GPT-2 maps each byte to a character that is printable and not whitespace, by a fixed rule.
The rule. Latin-1 gives every byte the character , and 188 of those are already visible: ! to ~ (0x21 to 0x7E, 94 characters), ¡ to ¬ (0xA1 to 0xAC, 12), and ® to ÿ (0xAE to 0xFF, 82). Those keep their own code point. The other bytes (0x00 to 0x20, 0x7F to 0xA0, and 0xAD) are walked in increasing order, and the -th of them () gets , the code points U+0100 to U+0143. Those are Latin Extended-A letters such as Ā, Ġ, Ń, all visible.
The map is injective because the kept bytes land in (each on itself, so distinct) and the remapped bytes land in (each on a new , so distinct), and the two ranges do not overlap. By the counting argument it is a bijection onto its 256-character image, so unicode_to_bytes exists. The image is not “all characters”: a real space " " or "ń" (U+0144) is not the image of any byte, and decode_chars must reject them.
Encode and decode. encode_bytes(data) replaces each byte by its character, so the output has exactly len(data) characters. decode_chars(text) applies the inverse character by character. Composing them in either order is the identity, for any bytes, including invalid UTF-8.
3. Worked example by hand
Section titled “3. Worked example by hand”Three small maps. Take and .
| Question | Work | Answer |
|---|---|---|
| is injective? | with | no; inverse(f) raises, naming a |
| is injective? | values are distinct | yes |
| is onto ? | image equals it | yes, so |
| is onto ? | has no preimage | no |
Where the space goes. Bytes 0x00 to 0x20 are all remapped (the first kept byte is 0x21 !), and they come first, so byte has . The space 0x20 = 32 has and maps to $\mathrm{chr}(256 + 32) = $ U+0120 Ġ. The newline 0x0A has and maps to U+010A Ċ. Byte 0x00 maps to U+0100 Ā.
Past the gap. DEL, 0x7F, is the first remapped byte after ~. Before it come 33 remapped bytes (0x00 to 0x20), so and it maps to U+0121 ġ. The soft hyphen 0xAD comes after 0x7F to 0xA0 (34 more), so , the last one: U+0143 Ń.
A sentence. "Hi there\n" is the bytes 48 69 20 74 68 65 72 65 0A:
| byte | 48 | 69 | 20 | 74 | 68 | 65 | 72 | 65 | 0A |
|---|---|---|---|---|---|---|---|---|---|
| kept? | H | i | no, | t | h | e | r | e | no, |
| char | H | i | Ġ | t | h | e | r | e | Ċ |
So encode_bytes(b"Hi there\n") == "HiĠthereĊ", nine bytes to nine characters, and in a GPT-2 vocabulary the word ” there” (with its leading space) is the token "Ġthere".
A non-ASCII character. "é" is the two UTF-8 bytes C3 A9. Both are kept Latin-1 characters, à and ©, so it encodes to "é": two characters, the familiar look of mis-decoded UTF-8.
These are the first cases in section 4: test_hand_example_encode and test_hand_example_small_maps.
4. The interface
Section titled “4. The interface”def is_injective(f: Mapping[K, V]) -> booldef is_surjective(f: Mapping[K, V], codomain: Iterable[V]) -> bool # ValueError if a value is outsidedef inverse(f: Mapping[K, V]) -> dict[V, K] # ValueError if not injectivedef bytes_to_unicode() -> dict[int, str] # a new dict each call, keys 0..255 in orderdef unicode_to_bytes() -> dict[str, int]def encode_bytes(data: bytes) -> str # TypeError for strdef decode_chars(text: str) -> bytes # ValueError outside the imageWrite bytes_to_unicode from the rule in section 2, not by pasting a table: the tests compare it with GPT-2’s, and the rule is what L1.5 reimplements in Rust.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_encode | unit | "Hi there\n" encodes to "HiĠthereĊ"; byte 0 is Ā; decode inverts it | you and the test agree on the rule |
test_hand_example_small_maps | unit | and of section 3 | the three definitions, on paper and in code |
test_matches_gpt2_table | golden | all 256 entries equal the transformers table | L1.2 loads GPT-2 and SmolLM2 vocabularies |
test_is_a_bijection_onto_256_characters | property | 256 keys, 256 distinct one-character values | lossless bytes to text and back |
test_every_character_is_printable_and_not_whitespace | property | no output is a space, control, or invisible character | merges files split on spaces |
test_printable_bytes_map_to_themselves | unit | the 188 kept bytes are fixed points; 0xAD is not | ASCII tokens read as themselves |
test_remapped_bytes_are_consecutive_from_256 | unit | the 68 others take U+0100 to U+0143 in order | the running count, not the byte value |
test_keys_in_byte_order_and_a_fresh_dict_each_call | boundary | a caller’s edit does not leak into the next call | tokenizers build their own tables from it |
test_unicode_to_bytes_is_the_inverse | property | both compositions are the identity | decoding generated tokens |
test_roundtrip_random_bytes | property | decode(encode(x)) == x for random bytes | tokens can end inside a UTF-8 character |
test_roundtrip_utf8_text | unit | é is two characters, the emoji four | the map works on bytes, not characters |
test_decode_rejects_characters_outside_the_image | boundary | " ", "\n", U+0144, € raise | a corrupt vocabulary fails loudly |
test_encode_rejects_str | boundary | encode_bytes("abc") is a TypeError | forgetting .encode("utf-8") is a bug |
test_inverse_rejects_a_collision | boundary | a repeated value raises and names it | two ids never share a string |
test_is_injective_cases | unit | empty, identity, permutation, collisions | injectivity is about values |
test_is_surjective_cases | boundary | empty codomain, missing targets, values outside | surjectivity needs a codomain |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
1. mapping every byte to itself, chr(b) | a bijection, but the space and control bytes stay invisible; merges lines split inside tokens | test_every_character_is_printable_and_not_whitespace (mutant s01) |
| 2. one range 0xA1 to 0xFF | the soft hyphen 0xAD keeps its invisible code point and every later remapped byte is off | test_printable_bytes_map_to_themselves (mutant s02) |
3. remapping to chr(256 + b) | still injective and visible, but not GPT-2’s table: no real vocabulary loads | test_remapped_bytes_are_consecutive_from_256 (mutant s03) |
4. decoding by arithmetic (ord(c) - 256, & 0xFF) | happens to work for bytes 0 to 32, decodes everything after DEL to the wrong byte, and accepts characters outside the image | test_roundtrip_random_bytes, test_decode_rejects_characters_outside_the_image (mutant s05) |
| 5. inverting with a dict comprehension | a collision keeps the last key silently; two ids share one string | test_inverse_rejects_a_collision (mutant s04) |
| 6. checking keys for injectivity | always true, since dict keys are distinct | test_is_injective_cases (mutant s08) |
| 7. “onto” as | a map that misses targets reports onto | test_is_surjective_cases (mutant s07) |
| 8. ignoring values outside the codomain | the question “onto ?” answered for a map that is not into | test_is_surjective_cases (mutant s06) |
| 9. caching the table in a module global | one caller’s edit changes every later tokenizer | test_keys_in_byte_order_and_a_fresh_dict_each_call (mutant s10) |
10. encoding a str by code points | "é" becomes one byte, wrong for anything past Latin-1 | test_encode_rejects_str (mutant s09) |
11. an off-by-one range end (0x7E vs 0x7D) | ~ is remapped | test_matches_gpt2_table (mutant m01) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Forward | L8.7 | Registered call site uses this module. |
| Direction | Module | How it uses this |
|---|---|---|
| Back | S-M05 | functions, inverses, and the pigeonhole principle as pen-and-paper problems |
| Forward | L1.1 | the tokenizer protocol: every byte-level tokenizer decodes ids through decode_chars |
| Forward | L1.2 | byte-level BPE: pre-tokenized text is encoded to UTF-8, mapped with encode_bytes, merged, and looked up in vocab.json; the HF loader reads Ġ-keys with unicode_to_bytes |
| Forward | L1.5 | your Rust tl-tok rebuilds the same table from the same rule (a re-implementation, not a call) and is tested id for id against L1.2 |
If you skip this module, ol check L1.2 stops with L1.2 needs M05.2: build it, or rerun with --ref-deps.
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
bytes_to_unicode | Hugging Face tokenizers ByteLevel | the same table in Rust, built once in a lazy_static, plus add_prefix_space and offset tracking | tokenizers/src/pre_tokenizers/byte_level.rs (bytes_char) |
encode_bytes / decode_chars | OpenAI tiktoken | no character map at all: ranks are keyed by raw bytes and stored base64 encoded in .tiktoken files | tiktoken/load.py (data_gym_to_mergeable_bpe_ranks rebuilds this map to read GPT-2’s original files) |
| the rule | openai/gpt-2 | the original 2019 function, with the comment explaining why whitespace and control characters are avoided | src/encoder.py |