File size: 5,127 Bytes
32215fc | 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 | // The Wildroot Mail engine: the tokenizer and rules around the student models. No dependencies.
// The host runs the ONNX files. This file turns mail into model inputs and model answers into a decision.
export const CATEGORIES = ['needs_reply', 'updates', 'promotional', 'sales', 'spam'];
const BODY_MARKER = /(?:^|\n)Body:\s*\n/;
const BULK_HEADER = /(?:^|\n)(?:Has unsubscribe header: yes|Mailing list: [^\n]+|Declared automated or bulk: yes)(?=\n|$)/;
const CJK = [[0x4E00, 0x9FFF], [0x3400, 0x4DBF], [0x20000, 0x2A6DF], [0x2A700, 0x2B73F], [0x2B740, 0x2B81F],
[0x2B920, 0x2CEAF], [0xF900, 0xFAFF], [0x2F800, 0x2FA1F]];
const WORD_LIMIT = 100;
const isCjk = (code) => CJK.some(([low, high]) => code >= low && code <= high);
const isSpace = (c) => c === '\t' || c === '\n' || c === '\r' || /\p{White_Space}/u.test(c);
const isControl = (c) => c !== '\t' && c !== '\n' && c !== '\r' && /\p{C}/u.test(c);
const isPunctuation = (c) => /[!-/:-@[-`{-~]/.test(c) || /\p{P}/u.test(c);
/** BERT normalization for an uncased model: clean, space out CJK, strip accents, lowercase one character at a time. */
export function normalize(text) {
let out = '';
for (const c of text) {
const code = c.codePointAt(0);
if (code === 0 || code === 0xFFFD || isControl(c)) continue;
if (isSpace(c)) out += ' ';
else out += isCjk(code) ? ` ${c} ` : c;
}
return Array.from(out.normalize('NFD').replace(/\p{Mn}/gu, ''), (c) => c.toLowerCase()).join('');
}
function pieces(word, vocab, unknown, ids) {
const chars = Array.from(word);
if (chars.length > WORD_LIMIT) return void ids.push(unknown);
const found = [];
for (let start = 0; start < chars.length;) {
let end = chars.length;
let id;
for (; end > start; end -= 1) {
id = vocab.get((start ? '##' : '') + chars.slice(start, end).join(''));
if (id !== undefined) break;
}
if (id === undefined) return void ids.push(unknown);
found.push(id);
start = end;
}
ids.push(...found);
}
export function loadVocab(text) {
return new Map(text.split('\n').filter((line, index, all) => line || index < all.length - 1).map((token, index) => [token, index]));
}
/** Token ids with [CLS] and [SEP], cut to `limit`, matching the tokenizer the models were trained with. */
export function tokenize(text, vocab, limit = 256) {
const unknown = vocab.get('[UNK]');
const room = limit - 2;
const ids = [];
let word = '';
const flush = () => {
if (word) pieces(word, vocab, unknown, ids);
word = '';
};
for (const c of normalize(text)) {
if (ids.length >= room) break;
if (isSpace(c)) flush();
else if (isPunctuation(c)) {
flush();
pieces(c, vocab, unknown, ids);
} else word += c;
}
flush();
return [vocab.get('[CLS]'), ...ids.slice(0, room), vocab.get('[SEP]')];
}
/** What the message's own headers declare. Only the part above the body counts. */
export function isBulk(text) {
const marker = BODY_MARKER.exec(text);
return BULK_HEADER.test(marker ? text.slice(0, marker.index) : text);
}
/** The two facts the models read beside the text. */
export const factInputs = ({ senderKnown, bulk }) => [senderKnown === true ? 1 : 0, bulk ? 1 : 0];
function lane(probabilities, bulk, acceptsPosts, rules) {
const values = Object.fromEntries(CATEGORIES.map((name, index) => [name, probabilities[index]]));
if (bulk) {
values.needs_reply = 0;
const total = CATEGORIES.reduce((sum, name) => sum + values[name], 0);
if (total > 0) for (const name of CATEGORIES) values[name] /= total;
else values.updates = 1;
}
const chosen = CATEGORIES.reduce((best, name) => (values[name] > values[best] ? name : best));
if (chosen === 'promotional' && acceptsPosts) return 'updates';
if (chosen === 'sales' && values.needs_reply >= rules.salesReplyFloor) return 'needs_reply';
return chosen;
}
const top = (values) => values.reduce((best, value, index) => (value > values[best] ? index : best), 0);
/**
* The student's answers and the facts the model cannot be argued out of, as one decision.
* answers: { category: [5], urgency: [5], personal: number }. facts: { bulk, acceptsPosts, senderKnown }.
*/
export function decide(answers, facts, rules) {
const { bulk, acceptsPosts, senderKnown } = facts;
let category = lane(answers.category, bulk, acceptsPosts, rules);
// Marketing never accepts posts, so a discussion list is the one kind of bulk mail that can wait on this person.
const listReply = bulk && acceptsPosts && answers.category[0] >= rules.listReplyFloor;
if (listReply) category = 'needs_reply';
let urgency = 1;
if (category === 'needs_reply') {
urgency = top(answers.urgency) + 1;
if (senderKnown === false) urgency = Math.min(urgency, rules.unknownSenderMaxUrgency);
}
return { category, urgency, personal: answers.personal >= rules.personalFloor };
}
/** A message can raise its score but cannot make itself trusted. */
export function urgentVerdict(score, { senderKnown, bulk }, rules) {
if (score < rules.actionThreshold) return 'clear';
return senderKnown === true && !bulk ? 'urgent' : 'unverified';
}
|