File size: 20,261 Bytes
ac68cef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
"""Constrained byte-fallback BPE vocabulary for TinyReceiptVQA.

The runtime in this module is deliberately implemented in pure Python.  The
optional ``tokenizers`` package is used only by :meth:`ByteFallbackBPE.build`
to make training fast; loading, encoding, decoding, and JSON serialization do
not depend on it.

This tokenizer differs from a general-purpose BPE in three useful ways for
receipt OCR:

* the structured-output tags and the ten ASCII digits are always atomic;
* whitespace is a hard BPE boundary;
* every UTF-8 byte has a reserved ``<0xNN>`` token, so unseen Unicode text
  never has to become ``<unk>``.

Encoding normalizes text to NFC.  Consequently, the round-trip guarantee is
``decode(encode(text)) == unicodedata.normalize("NFC", text)`` for valid
Unicode input.
"""
from __future__ import annotations

import hashlib
import json
import unicodedata
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path
from typing import Any


DEFAULT_VOCAB_SIZE = 1024 + 512
DEFAULT_ALPHABET_SIZE = 768
BPE_TYPE = "byte_fallback_bpe"
BPE_VERSION = 1

SPECIAL_TOKENS = ("<pad>", "<bos>", "<eos>", "<unk>")
STRUCTURAL_TOKENS = (
    "<field>",
    "</field>",
    "<value>",
    "</value>",
    "<op>",
    "</op>",
    "<answer>",
    "</answer>",
)
DIGIT_TOKENS = tuple("0123456789")
ATOMIC_TOKENS = STRUCTURAL_TOKENS + DIGIT_TOKENS
BYTE_TOKENS = tuple(f"<0x{value:02X}>" for value in range(256))

# Unicode White_Space, kept explicit so Python and browser implementations
# use exactly the same boundary predicate.  In particular, this intentionally
# excludes U+FEFF and Python's legacy U+001C..U+001F isspace() characters.
_WHITESPACE_CODEPOINTS = frozenset(
    (
        0x0009,
        0x000A,
        0x000B,
        0x000C,
        0x000D,
        0x0020,
        0x0085,
        0x00A0,
        0x1680,
        0x2028,
        0x2029,
        0x202F,
        0x205F,
        0x3000,
    )
    + tuple(range(0x2000, 0x200B))
)


def _is_whitespace(character: str) -> bool:
    return ord(character) in _WHITESPACE_CODEPOINTS


def _normalized(text: object) -> str:
    return unicodedata.normalize("NFC", str(text))


def _iter_bpe_spans(text: str) -> Iterable[str]:
    """Yield only spans in which BPE merges are permitted."""
    text = _normalized(text)
    tags = sorted(STRUCTURAL_TOKENS, key=len, reverse=True)
    start = 0
    cursor = 0
    while cursor < len(text):
        tag = next((tag for tag in tags if text.startswith(tag, cursor)), None)
        boundary_length = len(tag) if tag is not None else 0
        if not boundary_length and (
            text[cursor] in DIGIT_TOKENS or _is_whitespace(text[cursor])
        ):
            boundary_length = 1
        if boundary_length:
            if start < cursor:
                yield text[start:cursor]
            cursor += boundary_length
            start = cursor
        else:
            cursor += 1
    if start < len(text):
        yield text[start:]


