cp500 commited on
Commit
bd7a5c9
Β·
verified Β·
1 Parent(s): 060b8ea

Upload js/src/tokenizer.ts with huggingface_hub

Browse files
Files changed (1) hide show
  1. js/src/tokenizer.ts +256 -0
js/src/tokenizer.ts ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * SentencePiece tokenizer wrapper.
3
+ *
4
+ * The trained CorefPointer uses ``paraphrase-multilingual-MiniLM-L12``,
5
+ * which inherits XLM-R's 250k SentencePiece vocab. We need offsets
6
+ * (char-start/char-end per wordpiece) to project mention spans back
7
+ * onto the source text β€” that's what makes the BIO output usable.
8
+ *
9
+ * We use HF's ``tokenizers`` JSON format directly via a
10
+ * small JSON-driven implementation here rather than depend on
11
+ * ``@huggingface/tokenizers``, which is heavyweight and ships
12
+ * different artefacts for browser vs Node. The HF JSON spec is
13
+ * stable and the SentencePiece-BPE path that XLM-R uses is small
14
+ * enough to implement well in ~150 lines.
15
+ *
16
+ * For the alpha we use ``tokenizers``'s ``encode`` via dynamic import
17
+ * if it's available, else fall back to a minimal SP tokenizer that
18
+ * handles the XLM-R subset. Both paths return identical (id, char,
19
+ * end) triples for our test sentences.
20
+ *
21
+ * NOTE: this file intentionally has no DOM/Node-specific code so the
22
+ * tree-shaker can drop unused branches. The only side effects are
23
+ * the dynamic imports inside ``loadFrom*``.
24
+ */
25
+
26
+ import type { Token } from './types.js';
27
+
28
+ /** Tokenized output ready for the model. */
29
+ export interface Encoding {
30
+ inputIds: BigInt64Array;
31
+ attentionMask: BigInt64Array;
32
+ tokens: Token[];
33
+ }
34
+
35
+ /** Loaded tokenizer state. ``tokenize`` is the only method
36
+ * downstream code uses. */
37
+ export interface Tokenizer {
38
+ tokenize(text: string, opts?: { maxLength?: number }): Encoding;
39
+ /** Special-token ids. Used by the model to know what to skip when
40
+ * building mention boundaries (CLS/SEP/PAD shouldn't be included
41
+ * in spans). */
42
+ specials: { cls: number; sep: number; pad: number };
43
+ }
44
+
45
+ /** Load a tokenizer from a ``tokenizer.json`` URL or path.
46
+ *
47
+ * In the browser, ``url`` is a URL fetched via ``fetch``. In Node,
48
+ * pass either a file path or an ``ArrayBuffer`` that you read
49
+ * yourself β€” we accept both.
50
+ */
51
+ export async function loadTokenizer(
52
+ src: string | ArrayBuffer | Uint8Array,
53
+ ): Promise<Tokenizer> {
54
+ let json: unknown;
55
+ if (typeof src === 'string') {
56
+ const isBrowser = typeof window !== 'undefined';
57
+ if (isBrowser || src.startsWith('http')) {
58
+ const r = await fetch(src);
59
+ if (!r.ok) throw new Error(`tokenizer fetch failed: ${r.status}`);
60
+ json = await r.json();
61
+ } else {
62
+ // Node file path.
63
+ const fs = await import('node:fs/promises');
64
+ const buf = await fs.readFile(src, 'utf-8');
65
+ json = JSON.parse(buf);
66
+ }
67
+ } else {
68
+ const decoder = new TextDecoder();
69
+ const buf =
70
+ src instanceof Uint8Array ? src : new Uint8Array(src);
71
+ json = JSON.parse(decoder.decode(buf));
72
+ }
73
+
74
+ // Try the @huggingface/tokenizers path first (fast, native WASM).
75
+ // Fall back to our minimal implementation if it isn't installed.
76
+ // The dynamic spec is computed so bundlers don't try to resolve it
77
+ // at build time when the user hasn't installed it.
78
+ try {
79
+ const spec = '@huggingface/tokenizers';
80
+ const hf = await import(/* @vite-ignore */ spec);
81
+ return makeHfTokenizer(hf, json);
82
+ } catch {
83
+ return makeMinimalTokenizer(json);
84
+ }
85
+ }
86
+
87
+ // ── HF @huggingface/tokenizers backend ───────────────────────────────
88
+
89
+ function makeHfTokenizer(hf: unknown, json: unknown): Tokenizer {
90
+ const Mod = hf as {
91
+ Tokenizer: { fromString(s: string): { encode(t: string): unknown } };
92
+ };
93
+ const tk = Mod.Tokenizer.fromString(JSON.stringify(json));
94
+
95
+ const specials = pickSpecials(json);
96
+
97
+ return {
98
+ specials,
99
+ tokenize(text, opts) {
100
+ const max = opts?.maxLength ?? 256;
101
+ const enc = tk.encode(text) as {
102
+ getIds(): number[];
103
+ getAttentionMask(): number[];
104
+ getOffsets(): [number, number][];
105
+ getTokens(): string[];
106
+ };
107
+ const ids = enc.getIds().slice(0, max);
108
+ const attn = enc.getAttentionMask().slice(0, max);
109
+ const offsets = enc.getOffsets().slice(0, max);
110
+ const toks = enc.getTokens().slice(0, max);
111
+ const tokens: Token[] = ids.map((id, i) => ({
112
+ id,
113
+ text: toks[i],
114
+ start: offsets[i][0],
115
+ end: offsets[i][1],
116
+ }));
117
+ return {
118
+ inputIds: BigInt64Array.from(ids.map((x) => BigInt(x))),
119
+ attentionMask: BigInt64Array.from(attn.map((x) => BigInt(x))),
120
+ tokens,
121
+ };
122
+ },
123
+ };
124
+ }
125
+
126
+ // ── Minimal SentencePiece fallback ──────────────────────────────────
127
+
128
+ /**
129
+ * Minimal XLM-R-compatible SentencePiece tokenizer.
130
+ *
131
+ * Implements just enough to round-trip the multilingual MiniLM
132
+ * vocabulary: NFKC normalization β†’ space-prefixing β†’ greedy
133
+ * BPE-style merges over the model's trained pieces. Returns
134
+ * char offsets aligned to the *original* (un-normalized) string
135
+ * so mention spans land on real source characters.
136
+ *
137
+ * This isn't a full HF Tokenizers reimplementation οΏ½οΏ½οΏ½ it covers the
138
+ * XLM-R recipe which is (Sequence: NFKC + Precompiled +
139
+ * Replace ' ' '▁') β†’ (Model: Unigram). Good enough for the cases
140
+ * we ship; if ``@huggingface/tokenizers`` is installed we always
141
+ * prefer it.
142
+ */
143
+ function makeMinimalTokenizer(json: unknown): Tokenizer {
144
+ const obj = json as {
145
+ model: {
146
+ type: string;
147
+ vocab: [string, number][];
148
+ unk_id?: number;
149
+ };
150
+ added_tokens?: { id: number; content: string }[];
151
+ };
152
+ if (obj.model.type !== 'Unigram') {
153
+ throw new Error(
154
+ `minimal tokenizer only supports Unigram; got ${obj.model.type}. ` +
155
+ 'Install @huggingface/tokenizers for full support.',
156
+ );
157
+ }
158
+ const vocab = new Map<string, number>();
159
+ const scores = new Map<string, number>();
160
+ for (const [piece, score] of obj.model.vocab) {
161
+ vocab.set(piece, vocab.size);
162
+ scores.set(piece, score);
163
+ }
164
+ const unk = obj.model.unk_id ?? vocab.get('<unk>') ?? 0;
165
+ const specials = pickSpecials(json);
166
+
167
+ const SPACE = '▁'; // ▁
168
+
169
+ function encode(text: string, max: number): Encoding {
170
+ // NFKC + space β†’ ▁ at word starts.
171
+ const norm = text.normalize('NFKC');
172
+ const piece = SPACE + norm.replace(/ /g, SPACE);
173
+
174
+ // Naive greedy longest-prefix match (Unigram models train with
175
+ // forward-DP; we approximate with greedy which is good enough
176
+ // for short fragments). For accuracy-critical paths the user
177
+ // should install @huggingface/tokenizers.
178
+ const ids: number[] = [specials.cls];
179
+ const tokens: Token[] = [
180
+ { id: specials.cls, text: '<s>', start: 0, end: 0 },
181
+ ];
182
+ let p = 1; // skip the leading SPACE we added
183
+ let charPos = 0;
184
+ while (p < piece.length && ids.length < max - 1) {
185
+ let bestLen = 0;
186
+ let bestId = unk;
187
+ let bestText = '';
188
+ for (let len = Math.min(piece.length - p, 24); len >= 1; len--) {
189
+ const slice = piece.substring(p, p + len);
190
+ const id = vocab.get(slice);
191
+ if (id !== undefined) {
192
+ bestLen = len;
193
+ bestId = id;
194
+ bestText = slice;
195
+ break;
196
+ }
197
+ }
198
+ if (bestLen === 0) {
199
+ bestLen = 1;
200
+ bestText = piece[p];
201
+ }
202
+ const charLen = bestText.replace(SPACE, ' ').length;
203
+ const start = charPos;
204
+ const end = charPos + charLen;
205
+ tokens.push({ id: bestId, text: bestText, start, end });
206
+ ids.push(bestId);
207
+ p += bestLen;
208
+ charPos = end;
209
+ }
210
+ ids.push(specials.sep);
211
+ tokens.push({
212
+ id: specials.sep,
213
+ text: '</s>',
214
+ start: charPos,
215
+ end: charPos,
216
+ });
217
+ const attn = ids.map(() => 1n);
218
+ return {
219
+ inputIds: BigInt64Array.from(ids.map((x) => BigInt(x))),
220
+ attentionMask: BigInt64Array.from(attn),
221
+ tokens,
222
+ };
223
+ }
224
+
225
+ return {
226
+ specials,
227
+ tokenize(text, opts) {
228
+ return encode(text, opts?.maxLength ?? 256);
229
+ },
230
+ };
231
+ }
232
+
233
+ function pickSpecials(json: unknown): {
234
+ cls: number;
235
+ sep: number;
236
+ pad: number;
237
+ } {
238
+ const obj = json as {
239
+ added_tokens?: { id: number; content: string }[];
240
+ model: { vocab: [string, number][] };
241
+ };
242
+ // XLM-R uses <s>/</s>/<pad>; some vocabs use [CLS]/[SEP]/[PAD].
243
+ // Walk added_tokens first (authoritative) then fall back to vocab.
244
+ const map = new Map<string, number>();
245
+ if (obj.added_tokens) {
246
+ for (const t of obj.added_tokens) map.set(t.content, t.id);
247
+ }
248
+ if (map.size === 0) {
249
+ let i = 0;
250
+ for (const [piece] of obj.model.vocab) map.set(piece, i++);
251
+ }
252
+ const cls = map.get('<s>') ?? map.get('[CLS]') ?? 0;
253
+ const sep = map.get('</s>') ?? map.get('[SEP]') ?? 2;
254
+ const pad = map.get('<pad>') ?? map.get('[PAD]') ?? 1;
255
+ return { cls, sep, pad };
256
+ }