Skip to content

Injective, surjective, bijective; the GPT-2 byte map

ModuleM05.2 · build · Python · Pass 3 · 2 to 3 h
You buildpython/tinyllm/tok/bytes_unicode.py: is_injective, is_surjective, inverse, bytes_to_unicode, unicode_to_bytes, encode_bytes, decode_chars
Contractcourse/contracts/py/tinyllm/tok/bytes_unicode.pyi
Testscourse/tests/M05.2/ (what they check: section 4)
Needsno code from earlier modules · reading: S-M05 (functions and bijections problems)
Used byL1.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
MilestoneMS-P3 (tokens and data)
Optional depthRosen, Discrete Mathematics and Its Applications, section 2.3 “Functions”; Radford et al., “Language Models are Unsupervised Multitask Learners” (2019), section 2.2
  • 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), so L1.2 can load GPT-2 and SmolLM2 tokenizers.
Terminal window
ol start M05.2 # stubs bytes_unicode.py into your repo
ol tests M05.2 # read the test catalog first
ol check M05.2 # exit code is the verdict
ol diff M05.2 # after passing: your code against the reference

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.

SymbolMeaningType / shape
AA, BBfinite sets: the domain (inputs) and the codomain (allowed outputs)set
f:A→Bf: A \to Ba function: exactly one output f(a)∈Bf(a) \in B for every a∈Aa \in AMapping (keys AA)
f(A)={f(a):a∈A}f(A) = \{f(a) : a \in A\}the image: the outputs that actually occurset
∣A∣\lvert A \rvertthe number of elements of AAint
f−1:B→Af^{-1}: B \to Athe inverse of a bijection: f−1(f(a))=af^{-1}(f(a)) = a and f(f−1(b))=bf(f^{-1}(b)) = bdict
b∈{0,…,255}b \in \{0, \dots, 255\}a byte valueint
nnhow many bytes before bb were remapped (the running count)int
chr(c)\mathrm{chr}(c), ord(s)\mathrm{ord}(s)code point cc to its one-character string, and backstr, int

Functions as tables. A function f:A→Bf: A \to B assigns one output to every input. When AA is finite you can write it down as a table, and a Python dict is exactly that: its keys are AA 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 BB is not stored anywhere; it is part of the question you ask.

Injective (one to one). ff is injective when different inputs give different outputs: a≠a′⇒f(a)≠f(a′)a \ne a' \Rightarrow f(a) \ne f(a'). 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 BB). ff is surjective onto BB when every b∈Bb \in B is some f(a)f(a), that is f(A)=Bf(A) = B. This is a statement about BB, so it needs BB. If some f(a)f(a) lies outside BB, then ff is not a function into BB at all, and is_surjective raises instead of answering.

