/* SPDX-License-Identifier: AGPL-3.0-or-later */ /* Copyright (C) 2026 David (Corey) Herrera */ /* * Property tests for the BPE pre-tokenizer (src/core/bpe_pretokenize.c). * * Boundary oracle: a unigram tokenizer whose vocab is the 256 GPT-2 byte * characters (id == byte value) plus a raw ASCII " " (id 256). The unigram * path of ft_encode prepends " " to every string it encodes, and GPT-2 * byte-encoding never produces a raw 0x20, so id 256 appears exactly once at * the start of each pre-token. The output therefore spells out the pre-token * split byte for byte, with no merge-ordering ambiguity. * * Covers: the worked-example table in both modes, the per-code-point class * table over 1..0x10FFFF (+ samples above), invalid UTF-8, whitespace, * contractions, digit grouping, partition of random byte strings, and the * driver contract (NULL/empty, long pieces, pre-type dispatch, the once-only * unknown-pre warning). */ #include "lib/test.h" #include "../src/core/bpe_pretokenize.h" #include "../src/core/engine.h" #include "tokenizer/tokenizer.h" #include #include #include #include #define SP_ID 256 static ft_tokenizer *g_tok; static ft_tokenizer *make_oracle_tok(void) { ft_tokenizer *tok = ft_new(); ft_set_model_type(tok, 1); /* unigram: prepends " " per encoded piece */ for (int b = 0; b < 256; b++) { char raw[2] = {(char)b, 0}; if (b == 0) { /* gpt2_bytes_encode cannot see a NUL; text never contains one, * but keep ids aligned with a placeholder that never matches. */ ft_add_token(tok, ""); continue; } char *enc = gpt2_bytes_encode(raw); ft_add_token(tok, enc); free(enc); } ft_add_token(tok, " "); return tok; } /* Split `s[0..len)` with mode (llama?) into piece lengths. Returns count, or -1 * if the oracle output does not spell the input exactly. */ static int split_lens(const char *s, int llama, int *lens, int max) { int n = 0; int *ids = llama ? pn_bpe_encode_llama3(g_tok, s, &n) : pn_bpe_encode_qwen35(g_tok, s, &n); if (!ids) return -1; int pieces = -1; size_t byte = 0, slen = strlen(s); for (int i = 0; i < n; i++) { if (ids[i] == SP_ID) { if (pieces >= 0 && lens[pieces] == 0) { free(ids); return -1; } /* empty piece */ pieces++; if (pieces >= max) { free(ids); return -1; } lens[pieces] = 0; continue; } if (pieces < 0 || byte >= slen || ids[i] != (unsigned char)s[byte]) { free(ids); return -1; } byte++; lens[pieces]++; } free(ids); if (byte != slen) return -1; if (pieces >= 0 && lens[pieces] == 0) return -1; return pieces + 1; } /* Render a split as "a|b|c" with pieces copied raw. */ static void render(const char *s, int llama, char *out, size_t cap) { int lens[512]; int n = split_lens(s, llama, lens, 512); if (n < 0) { snprintf(out, cap, ""); return; } size_t o = 0, p = 0; for (int i = 0; i < n; i++) { if (i && o + 1 < cap) out[o++] = '|'; for (int k = 0; k < lens[i] && o + 1 < cap; k++) out[o++] = s[p++]; } out[o] = 0; } static int g_split_fail; static void expect_split(const char *in, int llama, const char *want) { char got[4096]; render(in, llama, got, sizeof got); if (strcmp(got, want) != 0) { printf(" split mismatch (%s) for \"", llama ? "L" : "Q"); for (const char *c = in; *c; c++) printf((unsigned char)*c < 0x20 || (unsigned char)*c >= 0x7F ? "\\x%02X" : "%c", (unsigned char)*c); printf("\": got [%s] want [%s]\n", got, want); g_split_fail++; } } static void expect_both(const char *in, const char *want) { expect_split(in, 0, want); expect_split(in, 1, want); } /* ------------------------------------------------------------------ */ static void test_worked_examples(void) { g_split_fail = 0; expect_both("Hello world", "Hello| world"); expect_both("a b", "a| | b"); expect_both("a ", "a| "); expect_both(" ", " "); expect_split("12", 0, "1|2"); expect_split("12", 1, "12"); expect_split("12345", 0, "1|2|3|4|5"); expect_split("12345", 1, "123|45"); expect_both("don't", "don|'t"); expect_both("DON'T", "DON|'T"); expect_both("we'LL go", "we|'LL| go"); expect_both("x!!\n\ny", "x|!!\n\n|y"); expect_both("world !!!", "world| !!!"); expect_both("\n\n foo", "\n\n| | foo"); expect_both("\n \n x", "\n \n| | x"); expect_both("\n ", "\n| "); expect_both("a\t5", "a|\t|5"); expect_both("\tfoo", "\tfoo"); expect_split("e\xCC\x81x", 0, "e\xCC\x81x"); expect_split("e\xCC\x81x", 1, "e|\xCC\x81x"); expect_split("e\xCC\x81!", 0, "e\xCC\x81|!"); expect_split("e\xCC\x81!", 1, "e|\xCC\x81!"); expect_split("1\xD9\xA0\xD9\xA1", 0, "1|\xD9\xA0\xD9\xA1"); expect_split("1\xD9\xA0\xD9\xA1", 1, "1\xD9\xA0\xD9\xA1"); expect_both("foo'", "foo|'"); expect_both("\xC3\x0A" "a", "\xC3\x0A" "a"); ASSERT_INT_EQ(g_split_fail, 0); } static void test_invalid_utf8(void) { g_split_fail = 0; expect_both("a\x80", "a|\x80"); /* stray continuation: not a letter */ expect_both("!\x80", "!\x80"); /* ... it is a symbol */ expect_both("a\xBF", "a|\xBF"); expect_both("a\xC3", "a\xC3"); /* lone lead at EOS: Latin-1 letter */ expect_both("a\xAA\xB5\xBA", "a\xAA\xB5\xBA"); expect_both("a\xD7", "a|\xD7"); expect_both("a\xF7", "a|\xF7"); expect_both("\n\x85\n", "\n\x85\n"); /* lone 0x85 / 0xA0 are whitespace */ expect_both("\n\xA0\n", "\n\xA0\n"); expect_both("a\xE2\x82", "a\xE2|\x82"); /* E2 82 at EOS: two width-1 code points */ expect_both("a\xF0\x9F\x98", "a\xF0|\x9F\x98"); /* truncated 4-byte at EOS */ expect_both("a\xF0\x9F\x98\x80", "a|\xF0\x9F\x98\x80"); /* U+1F600: symbol */ expect_both("a\xF8", "a\xF8"); expect_both("a\xFF", "a\xFF"); expect_both("a\xC0\xAF", "a|\xC0\xAF"); /* overlong '/' is a symbol */ expect_both("!\xC0\xAF", "!\xC0\xAF"); expect_both("a\xC1\x81", "a\xC1\x81"); /* overlong 'A' is a letter */ expect_both("a\xED\xA0\x80", "a|\xED\xA0\x80"); expect_both("!\xED\xA0\x80", "!\xED\xA0\x80"); expect_both("!\xF4\x90\x80\x80", "!\xF4\x90\x80\x80"); expect_both("x\xC3\x0A\xC3", "x\xC3\x0A\xC3"); ASSERT_INT_EQ(g_split_fail, 0); } static void test_whitespace(void) { g_split_fail = 0; expect_both("\r\n\r\nx", "\r\n\r\n|x"); expect_both("a\r\n\r\n", "a|\r\n\r\n"); expect_both("\t \n x", "\t \n| x"); expect_both("a \t", "a| \t"); expect_both("a \xE3\x80\x80" "b", "a| |\xE3\x80\x80" "b"); /* U+3000 dropped by R6 */ expect_both("a \xC2\xA0" "b", "a| |\xC2\xA0" "b"); /* NBSP dropped by R6 */ expect_both("\xC2\xA0" "foo", "\xC2\xA0" "foo"); /* NBSP as R2 prefix */ expect_both("a\xE3\x80\x80\xE3\x80\x80" "1", "a|\xE3\x80\x80|\xE3\x80\x80|1"); expect_both("a \n", "a| \n"); expect_both("x\x0B\x0C" "y", "x|\x0B|\x0C" "y"); expect_both("x\x1C" "y", "x|\x1C" "y"); /* 0x1C is a symbol */ ASSERT_INT_EQ(g_split_fail, 0); } static void test_contractions(void) { g_split_fail = 0; static const char *sfx[] = { "s", "t", "m", "d", "re", "ve", "ll" }; for (int i = 0; i < 7; i++) { size_t n = strlen(sfx[i]); for (int mask = 0; mask < (1 << n); mask++) { char c[3] = {0}; for (size_t k = 0; k < n; k++) c[k] = (char)((mask >> k) & 1 ? sfx[i][k] - 32 : sfx[i][k]); char in[32], want[32]; snprintf(in, sizeof in, "x'%s y", c); snprintf(want, sizeof want, "x|'%s| y", c); expect_both(in, want); } } expect_both("x'", "x|'"); expect_both("x'r", "x|'r"); expect_both("'sxyz", "'s|xyz"); expect_both("x'rx", "x|'rx"); expect_both("x\xE2\x80\x99s", "x|\xE2\x80\x99s"); /* U+2019 is not a contraction */ ASSERT_INT_EQ(g_split_fail, 0); } static void test_digits(void) { g_split_fail = 0; for (int n = 1; n <= 7; n++) { char in[16], wq[32], wl[32]; size_t q = 0, l = 0; for (int i = 0; i < n; i++) { in[i] = (char)('1' + i); if (i) wq[q++] = '|'; wq[q++] = in[i]; if (i && i % 3 == 0) wl[l++] = '|'; wl[l++] = in[i]; } in[n] = 0; wq[q] = 0; wl[l] = 0; expect_split(in, 0, wq); expect_split(in, 1, wl); } /* ASCII, fullwidth 1, Arabic-Indic 1, ASCII */ const char *mix = "1\xEF\xBC\x91\xD9\xA1" "2"; expect_split(mix, 0, "1|\xEF\xBC\x91|\xD9\xA1|2"); expect_split(mix, 1, "1\xEF\xBC\x91\xD9\xA1|2"); /* Extended Arabic-Indic and Devanagari digits */ expect_split("1\xDB\xB1", 1, "1\xDB\xB1"); expect_split("1\xE0\xA5\xA7", 1, "1|\xE0\xA5\xA7"); /* U+0967: letter only */ ASSERT_INT_EQ(g_split_fail, 0); } /* ---- class table sweep ---- */ typedef struct { unsigned lo, hi; } rng; static const rng X_LETTER[] = { {'A','Z'},{'a','z'},{0xAA,0xAA},{0xB5,0xB5},{0xBA,0xBA},{0xC0,0xD6},{0xD8,0xF6},{0xF8,0xFF}, {0x100,0x2AF},{0x370,0x3FF},{0x400,0x4FF},{0x530,0x58F},{0x590,0x5FF},{0x600,0x6FF}, {0x900,0x97F},{0x1100,0x11FF},{0x3040,0x309F},{0x30A0,0x30FF},{0x3400,0x4DBF}, {0x4E00,0x9FFF},{0xAC00,0xD7AF},{0xF900,0xFAFF},{0x20000,0x2FA1F}, }; static const rng X_NUMBER[] = { {'0','9'},{0x660,0x669},{0x6F0,0x6F9},{0xFF10,0xFF19} }; static const rng X_MARK[] = { {0x300,0x36F},{0x483,0x489},{0x591,0x5BD},{0x64B,0x65F},{0x900,0x903},{0x93A,0x94F}, {0x1AB0,0x1AFF},{0x1DC0,0x1DFF},{0x20D0,0x20FF},{0xFE20,0xFE2F}, }; static const rng X_SPACE[] = { {0x20,0x20},{0x09,0x09},{0x0A,0x0A},{0x0D,0x0D},{0x0B,0x0B},{0x0C,0x0C},{0x85,0x85}, {0xA0,0xA0},{0x1680,0x1680},{0x2000,0x200A},{0x2028,0x2029},{0x202F,0x202F}, {0x205F,0x205F},{0x3000,0x3000}, }; static int in_set(unsigned cp, const rng *r, size_t n) { for (size_t i = 0; i < n; i++) if (cp >= r[i].lo && cp <= r[i].hi) return 1; return 0; } #define IN(cp, set) in_set((cp), set, sizeof(set) / sizeof(set[0])) static int utf8_put(unsigned cp, char *o) { if (cp < 0x80) { o[0] = (char)cp; return 1; } if (cp < 0x800) { o[0] = (char)(0xC0 | (cp >> 6)); o[1] = (char)(0x80 | (cp & 0x3F)); return 2; } if (cp < 0x10000) { o[0] = (char)(0xE0 | (cp >> 12)); o[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); o[2] = (char)(0x80 | (cp & 0x3F)); return 3; } o[0] = (char)(0xF0 | (cp >> 18)); o[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); o[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); o[3] = (char)(0x80 | (cp & 0x3F)); return 4; } /* Is the first piece of `pre`+cp+`post` longer than `pre`? */ static int joined(const char *pre, unsigned cp, const char *post, int llama) { char buf[32]; size_t pl = strlen(pre); memcpy(buf, pre, pl); int w = utf8_put(cp, buf + pl); strcpy(buf + pl + w, post); int lens[8]; int n = split_lens(buf, llama, lens, 8); if (n < 0) return -1; return lens[0] > (int)pl; } static int check_cp(unsigned cp) { int letter = IN(cp, X_LETTER), number = IN(cp, X_NUMBER); int mark = IN(cp, X_MARK), space = IN(cp, X_SPACE); int nl = cp == 0x0A || cp == 0x0D; int symq = !space && !letter && !number && !mark; int syml = !space && !letter && !number; int bad = 0; bad |= joined("a", cp, "", 1) != letter; bad |= joined("a", cp, "", 0) != (letter || mark); bad |= joined("1", cp, "", 1) != number; bad |= joined("\n", cp, "\n", 0) != space; /* '!' joins a following symbol run (R4), newlines after it, or acts as the * one-code-point prefix of a letter run (R2). */ bad |= joined("!", cp, "", 0) != (symq || nl || letter || mark); bad |= joined("!", cp, "", 1) != (syml || nl || letter); if (bad) printf(" class mismatch at U+%04X\n", cp); return bad; } static void test_class_table(void) { int fails = 0; for (unsigned cp = 1; cp <= 0x10FFFF && fails < 10; cp++) fails += check_cp(cp); /* Values above U+10FFFF (leads F4..F7) decode as-is and belong to no set. */ for (unsigned cp = 0x110000; cp <= 0x1FFFFF && fails < 10; cp += 0x1111) fails += check_cp(cp); fails += check_cp(0x1FFFFF); ASSERT_INT_EQ(fails, 0); } /* ---- partition of random byte strings ---- */ static size_t ref_width(const unsigned char *s, size_t len, size_t p) { unsigned c = s[p]; if (c < 0x80) return 1; if ((c & 0xE0) == 0xC0 && p + 1 < len) return 2; if ((c & 0xF0) == 0xE0 && p + 2 < len) return 3; if ((c & 0xF8) == 0xF0 && p + 3 < len) return 4; return 1; } static void test_partition_random(void) { static const unsigned char alphabet[] = { 'a', 'Z', '1', ' ', '\n', '\r', '\t', '\'', 's', '!', '.', 0x80, 0xA0, 0xBF, 0xC3, 0xCC, 0xD9, 0xE2, 0xE3, 0xED, 0xF0, 0xF4, 0xF8, 0xFF, 0x85, 0x81, 0x82, 0x99, 'l', 'e', }; unsigned seed = 12345; int fails = 0; for (int iter = 0; iter < 20000 && fails < 5; iter++) { char s[64]; seed = seed * 1103515245u + 12345u; int len = 1 + (int)((seed >> 16) % 40); for (int i = 0; i < len; i++) { seed = seed * 1103515245u + 12345u; s[i] = (char)alphabet[(seed >> 16) % sizeof alphabet]; } s[len] = 0; for (int llama = 0; llama < 2; llama++) { int lens[64]; int n = split_lens(s, llama, lens, 64); if (n < 0) { fails++; printf(" partition broken (iter %d)\n", iter); continue; } /* Every boundary must sit on a code-point start of the decoder walk. */ size_t p = 0, b = 0; for (int k = 0; k < n; k++) { b += (size_t)lens[k]; while (p < b) p += ref_width((const unsigned char *)s, (size_t)len, p); if (p != b) { fails++; printf(" mid-code-point split (iter %d)\n", iter); break; } } } } ASSERT_INT_EQ(fails, 0); } /* ---- driver ---- */ static void test_driver_null_and_empty(void) { int n = -7; int *ids = pn_bpe_encode_qwen35(NULL, "abc", &n); ASSERT_NOT_NULL(ids); ASSERT_INT_EQ(n, 0); free(ids); n = -7; ids = pn_bpe_encode_llama3(g_tok, NULL, &n); ASSERT_NOT_NULL(ids); ASSERT_INT_EQ(n, 0); free(ids); n = -7; ids = pn_bpe_encode(g_tok, "", &n); ASSERT_NOT_NULL(ids); ASSERT_INT_EQ(n, 0); free(ids); n = -7; ids = pn_bpe_encode(NULL, "x", &n); ASSERT_NOT_NULL(ids); ASSERT_INT_EQ(n, 0); free(ids); } static void test_driver_long_piece(void) { size_t L = 10000; char *s = malloc(L + 1); memset(s, 'a', L); s[L] = 0; int lens[4]; ASSERT_INT_EQ(split_lens(s, 0, lens, 4), 1); ASSERT_INT_EQ(lens[0], (int)L); s[0] = ' '; ASSERT_INT_EQ(split_lens(s, 1, lens, 4), 1); free(s); } static void test_pre_from_name_table(void) { ASSERT_INT_EQ(pn_bpe_pre_from_name(NULL), PN_BPE_PRE_QWEN35); ASSERT_INT_EQ(pn_bpe_pre_from_name("qwen35"), PN_BPE_PRE_QWEN35); ASSERT_INT_EQ(pn_bpe_pre_from_name("qwen2"), PN_BPE_PRE_QWEN35); ASSERT_INT_EQ(pn_bpe_pre_from_name("gpt-2"), PN_BPE_PRE_QWEN35); ASSERT_INT_EQ(pn_bpe_pre_from_name("default"), PN_BPE_PRE_QWEN35); ASSERT_INT_EQ(pn_bpe_pre_from_name("llama-bpe"), PN_BPE_PRE_LLAMA3); ASSERT_INT_EQ(pn_bpe_pre_from_name("llama3"), PN_BPE_PRE_LLAMA3); ASSERT_INT_EQ(pn_bpe_pre_from_name("llama-v3"), PN_BPE_PRE_LLAMA3); ASSERT_INT_EQ(pn_bpe_pre_from_name(""), PN_BPE_PRE_UNKNOWN); ASSERT_INT_EQ(pn_bpe_pre_from_name("Qwen35"), PN_BPE_PRE_UNKNOWN); ASSERT_INT_EQ(pn_bpe_pre_from_name("llama"), PN_BPE_PRE_UNKNOWN); } /* Run pn_bpe_encode with pre-type `pre` on "12"; return piece count, and capture * anything written to stderr into `err`. */ static int dispatch_pieces(int pre, char *err, size_t cap) { fflush(stderr); int pfd[2]; if (pipe(pfd) != 0) return -1; int saved = dup(2); dup2(pfd[1], 2); close(pfd[1]); ft_set_pre_type(g_tok, pre); int n = 0; int *ids = pn_bpe_encode(g_tok, "12", &n); ft_set_pre_type(g_tok, 0); fflush(stderr); dup2(saved, 2); close(saved); ssize_t r = read(pfd[0], err, cap - 1); err[r > 0 ? r : 0] = 0; close(pfd[0]); int pieces = 0; for (int i = 0; i < n; i++) if (ids[i] == SP_ID) pieces++; free(ids); return pieces; } static void test_dispatch_and_warn_once(void) { static const char warn[] = "bpe: tokenizer.ggml.pre is unsupported; falling back to the qwen35/GPT-2 " "pre-tokenizer (token IDs may not match the reference for this model)\n"; char err[512]; ASSERT_INT_EQ(dispatch_pieces(0, err, sizeof err), 2); ASSERT_INT_EQ((int)strlen(err), 0); ASSERT_INT_EQ(dispatch_pieces(1, err, sizeof err), 1); ASSERT_INT_EQ((int)strlen(err), 0); ASSERT_INT_EQ(dispatch_pieces(7, err, sizeof err), 2); ASSERT_INT_EQ((int)strlen(err), 0); ASSERT_INT_EQ(dispatch_pieces(-1, err, sizeof err), 2); ASSERT_INT_EQ((int)strlen(err), 0); ASSERT_INT_EQ(dispatch_pieces(2, err, sizeof err), 2); ASSERT_INT_EQ(strcmp(err, warn), 0); for (int i = 0; i < 5; i++) { ASSERT_INT_EQ(dispatch_pieces(2, err, sizeof err), 2); ASSERT_INT_EQ((int)strlen(err), 0); } /* Direct entry points never warn. */ int n = 0; int *ids = pn_bpe_encode_qwen35(g_tok, "x", &n); free(ids); } int main(void) { printf("\n BPE pre-tokenizer property tests\n\n"); g_tok = make_oracle_tok(); RUN_TEST(dispatch_and_warn_once); /* first: the warning flag is process-wide */ RUN_TEST(worked_examples); RUN_TEST(invalid_utf8); RUN_TEST(whitespace); RUN_TEST(contractions); RUN_TEST(digits); RUN_TEST(class_table); RUN_TEST(partition_random); RUN_TEST(driver_null_and_empty); RUN_TEST(driver_long_piece); RUN_TEST(pre_from_name_table); ft_free(g_tok); TEST_REPORT(); return __ft_failed > 0 ? 1 : 0; }