File size: 11,894 Bytes
82444b2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Copyright 2026 Luke Steuber
# SPDX-License-Identifier: Apache-2.0
"""Word-level tokenizer for Chime, matching the LocalType keyboard's word rules.

A word is a letter followed by letters, apostrophes or hyphens. Words are normalized by folding
the typographic apostrophe (’) to ' and lowercasing, and the vocabulary is made of those words.

Token kinds, in the order they sit in the vocabulary:

    specials   <pad> <s> </s> <unk> <num> <sym>
    punct      one token per common punctuation mark (PUNCT below); look-alike
               characters are folded onto them (’ -> ', — -> -, 。 -> .), runs of
               the same mark collapse to one, and two or more dots become …
    emoji      every emoji the keyboard can offer, each as one token,
               ZWJ sequences and variation selectors included
    words      lowercased words, most frequent first

``<num>`` stands for any run of digits (with . , : / inside, so 12:30 and 1,000
are one token), ``<sym>`` for any other character (currency signs, stray
combining marks, control characters, an emoji the vocabulary lacks), and
``<unk>`` for a word outside the vocabulary. Whitespace is never a token.

Module-level functions work without a vocabulary; ``Tokenizer`` adds ids.
``detokenize(tokenize(text))`` is the normalized form of ``text``, and
``tokenize(detokenize(tokens)) == tokens`` for any ``tokens`` that ``tokenize``
produced.
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import Iterable, Iterator, Sequence

__all__ = [
    "PAD", "BOS", "EOS", "UNK", "NUM", "SYM", "SPECIALS", "PUNCT", "CANON",
    "WORD_RE", "PREFIX_RE", "EMOJI_RE", "START",
    "normalize", "normalize_word", "active_prefix", "last_completed_word",
    "is_sentence_start", "is_word", "is_emoji", "tokenize", "detokenize",
    "strip_presentation", "Tokenizer",
]

PAD, BOS, EOS, UNK, NUM, SYM = "<pad>", "<s>", "</s>", "<unk>", "<num>", "<sym>"
SPECIALS: tuple[str, ...] = (PAD, BOS, EOS, UNK, NUM, SYM)

# Punctuation that gets its own token. Everything else non-word becomes <sym>.
PUNCT: tuple[str, ...] = (
    ".", ",", "?", "!", "…", "'", '"', "-", ":", ";", "(", ")", "/", "&",
    "$", "%", "*", "+", "=", "@", "#", "~", "_", "[", "]", "<", ">", "^",
    "\\", "|", "°",
)
_PUNCT_SET = frozenset(PUNCT)

# Look-alikes folded onto the tokens above (the device folds only ’ inside words;
# outside words it does not care, so this only shapes the model's context).
CANON: dict[str, str] = {
    "’": "'", "‘": "'", "‚": "'", "`": "'", "´": "'",
    "“": '"', "”": '"', "„": '"', "«": '"', "»": '"',
    "–": "-", "—": "-", "‐": "-", "‑": "-", "−": "-", "﹣": "-", "-": "-",
    "。": ".", ".": ".", ",": ",", "、": ",", ":": ":", ";": ";",
    "?": "?", "!": "!", "(": "(", ")": ")", "¿": "?", "¡": "!",
    "‥": "…",
}

# Stored in place of a previous word at a sentence start.
START = "\u0002"

# Python's [^\W\d_] is the L categories plus the Nl/No number letters (Ⅻ, ², ½)
# that Java's \p{L} excludes; _exact_word_pieces fixes the rare non-ASCII case.
_L = r"[^\W\d_]"
_REST = r"(?:[^\W\d_]|['’-])*"  # a negated class cannot also list characters to allow
WORD_RE = re.compile(rf"{_L}{_REST}")
PREFIX_RE = re.compile(rf"{_L}{_REST}\Z")

_EMOJI_BASE = (
    r"(?:[\U0001F1E6-\U0001F1FF]{2}"                      # flag (two regional indicators)
    r"|[0-9#*]\uFE0F?\u20E3"                              # keycap
    r"|[\U0001F000-\U0001FAFF\u2600-\u27BF\u2300-\u23FF\u2B00-\u2BFF"
    r"\u3030\u303D\u3297\u3299\u00A9\u00AE\u2122\u2194-\u2199\u21A9\u21AA"
    r"\u24C2\u25AA-\u25FE\u2934\u2935])"
)
_EMOJI_MODS = r"[\uFE0E\uFE0F\U0001F3FB-\U0001F3FF\U000E0020-\U000E007F]*"
EMOJI_RE = re.compile(rf"{_EMOJI_BASE}{_EMOJI_MODS}(?:\u200D{_EMOJI_BASE}{_EMOJI_MODS})*")

_MASTER = re.compile(
    rf"(?P<w>{_L}{_REST})"
    rf"|(?P<e>{EMOJI_RE.pattern})"
    r"|(?P<n>\d+(?:[.,:/]\d+)*)"
    r"|(?P<s>\s+)"
    r"|(?P<p>.)",
    re.DOTALL,
)

# ------------------------------------------------------------------ keyboard word rules

def normalize(word: str) -> str:
    """Fold the typographic apostrophe and lowercase."""
    return word.replace("’", "'").lower()

def _is_exact_word(token: str) -> bool:
    if token.isascii():
        return True
    return token[0].isalpha() and all(c.isalpha() or c in "'’-" for c in token)

def _exact_word_pieces(token: str) -> Iterator[tuple[bool, str]]:
    """Split a regex word that contains Nl/No characters the device would not accept."""
    run = ""
    for c in token:
        if c.isalpha() or (run and c in "'’-"):
            run += c
        else:
            if run:
                yield True, run
                run = ""
            yield False, c
    if run:
        yield True, run

def normalize_word(text: str) -> str | None:
    """The first word in ``text``, normalized."""
    for m in WORD_RE.finditer(text):
        tok = m.group()
        if _is_exact_word(tok):
            return normalize(tok)
        for is_w, piece in _exact_word_pieces(tok):
            if is_w:
                return normalize(piece)
    return None

def active_prefix(before_cursor: str) -> str:
    """The raw word being typed at the cursor, or ''."""
    i = len(before_cursor)
    while i > 0 and (before_cursor[i - 1].isalpha() or before_cursor[i - 1] in "'’-"):
        i -= 1
    run = before_cursor[i:]
    for j, c in enumerate(run):  # the prefix must start with a letter
        if c.isalpha():
            return run[j:]
    return ""

def is_sentence_start(before_cursor: str) -> bool:
    """True for an empty field, or when the word follows . ! ? or a newline."""
    tail = before_cursor[: len(before_cursor) - len(active_prefix(before_cursor))].rstrip(" \t")
    return not tail or tail[-1] in ".!?\n"

def last_completed_word(before_cursor: str) -> str | None:
    """The normalized word before the one being typed.

    Punctuation, numbers and sentence ends reset context, so "Done. The" gives None.
    """
    tail = before_cursor[: len(before_cursor) - len(active_prefix(before_cursor))].rstrip(" \t")
    if not tail or not tail[-1].isalpha():
        return None
    last = None
    for m in WORD_RE.finditer(tail):
        last = m.group()
    if last is None:
        return None
    if not _is_exact_word(last):
        last = [p for is_w, p in _exact_word_pieces(last) if is_w][-1]
    return normalize(last)

# ------------------------------------------------------------------ tokenizing

def is_word(token: str) -> bool:
    """True for a word token (not special, punctuation or emoji)."""
    return bool(token) and token[0].isalpha() and token not in SPECIALS and token not in _PUNCT_SET \
        and not EMOJI_RE.fullmatch(token)

def is_emoji(token: str) -> bool:
    return bool(EMOJI_RE.fullmatch(token))

def strip_presentation(emoji: str) -> str:
    """Key for matching an emoji regardless of variation selectors (☀ and ☀️ are one)."""
    return emoji.replace("️", "").replace("︎", "")

def _emit(out: list[str], tok: str) -> None:
    prev = out[-1] if out else None
    if tok == ".":
        if prev == ".":
            out[-1] = "…"
            return
        if prev == "…":
            return
    elif tok == prev and tok in _PUNCT_SET:
        return
    out.append(tok)

def tokenize(text: str) -> list[str]:
    """Split ``text`` into normalized tokens: words, punct, emoji, <num> and <sym>."""
    out: list[str] = []
    for m in _MASTER.finditer(text):
        kind = m.lastgroup
        if kind == "w":
            tok = m.group()
            if _is_exact_word(tok):
                out.append(normalize(tok))
            else:
                for is_w, piece in _exact_word_pieces(tok):
                    if is_w:
                        out.append(normalize(piece))
                    else:
                        _emit(out, CANON.get(piece, piece) if piece in _PUNCT_SET or piece in CANON else SYM)
        elif kind == "s":
            continue
        elif kind == "e":
            out.append(m.group())
        elif kind == "n":
            out.append(NUM)
        else:
            c = m.group()
            c = CANON.get(c, c)
            _emit(out, c if c in _PUNCT_SET else SYM)
    return out

_NO_SPACE_BEFORE = frozenset({".", ",", "?", "!", "…", ":", ";", ")", "%", "]"})
_NO_SPACE_AFTER = frozenset({"(", "[", "$", "#", "@"})
_RENDER = {NUM: "0", SYM: "¤"}  # both tokenize back to themselves

def detokenize(tokens: Iterable[str]) -> str:
    """Join tokens into readable text; the inverse of ``tokenize`` on its own output."""
    parts: list[str] = []
    glue = False
    for tok in tokens:
        if tok in (BOS, EOS, PAD):
            continue
        shown = _RENDER.get(tok, tok)
        if parts and not glue and tok not in _NO_SPACE_BEFORE:
            parts.append(" ")
        parts.append(shown)
        glue = tok in _NO_SPACE_AFTER
    return "".join(parts)

# ------------------------------------------------------------------ vocabulary

class Tokenizer:
    """A fixed vocabulary: one token per line of ``vocab.txt``, id = line number."""

    def __init__(self, tokens: Sequence[str]):
        tokens = list(tokens)
        if tuple(tokens[: len(SPECIALS)]) != SPECIALS:
            raise ValueError(f"vocabulary must start with {SPECIALS}")
        if len(set(tokens)) != len(tokens):
            raise ValueError("vocabulary has duplicate tokens")
        self.tokens: list[str] = tokens
        self.ids: dict[str, int] = {t: i for i, t in enumerate(tokens)}
        self.pad_id, self.bos_id, self.eos_id, self.unk_id, self.num_id, self.sym_id = range(6)
        self._emoji_by_key: dict[str, str] = {}
        for t in tokens:
            if is_emoji(t):
                self._emoji_by_key.setdefault(strip_presentation(t), t)
        self.word_ids: list[int] = [i for i, t in enumerate(tokens) if is_word(t)]
        self.emoji_ids: list[int] = [i for i, t in enumerate(tokens) if is_emoji(t)]
        self.punct_ids: list[int] = [i for i, t in enumerate(tokens) if t in _PUNCT_SET]

    @classmethod
    def load(cls, path: str | Path) -> "Tokenizer":
        with open(path, encoding="utf-8") as f:
            return cls([line.rstrip("\n") for line in f if line != "\n"])

    def save(self, path: str | Path) -> None:
        Path(path).parent.mkdir(parents=True, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            for t in self.tokens:
                f.write(t + "\n")

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

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

    def canonical(self, token: str) -> str:
        """Map an emoji typed with or without a variation selector onto the vocabulary's form."""
        if token in self.ids or not is_emoji(token):
            return token
        return self._emoji_by_key.get(strip_presentation(token), token)

    def tokens_of(self, text: str) -> list[str]:
        return [self.canonical(t) for t in tokenize(text)]

    def id_of(self, token: str) -> int:
        return self.ids.get(self.canonical(token), self.unk_id)

    def encode_tokens(self, tokens: Iterable[str], bos: bool = True, eos: bool = True) -> list[int]:
        ids = [self.id_of(t) for t in tokens]
        if bos:
            ids.insert(0, self.bos_id)
        if eos:
            ids.append(self.eos_id)
        return ids

    def encode(self, text: str, bos: bool = True, eos: bool = True) -> list[int]:
        return self.encode_tokens(tokenize(text), bos=bos, eos=eos)

    def decode(self, ids: Iterable[int]) -> list[str]:
        return [self.tokens[i] for i in ids]

    def text(self, ids: Iterable[int]) -> str:
        return detokenize(self.decode(ids))

    def is_word_id(self, i: int) -> bool:
        return is_word(self.tokens[i])