Skip to main content

sioul_core/spam/
table.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The reduced table: the spam filter as every device runs it, a megabyte or
5//! two, with no word of your mail in it: each word under a 64-bit hash of it
6//! (`word_hash`), never the word itself, and only words seen five times or
7//! more.
8//!
9//! Two kinds (`Kind`), both linear, so that they fold (docs/spam-filter.md,
10//! "The model, and how it folds"):
11//!
12//! - **The classifier's** (`Kind::Rows`, what a training makes): fastText's
13//!   supervised model, two labels, reads a message as its words, then its
14//!   header facts as words (`features::header_words`), then the end of the
15//!   line. Each word brings its input rows (its own, when fastText knows it,
16//!   and those of its character n-grams' buckets), and the model's score is
17//!   the mean of every row's scalar `s_r = (w_spam − w_ham) · row_r`, the
18//!   softmax of two labels being the sigmoid of that. The table keeps, for
19//!   each word fastText knows, the sum of its rows' scalars; for each bucket,
20//!   its scalar; for the end of the line (`bias`), its own. A message's
21//!   score: `f = (bias + Σ its words' sums) / (1 + Σ their rows)`, a word
22//!   having `1 + ` its n-grams' count of rows when the table knows it, else
23//!   its n-grams' (`ngram_buckets`), each counted as often as it comes. No
24//!   header weights: the facts are words.
25//! - **The centroid's** (`Kind::Centroid`, format 1's only kind, still made
26//!   on request): fastText learns a vector for each word (the language
27//!   model, private, never shared), and a linear SVM weighs the mean of a
28//!   message's word vectors with its header features (`features.rs`). With
29//!   `u = w / σ` the SVM's embedding weights over the standardization's
30//!   deviations, a word scores `s_w = u · v_w`, an n-gram bucket `s_b = u ·
31//!   row_b`, and the mean of a message's word scores is the SVM's score of the
32//!   mean of its vectors. `f = bias + (mean of its words' scores, or 0
33//!   without words) − text_mean + Σ weightₕ·(xₕ − meanₕ)`, where a word
34//!   missing from the table scores the mean of its character n-grams'
35//!   buckets, as fastText builds the vector of a word it never saw. The
36//!   standardization's mean terms stay beside the weights rather than folded
37//!   into the bias, so that the explanation can say what departs from the
38//!   usual; the score is the same.
39//!
40//! Its probability of being spam, either kind: Platt's `p = 1 / (1 + exp(A·f + B))`.
41//!
42//! The file (little-endian), `$XDG_DATA_HOME/sioul/spam/table.bin`:
43//!
44//! | bytes | what |
45//! |---|---|
46//! | 8 | magic `SIOULSPM` |
47//! | 4 × 12 | format version (2), tokenizer version, features version, embedding dimension, minn, maxn, bucket count B, word count W, header feature count H (0: the classifier's kind; `features::N`: the centroid's), metadata length L, two zeros (so that the hashes start on 8 bytes) |
48//! | 4 × 4 | bias (the classifier's: the end of the line's scalar), text_mean (the classifier's: 0), Platt A, Platt B (f32) |
49//! | 8 × W | the words' hashes, ascending (u64) |
50//! | 4 × W | their scores (f32): the classifier's, the sum of each word's rows' scalars |
51//! | 4 × B | the buckets' scores (f32) |
52//! | 4 × H, 4 × H | the header features' weights, then their means (f32), the centroid's alone |
53//! | L | metadata, JSON (`Meta`) |
54//! | 8 | FNV-1a 64 of every byte before it |
55
56use super::features;
57use super::tokenize::{Lexicon, TOKENIZER};
58use serde::{Deserialize, Serialize};
59use std::collections::BTreeMap;
60use std::path::{Path, PathBuf};
61use std::sync::{Arc, Mutex};
62
63/// The file's first bytes.
64pub const MAGIC: [u8; 8] = *b"SIOULSPM";
65/// The file format's version: 2 since the classifier's kind (`Kind`); a
66/// Sioul that reads 1 refuses it, rather than reading its sums as scores.
67pub const VERSION: u32 = 2;
68/// Bytes before the words: the magic, twelve counts, four numbers.
69const HEADER: usize = 8 + 12 * 4 + 4 * 4;
70
71/// What the settings say of the training that made the table.
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct Meta {
74    /// When it was trained, Unix seconds.
75    #[serde(default)]
76    pub trained_at: i64,
77    /// Messages it learned from, by label.
78    #[serde(default)]
79    pub ham: u64,
80    #[serde(default)]
81    pub spam: u64,
82    /// Messages it was tested on (the newest), by label.
83    #[serde(default)]
84    pub test_ham: u64,
85    #[serde(default)]
86    pub test_spam: u64,
87    /// Aggregate measures on the test messages, by name ("auc", "ham_called_spam"…).
88    #[serde(default)]
89    pub metrics: BTreeMap<String, f64>,
90    /// The device it was trained on, as the sharing's devices name it.
91    #[serde(default, skip_serializing_if = "String::is_empty")]
92    pub device: String,
93    /// The tokenizer's words it was trained with (`tokenize::Lexicon`), which
94    /// it reads every message with; none in a table made before 8 October
95    /// 2026, which reads with the packs built in (`Table::lexicon`).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub lexicon: Option<crate::words::SpamWords>,
98}
99
100/// How a table scores a message (see the module's documentation).
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Kind {
103    /// fastText's classifier: the mean of every input row's scalar, the header facts as words.
104    Rows,
105    /// The language model's centroid, weighed by an SVM with the header features.
106    Centroid,
107}
108
109/// The spam filter, folded (see the module's documentation).
110#[derive(Debug, Clone, PartialEq)]
111pub struct Table {
112    /// The tokenizer's version it was trained with (`tokenize::TOKENIZER`).
113    pub tokenizer: u32,
114    /// The header features' version (`features::FEATURES`).
115    pub features: u32,
116    /// The language model's dimension, for the record.
117    pub dim: u32,
118    /// fastText's character n-grams: shortest, longest, and how many buckets they hash into.
119    pub minn: u32,
120    pub maxn: u32,
121    pub bucket: u32,
122    /// Each vocabulary word's score, under its hash (`word_hash`), ascending:
123    /// scoring looks words up by halves. A table read from its file is so; one
124    /// made in memory is once `sort`ed (writing sorts it too).
125    pub words: Vec<(u64, f32)>,
126    /// Each n-gram bucket's score, `bucket` of them.
127    pub buckets: Vec<f32>,
128    /// Each header feature's weight, over its deviation, and its mean
129    /// (`features::NAMES`' order); none in the classifier's table, which
130    /// reads the header facts as words: that is how its kind is told (`kind`).
131    pub weights: Vec<f32>,
132    pub means: Vec<f32>,
133    /// The SVM's intercept; the classifier's: the end of the line's scalar.
134    pub bias: f32,
135    /// The score of the mean message's text (`u · μ`); the classifier's: 0.
136    pub text_mean: f32,
137    /// Platt's A and B.
138    pub platt_a: f32,
139    pub platt_b: f32,
140    pub meta: Meta,
141}
142
143/// A message's score, and what made it.
144#[derive(Debug, Clone, PartialEq)]
145pub struct Score {
146    /// The SVM's decision value: above zero, spam.
147    pub f: f32,
148    /// The probability it is spam.
149    pub p: f32,
150    /// Each word's share of `f` (all its occurrences), the most toward spam first.
151    pub words: Vec<(String, f32)>,
152    /// Each header feature's share of `f`, by its place in `features::NAMES`, the most toward spam first.
153    pub signs: Vec<(usize, f32)>,
154}
155
156/// A word's key in the table: FNV-1a, 64 bits, over its UTF-8 bytes.
157pub fn word_hash(word: &str) -> u64 {
158    fnv64(word.as_bytes())
159}
160
161fn fnv64(bytes: &[u8]) -> u64 {
162    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
163    for b in bytes {
164        h ^= u64::from(*b);
165        h = h.wrapping_mul(0x0100_0000_01b3);
166    }
167    h
168}
169
170/// fastText's hash: 32-bit FNV-1a, each byte sign-extended first, as the C++
171/// casts it (`uint32_t(int8_t(c))`): bytes above 127 differ from plain FNV-1a.
172fn fasttext_hash(bytes: &[u8]) -> u32 {
173    let mut h: u32 = 2_166_136_261;
174    for b in bytes {
175        h ^= *b as i8 as i32 as u32;
176        h = h.wrapping_mul(16_777_619);
177    }
178    h
179}
180
181/// The buckets of a word's character n-grams, as fastText 0.8.0 computes them
182/// (<https://github.com/messense/fasttext-rs>, tag v0.8.0: `src/dictionary.rs`,
183/// `for_each_ngram` and `get_subwords_for_string`; `src/utils.rs`, `hash`):
184/// the word between `<` and `>`; from each character (not each byte: UTF-8
185/// continuation bytes start nothing), every n-gram of `minn` to `maxn`
186/// characters (a single character never at either end); each hashed
187/// (`fasttext_hash`) modulo `bucket`; in that order, repeats kept. fastText's
188/// input row for one is `nwords + bucket`, and the vector of a word it never
189/// saw is the mean of those rows (`get_word_vector_into`); of a word it knows,
190/// the mean of its own row and those.
191pub fn ngram_buckets(word: &str, minn: u32, maxn: u32, bucket: u32) -> Vec<u32> {
192    let mut out = Vec::new();
193    if maxn == 0 || bucket == 0 {
194        return out;
195    }
196    let marked = format!("<{word}>");
197    let bytes = marked.as_bytes();
198    let continuation = |b: u8| (b & 0xc0) == 0x80;
199    for i in 0..bytes.len() {
200        if continuation(bytes[i]) {
201            continue;
202        }
203        let mut j = i;
204        let mut n = 1;
205        while j < bytes.len() && n <= maxn {
206            j += 1;
207            while j < bytes.len() && continuation(bytes[j]) {
208                j += 1;
209            }
210            if n >= minn && !(n == 1 && (i == 0 || j == bytes.len())) {
211                out.push(fasttext_hash(&bytes[i..j]) % bucket);
212            }
213            n += 1;
214        }
215    }
216    out
217}
218
219/// Words sorted by their hash; two words of one hash (never seen yet: 64 bits)
220/// share their mean score.
221fn sorted(mut words: Vec<(u64, f32)>) -> Vec<(u64, f32)> {
222    words.sort_by_key(|(h, _)| *h);
223    let mut merged: Vec<(u64, f32, u32)> = Vec::with_capacity(words.len());
224    for (h, s) in words {
225        match merged.last_mut() {
226            Some((last, sum, n)) if *last == h => {
227                *sum += s;
228                *n += 1;
229            }
230            _ => merged.push((h, s, 1)),
231        }
232    }
233    merged.into_iter().map(|(h, sum, n)| (h, sum / n as f32)).collect()
234}
235
236impl Table {
237    /// The words it reads every message with (`tokenize::tokens_with`): its
238    /// own, or the packs built in when it keeps none (made before 8 October 2026).
239    pub fn lexicon(&self) -> Arc<Lexicon> {
240        self.meta.lexicon.as_ref().map_or_else(Lexicon::builtin, Lexicon::cached)
241    }
242
243    /// Its kind: no header weights, the classifier's, which reads the header
244    /// facts as words; else the centroid's.
245    pub fn kind(&self) -> Kind {
246        if self.weights.is_empty() && self.means.is_empty() { Kind::Rows } else { Kind::Centroid }
247    }
248
249    /// Sorts the words by their hash, as scoring needs them (`words`).
250    pub fn sort(&mut self) {
251        self.words = sorted(std::mem::take(&mut self.words));
252    }
253
254    /// A word's rows in the classifier's table: the sum of their scalars and
255    /// how many they are. Known: its own row and its n-grams'; else its
256    /// n-grams' alone (none without n-grams: nothing).
257    fn rows_of(&self, word: &str) -> (f64, usize) {
258        let buckets = ngram_buckets(word, self.minn, self.maxn, self.bucket);
259        match self.words.binary_search_by_key(&word_hash(word), |(h, _)| *h) {
260            Ok(at) => (f64::from(self.words[at].1), 1 + buckets.len()),
261            Err(_) => (buckets.iter().map(|b| f64::from(self.buckets.get(*b as usize).copied().unwrap_or(0.0))).sum(), buckets.len()),
262        }
263    }
264
265    /// A word's score in the centroid's table: its own, else the mean of its n-grams' buckets; 0 when it has none.
266    pub fn word_score(&self, word: &str) -> f32 {
267        if let Ok(at) = self.words.binary_search_by_key(&word_hash(word), |(h, _)| *h) {
268            return self.words[at].1;
269        }
270        let buckets = ngram_buckets(word, self.minn, self.maxn, self.bucket);
271        if buckets.is_empty() {
272            return 0.0;
273        }
274        let sum: f64 = buckets.iter().map(|b| f64::from(self.buckets.get(*b as usize).copied().unwrap_or(0.0))).sum();
275        (sum / buckets.len() as f64) as f32
276    }
277
278    /// A message's score from its words (`tokenize::tokens`) and its header
279    /// features (`features::features`), with what made it. The table's words
280    /// must be sorted (`sort`): one read from its file is. A header feature
281    /// that is missing (`f32::NAN`) weighs nothing: the classifier reads no
282    /// word for it; the centroid stands it at the training's mean, which is
283    /// over the messages that had it.
284    pub fn score(&self, words: &[String], features: &[f32]) -> Score {
285        match self.kind() {
286            Kind::Rows => self.score_rows(words, features),
287            Kind::Centroid => self.score_centroid(words, features),
288        }
289    }
290
291    /// The classifier's score: the mean of the rows' scalars of its words,
292    /// its header facts' words and the end of the line. Each word's share is
293    /// its rows' sum over every row; a header fact's, its word's.
294    fn score_rows(&self, words: &[String], features: &[f32]) -> Score {
295        // Each word once, with how often it comes, its rows' sum and their count.
296        let mut seen: BTreeMap<&str, (usize, f64, usize)> = BTreeMap::new();
297        for word in words {
298            seen.entry(word.as_str()).or_insert_with(|| {
299                let (sum, rows) = self.rows_of(word);
300                (0, sum, rows)
301            }).0 += 1;
302        }
303        let facts: Vec<(usize, f64, usize)> = features::header_words(features).iter().map(|(word, h)| {
304            let (sum, rows) = self.rows_of(word);
305            (*h, sum, rows)
306        }).collect();
307        let rows = 1 + seen.values().map(|(count, _, rows)| count * rows).sum::<usize>() + facts.iter().map(|(_, _, rows)| rows).sum::<usize>();
308        let rows = rows as f64;
309        let sum = f64::from(self.bias) + seen.values().map(|(count, sum, _)| *count as f64 * sum).sum::<f64>() + facts.iter().map(|(_, sum, _)| sum).sum::<f64>();
310        let f = (sum / rows) as f32;
311        let mut shares: Vec<(String, f32)> = seen.iter().map(|(word, (count, sum, _))| (word.to_string(), (*count as f64 * sum / rows) as f32)).collect();
312        let mut signs: Vec<(usize, f32)> = (0..features.len().min(features::N)).map(|h| (h, 0.0)).collect();
313        for (h, sum, _) in &facts {
314            if let Some(sign) = signs.get_mut(*h) {
315                sign.1 += (sum / rows) as f32;
316            }
317        }
318        let by_share = |a: &f32, b: &f32| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal);
319        shares.sort_by(|a, b| by_share(&a.1, &b.1).then_with(|| a.0.cmp(&b.0)));
320        signs.sort_by(|a, b| by_share(&a.1, &b.1).then_with(|| a.0.cmp(&b.0)));
321        Score { f, p: platt(f, self.platt_a, self.platt_b), words: shares, signs }
322    }
323
324    /// The centroid's score (see the module's documentation).
325    fn score_centroid(&self, words: &[String], features: &[f32]) -> Score {
326        // Each word once, with how often it comes and its score.
327        let mut seen: BTreeMap<&str, (usize, f32)> = BTreeMap::new();
328        for word in words {
329            seen.entry(word.as_str()).or_insert_with(|| (0, self.word_score(word))).0 += 1;
330        }
331        let total = words.len();
332        let mut text = -f64::from(self.text_mean);
333        let mut shares: Vec<(String, f32)> = Vec::with_capacity(seen.len());
334        if total > 0 {
335            let sum: f64 = seen.values().map(|(count, s)| *count as f64 * f64::from(*s)).sum();
336            text += sum / total as f64;
337            shares = seen.iter().map(|(word, (count, s))| (word.to_string(), (*count as f64 * (f64::from(*s) - f64::from(self.text_mean)) / total as f64) as f32)).collect();
338        }
339        let mut signs: Vec<(usize, f32)> = self.weights.iter().zip(&self.means).zip(features).enumerate().map(|(h, ((w, m), x))| (h, if x.is_nan() { 0.0 } else { w * (x - m) })).collect();
340        let f = (f64::from(self.bias) + text + signs.iter().map(|(_, c)| f64::from(*c)).sum::<f64>()) as f32;
341        let by_share = |a: &f32, b: &f32| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal);
342        shares.sort_by(|a, b| by_share(&a.1, &b.1).then_with(|| a.0.cmp(&b.0)));
343        signs.sort_by(|a, b| by_share(&a.1, &b.1).then_with(|| a.0.cmp(&b.0)));
344        Score { f, p: platt(f, self.platt_a, self.platt_b), words: shares, signs }
345    }
346
347    /// The table as its file holds it, words sorted (`sort`), checksummed.
348    pub fn to_bytes(&self) -> Vec<u8> {
349        let merged = sorted(self.words.clone());
350        let meta = serde_json::to_vec(&self.meta).unwrap_or_default();
351        let header = self.weights.len().min(self.means.len());
352        let mut out = Vec::with_capacity(HEADER + merged.len() * 12 + self.buckets.len() * 4 + header * 8 + meta.len() + 8);
353        out.extend_from_slice(&MAGIC);
354        let counts = [VERSION, self.tokenizer, self.features, self.dim, self.minn, self.maxn, self.buckets.len() as u32, merged.len() as u32, header as u32, meta.len() as u32, 0, 0];
355        for n in counts {
356            out.extend_from_slice(&n.to_le_bytes());
357        }
358        for x in [self.bias, self.text_mean, self.platt_a, self.platt_b] {
359            out.extend_from_slice(&x.to_le_bytes());
360        }
361        for (h, _) in &merged {
362            out.extend_from_slice(&h.to_le_bytes());
363        }
364        for (_, s) in &merged {
365            out.extend_from_slice(&s.to_le_bytes());
366        }
367        for x in self.buckets.iter().chain(&self.weights[..header]).chain(&self.means[..header]) {
368            out.extend_from_slice(&x.to_le_bytes());
369        }
370        out.extend_from_slice(&meta);
371        let sum = fnv64(&out);
372        out.extend_from_slice(&sum.to_le_bytes());
373        out
374    }
375
376    /// Reads a table, refusing one that is damaged, made by another tokenizer
377    /// or with other header features than this Sioul's: its scores would mean nothing.
378    pub fn from_bytes(bytes: &[u8]) -> Result<Table, String> {
379        if bytes.len() < HEADER + 8 || bytes[..8] != MAGIC {
380            return Err("not a spam table".into());
381        }
382        let (body, sum) = bytes.split_at(bytes.len() - 8);
383        if fnv64(body) != u64::from_le_bytes(sum.try_into().map_err(|_| "no checksum")?) {
384            return Err("damaged: its checksum does not match".into());
385        }
386        let u32_at = |i: usize| u32::from_le_bytes([body[8 + 4 * i], body[9 + 4 * i], body[10 + 4 * i], body[11 + 4 * i]]);
387        let f32_at = |at: usize| f32::from_le_bytes([body[at], body[at + 1], body[at + 2], body[at + 3]]);
388        let [version, tokenizer, features, dim, minn, maxn, bucket, words, header, meta, _, _] = std::array::from_fn(u32_at);
389        if version != VERSION {
390            return Err(format!("format {version}, this Sioul reads {VERSION}"));
391        }
392        if tokenizer != TOKENIZER {
393            return Err(format!("made with tokenizer {tokenizer}, this Sioul has {TOKENIZER}: train again"));
394        }
395        // No header weights: the classifier's kind; all of them: the centroid's.
396        if features != features::FEATURES || !(header == 0 || header as usize == features::N) {
397            return Err(format!("made with header features {features} ({header}), this Sioul has {} ({}): train again", features::FEATURES, features::N));
398        }
399        if minn > maxn || maxn > 64 {
400            return Err(format!("n-grams of {minn} to {maxn} characters"));
401        }
402        let (bucket, words, header, meta) = (bucket as usize, words as usize, header as usize, meta as usize);
403        let expected = (|| HEADER.checked_add(words.checked_mul(12)?)?.checked_add(bucket.checked_mul(4)?)?.checked_add(header * 8)?.checked_add(meta))();
404        if expected != Some(body.len()) {
405            return Err("damaged: its parts do not add up to its length".into());
406        }
407        let floats = |start: usize, n: usize| (0..n).map(|i| f32_at(start + 4 * i)).collect::<Vec<f32>>();
408        let hashes_at = HEADER;
409        let scores_at = hashes_at + 8 * words;
410        let buckets_at = scores_at + 4 * words;
411        let weights_at = buckets_at + 4 * bucket;
412        let means_at = weights_at + 4 * header;
413        let meta_at = means_at + 4 * header;
414        let hashes: Vec<u64> = (0..words).map(|i| u64::from_le_bytes(body[hashes_at + 8 * i..hashes_at + 8 * i + 8].try_into().unwrap_or_default())).collect();
415        if hashes.windows(2).any(|w| w[0] >= w[1]) {
416            return Err("damaged: its words are out of order".into());
417        }
418        let scores = floats(scores_at, words);
419        let table = Table {
420            tokenizer,
421            features,
422            dim,
423            minn,
424            maxn,
425            bucket: bucket as u32,
426            words: hashes.into_iter().zip(scores).collect(),
427            buckets: floats(buckets_at, bucket),
428            weights: floats(weights_at, header),
429            means: floats(means_at, header),
430            bias: f32_at(HEADER - 16),
431            text_mean: f32_at(HEADER - 12),
432            platt_a: f32_at(HEADER - 8),
433            platt_b: f32_at(HEADER - 4),
434            meta: serde_json::from_slice(&body[meta_at..]).map_err(|e| format!("damaged: its metadata: {e}"))?,
435        };
436        let finite = table.words.iter().map(|(_, s)| s).chain(&table.buckets).chain(&table.weights).chain(&table.means).chain([&table.bias, &table.text_mean, &table.platt_a, &table.platt_b]).all(|x| x.is_finite());
437        if !finite {
438            return Err("damaged: a score is not a number".into());
439        }
440        Ok(table)
441    }
442
443    /// Reads the table's file.
444    pub fn read(path: &Path) -> Result<Table, String> {
445        let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
446        Table::from_bytes(&bytes).map_err(|e| format!("{}: {e}", path.display()))
447    }
448
449    /// Writes the table where it goes, whole or not at all (written beside,
450    /// flushed, then moved); the one it replaces, when this Sioul can use it,
451    /// is kept as `table.prev.bin` (`keep_previous`).
452    pub fn write(&self, path: &Path) -> Result<(), String> {
453        use std::io::Write;
454        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
455        if let Some(parent) = path.parent() {
456            std::fs::create_dir_all(parent).map_err(fail)?;
457        }
458        let temporary = path.with_extension("bin.new");
459        let mut file = std::fs::File::create(&temporary).map_err(fail)?;
460        file.write_all(&self.to_bytes()).and_then(|()| file.sync_all()).map_err(fail)?;
461        drop(file);
462        // `cached` sees a new table by its size and time. A clock that ticks
463        // coarsely (Windows: every 15.6 ms) can give the new file the time of the
464        // one it replaces, and a new table often has the same size: the new one is
465        // made a millisecond younger than the one it replaces, at least.
466        let modified = |at: &Path| std::fs::metadata(at).ok().and_then(|m| m.modified().ok());
467        if let Some(old) = modified(path)
468            && modified(&temporary).is_none_or(|new| new <= old)
469        {
470            let _ = std::fs::File::options().write(true).open(&temporary).and_then(|f| f.set_modified(old + std::time::Duration::from_millis(1)));
471        }
472        keep_previous(path);
473        std::fs::rename(&temporary, path).map_err(fail)
474    }
475
476    /// The table at `path`, read once per process and again only when its
477    /// file changes (its size and time); none when there is none. One this
478    /// Sioul refuses (`read` says why: made by a newer Sioul, damaged) leaves
479    /// the one before it in use (`table.prev.bin`), when there is one it
480    /// reads. The phone's background process reads it once, then keeps it.
481    pub fn cached(path: &Path) -> Option<Arc<Table>> {
482        type Kept = Option<(PathBuf, Option<(u64, u128)>, Option<Arc<Table>>)>;
483        static KEPT: Mutex<Kept> = Mutex::new(None);
484        let seal = std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos())));
485        let mut kept = KEPT.lock().unwrap_or_else(|e| e.into_inner());
486        if let Some((at, known, table)) = kept.as_ref()
487            && at == path
488            && *known == seal
489        {
490            return table.clone();
491        }
492        let table = seal.and_then(|_| Table::read(path).or_else(|_| Table::read(&previous(path))).ok()).map(Arc::new);
493        *kept = Some((path.to_path_buf(), seal, table.clone()));
494        table
495    }
496}
497
498/// Platt's probability for a decision value.
499pub fn platt(f: f32, a: f32, b: f32) -> f32 {
500    let z = f64::from(a) * f64::from(f) + f64::from(b);
501    // The same value, written to never overflow.
502    let p = if z >= 0.0 { (-z).exp() / (1.0 + (-z).exp()) } else { 1.0 / (1.0 + z.exp()) };
503    p as f32
504}
505
506/// Where the table replaced by `Table::write`, or by another device's
507/// through the sharing, is kept.
508pub fn previous(path: &Path) -> PathBuf {
509    path.with_file_name("table.prev.bin")
510}
511
512/// Before another table takes `path`'s place (a training here, another
513/// device's table through the sharing): the one there, when this Sioul can
514/// use it, copied beside as `table.prev.bin` (written beside, then moved).
515/// A table this Sioul refuses then leaves the last good one in use
516/// (`Table::cached`), and never takes its place there. Best effort: a copy
517/// that fails leaves the one before as it was.
518pub fn keep_previous(path: &Path) {
519    if Table::read(path).is_err() {
520        return;
521    }
522    let kept = previous(path);
523    let temporary = kept.with_extension("bin.new");
524    if std::fs::copy(path, &temporary).and_then(|_| std::fs::rename(&temporary, &kept)).is_err() {
525        let _ = std::fs::remove_file(&temporary);
526    }
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532    use crate::spam::features::N;
533
534    /// A table small enough to score by hand: two words, eight buckets, the header's first feature weighed.
535    fn small() -> Table {
536        let mut weights = vec![0.0; N];
537        let mut means = vec![0.0; N];
538        weights[0] = 2.0;
539        means[0] = 0.25;
540        let mut table = Table {
541            tokenizer: TOKENIZER,
542            features: features::FEATURES,
543            dim: 4,
544            minn: 3,
545            maxn: 4,
546            bucket: 8,
547            words: vec![(word_hash("gratuit"), 3.0), (word_hash("reunion"), -1.0)],
548            buckets: vec![0.5, -0.5, 1.0, 0.0, 2.0, -1.0, 0.25, 0.75],
549            weights,
550            means,
551            bias: -0.5,
552            text_mean: 0.5,
553            platt_a: -2.0,
554            platt_b: 0.0,
555            meta: Meta { trained_at: 1_791_000_000, ham: 900, spam: 100, test_ham: 225, test_spam: 25, metrics: BTreeMap::from([("auc".to_string(), 0.99)]), device: "desk".into(), lexicon: None },
556        };
557        table.sort();
558        table
559    }
560
561    #[test]
562    fn ngrams_as_fasttext_cuts_them() {
563        // "<ab>": from each character, n-grams of three and four characters.
564        let wanted: Vec<u32> = ["<ab", "<ab>", "ab>"].iter().map(|g| fasttext_hash(g.as_bytes()) % 1_000_003).collect();
565        assert_eq!(ngram_buckets("ab", 3, 4, 1_000_003), wanted);
566        // A character is one, however many bytes: "é" is two, "<é>" one n-gram of three.
567        assert_eq!(ngram_buckets("é", 3, 6, 1_000_003), vec![fasttext_hash("<é>".as_bytes()) % 1_000_003]);
568        // The hash sign-extends bytes above 127, as fastText's C++ does: not plain FNV-1a.
569        let plain = |bytes: &[u8]| bytes.iter().fold(2_166_136_261u32, |h, b| (h ^ u32::from(*b)).wrapping_mul(16_777_619));
570        assert_eq!(fasttext_hash(b"<ab"), plain(b"<ab"));
571        assert_ne!(fasttext_hash("é".as_bytes()), plain("é".as_bytes()));
572        // fastText's own value for "a" (FNV-1a of 0x61), and none without buckets.
573        assert_eq!(fasttext_hash(b"a"), 0xe40c_292c);
574        assert!(ngram_buckets("abc", 3, 6, 0).is_empty());
575        assert_eq!(ngram_buckets("abcdefgh", 3, 6, 97).len(), 8 + 7 + 6 + 5);
576    }
577
578    #[test]
579    fn a_score_computed_by_hand() {
580        let table = small();
581        let unknown = ngram_buckets("offre", 3, 4, 8);
582        let offre: f32 = unknown.iter().map(|b| table.buckets[*b as usize]).sum::<f32>() / unknown.len() as f32;
583        assert!((table.word_score("offre") - offre).abs() < 1e-6);
584        let words: Vec<String> = ["gratuit", "gratuit", "reunion", "offre"].iter().map(|w| w.to_string()).collect();
585        let mut x = [0.0; N];
586        x[0] = 1.0;
587        let score = table.score(&words, &x);
588        // f = bias + mean(3, 3, -1, offre) − text_mean + 2·(1 − 0.25)
589        let f = -0.5 + (3.0 + 3.0 - 1.0 + offre) / 4.0 - 0.5 + 2.0 * 0.75;
590        assert!((score.f - f).abs() < 1e-5, "{} {f}", score.f);
591        assert!((score.p - 1.0 / (1.0 + (-2.0 * f).exp())).abs() < 1e-6);
592        // "gratuit" pushes most: twice (3 − 0.5) / 4; the header feature 2·0.75.
593        assert_eq!(score.words[0].0, "gratuit");
594        assert!((score.words[0].1 - 1.25).abs() < 1e-6);
595        assert_eq!(score.signs[0], (0, 1.5));
596        // The shares add up to the score, the bias aside.
597        let shares: f32 = score.words.iter().map(|(_, s)| s).sum::<f32>() + score.signs.iter().map(|(_, s)| s).sum::<f32>();
598        assert!((score.f - (shares - 0.5)).abs() < 1e-5);
599        // No word at all: the text is the mean message's, and the bias and the header decide.
600        let empty = table.score(&[], &x);
601        assert!((empty.f - (-0.5 - 0.5 + 1.5)).abs() < 1e-6 && empty.words.is_empty());
602        // The header feature missing: it stands at its mean and weighs nothing.
603        let mut missing = x;
604        missing[0] = f32::NAN;
605        let unknown = table.score(&words, &missing);
606        assert!((unknown.f - (f - 1.5)).abs() < 1e-5 && unknown.f.is_finite(), "{}", unknown.f);
607        assert_eq!(unknown.signs[0].1, 0.0);
608    }
609
610    /// The classifier's table, small enough to score by hand: two words, a
611    /// header fact's word, eight buckets, the end of the line's scalar.
612    fn small_rows() -> Table {
613        let mut table = Table {
614            weights: Vec::new(),
615            means: Vec::new(),
616            words: vec![(word_hash("gratuit"), 6.0), (word_hash("reunion"), -4.0), (word_hash("H:DKIM_SIGNED_BY_SENDER"), -20.0)],
617            bias: 0.5,
618            text_mean: 0.0,
619            ..small()
620        };
621        table.sort();
622        table
623    }
624
625    #[test]
626    fn the_classifier_scored_by_hand() {
627        let table = small_rows();
628        assert_eq!((table.kind(), small().kind()), (Kind::Rows, Kind::Centroid));
629        let rows = |word: &str| ngram_buckets(word, 3, 4, 8);
630        // A word it knows: its sum, over its own row and its n-grams'; one it does not: its n-grams' alone.
631        let offre: f64 = rows("offre").iter().map(|b| f64::from(table.buckets[*b as usize])).sum();
632        let words: Vec<String> = ["gratuit", "gratuit", "reunion", "offre"].iter().map(|w| w.to_string()).collect();
633        let none = [f32::NAN; N];
634        let score = table.score(&words, &none);
635        let count = 1 + 2 * (1 + rows("gratuit").len()) + (1 + rows("reunion").len()) + rows("offre").len();
636        let f = (0.5 + 2.0 * 6.0 - 4.0 + offre) / count as f64;
637        assert!((f64::from(score.f) - f).abs() < 1e-6, "{} {f}", score.f);
638        assert!((score.p - 1.0 / (1.0 + (-2.0 * f as f32).exp())).abs() < 1e-6);
639        // "gratuit" pushes most, twice its sum over every row; nothing known of the headers: no fact weighs.
640        assert_eq!(score.words[0].0, "gratuit");
641        assert!((f64::from(score.words[0].1) - 12.0 / count as f64).abs() < 1e-6);
642        assert!(score.signs.iter().all(|(_, s)| *s == 0.0) && score.signs.len() == N);
643        // A fact that holds: its word read after the text's, its rows counted; its share its feature's.
644        let mut signed = [f32::NAN; N];
645        let by_sender = features::index("dkim_signed_by_sender").unwrap();
646        signed[by_sender] = 1.0;
647        let vouched = table.score(&words, &signed);
648        let fact = 1 + rows("H:DKIM_SIGNED_BY_SENDER").len();
649        let g = (0.5 + 2.0 * 6.0 - 4.0 + offre - 20.0) / (count + fact) as f64;
650        assert!((f64::from(vouched.f) - g).abs() < 1e-6 && vouched.f < score.f, "{} {g}", vouched.f);
651        let last = vouched.signs.last().unwrap();
652        assert_eq!(last.0, by_sender);
653        assert!((f64::from(last.1) - (-20.0 / (count + fact) as f64)).abs() < 1e-6);
654        // The shares add up to the score, the end of the line's aside.
655        let shares: f64 = vouched.words.iter().map(|(_, s)| f64::from(*s)).sum::<f64>() + vouched.signs.iter().map(|(_, s)| f64::from(*s)).sum::<f64>();
656        assert!((f64::from(vouched.f) - (shares + 0.5 / (count + fact) as f64)).abs() < 1e-5);
657        // No word at all and nothing known: the end of the line alone.
658        assert!((table.score(&[], &none).f - 0.5).abs() < 1e-6);
659        // Written and read back: the same, the same kind.
660        let back = Table::from_bytes(&table.to_bytes()).unwrap();
661        assert_eq!((back.kind(), &back), (Kind::Rows, &table));
662    }
663
664    #[test]
665    fn written_and_read_back() {
666        let table = small();
667        let back = Table::from_bytes(&table.to_bytes()).unwrap();
668        let sorted = table.clone();
669        assert_eq!(back, sorted);
670        // Made in memory in any order: sorted for scoring, two words of one hash merged.
671        let mut loose = Table { words: vec![(9, 1.0), (3, 2.0), (9, 3.0)], ..table.clone() };
672        loose.sort();
673        assert_eq!(loose.words, vec![(3, 2.0), (9, 2.0)]);
674        let dir = std::env::temp_dir().join(format!("sioul-spam-table-{}", std::process::id()));
675        let _ = std::fs::remove_dir_all(&dir);
676        let path = dir.join("spam").join("table.bin");
677        table.write(&path).unwrap();
678        assert_eq!(Table::read(&path).unwrap(), sorted);
679        assert_eq!(Table::cached(&path).as_deref(), Some(&sorted));
680        // A new one replaces it; the one before is kept. The one in place dated
681        // ahead, as a coarse clock can leave it (Windows): still seen as new.
682        std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)).unwrap();
683        let newer = Table { bias: 1.0, ..table.clone() };
684        newer.write(&path).unwrap();
685        assert_eq!(Table::read(&previous(&path)).unwrap().bias, -0.5);
686        assert_eq!(Table::cached(&path).map(|t| t.bias), Some(1.0));
687        let _ = std::fs::remove_dir_all(&dir);
688    }
689
690    /// A table keeps the tokenizer's words it was trained with, and reads with
691    /// them; one made before reads with the packs built in.
692    #[test]
693    fn a_table_keeps_its_words() {
694        let builtin = crate::words::Words::builtin_ref().spam.clone();
695        let mut words = builtin.clone();
696        words.provider_tags.push("verdächtig".into());
697        let table = Table { meta: Meta { lexicon: Some(words.clone()), ..small().meta }, ..small() };
698        let back = Table::from_bytes(&table.to_bytes()).unwrap();
699        assert_eq!(back.meta.lexicon.as_ref(), Some(&words));
700        assert_eq!(back.lexicon().words, words);
701        assert_eq!(back.lexicon().without_provider_tags("[VERDÄCHTIG] Rechnung"), "Rechnung");
702        assert_eq!(back.lexicon().without_provider_tags("[Verdachtig] Rechnung"), "Rechnung");
703        let older = small();
704        assert!(!String::from_utf8_lossy(&older.to_bytes()).contains("lexicon"), "a table without words writes none");
705        let older = Table::from_bytes(&older.to_bytes()).unwrap();
706        assert!(older.meta.lexicon.is_none());
707        assert_eq!(older.lexicon().words, builtin);
708        assert_eq!(older.lexicon().without_provider_tags("[VERDÄCHTIG] Rechnung"), "[VERDÄCHTIG] Rechnung");
709        assert_eq!(older.lexicon().without_provider_tags("[SPAM] Rechnung"), "Rechnung");
710    }
711
712    /// A table this Sioul refuses (another device's, made by a newer Sioul)
713    /// leaves the last good one in use, and never takes its place beside.
714    #[test]
715    fn a_refused_table_leaves_the_last_good_one_in_use() {
716        let dir = std::env::temp_dir().join(format!("sioul-spam-previous-{}", std::process::id()));
717        let _ = std::fs::remove_dir_all(&dir);
718        let path = dir.join("table.bin");
719        // Each file its own time: the cache knows a file by its size and time.
720        let dated = |path: &Path, second: u64| std::fs::File::options().write(true).open(path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(second)).unwrap();
721        let good = small();
722        good.write(&path).unwrap();
723        dated(&path, 1_791_000_000);
724        assert!(!previous(&path).exists(), "nothing before the first");
725        // A newer Sioul's table comes: the good one kept beside, and still in use.
726        let newer = |tokenizer: u32| Table { tokenizer, bias: 9.0, ..small() }.to_bytes();
727        keep_previous(&path);
728        std::fs::write(&path, newer(TOKENIZER + 1)).unwrap();
729        dated(&path, 1_791_000_100);
730        assert!(Table::read(&path).is_err());
731        assert_eq!(Table::cached(&path).map(|t| t.bias), Some(-0.5));
732        // Another one this Sioul refuses: the good one stays the one before.
733        keep_previous(&path);
734        std::fs::write(&path, newer(TOKENIZER + 2)).unwrap();
735        dated(&path, 1_791_000_200);
736        assert_eq!(Table::read(&previous(&path)).unwrap(), good);
737        assert_eq!(Table::cached(&path).map(|t| t.bias), Some(-0.5));
738        // One it reads again: in use, the good one before it.
739        let fresh = Table { bias: 0.25, ..small() };
740        fresh.write(&path).unwrap();
741        dated(&path, 1_791_000_300);
742        assert_eq!(Table::cached(&path).map(|t| t.bias), Some(0.25));
743        assert_eq!(Table::read(&previous(&path)).unwrap(), good);
744        // None at all: nothing, whatever is beside it.
745        std::fs::remove_file(&path).unwrap();
746        assert!(Table::cached(&path).is_none());
747        let _ = std::fs::remove_dir_all(&dir);
748    }
749
750    #[test]
751    fn a_damaged_or_foreign_table_is_refused() {
752        let bytes = small().to_bytes();
753        // One byte changed anywhere: the checksum says so.
754        for at in [10, HEADER + 3, bytes.len() / 2, bytes.len() - 9] {
755            let mut damaged = bytes.clone();
756            damaged[at] ^= 0x40;
757            assert!(Table::from_bytes(&damaged).is_err(), "byte {at}");
758        }
759        assert!(Table::from_bytes(&bytes[..bytes.len() - 1]).is_err());
760        assert!(Table::from_bytes(b"not a table at all").is_err());
761        // Another tokenizer, other header features: refused, even sound.
762        let other = Table { tokenizer: TOKENIZER + 1, ..small() };
763        assert!(Table::from_bytes(&other.to_bytes()).unwrap_err().contains("tokenizer"));
764        let fewer = Table { weights: vec![0.0; N - 1], means: vec![0.0; N - 1], ..small() };
765        assert!(Table::from_bytes(&fewer.to_bytes()).unwrap_err().contains("header features"));
766        // The version before (features 3: the provider's checks alone, none of Sioul's): refused.
767        let older = Table { features: features::FEATURES - 1, ..small() };
768        assert!(Table::from_bytes(&older.to_bytes()).unwrap_err().contains(&format!("header features {}", features::FEATURES - 1)));
769        let broken = Table { buckets: vec![f32::NAN; 8], ..small() };
770        assert!(Table::from_bytes(&broken.to_bytes()).is_err());
771        // Format 1 (made before the classifier's kind): refused, its checksum made good again.
772        let mut first = bytes.clone();
773        first[8..12].copy_from_slice(&1u32.to_le_bytes());
774        let body = first.len() - 8;
775        let sum = fnv64(&first[..body]);
776        first[body..].copy_from_slice(&sum.to_le_bytes());
777        assert!(Table::from_bytes(&first).unwrap_err().contains("format 1"));
778    }
779}