Bijective. Both at once. Then every b∈Bb \in B has exactly one preimage, and f−1(b)f^{-1}(b) = that preimage is a function B→AB \to A. If ff is not injective, two keys compete for f−1(b)f^{-1}(b); 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 ff is injective, its image has exactly ∣A∣\lvert A \rvert elements, one per input. So when ∣A∣=∣B∣\lvert A \rvert = \lvert B \rvert, an injective ff hits all of BB and is also surjective. Conversely a surjective ff needs at least ∣B∣\lvert B \rvert distinct outputs, which uses up all ∣A∣\lvert A \rvert 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:

  1. vocab.json and tokenizer.json are JSON, whose keys must be valid Unicode text. A lone byte 0xC3 is not valid UTF-8, so a raw byte string cannot be a key.
  2. merges.txt stores 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.
  3. Control characters (0x00 to 0x1F) and the soft hyphen 0xAD are 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 bb the character chr(b)\mathrm{chr}(b), 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 256−188=68256 - 188 = 68 bytes (0x00 to 0x20, 0x7F to 0xA0, and 0xAD) are walked in increasing order, and the nn-th of them (n=0,1,…,67n = 0, 1, \dots, 67) gets chr(256+n)\mathrm{chr}(256 + n), 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 {0x21,…,0xFF}\{0x21, \dots, 0xFF\} (each on itself, so distinct) and the remapped bytes land in {256,…,323}\{256, \dots, 323\} (each on a new nn, 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.

Three small maps. Take f={1↦a,2↦b,3↦a}f = \{1 \mapsto a, 2 \mapsto b, 3 \mapsto a\} and g={1↦a,2↦b}g = \{1 \mapsto a, 2 \mapsto b\}.

QuestionWorkAnswer
is ff injective?f(1)=f(3)=af(1) = f(3) = a with 1≠31 \ne 3no; inverse(f) raises, naming a
is gg injective?values a,ba, b are distinctyes
is gg onto {a,b}\{a, b\}?image {a,b}\{a, b\} equals ityes, so g−1={a↦1,b↦2}g^{-1} = \{a \mapsto 1, b \mapsto 2\}
is gg onto {a,b,c}\{a, b, c\}?cc has no preimageno

Where the space goes. Bytes 0x00 to 0x20 are all remapped (the first kept byte is 0x21 !), and they come first, so byte b≤0x20b \le 0x20 has n=bn = b. The space 0x20 = 32 has n=32n = 32 and maps to $\mathrm{chr}(256 + 32) = $ U+0120 Ġ. The newline 0x0A has n=10n = 10 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 n=33n = 33 and it maps to U+0121 ġ. The soft hyphen 0xAD comes after 0x7F to 0xA0 (34 more), so n=33+34=67n = 33 + 34 = 67, the last one: U+0143 Ń.

A sentence. "Hi there\n" is the bytes 48 69 20 74 68 65 72 65 0A:

byte48692074686572650A
kept?Hino, n=32n = 32thereno, n=10n = 10
charHiĠthereĊ

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.

python/tinyllm/tok/bytes_unicode.py
def is_injective(f: Mapping[K, V]) -> bool
def is_surjective(f: Mapping[K, V], codomain: Iterable[V]) -> bool # ValueError if a value is outside
def inverse(f: Mapping[K, V]) -> dict[V, K] # ValueError if not injective
def bytes_to_unicode() -> dict[int, str] # a new dict each call, keys 0..255 in order
def unicode_to_bytes() -> dict[str, int]
def encode_bytes(data: bytes) -> str # TypeError for str
def decode_chars(text: str) -> bytes # ValueError outside the image

Write 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.

TestKINDChecksWhy it matters downstream
test_hand_example_encodeunit"Hi there\n" encodes to "HiĠthereĊ"; byte 0 is Ā; decode inverts ityou and the test agree on the rule
test_hand_example_small_mapsunitff and gg of section 3the three definitions, on paper and in code
test_matches_gpt2_tablegoldenall 256 entries equal the transformers tableL1.2 loads GPT-2 and SmolLM2 vocabularies
test_is_a_bijection_onto_256_charactersproperty256 keys, 256 distinct one-character valueslossless bytes to text and back
test_every_character_is_printable_and_not_whitespacepropertyno output is a space, control, or invisible charactermerges files split on spaces
test_printable_bytes_map_to_themselvesunitthe 188 kept bytes are fixed points; 0xAD is notASCII tokens read as themselves
test_remapped_bytes_are_consecutive_from_256unitthe 68 others take U+0100 to U+0143 in orderthe running count, not the byte value
test_keys_in_byte_order_and_a_fresh_dict_each_callboundarya caller’s edit does not leak into the next calltokenizers build their own tables from it
test_unicode_to_bytes_is_the_inversepropertyboth compositions are the identitydecoding generated tokens
test_roundtrip_random_bytespropertydecode(encode(x)) == x for random bytestokens can end inside a UTF-8 character
test_roundtrip_utf8_textunité is two characters, the emoji fourthe map works on bytes, not characters
test_decode_rejects_characters_outside_the_imageboundary" ", "\n", U+0144, € raisea corrupt vocabulary fails loudly
test_encode_rejects_strboundaryencode_bytes("abc") is a TypeErrorforgetting .encode("utf-8") is a bug
test_inverse_rejects_a_collisionboundarya repeated value raises and names ittwo ids never share a string
test_is_injective_casesunitempty, identity, permutation, collisionsinjectivity is about values
test_is_surjective_casesboundaryempty codomain, missing targets, values outsidesurjectivity needs a codomain
PitfallSymptomCaught by
1. mapping every byte to itself, chr(b)a bijection, but the space and control bytes stay invisible; merges lines split inside tokenstest_every_character_is_printable_and_not_whitespace (mutant s01)
2. one range 0xA1 to 0xFFthe soft hyphen 0xAD keeps its invisible code point and every later remapped byte is offtest_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 loadstest_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 imagetest_roundtrip_random_bytes, test_decode_rejects_characters_outside_the_image (mutant s05)
5. inverting with a dict comprehensiona collision keeps the last key silently; two ids share one stringtest_inverse_rejects_a_collision (mutant s04)
6. checking keys for injectivityalways true, since dict keys are distincttest_is_injective_cases (mutant s08)
7. “onto” as f(A)⊆Bf(A) \subseteq Ba map that misses targets reports ontotest_is_surjective_cases (mutant s07)
8. ignoring values outside the codomainthe question “onto BB?” answered for a map that is not into BBtest_is_surjective_cases (mutant s06)
9. caching the table in a module globalone caller’s edit changes every later tokenizertest_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-1test_encode_rejects_str (mutant s09)
11. an off-by-one range end (0x7E vs 0x7D)~ is remappedtest_matches_gpt2_table (mutant m01)

| Forward | L8.7 | Registered call site uses this module. |

DirectionModuleHow it uses this
BackS-M05functions, inverses, and the pigeonhole principle as pen-and-paper problems
ForwardL1.1the tokenizer protocol: every byte-level tokenizer decodes ids through decode_chars
ForwardL1.2byte-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
ForwardL1.5your 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.

Your pieceProduction equivalentWhat it addsWhere to look
bytes_to_unicodeHugging Face tokenizers ByteLevelthe same table in Rust, built once in a lazy_static, plus add_prefix_space and offset trackingtokenizers/src/pre_tokenizers/byte_level.rs (bytes_char)
encode_bytes / decode_charsOpenAI tiktokenno character map at all: ranks are keyed by raw bytes and stored base64 encoded in .tiktoken filestiktoken/load.py (data_gym_to_mergeable_bpe_ranks rebuilds this map to read GPT-2’s original files)
the ruleopenai/gpt-2the original 2019 function, with the comment explaining why whitespace and control characters are avoidedsrc/encoder.py