1use 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
63pub const MAGIC: [u8; 8] = *b"SIOULSPM";
65pub const VERSION: u32 = 2;
68const HEADER: usize = 8 + 12 * 4 + 4 * 4;
70
71#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct Meta {
74 #[serde(default)]
76 pub trained_at: i64,
77 #[serde(default)]
79 pub ham: u64,
80 #[serde(default)]
81 pub spam: u64,
82 #[serde(default)]
84 pub test_ham: u64,
85 #[serde(default)]
86 pub test_spam: u64,
87 #[serde(default)]
89 pub metrics: BTreeMap<String, f64>,
90 #[serde(default, skip_serializing_if = "String::is_empty")]
92 pub device: String,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub lexicon: Option<crate::words::SpamWords>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Kind {
103 Rows,
105 Centroid,
107}
108
109#[derive(Debug, Clone, PartialEq)]
111pub struct Table {
112 pub tokenizer: u32,
114 pub features: u32,
116 pub dim: u32,
118 pub minn: u32,
120 pub maxn: u32,
121 pub bucket: u32,
122 pub words: Vec<(u64, f32)>,
126 pub buckets: Vec<f32>,
128 pub weights: Vec<f32>,
132 pub means: Vec<f32>,
133 pub bias: f32,
135 pub text_mean: f32,
137 pub platt_a: f32,
139 pub platt_b: f32,
140 pub meta: Meta,
141}
142
143#[derive(Debug, Clone, PartialEq)]
145pub struct Score {
146 pub f: f32,
148 pub p: f32,
150 pub words: Vec<(String, f32)>,
152 pub signs: Vec<(usize, f32)>,
154}
155
156pub 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
170fn 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
181pub 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
219fn 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 pub fn lexicon(&self) -> Arc<Lexicon> {
240 self.meta.lexicon.as_ref().map_or_else(Lexicon::builtin, Lexicon::cached)
241 }
242
243 pub fn kind(&self) -> Kind {
246 if self.weights.is_empty() && self.means.is_empty() { Kind::Rows } else { Kind::Centroid }
247 }
248
249 pub fn sort(&mut self) {
251 self.words = sorted(std::mem::take(&mut self.words));
252 }
253
254 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 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 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 fn score_rows(&self, words: &[String], features: &[f32]) -> Score {
295 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 fn score_centroid(&self, words: &[String], features: &[f32]) -> Score {
326 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 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 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 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 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 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 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 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
498pub fn platt(f: f32, a: f32, b: f32) -> f32 {
500 let z = f64::from(a) * f64::from(f) + f64::from(b);
501 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
506pub fn previous(path: &Path) -> PathBuf {
509 path.with_file_name("table.prev.bin")
510}
511
512pub 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 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 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 assert_eq!(ngram_buckets("é", 3, 6, 1_000_003), vec![fasttext_hash("<é>".as_bytes()) % 1_000_003]);
568 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 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 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 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 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 let empty = table.score(&[], &x);
601 assert!((empty.f - (-0.5 - 0.5 + 1.5)).abs() < 1e-6 && empty.words.is_empty());
602 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 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 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 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 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 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 assert!((table.score(&[], &none).f - 0.5).abs() < 1e-6);
659 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 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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}