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