File size: 9,185 Bytes
c971a45 | 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 | // Self-written tokenizer for our Marian SPM-BPE exports — replaces
// transformers.js on the app path. Loads the same HF tokenizer.json and
// reproduces @huggingface/tokenizers@0.1.3 byte-for-byte for the pipeline our
// two models use (parity enforced by test/tokenizer_parity.test.js):
//
// normalize: Precompiled → Strip(right) → Replace(/ {2,}/ → ▁)
// pre-token: Metaspace(▁, prepend always)
// model: BPE (byte_fallback, no fuse on encode)
// post: TemplateProcessing (append </s>)
// decode: Metaspace join, skip-special filter, no cleanup
//
// The Precompiled stage is deliberately the transformers.js APPROXIMATION of
// SentencePiece's charsmap (control-char strip + whitespace folding + NFKC
// with the ~ split quirk), not the real charsmap trie: the app has always
// tokenized through that approximation, and the golden outputs pin it.
//
// The constructor validates that tokenizer.json actually declares this exact
// pipeline and throws otherwise — a future re-export with a different shape
// must fail loudly, never mis-tokenize silently.
const need = (cond, what) => {
if (!cond) throw new Error(`spm_tokenizer: unsupported tokenizer.json: ${what}`);
};
// Encode LRU bounds: app sentences are ≤200 chars (MAX_SENT_CHARS), so 512
// covers every real caller; 4096 entries ≈ a few MB worst case.
const CACHE_CAP = 4096;
const CACHE_MAX_CHARS = 512;
export class SpmTokenizer {
// tokenizerJson: parsed tokenizer.json; config: parsed tokenizer_config.json
// (only clean_up_tokenization_spaces is read from it).
constructor(tokenizerJson, config = {}) {
const { model, normalizer, pre_tokenizer, post_processor, decoder, added_tokens } = tokenizerJson;
need(model?.type === 'BPE' && !Array.isArray(model.vocab), 'model must be BPE with object vocab');
need(Array.isArray(model.merges?.[0]), 'merges must use the pair-array format');
need(!model.ignore_merges && !model.end_of_word_suffix && !model.continuing_subword_suffix,
'BPE suffix/ignore_merges options');
need(normalizer?.type === 'Sequence'
&& normalizer.normalizers.map((n) => n.type).join(',') === 'Precompiled,Strip,Replace'
&& normalizer.normalizers[1].strip_left === false && normalizer.normalizers[1].strip_right === true
&& normalizer.normalizers[2].pattern?.Regex === ' {2,}',
'normalizer must be [Precompiled, Strip(right), Replace(/ {2,}/)]');
need(pre_tokenizer?.type === 'Metaspace' && pre_tokenizer.prepend_scheme === 'always',
'pre_tokenizer must be Metaspace(always)');
need(decoder?.type === 'Metaspace', 'decoder must be Metaspace');
const single = post_processor?.single;
need(post_processor?.type === 'TemplateProcessing' && single?.length === 2
&& single[0].Sequence?.id === 'A' && typeof single[1].SpecialToken?.id === 'string',
'post_processor must be TemplateProcessing [A, eos]');
need((added_tokens ?? []).every((t) => !t.normalized && !t.lstrip && !t.rstrip && !t.single_word),
'added tokens must be unnormalized, no lstrip/rstrip/single_word');
this.replacement = pre_tokenizer.replacement ?? '▁';
this.spaceRun = normalizer.normalizers[2].content ?? '';
this.eosToken = single[1].SpecialToken.id;
this.cleanUp = config.clean_up_tokenization_spaces ?? true;
this.vocab = new Map(Object.entries(model.vocab)); // token -> id
this.byteFallback = model.byte_fallback ?? false;
this.unkToken = model.unk_token ?? null;
// rank of each merge pair, nested left -> right -> rank
this.merges = new Map();
model.merges.forEach(([a, b], rank) => {
let m = this.merges.get(a);
if (!m) this.merges.set(a, (m = new Map()));
if (!m.has(b)) m.set(b, rank);
});
// Added tokens are matched as literal substrings before normalization and
// override the vocab id for their content.
this.addedContents = [];
this.specialTokens = new Set();
for (const t of added_tokens ?? []) {
this.addedContents.push(t.content);
this.vocab.set(t.content, t.id);
if (t.special) this.specialTokens.add(t.content);
}
this.idToToken = [];
for (const [tok, id] of this.vocab) this.idToToken[id] = tok;
this.unkId = this.vocab.get(this.unkToken);
this.textEncoder = new TextEncoder();
this.cache = new Map(); // encode LRU (insertion order = recency)
}
// Precompiled (transformers.js approximation) → Strip(right) → Replace.
normalize(text) {
text = text.replace(/[\u0001-\u0008\u000B\u000E-\u001F\u007F\u008F\u009F]/g, '');
text = text.replace(/[\u0009\u000A\u000C\u000D\u00A0\u1680\u2000-\u200F\u2028\u2029\u202F\u205F\u2581\u3000\uFEFF\uFFFD]/g, ' ');
// NFKC would fold ~ (U+FF5E) into ~; SentencePiece keeps it, so normalize
// around it.
text = text.includes('~')
? text.split('~').map((p) => p.normalize('NFKC')).join('~')
: text.normalize('NFKC');
return text.trimEnd().replace(/ {2,}/gu, this.spaceRun);
}
// Greedy longest-match split on added-token contents; non-matching spans
// stay as plain-text sections.
splitOnAdded(text) {
const sections = [];
let start = 0;
for (let i = 0; i < text.length; ) {
let match = null;
for (const c of this.addedContents) {
if (text.startsWith(c, i) && (!match || c.length > match.length)) match = c;
}
if (!match) { i++; continue; }
if (i > start) sections.push(text.slice(start, i));
sections.push(match);
i += match.length;
start = i;
}
if (start < text.length) sections.push(text.slice(start));
return sections;
}
// Merge the lowest-ranked adjacent pair (leftmost on rank ties) until no
// pair is mergeable. `word` is an array of code points.
bpe(word) {
for (;;) {
let at = -1;
let best = Infinity;
for (let i = 0; i + 1 < word.length; i++) {
const rank = this.merges.get(word[i])?.get(word[i + 1]);
if (rank !== undefined && rank < best) { best = rank; at = i; }
}
if (at < 0) return word;
word.splice(at, 2, word[at] + word[at + 1]);
}
}
// BPE pieces -> token strings, with <0xXX> byte fallback for OOV pieces.
piecesToTokens(pieces) {
const out = [];
for (const p of pieces) {
if (this.vocab.has(p)) {
out.push(p);
} else if (this.byteFallback) {
const bytes = Array.from(this.textEncoder.encode(p),
(x) => `<0x${x.toString(16).toUpperCase().padStart(2, '0')}>`);
if (bytes.every((b) => this.vocab.has(b))) out.push(...bytes);
else if (this.unkToken != null) out.push(this.unkToken);
} else if (this.unkToken != null) {
out.push(this.unkToken);
}
}
return out;
}
// text -> token ids, appending eos (TemplateProcessing) by default.
// Sentence-level LRU: novels repeat short lines (dialogue beats, sound
// words), and the app encodes per sentence — cache whole results. Misses
// pay one Map lookup + one array copy, ~nothing next to the BPE scan.
encode(text, { addSpecialTokens = true } = {}) {
const key = (addSpecialTokens ? 'S' : 'R') + text;
const cacheable = text.length <= CACHE_MAX_CHARS;
if (cacheable) {
const hit = this.cache.get(key);
if (hit !== undefined) {
this.cache.delete(key); // refresh recency
this.cache.set(key, hit);
return hit.slice(); // callers own their copy — the cache stays clean
}
}
const ids = this.encodeUncached(text, addSpecialTokens);
if (cacheable) {
this.cache.set(key, ids.slice());
if (this.cache.size > CACHE_CAP) this.cache.delete(this.cache.keys().next().value);
}
return ids;
}
encodeUncached(text, addSpecialTokens) {
const tokens = [];
for (const section of this.splitOnAdded(text)) {
if (this.addedContents.includes(section)) { tokens.push(section); continue; }
const norm = this.normalize(section);
if (norm.length === 0) continue;
let pre = norm.replaceAll(' ', this.replacement);
if (!pre.startsWith(this.replacement)) pre = this.replacement + pre;
tokens.push(...this.piecesToTokens(this.bpe(Array.from(pre))));
}
if (addSpecialTokens) tokens.push(this.eosToken);
return tokens.map((t) => this.vocab.get(t) ?? this.unkId);
}
// ids -> text. Option name matches the transformers.js call sites.
decode(ids, { skip_special_tokens = false } = {}) {
let tokens = Array.from(ids, (i) => this.idToToken[Number(i)] ?? this.unkToken);
if (skip_special_tokens) tokens = tokens.filter((t) => !this.specialTokens.has(t));
let text = '';
for (let i = 0; i < tokens.length; i++) {
let s = tokens[i].replaceAll(this.replacement, ' ');
if (i === 0 && s.startsWith(' ')) s = s.slice(1);
text += s;
}
if (this.cleanUp) {
text = text.replace(/ \./g, '.').replace(/ \?/g, '?').replace(/ !/g, '!')
.replace(/ ,/g, ',').replace(/ ' /g, "'").replace(/ n't/g, "n't")
.replace(/ 'm/g, "'m").replace(/ 's/g, "'s").replace(/ 've/g, "'ve")
.replace(/ 're/g, "'re");
}
return text;
}
}
|