def _fingerprint_payload(payload: Mapping[str, Any]) -> str:
    unhashed = dict(payload)
    unhashed.pop("tokenizer_hash", None)
    encoded = json.dumps(
        unhashed,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


class ByteFallbackBPE:
    """NFC, structured-output-aware byte-fallback BPE vocabulary."""

    def __init__(
        self,
        itos: Sequence[str],
        merges: Sequence[Sequence[str]],
        *,
        atomic_tokens: Sequence[str] = ATOMIC_TOKENS,
        byte_tokens: Sequence[str] = BYTE_TOKENS,
        unused_tokens: Sequence[str] = (),
    ) -> None:
        self.itos = list(itos)
        self.stoi = {token: index for index, token in enumerate(self.itos)}
        self.merges = [(str(pair[0]), str(pair[1])) for pair in merges]
        self.atomic_tokens = tuple(atomic_tokens)
        self.byte_tokens = tuple(byte_tokens)
        self.unused_tokens = tuple(unused_tokens)

        self._atomic_set = frozenset(self.atomic_tokens)
        self._byte_set = frozenset(self.byte_tokens)
        self._unused_set = frozenset(self.unused_tokens)
        self._tags_longest_first = tuple(
            sorted(
                (token for token in self.atomic_tokens if len(token) > 1),
                key=len,
                reverse=True,
            )
        )
        self._merge_ranks = {
            (left, right): rank for rank, (left, right) in enumerate(self.merges)
        }
        self._validate()

    @classmethod
    def build(
        cls,
        records: Iterable[Mapping[str, Any]],
        *,
        vocab_size: int = DEFAULT_VOCAB_SIZE,
        min_frequency: int = 2,
        alphabet_size: int = DEFAULT_ALPHABET_SIZE,
        text_fields: Sequence[str] = ("question", "target"),
    ) -> "ByteFallbackBPE":
        """Train from the records explicitly supplied by the caller.

        Callers should pass training records only.  This method never discovers
        or reads validation/heldout data on its own.
        """
        reserved = list(SPECIAL_TOKENS + ATOMIC_TOKENS + BYTE_TOKENS)
        if vocab_size < len(reserved):
            raise ValueError(
                f"vocab_size={vocab_size} is smaller than the "
                f"{len(reserved)} required special/atomic/byte tokens"
            )
        if min_frequency < 1:
            raise ValueError("min_frequency must be at least 1")
        maximum_alphabet = vocab_size - len(reserved)
        if not 1 <= alphabet_size <= maximum_alphabet:
            raise ValueError(
                f"alphabet_size must be between 1 and {maximum_alphabet}, "
                f"got {alphabet_size}"
            )
        if not text_fields:
            raise ValueError("text_fields must not be empty")

        try:
            from tokenizers import Tokenizer, models, normalizers, trainers
        except ImportError as exc:  # pragma: no cover - environment dependent
            raise RuntimeError(
                "ByteFallbackBPE.build() requires the optional 'tokenizers' "
                "package; install it for training. Runtime loading and "
                "encode/decode do not require it."
            ) from exc

        def training_spans() -> Iterable[str]:
            for record in records:
                if not isinstance(record, Mapping):
                    raise TypeError(
                        "each training record must be a mapping containing "
                        f"{tuple(text_fields)!r}"
                    )
                for field in text_fields:
                    value = record.get(field)
                    if value is None:
                        continue
                    yield from _iter_bpe_spans(str(value))

        tokenizer = Tokenizer(
            models.BPE(unk_token="<unk>", byte_fallback=True)
        )
        tokenizer.normalizer = normalizers.NFC()
        trainer = trainers.BpeTrainer(
            vocab_size=vocab_size,
            min_frequency=min_frequency,
            show_progress=False,
            special_tokens=reserved,
            limit_alphabet=alphabet_size,
        )
        tokenizer.train_from_iterator(training_spans(), trainer=trainer)

        trained = json.loads(tokenizer.to_str())
        model_data = trained["model"]
        vocab_by_token = {
            str(token): int(index)
            for token, index in model_data["vocab"].items()
        }
        ordered = sorted(vocab_by_token.items(), key=lambda item: item[1])
        if any(index != expected for expected, (_, index) in enumerate(ordered)):
            raise ValueError("trainer returned a non-contiguous vocabulary")
        itos = [token for token, _ in ordered]

        merges: list[tuple[str, str]] = []
        for raw_pair in model_data.get("merges") or []:
            if isinstance(raw_pair, str):
                pair = raw_pair.split(" ", 1)
            else:
                pair = list(raw_pair)
            if len(pair) != 2:
                raise ValueError(f"invalid BPE merge from trainer: {raw_pair!r}")
            left, right = str(pair[0]), str(pair[1])
            merged = left + right
            # Training spans already exclude these boundaries.  Keep this
            # defensive filter so a future trainer/pre-tokenizer change cannot
            # silently weaken the OCR constraints.
            if (
                left in BYTE_TOKENS
                or right in BYTE_TOKENS
                or left in ATOMIC_TOKENS
                or right in ATOMIC_TOKENS
                or any(char in DIGIT_TOKENS for char in merged)
                or any(_is_whitespace(char) for char in merged)
            ):
                continue
            if merged not in vocab_by_token:
                raise ValueError(
                    f"merge output {merged!r} is absent from trained vocabulary"
                )
            merges.append((left, right))

        unused_tokens: list[str] = []
        occupied = set(itos)
        counter = 0
        while len(itos) < vocab_size:
            candidate = f"<unused_{counter:04d}>"
            counter += 1
            if candidate in occupied:
                continue
            occupied.add(candidate)
            unused_tokens.append(candidate)
            itos.append(candidate)
        if len(itos) != vocab_size:
            raise ValueError(
                f"trainer returned {len(itos)} tokens for vocab_size={vocab_size}"
            )

        return cls(
            itos,
            merges,
            atomic_tokens=ATOMIC_TOKENS,
            byte_tokens=BYTE_TOKENS,
            unused_tokens=unused_tokens,
        )

    # A name that reads naturally at integration call sites.
    train_from_records = build

    def _validate(self) -> None:
        if len(self.itos) != len(self.stoi):
            raise ValueError("itos contains duplicate token strings")
        if tuple(self.itos[: len(SPECIAL_TOKENS)]) != SPECIAL_TOKENS:
            raise ValueError(
                f"vocabulary must begin with {SPECIAL_TOKENS!r}; pad must be id 0"
            )
        if self.byte_tokens != BYTE_TOKENS:
            raise ValueError("byte_tokens must contain <0x00> through <0xFF>")
        missing = [
            token
            for token in SPECIAL_TOKENS + self.atomic_tokens + self.byte_tokens
            if token not in self.stoi
        ]
        if missing:
            raise ValueError(f"vocabulary is missing required tokens: {missing!r}")
        if len(self._merge_ranks) != len(self.merges):
            raise ValueError("duplicate merge pairs are not allowed")
        if not self._unused_set.issubset(self.stoi):
            raise ValueError("unused_tokens contains a token absent from itos")

        for left, right in self.merges:
            merged = left + right
            if left not in self.stoi or right not in self.stoi:
                raise ValueError(f"merge input is absent from vocabulary: {(left, right)!r}")
            if merged not in self.stoi:
                raise ValueError(f"merge output is absent from vocabulary: {merged!r}")
            if left in self._byte_set or right in self._byte_set:
                raise ValueError("byte fallback tokens must never participate in merges")
            if left in self._atomic_set or right in self._atomic_set:
                raise ValueError("atomic tokens must never participate in merges")
            if any(char in DIGIT_TOKENS for char in merged):
                raise ValueError("digits must never participate in merges")
            if any(_is_whitespace(char) for char in merged):
                raise ValueError("whitespace must never participate in merges")

    @property
    def pad(self) -> int:
        return self.stoi["<pad>"]

    @property
    def bos(self) -> int:
        return self.stoi["<bos>"]

    @property
    def eos(self) -> int:
        return self.stoi["<eos>"]

    @property
    def unk(self) -> int:
        return self.stoi["<unk>"]

    @property
    def vocab_size(self) -> int:
        return len(self.itos)

    def __len__(self) -> int:
        return len(self.itos)

    def _fallback_ids(self, character: str) -> list[int]:
        return [
            self.stoi[f"<0x{byte:02X}>"]
            for byte in character.encode("utf-8", errors="strict")
        ]

    def _apply_bpe(self, initial: list[str]) -> list[str]:
        tokens = initial
        while len(tokens) > 1:
            best_pair: tuple[str, str] | None = None
            best_rank: int | None = None
            for index in range(len(tokens) - 1):
                pair = (tokens[index], tokens[index + 1])
                rank = self._merge_ranks.get(pair)
                if rank is not None and (best_rank is None or rank < best_rank):
                    best_pair = pair
                    best_rank = rank
            if best_pair is None:
                break

            merged: list[str] = []
            index = 0
            while index < len(tokens):
                if (
                    index + 1 < len(tokens)
                    and (tokens[index], tokens[index + 1]) == best_pair
                ):
                    merged.append(tokens[index] + tokens[index + 1])
                    index += 2
                else:
                    merged.append(tokens[index])
                    index += 1
            tokens = merged
        return tokens

    def _encode_mergeable_span(self, span: str) -> list[int]:
        initial: list[str] = []
        for character in span:
            if (
                character in self.stoi
                and character not in self._byte_set
                and character not in self._unused_set
            ):
                initial.append(character)
            else:
                initial.extend(
                    f"<0x{byte:02X}>"
                    for byte in character.encode("utf-8", errors="strict")
                )
        return [self.stoi[token] for token in self._apply_bpe(initial)]

    def encode(
        self,
        text: str,
        add_bos: bool = False,
        add_eos: bool = False,
        max_len: int = 0,
    ) -> list[int]:
        normalized = _normalized(text)
        ids: list[int] = []
        span_start = 0
        cursor = 0
        while cursor < len(normalized):
            tag = next(
                (
                    token
                    for token in self._tags_longest_first
                    if normalized.startswith(token, cursor)
                ),
                None,
            )
            is_digit = normalized[cursor] in DIGIT_TOKENS
            is_space = _is_whitespace(normalized[cursor])
            if tag is None and not is_digit and not is_space:
                cursor += 1
                continue

            if span_start < cursor:
                ids.extend(self._encode_mergeable_span(normalized[span_start:cursor]))
            if tag is not None:
                ids.append(self.stoi[tag])
                cursor += len(tag)
            elif is_digit:
                ids.append(self.stoi[normalized[cursor]])
                cursor += 1
            else:
                character = normalized[cursor]
                if character in self.stoi and character not in self._byte_set:
                    ids.append(self.stoi[character])
                else:
                    ids.extend(self._fallback_ids(character))
                cursor += 1
            span_start = cursor

        if span_start < len(normalized):
            ids.extend(self._encode_mergeable_span(normalized[span_start:]))
        if add_bos:
            ids.insert(0, self.bos)
        if add_eos:
            ids.append(self.eos)
        if max_len:
            ids = ids[:max_len]
            if add_eos and ids[-1] != self.eos:
                ids[-1] = self.eos
        return ids

    def decode(self, ids: Iterable[int], *, errors: str = "replace") -> str:
        output = bytearray()
        for raw_id in ids:
            token_id = int(raw_id)
            if token_id == self.eos:
                break
            if token_id in (self.pad, self.bos):
                continue
            if not 0 <= token_id < len(self.itos):
                continue
            token = self.itos[token_id]
            if token in self._unused_set:
                continue
            if token in self._byte_set:
                output.append(int(token[3:5], 16))
            else:
                output.extend(token.encode("utf-8", errors="strict"))
        return output.decode("utf-8", errors=errors)

    def to_json(self) -> dict[str, Any]:
        payload: dict[str, Any] = {
            "type": BPE_TYPE,
            "version": BPE_VERSION,
            "vocab_size": len(self.itos),
            "itos": list(self.itos),
            "merges": [[left, right] for left, right in self.merges],
            "normalization": "NFC",
            "atomic_tokens": list(self.atomic_tokens),
            "byte_tokens": list(self.byte_tokens),
            "unused_tokens": list(self.unused_tokens),
            "special_tokens": {
                "pad": "<pad>",
                "bos": "<bos>",
                "eos": "<eos>",
                "unk": "<unk>",
            },
        }
        payload["tokenizer_hash"] = _fingerprint_payload(payload)
        return payload

    @classmethod
    def from_json(cls, obj: Mapping[str, Any]) -> "ByteFallbackBPE":
        if not is_byte_fallback_bpe_json(obj):
            raise ValueError("expected a byte_fallback_bpe version 1 vocabulary")
        if obj.get("normalization") != "NFC":
            raise ValueError("byte_fallback_bpe version 1 requires NFC normalization")

        expected_hash = obj.get("tokenizer_hash")
        if expected_hash is not None and expected_hash != _fingerprint_payload(obj):
            raise ValueError("tokenizer_hash does not match vocabulary contents")

        tokenizer = cls(
            obj["itos"],
            obj.get("merges") or (),
            atomic_tokens=obj.get("atomic_tokens") or ATOMIC_TOKENS,
            byte_tokens=obj.get("byte_tokens") or BYTE_TOKENS,
            unused_tokens=obj.get("unused_tokens") or (),
        )
        declared_size = int(obj.get("vocab_size", -1))
        if declared_size != len(tokenizer):
            raise ValueError(
                f"declared vocab_size={declared_size} but itos has "
                f"{len(tokenizer)} entries"
            )
        return tokenizer

    def save(self, path: str | Path) -> None:
        Path(path).write_text(
            json.dumps(self.to_json(), ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )

    @classmethod
    def load(cls, path: str | Path) -> "ByteFallbackBPE":
        return cls.from_json(json.loads(Path(path).read_text(encoding="utf-8")))


# Slightly more explicit alias for call sites that use "Vocab" terminology.
ByteFallbackBPEVocab = ByteFallbackBPE


def is_byte_fallback_bpe_json(obj: object) -> bool:
    return (
        isinstance(obj, Mapping)
        and obj.get("type") == BPE_TYPE
        and int(obj.get("version", 0)) == BPE_VERSION
    )


def load_bpe1536_vocab(obj: Mapping[str, Any]) -> ByteFallbackBPEVocab:
    """Load the single tokenizer contract supported by release tooling."""
    tokenizer = ByteFallbackBPEVocab.from_json(obj)
    tokenizer_hash = obj.get("tokenizer_hash")
    if (
        not isinstance(tokenizer_hash, str)
        or len(tokenizer_hash) != 64
        or tokenizer_hash != _fingerprint_payload(obj)
    ):
        raise ValueError("BPE1536 release vocabulary requires a valid tokenizer_hash")
    if len(tokenizer) != DEFAULT_VOCAB_SIZE:
        raise ValueError(
            f"release tokenizer must contain {DEFAULT_VOCAB_SIZE} tokens, "
            f"got {len(tokenizer)}"
        )
    return tokenizer


def bpe1536_contract(obj: Mapping[str, Any]) -> dict[str, Any]:
    """Return the manifest fields after fully validating a release vocabulary."""
    tokenizer = load_bpe1536_vocab(obj)
    return {
        "type": BPE_TYPE,
        "version": BPE_VERSION,
        "vocab_size": len(tokenizer),
        "normalization": "NFC",
        "tokenizer_hash": str(obj.get("tokenizer_hash", "")),
    }