File size: 6,000 Bytes
1499ec4 | 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 | // Byte-level BPE tokenizer for ModernBERT (tokenizer.json), written to return CHARACTER OFFSETS for every token.
// transformers.js does not expose offset mappings, and the extractor needs them to cite exact spans, so this is a small,
// self-contained reimplementation of what Hugging Face `tokenizers` does for this tokenizer:
// NFC normalizer -> added-token split (leftmost-longest) -> ByteLevel pre-tokenizer (GPT-2 regex) -> BPE (merge ranks).
// Offsets follow `tokenizers` exactly: a token covers the characters its bytes came from (a token that holds only part of a
// multi-byte character still covers that whole character). Two offset systems are returned:
// offsets - Python string indices (Unicode code points): what solvi's Quote(start, end) and the Python extractor use;
// offsets16 - JavaScript string indices (UTF-16 code units): for highlighting in the page.
// The input must already be NFC-normalized (the app normalizes every document once, on input).
const GPT2_SPLIT = /'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
function bytesToUnicode() {
const bs = [];
for (let i = 33; i <= 126; i++) bs.push(i);
for (let i = 161; i <= 172; i++) bs.push(i);
for (let i = 174; i <= 255; i++) bs.push(i);
const cs = bs.slice();
let n = 0;
for (let b = 0; b < 256; b++) {
if (!bs.includes(b)) { bs.push(b); cs.push(256 + n); n++; }
}
const map = new Array(256);
bs.forEach((b, i) => { map[b] = String.fromCharCode(cs[i]); });
return map;
}
export class ByteLevelBPE {
constructor(tokenizerJson) {
const t = tokenizerJson;
const m = t.model;
if (m.type !== "BPE") throw new Error("expected a BPE tokenizer");
this.vocab = new Map(Object.entries(m.vocab));
this.ranks = new Map();
m.merges.forEach((mg, i) => {
const [a, b] = Array.isArray(mg) ? mg : mg.split(" ");
this.ranks.set(a + "\u0000" + b, i);
});
this.byteMap = bytesToUnicode();
this.added = new Map(); // first char -> [{content, id}] sorted longest first
for (const a of t.added_tokens || []) {
if (!a.content) continue;
const k = a.content[0];
if (!this.added.has(k)) this.added.set(k, []);
this.added.get(k).push({ content: a.content, id: a.id });
this.vocab.set(a.content, a.id);
}
for (const lst of this.added.values()) lst.sort((x, y) => y.content.length - x.content.length);
const special = (name) => (t.added_tokens || []).find((a) => a.content === name)?.id;
this.cls = special("[CLS]"); this.sep = special("[SEP]"); this.pad = special("[PAD]");
this.cache = new Map();
this.enc = new TextEncoder();
}
bpe(word) { // word: string of byte-level chars -> array of token strings
const hit = this.cache.get(word);
if (hit) return hit;
let parts = Array.from(word);
while (parts.length > 1) {
let best = Infinity, at = -1;
for (let i = 0; i < parts.length - 1; i++) {
const r = this.ranks.get(parts[i] + "\u0000" + parts[i + 1]);
if (r !== undefined && r < best) { best = r; at = i; }
}
if (at < 0) break;
parts = parts.slice(0, at).concat([parts[at] + parts[at + 1]], parts.slice(at + 2));
}
if (this.cache.size > 50000) this.cache.clear();
this.cache.set(word, parts);
return parts;
}
// text (NFC) -> {ids, offsets: [[cpStart, cpEnd]], offsets16: [[u16Start, u16End]]}
encode(text) {
// per-UTF-16-index maps: byte offset of the code unit's character, and the code point index
const n16 = text.length;
const u16ToByte = new Int32Array(n16 + 1);
const u16ToCp = new Int32Array(n16 + 1);
const byteCp = [], byteU16s = [], byteU16e = [];
let b = 0, cp = 0;
for (let i = 0; i < n16;) {
const c = text.codePointAt(i);
const w = c > 0xffff ? 2 : 1;
const nb = c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
for (let k = 0; k < w; k++) { u16ToByte[i + k] = b; u16ToCp[i + k] = cp; }
for (let k = 0; k < nb; k++) { byteCp.push(cp); byteU16s.push(i); byteU16e.push(i + w); }
b += nb; cp += 1; i += w;
}
u16ToByte[n16] = b; u16ToCp[n16] = cp;
const ids = [], offsets = [], offsets16 = [];
const pushPiece = (s16, e16) => { // one pre-token -> BPE tokens with offsets
const piece = text.slice(s16, e16);
const bytes = this.enc.encode(piece);
let word = "";
for (const x of bytes) word += this.byteMap[x];
let pos = u16ToByte[s16];
for (const tok of this.bpe(word)) {
const id = this.vocab.get(tok);
if (id === undefined) throw new Error("token not in vocab: " + tok);
const bs = pos, be = pos + tok.length; // one byte-level char per byte
ids.push(id);
offsets.push([byteCp[bs], byteCp[be - 1] + 1]);
offsets16.push([byteU16s[bs], byteU16e[be - 1]]);
pos = be;
}
};
const pretok = (s16, e16) => {
if (e16 <= s16) return;
const seg = text.slice(s16, e16);
GPT2_SPLIT.lastIndex = 0;
let m;
while ((m = GPT2_SPLIT.exec(seg)) !== null) {
if (m[0].length === 0) { GPT2_SPLIT.lastIndex++; continue; }
pushPiece(s16 + m.index, s16 + m.index + m[0].length);
}
};
// added tokens (runs of spaces, special tokens): leftmost-longest match, the rest goes through the pre-tokenizer
let start = 0, i = 0;
while (i < n16) {
const cands = this.added.get(text[i]);
let match = null;
if (cands) for (const a of cands) if (text.startsWith(a.content, i)) { match = a; break; }
if (match) {
pretok(start, i);
ids.push(match.id);
offsets.push([u16ToCp[i], u16ToCp[i + match.content.length]]);
offsets16.push([i, i + match.content.length]);
i += match.content.length;
start = i;
} else i++;
}
pretok(start, n16);
return { ids, offsets, offsets16 };
}
}
|