← Back to C-Kernel-Engine Docs Doxygen Source Documentation
 
Loading...
Searching...
No Matches
true_bpe.c
Go to the documentation of this file.
1/*
2 * True BPE (Byte-Pair Encoding) Tokenizer [v4 - correctness complete, optimization pending]
3 *
4 * Implements the actual BPE algorithm used by GPT-2, LLaMA, Qwen, etc.
5 * Unlike greedy longest-match, this applies merge rules in priority order.
6 * Achieves 100% token-exact parity with HuggingFace (8/8 tests passing).
7 *
8 * Algorithm:
9 * 1. Split text into initial tokens (characters or bytes)
10 * 2. Find the highest-priority merge that can be applied
11 * 3. Apply that merge (combine two adjacent tokens into one)
12 * 4. Repeat until no more merges possible
13 * 5. Look up final tokens in vocabulary to get IDs
14 *
15 * Data Structures:
16 * - Vocabulary: token string -> token ID (hash table, FNV-1a, 65K buckets)
17 * - Merge rules: (left_id, right_id) -> (merged_id, priority) (hash table, MurmurHash3, 65K buckets)
18 * - Token list: dynamic array for the working token sequence
19 *
20 * TODO: Performance optimizations (correctness is done, these are for throughput):
21 *
22 * 1. Arena allocator for token strings
23 * - token_list_append() and token_list_merge_at() malloc/free per token string
24 * - Replace with scratch arena (~64KB) on CKTrueBPE struct, reset per encode_chunk()
25 * - Strings only live for one encode call, so arena lifetime matches perfectly
26 *
27 * 2. Linked list instead of array shift in merge loop
28 * - token_list_merge_at() shifts entire tail array left by 1: O(n) per merge
29 * - Use doubly-linked list of nodes (allocated from arena): O(1) per merge
30 * - Store only token ID per node, use id_to_token[] for string when needed
31 *
32 * 3. Priority queue for find_best_merge()
33 * - Currently rescans ALL adjacent pairs every iteration: O(n) per merge
34 * - Total merge loop is O(n * m) where m = merges applied
35 * - Use min-heap of candidate merges, pop best, re-insert affected neighbors
36 * - Would reduce to O(n log n) total
37 *
38 * 4. Eliminate redundant string storage
39 * - CKBPEToken stores both .str (malloc'd) and .id
40 * - After initial char->ID lookup, only IDs needed for merge lookup
41 * - Use id_to_token[merged_id] to get string when constructing merged tokens
42 *
43 * Current profile: tokenizer runs 2x per prompt (not per token), so these
44 * matter for batch tokenization / server use / 32K+ context, not for
45 * interactive chat where GEMM kernels dominate at 57% of compute.
46 *
47 * By Anthony Shivakumar
48 */
49
50#include <stdio.h>
51#include <stdlib.h>
52#include <string.h>
53#include <stdint.h>
54#include <stdbool.h>
55#include <limits.h>
56
57#include "tokenizer/true_bpe.h"
58
59/* ═══════════════════════════════════════════════════════════════════════════════
60 * Constants
61 * ═══════════════════════════════════════════════════════════════════════════════ */
62
63#define MERGE_HASH_SIZE 65536 /* Size of merge lookup hash table */
64#define INITIAL_TOKEN_CAPACITY 256 /* Initial capacity for token list */
65#define MAX_TOKEN_LEN 128 /* Maximum length of a single token string */
66
67/* ═══════════════════════════════════════════════════════════════════════════════
68 * Data Structures
69 * ═══════════════════════════════════════════════════════════════════════════════ */
70
71/* A single merge rule */
72typedef struct {
73 int32_t left_id; /* Left token ID */
74 int32_t right_id; /* Right token ID */
75 int32_t merged_id; /* Resulting merged token ID */
76 int32_t priority; /* Lower = higher priority (applied first) */
77} CKBPEMerge;
78
79/* Hash table entry for merge lookup */
80typedef struct CKMergeEntry {
81 uint64_t key; /* Hash key: (left_id << 32) | right_id */
82 CKBPEMerge merge; /* The merge rule */
83 struct CKMergeEntry *next; /* Chain for collision handling */
84} CKMergeEntry;
85
86/* Merge lookup hash table */
87typedef struct {
88 CKMergeEntry **buckets;
89 size_t num_buckets;
90 size_t num_entries;
91} CKMergeTable;
92
93/* A working token during BPE encoding */
94typedef struct {
95 char *str; /* Token string */
96 int32_t id; /* Token ID (-1 if not yet looked up) */
97 uint16_t len; /* String length */
98 bool is_merged; /* True if this is result of a merge */
99} CKBPEToken;
100
101/* Dynamic token list for BPE processing */
102typedef struct {
103 CKBPEToken *tokens;
104 size_t count;
105 size_t capacity;
106} CKBPETokenList;
107
108#define MAX_SPECIAL_TOKENS 512 /* Maximum number of special tokens */
109
110/* Special token entry for pre-BPE matching */
111typedef struct {
112 char *token; /* Token string to match */
113 int32_t id; /* Token ID to output */
114 int len; /* Length of token string (for faster matching) */
115} CKSpecialToken;
116
117/* Main True BPE tokenizer structure */
118struct CKTrueBPE {
119 /* Vocabulary: token string -> token ID */
121
122 /* Reverse vocabulary: token ID -> token string */
123 char **id_to_token;
124 size_t vocab_size;
125 size_t vocab_capacity;
126
127 /* Merge rules: (left_id, right_id) -> merged_id with priority */
128 CKMergeTable *merges;
129 int32_t num_merges;
130
131 /* Special token IDs */
132 int32_t unk_id;
133 int32_t bos_id;
134 int32_t eos_id;
135 int32_t pad_id;
136
137 /* Special tokens to match before BPE (sorted by length, longest first) */
138 CKSpecialToken special_tokens[MAX_SPECIAL_TOKENS];
139 int num_special_tokens;
140
141 /* Configuration */
143
144 /* String buffer for token operations */
145 char *str_buffer;
146 size_t str_buffer_size;
147};
148
149/* ═══════════════════════════════════════════════════════════════════════════════
150 * Merge Table Operations
151 * ═══════════════════════════════════════════════════════════════════════════════ */
152
153static uint64_t merge_key(int32_t left_id, int32_t right_id) {
154 return ((uint64_t)left_id << 32) | (uint32_t)right_id;
155}
156
157static size_t merge_hash(uint64_t key, size_t num_buckets) {
158 /* Simple hash mixing */
159 key ^= key >> 33;
160 key *= 0xff51afd7ed558ccdULL;
161 key ^= key >> 33;
162 key *= 0xc4ceb9fe1a85ec53ULL;
163 key ^= key >> 33;
164 return key % num_buckets;
165}
166
167static CKMergeTable *merge_table_create(size_t num_buckets) {
168 CKMergeTable *table = (CKMergeTable *)malloc(sizeof(CKMergeTable));
169 if (!table) return NULL;
170
171 table->buckets = (CKMergeEntry **)calloc(num_buckets, sizeof(CKMergeEntry *));
172 if (!table->buckets) {
173 free(table);
174 return NULL;
175 }
176
177 table->num_buckets = num_buckets;
178 table->num_entries = 0;
179 return table;
180}
181
182static void merge_table_free(CKMergeTable *table) {
183 if (!table) return;
184
185 for (size_t i = 0; i < table->num_buckets; i++) {
186 CKMergeEntry *entry = table->buckets[i];
187 while (entry) {
188 CKMergeEntry *next = entry->next;
189 free(entry);
190 entry = next;
191 }
192 }
193
194 free(table->buckets);
195 free(table);
196}
197
198static int merge_table_insert(CKMergeTable *table, const CKBPEMerge *merge) {
199 uint64_t key = merge_key(merge->left_id, merge->right_id);
200 size_t bucket = merge_hash(key, table->num_buckets);
201
202 /* Check if already exists */
203 CKMergeEntry *entry = table->buckets[bucket];
204 while (entry) {
205 if (entry->key == key) {
206 /* Update existing */
207 entry->merge = *merge;
208 return 0;
209 }
210 entry = entry->next;
211 }
212
213 /* Create new entry */
214 entry = (CKMergeEntry *)malloc(sizeof(CKMergeEntry));
215 if (!entry) return -1;
216
217 entry->key = key;
218 entry->merge = *merge;
219 entry->next = table->buckets[bucket];
220 table->buckets[bucket] = entry;
221 table->num_entries++;
222
223 return 0;
224}
225
226static const CKBPEMerge *merge_table_lookup(const CKMergeTable *table, int32_t left_id, int32_t right_id) {
227 uint64_t key = merge_key(left_id, right_id);
228 size_t bucket = merge_hash(key, table->num_buckets);
229
230 CKMergeEntry *entry = table->buckets[bucket];
231 while (entry) {
232 if (entry->key == key) {
233 return &entry->merge;
234 }
235 entry = entry->next;
236 }
237
238 return NULL;
239}
240
241/* ═══════════════════════════════════════════════════════════════════════════════
242 * Token List Operations
243 * ═══════════════════════════════════════════════════════════════════════════════ */
244
245static CKBPETokenList *token_list_create(size_t initial_capacity) {
246 CKBPETokenList *list = (CKBPETokenList *)malloc(sizeof(CKBPETokenList));
247 if (!list) return NULL;
248
249 list->tokens = (CKBPEToken *)calloc(initial_capacity, sizeof(CKBPEToken));
250 if (!list->tokens) {
251 free(list);
252 return NULL;
253 }
254
255 list->count = 0;
256 list->capacity = initial_capacity;
257 return list;
258}
259
260static void token_list_free(CKBPETokenList *list) {
261 if (!list) return;
262
263 for (size_t i = 0; i < list->count; i++) {
264 if (list->tokens[i].str) {
265 free(list->tokens[i].str);
266 }
267 }
268
269 free(list->tokens);
270 free(list);
271}
272
273static void token_list_clear(CKBPETokenList *list) {
274 for (size_t i = 0; i < list->count; i++) {
275 if (list->tokens[i].str) {
276 free(list->tokens[i].str);
277 list->tokens[i].str = NULL;
278 }
279 }
280 list->count = 0;
281}
282
283static int token_list_append(CKBPETokenList *list, const char *str, size_t len, int32_t id) {
284 if (list->count >= list->capacity) {
285 size_t new_cap = list->capacity * 2;
286 CKBPEToken *new_tokens = (CKBPEToken *)realloc(list->tokens, new_cap * sizeof(CKBPEToken));
287 if (!new_tokens) return -1;
288 list->tokens = new_tokens;
289 list->capacity = new_cap;
290 /* Zero new entries */
291 memset(list->tokens + list->count, 0, (new_cap - list->count) * sizeof(CKBPEToken));
292 }
293
294 CKBPEToken *tok = &list->tokens[list->count];
295 tok->str = (char *)malloc(len + 1);
296 if (!tok->str) return -1;
297
298 memcpy(tok->str, str, len);
299 tok->str[len] = '\0';
300 tok->len = (uint16_t)len;
301 tok->id = id;
302 tok->is_merged = false;
303
304 list->count++;
305 return 0;
306}
307
308/* Merge tokens at positions i and i+1 into a single token */
309static int token_list_merge_at(CKBPETokenList *list, size_t pos, const char *merged_str, size_t merged_len, int32_t merged_id) {
310 if (pos + 1 >= list->count) return -1;
311
312 /* Free old strings */
313 free(list->tokens[pos].str);
314 free(list->tokens[pos + 1].str);
315
316 /* Create merged token */
317 list->tokens[pos].str = (char *)malloc(merged_len + 1);
318 if (!list->tokens[pos].str) return -1;
319
320 memcpy(list->tokens[pos].str, merged_str, merged_len);
321 list->tokens[pos].str[merged_len] = '\0';
322 list->tokens[pos].len = (uint16_t)merged_len;
323 list->tokens[pos].id = merged_id;
324 list->tokens[pos].is_merged = true;
325
326 /* Shift remaining tokens left */
327 for (size_t i = pos + 1; i < list->count - 1; i++) {
328 list->tokens[i] = list->tokens[i + 1];
329 }
330 list->count--;
331
332 /* Clear the now-unused last slot */
333 list->tokens[list->count].str = NULL;
334
335 return 0;
336}
337
338/* ═══════════════════════════════════════════════════════════════════════════════
339 * True BPE Tokenizer API
340 * ═══════════════════════════════════════════════════════════════════════════════ */
341
342CKTrueBPE *ck_true_bpe_create(void) {
343 CKTrueBPE *bpe = (CKTrueBPE *)calloc(1, sizeof(CKTrueBPE));
344 if (!bpe) return NULL;
345
346 /* Create vocabulary hash table */
348 if (!bpe->vocab) {
349 free(bpe);
350 return NULL;
351 }
352
353 /* Create merge table */
354 bpe->merges = merge_table_create(MERGE_HASH_SIZE);
355 if (!bpe->merges) {
356 ck_tokenizer_hash_table_free(bpe->vocab, true);
357 free(bpe);
358 return NULL;
359 }
360
361 /* Initialize reverse vocabulary */
362 bpe->vocab_capacity = 4096;
363 bpe->id_to_token = (char **)calloc(bpe->vocab_capacity, sizeof(char *));
364 if (!bpe->id_to_token) {
365 merge_table_free(bpe->merges);
366 ck_tokenizer_hash_table_free(bpe->vocab, true);
367 free(bpe);
368 return NULL;
369 }
370
371 /* String buffer for token operations */
372 bpe->str_buffer_size = 4096;
373 bpe->str_buffer = (char *)malloc(bpe->str_buffer_size);
374 if (!bpe->str_buffer) {
375 free(bpe->id_to_token);
376 merge_table_free(bpe->merges);
377 ck_tokenizer_hash_table_free(bpe->vocab, true);
378 free(bpe);
379 return NULL;
380 }
381
382 /* Default special token IDs */
383 bpe->unk_id = 0;
384 bpe->bos_id = -1;
385 bpe->eos_id = -1;
386 bpe->pad_id = -1;
387
388 /* Initialize special tokens array */
389 bpe->num_special_tokens = 0;
390 for (int i = 0; i < MAX_SPECIAL_TOKENS; i++) {
391 bpe->special_tokens[i].token = NULL;
392 bpe->special_tokens[i].id = -1;
393 bpe->special_tokens[i].len = 0;
394 }
395
396 /* Default config */
397 bpe->config.add_bos = false;
398 bpe->config.add_eos = false;
399 bpe->config.byte_fallback = true;
400 bpe->config.space_prefix_style = CK_SPACE_PREFIX_AUTO;
401 bpe->config.pretokenizer = CK_BPE_PRETOKENIZER_GPT2;
402
403 return bpe;
404}
405
406void ck_true_bpe_free(CKTrueBPE *bpe) {
407 if (!bpe) return;
408
409 if (bpe->vocab) {
410 ck_tokenizer_hash_table_free(bpe->vocab, true);
411 }
412
413 if (bpe->merges) {
414 merge_table_free(bpe->merges);
415 }
416
417 if (bpe->id_to_token) {
418 for (size_t i = 0; i < bpe->vocab_size; i++) {
419 if (bpe->id_to_token[i]) {
420 free(bpe->id_to_token[i]);
421 }
422 }
423 free(bpe->id_to_token);
424 }
425
426 if (bpe->str_buffer) {
427 free(bpe->str_buffer);
428 }
429
430 /* Free special tokens */
431 for (int i = 0; i < bpe->num_special_tokens; i++) {
432 if (bpe->special_tokens[i].token) {
433 free(bpe->special_tokens[i].token);
434 }
435 }
436
437 free(bpe);
438}
439
440/* ═══════════════════════════════════════════════════════════════════════════════
441 * Vocabulary Management
442 * ═══════════════════════════════════════════════════════════════════════════════ */
443
444/* Token info stored in vocab hash table */
445typedef struct {
446 int32_t id;
447 float score;
448} BPETokenInfo;
449
450int ck_true_bpe_add_token(CKTrueBPE *bpe, const char *token, int32_t id, float score) {
451 if (!bpe || !token) return -1;
452
453 /* Ensure reverse vocab has space */
454 if (id >= (int32_t)bpe->vocab_capacity) {
455 size_t new_cap = bpe->vocab_capacity * 2;
456 while (new_cap <= (size_t)id) new_cap *= 2;
457
458 char **new_array = (char **)realloc(bpe->id_to_token, new_cap * sizeof(char *));
459 if (!new_array) return -1;
460
461 memset(new_array + bpe->vocab_capacity, 0, (new_cap - bpe->vocab_capacity) * sizeof(char *));
462 bpe->id_to_token = new_array;
463 bpe->vocab_capacity = new_cap;
464 }
465
466 /* Check if token exists */
467 BPETokenInfo *existing = (BPETokenInfo *)ck_tokenizer_hash_table_lookup(bpe->vocab, token);
468 if (existing) {
469 existing->id = id;
470 existing->score = score;
471 if (bpe->id_to_token[id]) free(bpe->id_to_token[id]);
472 bpe->id_to_token[id] = strdup(token);
473 return 0;
474 }
475
476 /* Create new token info */
477 BPETokenInfo *info = (BPETokenInfo *)malloc(sizeof(BPETokenInfo));
478 if (!info) return -1;
479
480 info->id = id;
481 info->score = score;
482
483 if (ck_tokenizer_hash_table_insert(bpe->vocab, token, info) != 0) {
484 free(info);
485 return -1;
486 }
487
488 if (id >= (int32_t)bpe->vocab_size) {
489 bpe->vocab_size = id + 1;
490 }
491
492 if (bpe->id_to_token[id]) free(bpe->id_to_token[id]);
493 bpe->id_to_token[id] = strdup(token);
494
495 return 0;
496}
497
498int ck_true_bpe_add_merge(CKTrueBPE *bpe, int32_t left_id, int32_t right_id, int32_t merged_id, int32_t priority) {
499 if (!bpe) return -1;
500
501 CKBPEMerge merge = {
502 .left_id = left_id,
503 .right_id = right_id,
504 .merged_id = merged_id,
505 .priority = priority
506 };
507
508 int ret = merge_table_insert(bpe->merges, &merge);
509 if (ret == 0) {
510 bpe->num_merges++;
511 }
512 return ret;
513}
514
515int ck_true_bpe_add_merge_by_tokens(CKTrueBPE *bpe, const char *left, const char *right, int32_t priority) {
516 if (!bpe || !left || !right) return -1;
517
518 /* Look up token IDs */
519 BPETokenInfo *left_info = (BPETokenInfo *)ck_tokenizer_hash_table_lookup(bpe->vocab, left);
520 BPETokenInfo *right_info = (BPETokenInfo *)ck_tokenizer_hash_table_lookup(bpe->vocab, right);
521
522 if (!left_info || !right_info) {
523 return -1; /* Tokens not in vocabulary */
524 }
525
526 /* Create merged token string */
527 size_t left_len = strlen(left);
528 size_t right_len = strlen(right);
529 size_t merged_len = left_len + right_len;
530
531 if (merged_len >= bpe->str_buffer_size) {
532 return -1; /* Too long */
533 }
534
535 memcpy(bpe->str_buffer, left, left_len);
536 memcpy(bpe->str_buffer + left_len, right, right_len);
537 bpe->str_buffer[merged_len] = '\0';
538
539 /* Look up or create merged token */
540 BPETokenInfo *merged_info = (BPETokenInfo *)ck_tokenizer_hash_table_lookup(bpe->vocab, bpe->str_buffer);
541 int32_t merged_id;
542
543 if (merged_info) {
544 merged_id = merged_info->id;
545 } else {
546 /* Merged token should already exist in vocabulary */
547 return -1;
548 }
549
550 return ck_true_bpe_add_merge(bpe, left_info->id, right_info->id, merged_id, priority);
551}
552
553void ck_true_bpe_set_special_ids(CKTrueBPE *bpe, int32_t unk, int32_t bos, int32_t eos, int32_t pad) {
554 if (!bpe) return;
555 bpe->unk_id = unk;
556 bpe->bos_id = bos;
557 bpe->eos_id = eos;
558 bpe->pad_id = pad;
559}
560
561void ck_true_bpe_set_config(CKTrueBPE *bpe, const CKBPEConfig *config) {
562 if (!bpe || !config) return;
563 bpe->config = *config;
564}
565
566int ck_true_bpe_add_special_token(CKTrueBPE *bpe, const char *token, int32_t id) {
567 if (!bpe || !token || id < 0) return -1;
568 if (bpe->num_special_tokens >= MAX_SPECIAL_TOKENS) return -1;
569
570 int token_len = (int)strlen(token);
571 if (token_len == 0) return -1;
572
573 /* Check if already exists */
574 for (int i = 0; i < bpe->num_special_tokens; i++) {
575 if (bpe->special_tokens[i].token &&
576 strcmp(bpe->special_tokens[i].token, token) == 0) {
577 /* Update ID for existing token */
578 bpe->special_tokens[i].id = id;
579 return 0;
580 }
581 }
582
583 /* Find insertion point (keep sorted by length, longest first) */
584 int insert_idx = bpe->num_special_tokens;
585 for (int i = 0; i < bpe->num_special_tokens; i++) {
586 if (token_len > bpe->special_tokens[i].len) {
587 insert_idx = i;
588 break;
589 }
590 }
591
592 /* Shift existing entries down */
593 for (int i = bpe->num_special_tokens; i > insert_idx; i--) {
594 bpe->special_tokens[i] = bpe->special_tokens[i - 1];
595 }
596
597 /* Insert new entry */
598 bpe->special_tokens[insert_idx].token = strdup(token);
599 if (!bpe->special_tokens[insert_idx].token) return -1;
600 bpe->special_tokens[insert_idx].id = id;
601 bpe->special_tokens[insert_idx].len = token_len;
602 bpe->num_special_tokens++;
603
604 return 0;
605}
606
607int ck_true_bpe_load_binary(CKTrueBPE *bpe,
608 int vocab_size,
609 const int32_t *offsets,
610 const char *strings,
611 int num_merges,
612 const int32_t *merges) {
613 if (!bpe || !offsets || !strings || vocab_size <= 0) return -1;
614
615 for (int i = 0; i < vocab_size; i++) {
616 const char *token = strings + offsets[i];
617 if (ck_true_bpe_add_token(bpe, token, i, 0.0f) != 0) {
618 return -1;
619 }
620 }
621
622 if (merges && num_merges > 0) {
623 for (int i = 0; i < num_merges; i++) {
624 int32_t left = merges[i * 3 + 0];
625 int32_t right = merges[i * 3 + 1];
626 int32_t merged = merges[i * 3 + 2];
627 if (left < 0 || right < 0 || merged < 0) {
628 continue;
629 }
630 if (ck_true_bpe_add_merge(bpe, left, right, merged, i) != 0) {
631 return -1;
632 }
633 }
634 }
635
636 return 0;
637}
638
639static int32_t lookup_token_exact(const CKTrueBPE *bpe, const char *token) {
640 if (!bpe || !token) return -1;
641 BPETokenInfo *info = (BPETokenInfo *)ck_tokenizer_hash_table_lookup(bpe->vocab, token);
642 return info ? info->id : -1;
643}
644
645int32_t ck_true_bpe_lookup(const CKTrueBPE *bpe, const char *token) {
646 int32_t id = lookup_token_exact(bpe, token);
647 if (id >= 0) return id;
648 return bpe ? bpe->unk_id : -1;
649}
650
651const char *ck_true_bpe_id_to_token(const CKTrueBPE *bpe, int32_t id) {
652 if (!bpe || id < 0 || id >= (int32_t)bpe->vocab_size) return NULL;
653 return bpe->id_to_token[id];
654}
655
656/* ═══════════════════════════════════════════════════════════════════════════════
657 * Space Prefix Detection
658 * ═══════════════════════════════════════════════════════════════════════════════ */
659
661 if (!bpe) return CK_SPACE_PREFIX_GPT2;
662
663 if (bpe->config.space_prefix_style != CK_SPACE_PREFIX_AUTO) {
664 return bpe->config.space_prefix_style;
665 }
666
667 /* Count tokens starting with each style */
668 int gpt2_count = 0; /* Ġ (0xC4 0xA0) */
669 int spm_count = 0; /* ▁ (0xE2 0x96 0x81) */
670
671 for (size_t i = 0; i < bpe->vocab_size; i++) {
672 const char *token = bpe->id_to_token[i];
673 if (!token) continue;
674
675 unsigned char c0 = (unsigned char)token[0];
676 unsigned char c1 = (unsigned char)token[1];
677
678 if (c0 == 0xC4 && c1 == 0xA0) {
679 gpt2_count++;
680 } else if (c0 == 0xE2 && c1 == 0x96 && (unsigned char)token[2] == 0x81) {
681 spm_count++;
682 }
683 }
684
685 CKSpacePrefixStyle detected;
686 if (spm_count > gpt2_count * 2 && spm_count > 0) {
687 detected = CK_SPACE_PREFIX_SPM;
688 } else if (gpt2_count > 0) {
689 detected = CK_SPACE_PREFIX_GPT2;
690 } else {
691 /* No Ġ/▁ markers found: treat vocab as ASCII identity BPE. */
692 detected = CK_SPACE_PREFIX_ASCII;
693 }
694 bpe->config.space_prefix_style = detected;
695
696 return detected;
697}
698
699/* ═══════════════════════════════════════════════════════════════════════════════
700 * True BPE Encoding
701 * ═══════════════════════════════════════════════════════════════════════════════ */
702
703/*
704 * GPT-2 Byte-Level BPE Character Mapping
705 *
706 * GPT-2 uses a byte-level encoding where certain bytes are mapped to
707 * special Unicode characters to avoid issues with control characters:
708 *
709 * - Space (0x20) → Ġ (U+0120, bytes 0xC4 0xA0)
710 * - Newline (0x0A) → Ċ (U+010A, bytes 0xC4 0x8A)
711 * - Tab (0x09) → ĉ (U+0109, bytes 0xC4 0x89)
712 * - Carriage return (0x0D) → č (U+010D, bytes 0xC4 0x8D)
713 *
714 * Bytes represented directly by Unicode keep their value. The remaining 68
715 * bytes map, in byte order, to U+0100..U+0143. This is the exact GPT-2 table;
716 * it is not equivalent to adding 0x100 to the original byte.
717 */
718
719static bool gpt2_byte_is_identity(unsigned int byte) {
720 return (byte >= 0x21 && byte <= 0x7E) ||
721 (byte >= 0xA1 && byte <= 0xAC) ||
722 (byte >= 0xAE && byte <= 0xFF);
723}
724
725static unsigned int gpt2_byte_to_codepoint(unsigned int byte) {
726 if (gpt2_byte_is_identity(byte)) return byte;
727
728 unsigned int mapped_index = 0;
729 for (unsigned int candidate = 0; candidate < byte; candidate++) {
730 if (!gpt2_byte_is_identity(candidate)) mapped_index++;
731 }
732 return 0x100 + mapped_index;
733}
734
735/* Convert a byte to GPT-2 byte-level BPE representation */
736static int byte_to_gpt2(unsigned char byte, char *out) {
737 unsigned int codepoint = gpt2_byte_to_codepoint(byte);
738
739 /* Encode as UTF-8 */
740 if (codepoint < 0x80) {
741 out[0] = (char)codepoint;
742 return 1;
743 } else if (codepoint < 0x800) {
744 out[0] = (char)(0xC0 | (codepoint >> 6));
745 out[1] = (char)(0x80 | (codepoint & 0x3F));
746 return 2;
747 } else {
748 out[0] = (char)(0xE0 | (codepoint >> 12));
749 out[1] = (char)(0x80 | ((codepoint >> 6) & 0x3F));
750 out[2] = (char)(0x80 | (codepoint & 0x3F));
751 return 3;
752 }
753}
754
755/* Preprocess text: convert to byte-level BPE representation */
756static int preprocess_text(const CKTrueBPE *bpe, const char *text, int text_len, char *out, int out_max) {
757 CKSpacePrefixStyle style = bpe->config.space_prefix_style;
758 int out_len = 0;
759
761 if (text_len > out_max) return -1;
762 memcpy(out, text, (size_t)text_len);
763 return text_len;
764 }
765
766 /* For SentencePiece, add ▁ at start */
767 if (style == CK_SPACE_PREFIX_SPM && text_len > 0 && text[0] != ' ') {
768 if (out_len + 3 > out_max) return -1;
769 out[out_len++] = (char)0xE2;
770 out[out_len++] = (char)0x96;
771 out[out_len++] = (char)0x81;
772 }
773
774 for (int i = 0; i < text_len; i++) {
775 unsigned char byte = (unsigned char)text[i];
776
777 if (style == CK_SPACE_PREFIX_SPM) {
778 /* SentencePiece style: only convert spaces */
779 if (byte == ' ') {
780 if (out_len + 3 > out_max) return -1;
781 out[out_len++] = (char)0xE2;
782 out[out_len++] = (char)0x96;
783 out[out_len++] = (char)0x81;
784 } else {
785 if (out_len + 1 > out_max) return -1;
786 out[out_len++] = (char)byte;
787 }
788 } else {
789 /* GPT-2 style: full byte-level encoding */
790 char encoded[4];
791 int enc_len = byte_to_gpt2(byte, encoded);
792 if (out_len + enc_len > out_max) return -1;
793 for (int j = 0; j < enc_len; j++) {
794 out[out_len++] = encoded[j];
795 }
796 }
797 }
798
799 return out_len;
800}
801
802/* Get UTF-8 character length */
803static int utf8_char_len(unsigned char c) {
804 if ((c & 0x80) == 0) return 1; /* 0xxxxxxx */
805 if ((c & 0xE0) == 0xC0) return 2; /* 110xxxxx */
806 if ((c & 0xF0) == 0xE0) return 3; /* 1110xxxx */
807 if ((c & 0xF8) == 0xF0) return 4; /* 11110xxx */
808 return 1; /* Invalid, treat as single byte */
809}
810
811/* ═══════════════════════════════════════════════════════════════════════════════
812 * GPT-2 Style Pretokenizer
813 *
814 * The GPT-2 pretokenizer uses a regex to split text into chunks BEFORE BPE:
815 * (?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}|
816 * ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+
817 *
818 * Key behaviors:
819 * - Words get an optional leading space attached: "hello world" -> ["hello", " world"]
820 * - Multiple spaces before a word: " hello" -> [" ", " hello"] (n-1 spaces, then space+word)
821 * - Numbers stay together: "123" -> ["123"]
822 * - Punctuation may get leading space: " ," -> [" ,"]
823 *
824 * This implementation provides a simplified version that handles common cases.
825 * ═══════════════════════════════════════════════════════════════════════════════ */
826
827/* Character classification helpers */
828static bool is_letter(unsigned char c) {
829 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
830}
831
832static bool is_digit(unsigned char c) {
833 return c >= '0' && c <= '9';
834}
835
836static bool is_whitespace(unsigned char c) {
837 return c == ' ' || c == '\t' || c == '\n' || c == '\r';
838}
839
840/* Check if this is the GPT-2 Ġ character (0xC4 0xA0) */
841static bool is_gpt2_space(const char *s, int len) {
842 return len >= 2 && (unsigned char)s[0] == 0xC4 && (unsigned char)s[1] == 0xA0;
843}
844
845/* Check if this is a GPT-2 encoded letter (regular ASCII letter in byte-level) */
846static bool is_bpe_letter(const char *s, int len) {
847 if (len == 1) {
848 unsigned char c = (unsigned char)s[0];
849 return is_letter(c);
850 }
851 /* Keep mapped UTF-8 bytes in one word chunk so BPE merges can reconstruct
852 * accented and non-ASCII letters. Exclude GPT-2 whitespace controls. */
853 if (len >= 2 && (unsigned char)s[0] == 0xC4) {
854 unsigned char c1 = (unsigned char)s[1];
855 if (c1 == 0xA0 || c1 == 0x8A || c1 == 0x89 || c1 == 0x8D) {
856 return false; /* space, newline, tab, carriage return */
857 }
858 }
859 return true;
860}
861
862/* Check if this is a GPT-2 encoded digit */
863static bool is_bpe_digit(const char *s, int len) {
864 if (len == 1) {
865 unsigned char c = (unsigned char)s[0];
866 return is_digit(c);
867 }
868 return false;
869}
870
871/* Pretokenizer chunk types */
872typedef enum {
873 CHUNK_WORD, /* Letters (with optional leading space) */
874 CHUNK_NUMBER, /* Digits */
875 CHUNK_WHITESPACE, /* Whitespace (not attached to word) */
876 CHUNK_OTHER /* Punctuation, etc. */
878
879/* A chunk from pretokenization */
880typedef struct {
881 const char *start;
882 int len;
883 ChunkType type;
884} PretokChunk;
885
886/* Check if character is a newline (Ċ = 0xC4 0x8A in byte-level BPE) */
887static bool is_bpe_newline(const char *s, int len) {
888 return len >= 2 && (unsigned char)s[0] == 0xC4 && (unsigned char)s[1] == 0x8A;
889}
890
891/* Check if this is a non-letter, non-digit, non-newline character that can prefix a word */
892static bool is_word_prefix_char(const char *s, int len) {
893 /* In GPT-2 regex: [^\r\n\p{L}\p{N}]? matches any char except newline, letter, digit */
894 if (len == 1) {
895 unsigned char c = (unsigned char)s[0];
896 return !is_letter(c) && !is_digit(c) && c != '\n' && c != '\r';
897 }
898 /* Multi-byte: check if it's not a letter/digit (newline is Ċ which we handle separately) */
899 if (is_gpt2_space(s, len)) return true; /* Space can prefix */
900 if (is_bpe_newline(s, len)) return false; /* Newline cannot prefix */
901 return true; /* Other multi-byte chars can prefix */
902}
903
904/* Check if character is punctuation (not space, not letter, not digit) */
905static bool is_bpe_punct(const char *s, int len) {
906 if (len == 1) {
907 unsigned char c = (unsigned char)s[0];
908 return !is_letter(c) && !is_digit(c) && c != ' ' && c != '\t' && c != '\n' && c != '\r';
909 }
910 /* Multi-byte: not Ġ (space) or Ċ (newline) */
911 if (is_gpt2_space(s, len)) return false;
912 if (len >= 2 && (unsigned char)s[0] == 0xC4) {
913 unsigned char c1 = (unsigned char)s[1];
914 /* Ċ (newline), ĉ (tab), č (CR) etc. are not punctuation */
915 if (c1 == 0x8A || c1 == 0x89 || c1 == 0x8D) return false;
916 }
917 return true;
918}
919
920/*
921 * GPT-2 Pretokenizer
922 *
923 * Splits byte-level encoded text into chunks for independent BPE processing.
924 *
925 * The GPT-2 regex pattern (in order of matching):
926 * 1. (?i:'s|'t|'re|'ve|'m|'ll|'d) - Contractions
927 * 2. [^\r\n\p{L}\p{N}]?\p{L}+ - Words with optional prefix
928 * 3. \p{N} - Single digit
929 * 4. ?[^\s\p{L}\p{N}]+[\r\n]* - Optional space + punctuation + newlines
930 * 5. \s*[\r\n]+ - Whitespace + newlines
931 * 6. \s+(?!\S) - Trailing whitespace
932 * 7. \s+ - Whitespace
933 *
934 * Returns array of chunks (caller provides buffer).
935 */
936static int gpt2_pretokenize(const char *text, int text_len, PretokChunk *chunks, int max_chunks,
937 CKBPEPretokenizer pretokenizer) {
938 int num_chunks = 0;
939 int pos = 0;
940
941 while (pos < text_len && num_chunks < max_chunks) {
942 int chunk_start = pos;
943 int char_len = utf8_char_len((unsigned char)text[pos]);
944 if (pos + char_len > text_len) char_len = text_len - pos;
945
946 /* Pattern 2: [^\r\n\p{L}\p{N}]?\p{L}+ - Words with optional punctuation prefix */
947 /* This pattern MUST be checked before pattern 4 (punctuation) */
948 /* Check if we have: letter, or punctuation followed by letter */
949 bool is_word = false;
950 int word_start = pos;
951 int prefix_len = 0;
952
953 if (is_bpe_letter(text + pos, char_len)) {
954 /* Word without prefix */
955 is_word = true;
956 } else if (pretokenizer == CK_BPE_PRETOKENIZER_GPT2 &&
957 is_word_prefix_char(text + pos, char_len) &&
958 !is_gpt2_space(text + pos, text_len - pos)) {
959 /* Check if punctuation is followed by a letter */
960 int after = pos + char_len;
961 if (after < text_len) {
962 int next_len = utf8_char_len((unsigned char)text[after]);
963 if (is_bpe_letter(text + after, next_len)) {
964 /* This is pattern 2: punctuation + letters */
965 is_word = true;
966 prefix_len = char_len;
967 }
968 }
969 }
970
971 if (is_word) {
972 /* Collect the word (with optional prefix) */
973 pos = word_start + prefix_len; /* Skip prefix if any */
974 while (pos < text_len) {
975 int clen = utf8_char_len((unsigned char)text[pos]);
976 if (is_bpe_letter(text + pos, clen)) {
977 pos += clen;
978 } else {
979 break;
980 }
981 }
982 chunks[num_chunks].start = text + chunk_start;
983 chunks[num_chunks].len = pos - chunk_start;
984 chunks[num_chunks].type = CHUNK_WORD;
985 num_chunks++;
986 continue;
987 }
988
989 /* Pattern 3: \p{N} - Single digit */
990 if (is_bpe_digit(text + pos, char_len)) {
991 pos += char_len;
992 chunks[num_chunks].start = text + chunk_start;
993 chunks[num_chunks].len = pos - chunk_start;
994 chunks[num_chunks].type = CHUNK_NUMBER;
995 num_chunks++;
996 continue;
997 }
998
999 /* Pattern 4: ?[^\s\p{L}\p{N}]+[\r\n]* - Optional space + punctuation + newlines */
1000 /* At this point, we know the current char is NOT followed by letters (checked above) */
1001 /* Check for space (Ġ) followed by punctuation, OR just punctuation */
1002 bool has_leading_space = is_gpt2_space(text + pos, text_len - pos);
1003 int punct_start = has_leading_space ? pos + 2 : pos;
1004
1005 if (punct_start < text_len) {
1006 int pchar_len = utf8_char_len((unsigned char)text[punct_start]);
1007 if (is_bpe_punct(text + punct_start, pchar_len)) {
1008 /* This matches pattern 4: space? + punctuation + newlines? */
1009 if (has_leading_space) {
1010 pos += 2; /* Include the leading space */
1011 }
1012 /* Collect punctuation characters */
1013 /* Pattern 4 collects ALL consecutive punctuation, NOT stopping before letters */
1014 /* The key insight: if we have " (" before "int", the " (" is pattern 4, */
1015 /* and "(int" would be pattern 2, but pattern 4 matches first since " (" */
1016 /* is a complete match for pattern 4 (space + one punctuation). */
1017 while (pos < text_len) {
1018 int clen = utf8_char_len((unsigned char)text[pos]);
1019 if (is_bpe_punct(text + pos, clen)) {
1020 pos += clen;
1021 } else {
1022 break;
1023 }
1024 }
1025 /* Include trailing newlines */
1026 while (pos < text_len && is_bpe_newline(text + pos, text_len - pos)) {
1027 pos += 2;
1028 }
1029 chunks[num_chunks].start = text + chunk_start;
1030 chunks[num_chunks].len = pos - chunk_start;
1031 chunks[num_chunks].type = CHUNK_OTHER;
1032 num_chunks++;
1033 continue;
1034 }
1035 }
1036
1037 /* Pattern 5/6/7: Whitespace handling */
1038 if (is_gpt2_space(text + pos, text_len - pos)) {
1039 /* Count consecutive spaces */
1040 int space_count = 0;
1041 int space_end = pos;
1042 while (space_end < text_len && is_gpt2_space(text + space_end, text_len - space_end)) {
1043 space_count++;
1044 space_end += 2;
1045 }
1046
1047 /* Check if spaces are followed by newlines (pattern 5) */
1048 if (space_end < text_len && is_bpe_newline(text + space_end, text_len - space_end)) {
1049 /* Whitespace + newlines */
1050 while (space_end < text_len && is_bpe_newline(text + space_end, text_len - space_end)) {
1051 space_end += 2;
1052 }
1053 chunks[num_chunks].start = text + pos;
1054 chunks[num_chunks].len = space_end - pos;
1055 chunks[num_chunks].type = CHUNK_WHITESPACE;
1056 num_chunks++;
1057 pos = space_end;
1058 continue;
1059 }
1060
1061 /* Check what follows the spaces */
1062 if (space_end < text_len) {
1063 int next_len = utf8_char_len((unsigned char)text[space_end]);
1064 if (is_bpe_letter(text + space_end, next_len)) {
1065 /* Letters follow - output (n-1) spaces, then space+word (pattern 2) */
1066 if (space_count > 1) {
1067 chunks[num_chunks].start = text + pos;
1068 chunks[num_chunks].len = (space_count - 1) * 2;
1069 chunks[num_chunks].type = CHUNK_WHITESPACE;
1070 num_chunks++;
1071 pos += (space_count - 1) * 2;
1072 if (num_chunks >= max_chunks) break;
1073 }
1074 /* Collect space + word */
1075 chunk_start = pos;
1076 pos += 2; /* Skip the Ġ */
1077 while (pos < text_len) {
1078 int clen = utf8_char_len((unsigned char)text[pos]);
1079 if (is_bpe_letter(text + pos, clen)) {
1080 pos += clen;
1081 } else {
1082 break;
1083 }
1084 }
1085 chunks[num_chunks].start = text + chunk_start;
1086 chunks[num_chunks].len = pos - chunk_start;
1087 chunks[num_chunks].type = CHUNK_WORD;
1088 num_chunks++;
1089 continue;
1090 } else if (is_bpe_digit(text + space_end, next_len)) {
1091 /* Digit follows - output (n-1) spaces, then space+digit */
1092 if (space_count > 1) {
1093 chunks[num_chunks].start = text + pos;
1094 chunks[num_chunks].len = (space_count - 1) * 2;
1095 chunks[num_chunks].type = CHUNK_WHITESPACE;
1096 num_chunks++;
1097 pos += (space_count - 1) * 2;
1098 if (num_chunks >= max_chunks) break;
1099 }
1100 /* Space + single digit */
1101 chunk_start = pos;
1102 pos += 2 + 1; /* Ġ + digit */
1103 chunks[num_chunks].start = text + chunk_start;
1104 chunks[num_chunks].len = pos - chunk_start;
1105 chunks[num_chunks].type = CHUNK_NUMBER;
1106 num_chunks++;
1107 continue;
1108 }
1109 /* Pattern 4 would have caught space+punct, so this is trailing space before something else */
1110 }
1111
1112 /* Just whitespace */
1113 chunks[num_chunks].start = text + pos;
1114 chunks[num_chunks].len = space_count * 2;
1115 chunks[num_chunks].type = CHUNK_WHITESPACE;
1116 num_chunks++;
1117 pos = space_end;
1118 continue;
1119 }
1120
1121 /* Pattern 5: Newlines (Ċ) */
1122 if (is_bpe_newline(text + pos, text_len - pos)) {
1123 while (pos < text_len && is_bpe_newline(text + pos, text_len - pos)) {
1124 pos += 2;
1125 }
1126 chunks[num_chunks].start = text + chunk_start;
1127 chunks[num_chunks].len = pos - chunk_start;
1128 chunks[num_chunks].type = CHUNK_OTHER;
1129 num_chunks++;
1130 continue;
1131 }
1132
1133 /* Fallback: single character chunk */
1134 pos += char_len;
1135 chunks[num_chunks].start = text + chunk_start;
1136 chunks[num_chunks].len = pos - chunk_start;
1137 chunks[num_chunks].type = CHUNK_OTHER;
1138 num_chunks++;
1139 }
1140
1141 return num_chunks;
1142}
1143
1144/* Initialize token list from preprocessed text */
1145static int init_tokens_from_text(CKTrueBPE *bpe, CKBPETokenList *list, const char *text, int text_len) {
1146 token_list_clear(list);
1147
1148 CKSpacePrefixStyle style = bpe->config.space_prefix_style;
1149 if (style == CK_SPACE_PREFIX_AUTO) {
1151 }
1152
1153 int pos = 0;
1154 while (pos < text_len) {
1155 int char_len = (style == CK_SPACE_PREFIX_ASCII)
1156 ? 1
1157 : utf8_char_len((unsigned char)text[pos]);
1158 if (pos + char_len > text_len) {
1159 char_len = text_len - pos; /* Truncated UTF-8 */
1160 }
1161
1162 /* Look up this character/byte in vocabulary */
1163 char char_buf[8];
1164 memcpy(char_buf, text + pos, char_len);
1165 char_buf[char_len] = '\0';
1166
1167 int32_t id = lookup_token_exact(bpe, char_buf);
1168
1169 if (token_list_append(list, char_buf, char_len, id) != 0) {
1170 return -1;
1171 }
1172
1173 pos += char_len;
1174 }
1175
1176 return 0;
1177}
1178
1179/* Find the best (highest priority = lowest number) merge in the token list */
1180static int find_best_merge(const CKTrueBPE *bpe, const CKBPETokenList *list,
1181 size_t *best_pos, const CKBPEMerge **best_merge) {
1182 *best_pos = 0;
1183 *best_merge = NULL;
1184 int32_t best_priority = INT32_MAX;
1185
1186 for (size_t i = 0; i + 1 < list->count; i++) {
1187 int32_t left_id = list->tokens[i].id;
1188 int32_t right_id = list->tokens[i + 1].id;
1189
1190 if (left_id < 0 || right_id < 0) continue; /* Unknown tokens can't merge */
1191
1192 const CKBPEMerge *merge = merge_table_lookup(bpe->merges, left_id, right_id);
1193 if (merge && merge->priority < best_priority) {
1194 best_priority = merge->priority;
1195 *best_pos = i;
1196 *best_merge = merge;
1197 }
1198 }
1199
1200 return (*best_merge != NULL) ? 0 : -1;
1201}
1202
1203/* Apply BPE merges until no more possible */
1204static int apply_bpe_merges(CKTrueBPE *bpe, CKBPETokenList *list) {
1205 char merged_buf[MAX_TOKEN_LEN * 2];
1206
1207 while (list->count > 1) {
1208 size_t best_pos;
1209 const CKBPEMerge *best_merge;
1210
1211 if (find_best_merge(bpe, list, &best_pos, &best_merge) != 0) {
1212 break; /* No more merges possible */
1213 }
1214
1215 /* Get merged token string */
1216 const char *merged_str = bpe->id_to_token[best_merge->merged_id];
1217 if (!merged_str) {
1218 /* Construct from left + right */
1219 size_t left_len = list->tokens[best_pos].len;
1220 size_t right_len = list->tokens[best_pos + 1].len;
1221
1222 if (left_len + right_len >= sizeof(merged_buf)) {
1223 break; /* Too long */
1224 }
1225
1226 memcpy(merged_buf, list->tokens[best_pos].str, left_len);
1227 memcpy(merged_buf + left_len, list->tokens[best_pos + 1].str, right_len);
1228 merged_buf[left_len + right_len] = '\0';
1229 merged_str = merged_buf;
1230 }
1231
1232 /* Apply the merge */
1233 if (token_list_merge_at(list, best_pos, merged_str, strlen(merged_str), best_merge->merged_id) != 0) {
1234 break;
1235 }
1236 }
1237
1238 return 0;
1239}
1240
1241/*
1242 * Encode a single chunk (after pretokenization) using BPE
1243 */
1244static int encode_chunk(CKTrueBPE *bpe, const char *chunk, int chunk_len,
1245 int32_t *ids, int max_ids, CKBPETokenList *list) {
1246 if (chunk_len <= 0) return 0;
1247
1248 /* First, try to look up the entire chunk as a single token */
1249 char chunk_buf[256];
1250 if (chunk_len < (int)sizeof(chunk_buf)) {
1251 memcpy(chunk_buf, chunk, chunk_len);
1252 chunk_buf[chunk_len] = '\0';
1253 int32_t chunk_id = lookup_token_exact(bpe, chunk_buf);
1254 if (chunk_id >= 0) {
1255 /* Entire chunk is a single token */
1256 if (max_ids >= 1) {
1257 ids[0] = chunk_id;
1258 return 1;
1259 }
1260 return 0;
1261 }
1262 }
1263
1264 /* Initialize token list from chunk characters */
1265 if (init_tokens_from_text(bpe, list, chunk, chunk_len) != 0) {
1266 return 0;
1267 }
1268
1269 /* Apply BPE merges to this chunk */
1270 apply_bpe_merges(bpe, list);
1271
1272 /* Extract token IDs from this chunk */
1273 int out_idx = 0;
1274 CKSpacePrefixStyle style = bpe->config.space_prefix_style;
1275 for (size_t i = 0; i < list->count && out_idx < max_ids; i++) {
1276 int32_t id = list->tokens[i].id;
1277
1278 /* Handle unknown tokens */
1279 if (id < 0) {
1280 if (bpe->config.byte_fallback) {
1281 /* Output each byte as separate token (byte fallback) */
1282 for (size_t j = 0; j < list->tokens[i].len && out_idx < max_ids; j++) {
1283 unsigned char raw_b = (unsigned char)list->tokens[i].str[j];
1284 int32_t byte_id = -1;
1285
1287 char raw_tok[2] = { (char)raw_b, '\0' };
1288 byte_id = lookup_token_exact(bpe, raw_tok);
1289 }
1290
1291 /*
1292 * Keep legacy <0xHH> fallback for older tokenizers,
1293 * then try GPT-2 byte-level piece fallback for modern BPE vocabs.
1294 */
1295 if (byte_id < 0) {
1296 char byte_token[8];
1297 snprintf(byte_token, sizeof(byte_token), "<0x%02X>", raw_b);
1298 byte_id = lookup_token_exact(bpe, byte_token);
1299 }
1300
1301 if (byte_id < 0) {
1302 char mapped[8];
1303 int mapped_len = byte_to_gpt2(raw_b, mapped);
1304 if (mapped_len > 0 && mapped_len < (int)sizeof(mapped)) {
1305 mapped[mapped_len] = '\0';
1306 byte_id = lookup_token_exact(bpe, mapped);
1307 }
1308 }
1309
1310 ids[out_idx++] = (byte_id >= 0) ? byte_id : bpe->unk_id;
1311 }
1312 } else {
1313 ids[out_idx++] = bpe->unk_id;
1314 }
1315 } else {
1316 ids[out_idx++] = id;
1317 }
1318 }
1319
1320 return out_idx;
1321}
1322
1323/*
1324 * Helper: Encode a segment of text (no special tokens) using BPE
1325 */
1326static int encode_text_segment(CKTrueBPE *bpe, const char *text, int text_len,
1327 int32_t *ids, int max_ids) {
1328 if (text_len <= 0) return 0;
1329
1330 /* Preprocess text (byte-level encoding) */
1331 char preprocessed[16384];
1332 int pp_len = preprocess_text(bpe, text, text_len, preprocessed, sizeof(preprocessed) - 1);
1333 if (pp_len < 0) {
1334 return 0;
1335 }
1336 preprocessed[pp_len] = '\0';
1337
1338 int out_idx = 0;
1339 CKSpacePrefixStyle style = bpe->config.space_prefix_style;
1340
1341 /* For GPT-2 style, use pretokenizer to split into chunks */
1343
1344 /* Pretokenize */
1345 PretokChunk chunks[1024];
1346 int num_chunks = gpt2_pretokenize(
1347 preprocessed, pp_len, chunks, 1024, bpe->config.pretokenizer);
1348
1349 /* Create reusable token list */
1350 CKBPETokenList *list = token_list_create(INITIAL_TOKEN_CAPACITY);
1351 if (!list) return out_idx;
1352
1353 /* Process each chunk independently with BPE */
1354 for (int c = 0; c < num_chunks && out_idx < max_ids; c++) {
1355 int chunk_ids = encode_chunk(bpe, chunks[c].start, chunks[c].len,
1356 ids + out_idx, max_ids - out_idx, list);
1357 out_idx += chunk_ids;
1358 }
1359
1360 token_list_free(list);
1361 } else {
1362 /* SentencePiece style: no pretokenization, process entire text */
1363 CKBPETokenList *list = token_list_create(INITIAL_TOKEN_CAPACITY);
1364 if (!list) return out_idx;
1365
1366 int chunk_ids = encode_chunk(bpe, preprocessed, pp_len,
1367 ids + out_idx, max_ids - out_idx, list);
1368 out_idx += chunk_ids;
1369
1370 token_list_free(list);
1371 }
1372
1373 return out_idx;
1374}
1375
1376/*
1377 * Check if text at position matches a special token
1378 * Returns: matched special token index, or -1 if no match
1379 */
1380static int match_special_token(const CKTrueBPE *bpe, const char *text, int text_len, int pos) {
1381 int remaining = text_len - pos;
1382 const char *cur = text + pos;
1383
1384 /* Special tokens are sorted longest first, so first match is best */
1385 for (int i = 0; i < bpe->num_special_tokens; i++) {
1386 int tok_len = bpe->special_tokens[i].len;
1387 if (tok_len <= remaining &&
1388 memcmp(cur, bpe->special_tokens[i].token, tok_len) == 0) {
1389 return i;
1390 }
1391 }
1392 return -1;
1393}
1394
1395int ck_true_bpe_encode(CKTrueBPE *bpe, const char *text, int text_len, int32_t *ids, int max_ids) {
1396 if (!bpe || !text || !ids || max_ids <= 0) return 0;
1397 if (text_len < 0) text_len = (int)strlen(text);
1398 if (text_len == 0) return 0;
1399
1400 /* Auto-detect space style if needed */
1401 if (bpe->config.space_prefix_style == CK_SPACE_PREFIX_AUTO) {
1403 }
1404
1405 int out_idx = 0;
1406
1407 /* Add BOS token if configured */
1408 if (bpe->config.add_bos && bpe->bos_id >= 0 && out_idx < max_ids) {
1409 ids[out_idx++] = bpe->bos_id;
1410 }
1411
1412 /* If no special tokens registered, use fast path */
1413 if (bpe->num_special_tokens == 0) {
1414 out_idx += encode_text_segment(bpe, text, text_len, ids + out_idx, max_ids - out_idx);
1415 } else {
1416 /* Scan for special tokens and encode segments between them */
1417 int pos = 0;
1418 int segment_start = 0;
1419
1420 while (pos < text_len && out_idx < max_ids) {
1421 int match = match_special_token(bpe, text, text_len, pos);
1422
1423 if (match >= 0) {
1424 /* Found special token - first encode any text before it */
1425 if (pos > segment_start) {
1426 int seg_len = pos - segment_start;
1427 out_idx += encode_text_segment(bpe, text + segment_start, seg_len,
1428 ids + out_idx, max_ids - out_idx);
1429 }
1430
1431 /* Output the special token ID */
1432 if (out_idx < max_ids) {
1433 ids[out_idx++] = bpe->special_tokens[match].id;
1434 }
1435
1436 /* Advance past the special token */
1437 pos += bpe->special_tokens[match].len;
1438 segment_start = pos;
1439 } else {
1440 /* No special token here, advance to next character */
1441 pos++;
1442 }
1443 }
1444
1445 /* Encode any remaining text after last special token */
1446 if (segment_start < text_len && out_idx < max_ids) {
1447 out_idx += encode_text_segment(bpe, text + segment_start, text_len - segment_start,
1448 ids + out_idx, max_ids - out_idx);
1449 }
1450 }
1451
1452 /* Add EOS token if configured */
1453 if (bpe->config.add_eos && bpe->eos_id >= 0 && out_idx < max_ids) {
1454 ids[out_idx++] = bpe->eos_id;
1455 }
1456
1457 return out_idx;
1458}
1459
1460/* ═══════════════════════════════════════════════════════════════════════════════
1461 * Decoding
1462 * ═══════════════════════════════════════════════════════════════════════════════ */
1463
1464/* Decode one UTF-8 scalar from s[0..len) and report codepoint + bytes consumed. */
1465static int decode_utf8_scalar(const unsigned char *s, int len, int *out_cp, int *out_used) {
1466 if (!s || len <= 0 || !out_cp || !out_used) return -1;
1467 unsigned char c0 = s[0];
1468 if ((c0 & 0x80) == 0) {
1469 *out_cp = (int)c0;
1470 *out_used = 1;
1471 return 0;
1472 }
1473 if ((c0 & 0xE0) == 0xC0) {
1474 if (len < 2) return -1;
1475 if ((s[1] & 0xC0) != 0x80) return -1;
1476 *out_cp = ((int)(c0 & 0x1F) << 6) | (int)(s[1] & 0x3F);
1477 *out_used = 2;
1478 return 0;
1479 }
1480 if ((c0 & 0xF0) == 0xE0) {
1481 if (len < 3) return -1;
1482 if ((s[1] & 0xC0) != 0x80 || (s[2] & 0xC0) != 0x80) return -1;
1483 *out_cp = ((int)(c0 & 0x0F) << 12) |
1484 ((int)(s[1] & 0x3F) << 6) |
1485 (int)(s[2] & 0x3F);
1486 *out_used = 3;
1487 return 0;
1488 }
1489 if ((c0 & 0xF8) == 0xF0) {
1490 if (len < 4) return -1;
1491 if ((s[1] & 0xC0) != 0x80 || (s[2] & 0xC0) != 0x80 || (s[3] & 0xC0) != 0x80) return -1;
1492 *out_cp = ((int)(c0 & 0x07) << 18) |
1493 ((int)(s[1] & 0x3F) << 12) |
1494 ((int)(s[2] & 0x3F) << 6) |
1495 (int)(s[3] & 0x3F);
1496 *out_used = 4;
1497 return 0;
1498 }
1499 return -1;
1500}
1501
1502/* Invert byte_to_gpt2(): convert mapped codepoint back to original byte if possible. */
1503static int gpt2_codepoint_to_byte(int cp) {
1504 if (cp >= 0 && cp <= 0xFF && gpt2_byte_is_identity((unsigned int)cp)) {
1505 return cp;
1506 }
1507 if (cp >= 0x100 && cp <= 0x143) {
1508 int mapped_index = cp - 0x100;
1509 for (int byte = 0; byte <= 0xFF; byte++) {
1510 if (gpt2_byte_is_identity((unsigned int)byte)) continue;
1511 if (mapped_index-- == 0) return byte;
1512 }
1513 }
1514 return -1;
1515}
1516
1517int ck_true_bpe_decode(const CKTrueBPE *bpe, const int32_t *ids, int num_ids, char *text, int max_len) {
1518 if (!bpe || !ids || !text || max_len <= 0) return 0;
1519
1520 if (bpe->config.space_prefix_style == CK_SPACE_PREFIX_AUTO) {
1521 ck_true_bpe_detect_space_style((CKTrueBPE *)bpe);
1522 }
1523 CKSpacePrefixStyle style = bpe->config.space_prefix_style;
1524
1525 int len = 0;
1526 for (int i = 0; i < num_ids && len < max_len - 1; i++) {
1527 int32_t id = ids[i];
1528 if (id < 0) continue;
1529
1530 /* Skip special tokens */
1531 if (id == bpe->bos_id || id == bpe->eos_id || id == bpe->pad_id) {
1532 continue;
1533 }
1534
1535 const char *token = ck_true_bpe_id_to_token(bpe, id);
1536 if (!token) continue;
1537
1539 int token_len_ascii = (int)strlen(token);
1540 for (int j = 0; j < token_len_ascii && len < max_len - 1; ) {
1541 if (j + 2 < token_len_ascii &&
1542 (unsigned char)token[j] == 0xE2 &&
1543 (unsigned char)token[j + 1] == 0x96 &&
1544 (unsigned char)token[j + 2] == 0x81) {
1545 text[len++] = ' ';
1546 j += 3;
1547 continue;
1548 }
1549 text[len++] = token[j++];
1550 }
1551 continue;
1552 }
1553
1554 int token_len = (int)strlen(token);
1555
1556 /* Check for SentencePiece space marker ▁ (U+2581) at start */
1557 if (token_len >= 3 &&
1558 (unsigned char)token[0] == 0xE2 &&
1559 (unsigned char)token[1] == 0x96 &&
1560 (unsigned char)token[2] == 0x81) {
1561 /* ▁ -> space */
1562 if (len < max_len - 1) text[len++] = ' ';
1563 token += 3;
1564 token_len -= 3;
1565 }
1566
1567 /* Process rest of token, decoding GPT-2 byte-level encoding */
1568 int pos = 0;
1569 while (pos < token_len && len < max_len - 1) {
1570 int cp = 0;
1571 int used = 0;
1572 if (decode_utf8_scalar((const unsigned char *)token + pos, token_len - pos, &cp, &used) == 0) {
1573 if (cp == 0x2581) {
1574 text[len++] = ' ';
1575 } else {
1576 int decoded = gpt2_codepoint_to_byte(cp);
1577 if (decoded >= 0) {
1578 text[len++] = (char)decoded;
1579 } else {
1580 /* Not part of byte-level mapping: preserve original UTF-8 bytes. */
1581 for (int j = 0; j < used && pos + j < token_len && len < max_len - 1; j++) {
1582 text[len++] = token[pos + j];
1583 }
1584 }
1585 }
1586 pos += used;
1587 } else {
1588 /* Invalid UTF-8 in token string: copy one byte to avoid stalling. */
1589 text[len++] = token[pos];
1590 pos += 1;
1591 }
1592 }
1593 }
1594
1595 text[len] = '\0';
1596 return len;
1597}
1598
1599/* ═══════════════════════════════════════════════════════════════════════════════
1600 * Statistics
1601 * ═══════════════════════════════════════════════════════════════════════════════ */
1602
1603size_t ck_true_bpe_vocab_size(const CKTrueBPE *bpe) {
1604 return bpe ? bpe->vocab_size : 0;
1605}
1606
1607int32_t ck_true_bpe_num_merges(const CKTrueBPE *bpe) {
1608 return bpe ? bpe->num_merges : 0;
1609}
#define CK_TOKENIZER_HT_BUCKETS_LARGE
Definition hash_table.h:142
void ck_tokenizer_hash_table_free(CKTokenizerHashTable *table, bool free_values)
Definition hash_table.c:144
int ck_tokenizer_hash_table_insert(CKTokenizerHashTable *table, const char *key, void *value)
Definition hash_table.c:162
CKTokenizerHashTable * ck_tokenizer_hash_table_create(size_t bucket_count)
Definition hash_table.c:84
void * ck_tokenizer_hash_table_lookup(CKTokenizerHashTable *table, const char *key)
Definition hash_table.c:202
const int32_t * ids
Definition tokenizer.h:444
int32_t id
Definition tokenizer.h:316
CKSpacePrefixStyle
Definition tokenizer.h:60
@ CK_SPACE_PREFIX_AUTO
Definition tokenizer.h:61
@ CK_SPACE_PREFIX_SPM
Definition tokenizer.h:63
@ CK_SPACE_PREFIX_GPT2
Definition tokenizer.h:62
@ CK_SPACE_PREFIX_ASCII
Definition tokenizer.h:64
const int32_t int num_ids
Definition tokenizer.h:445
const char * text
Definition tokenizer.h:564
const char * token
Definition tokenizer.h:307
int32_t float * score
Definition tokenizer.h:328
int32_t unk
Definition tokenizer.h:230
int32_t int32_t int32_t eos
Definition tokenizer.h:232
int32_t int32_t int32_t int32_t pad
Definition tokenizer.h:233
CKSpacePrefixStyle style
Definition tokenizer.h:288
int32_t int32_t bos
Definition tokenizer.h:231
const int32_t int int * out_len
Definition tokenizer.h:446
static bool is_bpe_punct(const char *s, int len)
Definition true_bpe.c:905
static bool gpt2_byte_is_identity(unsigned int byte)
Definition true_bpe.c:719
static const CKBPEMerge * merge_table_lookup(const CKMergeTable *table, int32_t left_id, int32_t right_id)
Definition true_bpe.c:226
static int gpt2_pretokenize(const char *text, int text_len, PretokChunk *chunks, int max_chunks, CKBPEPretokenizer pretokenizer)
Definition true_bpe.c:936
int ck_true_bpe_decode(const CKTrueBPE *bpe, const int32_t *ids, int num_ids, char *text, int max_len)
Definition true_bpe.c:1517
static bool is_gpt2_space(const char *s, int len)
Definition true_bpe.c:841
int ck_true_bpe_encode(CKTrueBPE *bpe, const char *text, int text_len, int32_t *ids, int max_ids)
Definition true_bpe.c:1395
static bool is_letter(unsigned char c)
Definition true_bpe.c:828
static int encode_text_segment(CKTrueBPE *bpe, const char *text, int text_len, int32_t *ids, int max_ids)
Definition true_bpe.c:1326
static CKMergeTable * merge_table_create(size_t num_buckets)
Definition true_bpe.c:167
void ck_true_bpe_set_config(CKTrueBPE *bpe, const CKBPEConfig *config)
Definition true_bpe.c:561
static void merge_table_free(CKMergeTable *table)
Definition true_bpe.c:182
#define MAX_TOKEN_LEN
Definition true_bpe.c:65
CKSpacePrefixStyle ck_true_bpe_detect_space_style(CKTrueBPE *bpe)
Definition true_bpe.c:660
static CKBPETokenList * token_list_create(size_t initial_capacity)
Definition true_bpe.c:245
#define MAX_SPECIAL_TOKENS
Definition true_bpe.c:108
void ck_true_bpe_free(CKTrueBPE *bpe)
Definition true_bpe.c:406
void ck_true_bpe_set_special_ids(CKTrueBPE *bpe, int32_t unk, int32_t bos, int32_t eos, int32_t pad)
Definition true_bpe.c:553
static bool is_bpe_letter(const char *s, int len)
Definition true_bpe.c:846
static int init_tokens_from_text(CKTrueBPE *bpe, CKBPETokenList *list, const char *text, int text_len)
Definition true_bpe.c:1145
ChunkType
Definition true_bpe.c:872
@ CHUNK_NUMBER
Definition true_bpe.c:874
@ CHUNK_OTHER
Definition true_bpe.c:876
@ CHUNK_WHITESPACE
Definition true_bpe.c:875
@ CHUNK_WORD
Definition true_bpe.c:873
static bool is_bpe_newline(const char *s, int len)
Definition true_bpe.c:887
static bool is_bpe_digit(const char *s, int len)
Definition true_bpe.c:863
int ck_true_bpe_add_merge(CKTrueBPE *bpe, int32_t left_id, int32_t right_id, int32_t merged_id, int32_t priority)
Definition true_bpe.c:498
static void token_list_clear(CKBPETokenList *list)
Definition true_bpe.c:273
static int encode_chunk(CKTrueBPE *bpe, const char *chunk, int chunk_len, int32_t *ids, int max_ids, CKBPETokenList *list)
Definition true_bpe.c:1244
CKTrueBPE * ck_true_bpe_create(void)
Definition true_bpe.c:342
static int utf8_char_len(unsigned char c)
Definition true_bpe.c:803
static void token_list_free(CKBPETokenList *list)
Definition true_bpe.c:260
static int preprocess_text(const CKTrueBPE *bpe, const char *text, int text_len, char *out, int out_max)
Definition true_bpe.c:756
static int token_list_append(CKBPETokenList *list, const char *str, size_t len, int32_t id)
Definition true_bpe.c:283
static bool is_whitespace(unsigned char c)
Definition true_bpe.c:836
int32_t ck_true_bpe_num_merges(const CKTrueBPE *bpe)
Definition true_bpe.c:1607
int ck_true_bpe_add_special_token(CKTrueBPE *bpe, const char *token, int32_t id)
Definition true_bpe.c:566
const char * ck_true_bpe_id_to_token(const CKTrueBPE *bpe, int32_t id)
Definition true_bpe.c:651
static int decode_utf8_scalar(const unsigned char *s, int len, int *out_cp, int *out_used)
Definition true_bpe.c:1465
#define MERGE_HASH_SIZE
Definition true_bpe.c:63
static int match_special_token(const CKTrueBPE *bpe, const char *text, int text_len, int pos)
Definition true_bpe.c:1380
int ck_true_bpe_load_binary(CKTrueBPE *bpe, int vocab_size, const int32_t *offsets, const char *strings, int num_merges, const int32_t *merges)
Definition true_bpe.c:607
static size_t merge_hash(uint64_t key, size_t num_buckets)
Definition true_bpe.c:157
static int apply_bpe_merges(CKTrueBPE *bpe, CKBPETokenList *list)
Definition true_bpe.c:1204
static int find_best_merge(const CKTrueBPE *bpe, const CKBPETokenList *list, size_t *best_pos, const CKBPEMerge **best_merge)
Definition true_bpe.c:1180
static bool is_word_prefix_char(const char *s, int len)
Definition true_bpe.c:892
static int merge_table_insert(CKMergeTable *table, const CKBPEMerge *merge)
Definition true_bpe.c:198
size_t ck_true_bpe_vocab_size(const CKTrueBPE *bpe)
Definition true_bpe.c:1603
int ck_true_bpe_add_token(CKTrueBPE *bpe, const char *token, int32_t id, float score)
Definition true_bpe.c:450
static uint64_t merge_key(int32_t left_id, int32_t right_id)
Definition true_bpe.c:153
static int gpt2_codepoint_to_byte(int cp)
Definition true_bpe.c:1503
static bool is_digit(unsigned char c)
Definition true_bpe.c:832
static int32_t lookup_token_exact(const CKTrueBPE *bpe, const char *token)
Definition true_bpe.c:639
static int byte_to_gpt2(unsigned char byte, char *out)
Definition true_bpe.c:736
static unsigned int gpt2_byte_to_codepoint(unsigned int byte)
Definition true_bpe.c:725
static int token_list_merge_at(CKBPETokenList *list, size_t pos, const char *merged_str, size_t merged_len, int32_t merged_id)
Definition true_bpe.c:309
int32_t ck_true_bpe_lookup(const CKTrueBPE *bpe, const char *token)
Definition true_bpe.c:645
int ck_true_bpe_add_merge_by_tokens(CKTrueBPE *bpe, const char *left, const char *right, int32_t priority)
Definition true_bpe.c:515
#define INITIAL_TOKEN_CAPACITY
Definition true_bpe.c:64
const CKBPEConfig * config
Definition true_bpe.h:179
int const int32_t const char int num_merges
Definition true_bpe.h:196
int const int32_t const char * strings
Definition true_bpe.h:195
int const int32_t const char int const int32_t * merges
Definition true_bpe.h:197
int32_t int32_t int32_t int32_t priority
Definition true_bpe.h:123
const int32_t int char int max_len
Definition true_bpe.h:288
int32_t left_id
Definition true_bpe.h:120
const char int text_len
Definition true_bpe.h:270
CKBPEPretokenizer
Definition true_bpe.h:53
@ CK_BPE_PRETOKENIZER_GPT2
Definition true_bpe.h:54
int vocab_size
Definition true_bpe.h:193
int32_t int32_t right_id
Definition true_bpe.h:121
int const int32_t * offsets
Definition true_bpe.h:194
const char * left
Definition true_bpe.h:138
int32_t int32_t int32_t merged_id
Definition true_bpe.h:122
const char int int32_t int max_ids
Definition true_bpe.h:272
const char const char * right
Definition true_bpe.h:139
uint32_t start
Definition utf8.c:214