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;
  }
}