1use crate::corpus::{self, KEEP_FREE, Place, Record};
58use crate::detail::{self, Detail, Origin, Tested};
59use crate::eval::{self, Numbers};
60use crate::external;
61use crate::labels::{self, Copy, Evidence, Label};
62use crate::platt::{self, Platt};
63use crate::spamcore::{self as spam, N, Table};
64use crate::svm::{self, HAM, Problem, SPAM};
65use crate::consent::MicrosoftIds;
66use crate::{Cancel, Dirs, LearnError, Progress, Stage, io_error, now};
67use serde::{Deserialize, Serialize};
68use sioul_core::card::Card;
69use sioul_core::config::Config;
70use sioul_core::spam::tokenize::Lexicon;
71use sioul_core::words::SpamWords;
72use std::collections::{BTreeMap, HashMap, HashSet};
73use std::io::{BufReader, BufWriter, Seek, Write};
74use std::path::{Path, PathBuf};
75
76#[derive(Debug, Clone, PartialEq)]
78pub struct Options {
79 pub dim: u32,
81 pub epochs: u32,
84 pub min_count: u32,
86 pub bucket: u32,
88 pub minn: u32,
90 pub maxn: u32,
91 pub threads: u32,
93 pub seed: u64,
95 pub ham_weight: f64,
97 pub costs: Vec<f64>,
99 pub threshold_spam: f64,
101 pub threshold_unsure: f64,
102 pub least_of_each: u64,
104 pub replace: bool,
107 pub model: Model,
109 pub lexicon: SpamWords,
113 pub microsoft: MicrosoftIds,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq)]
122pub enum Model {
123 Supervised { word_ngrams: u32, lr: f64 },
127 Centroid,
128}
129
130impl Default for Model {
131 fn default() -> Self {
132 Model::Supervised { word_ngrams: 1, lr: 0.1 }
133 }
134}
135
136impl Model {
137 pub fn epochs(&self) -> u32 {
139 match self {
140 Model::Supervised { .. } => 25,
141 Model::Centroid => 5,
142 }
143 }
144
145 pub fn name(&self) -> &'static str {
147 match self {
148 Model::Supervised { .. } => "supervised",
149 Model::Centroid => "centroid",
150 }
151 }
152}
153
154impl Default for Options {
155 fn default() -> Self {
156 Options {
157 dim: 100,
158 epochs: Model::default().epochs(),
159 min_count: 5,
160 bucket: 200_000,
161 minn: 3,
162 maxn: 6,
163 threads: std::thread::available_parallelism().map_or(4, |n| n.get() as u32),
164 seed: 1,
165 ham_weight: 5.0,
166 costs: vec![0.01, 0.1, 1.0, 10.0],
167 threshold_spam: 0.95,
168 threshold_unsure: 0.5,
169 least_of_each: 20,
170 replace: true,
171 model: Model::default(),
172 lexicon: sioul_core::words::Words::builtin_ref().spam.clone(),
173 microsoft: MicrosoftIds::default(),
174 }
175 }
176}
177
178impl Options {
179 pub fn of(config: &Config) -> Options {
182 let (spam, unsure) = config.spam.thresholds();
183 let lexicon = sioul_core::words::Words::of(config).spam.clone();
184 Options { threshold_spam: f64::from(spam), threshold_unsure: f64::from(unsure), lexicon, microsoft: MicrosoftIds::of(config), ..Options::default() }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct Summary {
191 pub trained_at: i64,
193 pub seconds: f64,
194 pub tokenizer: u32,
195 pub features: u32,
196 pub replaced: bool,
198 pub reason: String,
202 pub threshold_spam: f64,
203 pub threshold_unsure: f64,
204 pub labels: labels::Summary,
205 pub split: Split,
206 pub accounts: BTreeMap<String, Counts>,
208 pub model: ModelSummary,
209 pub test: Numbers,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub test_settled: Option<Numbers>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub current: Option<Numbers>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub compared: Option<Compared>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub refit: Option<Refit>,
226 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
228 pub outside: BTreeMap<String, Outside>,
229 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
233 pub trial: bool,
234}
235
236#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
239pub struct Outside {
240 pub train_ham: u64,
241 pub train_spam: u64,
242 pub held_ham: u64,
243 pub held_spam: u64,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub baseline: Option<Numbers>,
248}
249
250#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
253pub struct Compared {
254 pub since: i64,
256 pub ham: u64,
257 pub spam: u64,
258 pub new_ham_lost: u64,
259 pub current_ham_lost: u64,
260}
261
262#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
265pub struct Refit {
266 pub ham: u64,
267 pub spam: u64,
268 pub platt_a: f64,
271 pub platt_b: f64,
272 pub calibration_scores: u64,
273 pub fold_error: f64,
275}
276
277#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
278pub struct Split {
279 pub train_ham: u64,
280 pub train_spam: u64,
281 pub test_ham: u64,
282 pub test_spam: u64,
283 pub test_from: i64,
285}
286
287#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
288pub struct Counts {
289 pub train_ham: u64,
290 pub train_spam: u64,
291 pub test_ham: u64,
292 pub test_spam: u64,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
298pub struct ModelSummary {
299 #[serde(default, skip_serializing_if = "String::is_empty")]
301 pub kind: String,
302 #[serde(default, skip_serializing_if = "is_zero")]
304 pub word_ngrams: u32,
305 #[serde(default, skip_serializing_if = "is_zero_f")]
306 pub lr: f64,
307 pub dim: u32,
308 pub vocabulary: u64,
310 pub bucket: u32,
311 pub minn: u32,
312 pub maxn: u32,
313 pub epochs: u32,
314 #[serde(default)]
316 pub min_count: u32,
317 #[serde(default)]
319 pub threads: u32,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
322 pub costs: Vec<f64>,
323 pub c: f64,
325 pub validation_auc: BTreeMap<String, f64>,
326 pub ham_weight: f64,
327 pub platt_a: f64,
328 pub platt_b: f64,
329 pub calibration_scores: u64,
331 pub fold_error: f64,
333 pub table_bytes: u64,
335 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
338 pub header_weights: BTreeMap<String, f64>,
339}
340
341fn is_zero(n: &u32) -> bool {
342 *n == 0
343}
344
345fn is_zero_f(x: &f64) -> bool {
346 *x == 0.0
347}
348
349pub fn last(dirs: &Dirs) -> Option<Summary> {
351 std::fs::read_to_string(dirs.trained()).ok().and_then(|t| toml::from_str(&t).ok())
352}
353
354pub fn run(config: &Config, dirs: &Dirs, here: &dyn Fn(&sioul_core::config::Account) -> bool, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<(corpus::Update, Summary), LearnError> {
361 let update = corpus::update(&config.accounts, &|a| sioul_sync::secret::password(a), dirs, here, &corpus::free_space, progress, cancel);
362 if update.cancelled {
363 return Err(LearnError::Cancelled);
364 }
365 let summary = train(dirs, &trusted_ids(config), &Options::of(config), progress, cancel)?;
366 Ok((update, summary))
367}
368
369pub fn trusted_ids(config: &Config) -> BTreeMap<String, Vec<String>> {
372 config.mail_sources().into_iter().filter_map(|s| Some((s.account?, s.trusted_ids))).collect()
373}
374
375pub(crate) struct Message {
377 pub(crate) label: Label,
378 pub(crate) date: i64,
379 pub(crate) account: String,
380 pub(crate) place: Option<Place>,
382 pub(crate) evidence: Option<Evidence>,
383 pub(crate) source: Option<usize>,
385 pub(crate) ordinal: u64,
387 pub(crate) unsettled: bool,
389 pub(crate) known: bool,
392 pub(crate) features: [f32; N],
393 pub(crate) offset: u64,
395 pub(crate) length: u64,
396 pub(crate) words: u64,
397}
398
399struct TokenFile(PathBuf);
401
402impl Drop for TokenFile {
403 fn drop(&mut self) {
404 let _ = std::fs::remove_file(&self.0);
405 }
406}
407
408const LINE_WORDS: usize = 1000;
410
411pub fn train(dirs: &Dirs, trusted: &BTreeMap<String, Vec<String>>, options: &Options, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Summary, LearnError> {
414 train_with(dirs, trusted, options, 0, progress, cancel).map(|(summary, _)| summary)
415}
416
417pub fn train_with(dirs: &Dirs, trusted: &BTreeMap<String, Vec<String>>, options: &Options, errors: usize, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<(Summary, Detail), LearnError> {
422 let started = std::time::Instant::now();
423 let started_at = now();
424 let check = || if cancel.cancelled() { Err(LearnError::Cancelled) } else { Ok(()) };
425
426 let mut copies = Vec::new();
428 let mut texts = 0u64;
429 corpus::read_all(dirs, &options.microsoft, |record| {
430 texts += record.plain.as_ref().map_or(0, |t| t.text.len() as u64) + record.html.as_ref().map_or(0, |t| t.text.len() as u64) + record.header.len() as u64;
431 copies.push(Copy::of(&record));
432 if copies.len() % 1000 == 0 {
433 progress(&Progress { stage: Stage::Labels, done: copies.len() as u64, total: 0, detail: String::new() });
434 }
435 })?;
436 let log = labels::read_log(dirs);
437 let (labeled, label_summary) = labels::decide(copies, &log, &labels::read_moved(dirs), &labels::read_flagged(dirs));
438 let own_keys: HashSet<String> = labeled.iter().map(|l| l.key.clone()).collect();
440 let yours = |message: &external::Message| message.message_id().is_some_and(|id| own_keys.contains(&id));
441 let mut outside: BTreeMap<String, Outside> = BTreeMap::new();
442 external::read_all(dirs, |source, message| {
443 if yours(&message) {
444 return;
445 }
446 texts += (message.subject.len() + message.text.len() + message.html.as_ref().map_or(0, String::len)) as u64;
447 let counts = outside.entry(source.to_string()).or_default();
448 match message.label {
449 Label::Ham => counts.train_ham += 1,
450 Label::Spam => counts.train_spam += 1,
451 }
452 })?;
453 let sources: Vec<String> = outside.keys().cloned().collect();
454 progress(&Progress { stage: Stage::Labels, done: label_summary.records, total: label_summary.records, detail: String::new() });
455 let (ham, spam) = outside.values().fold((label_summary.ham, label_summary.spam), |(h, s), o| (h + o.train_ham, s + o.train_spam));
456 if ham < options.least_of_each || spam < options.least_of_each {
457 return Err(LearnError::TooFew { ham, spam });
458 }
459 check()?;
460
461 let model_bytes = (u64::from(options.bucket) + 2 * (label_summary.records + ham + spam).min(500_000)) * u64::from(options.dim) * 4;
463 std::fs::create_dir_all(&dirs.cache).map_err(|e| io_error(&dirs.cache, e))?;
464 if let Some(free) = corpus::free_space(&dirs.cache)
465 && free < KEEP_FREE + texts + model_bytes
466 {
467 return Err(LearnError::Disk(format!("{} MB free, {} MB needed above the 1 GB kept free", free >> 20, (texts + model_bytes) >> 20)));
468 }
469
470 let chosen: HashMap<Place, usize> = labeled.iter().enumerate().map(|(i, l)| (l.place.clone(), i)).collect();
472 let token_path = dirs.cache.join(format!("tokens-{}.txt", std::process::id()));
473 let token_file = TokenFile(token_path.clone());
474 let mut messages: Vec<Option<Message>> = (0..labeled.len()).map(|_| None).collect();
475 let mut outside_messages: Vec<Message> = Vec::new();
476 let tokens_total = labeled.len() as u64 + (ham + spam) - (label_summary.ham + label_summary.spam);
478 let lexicon = Lexicon::cached(&options.lexicon);
479 {
480 let file = std::fs::File::create(&token_path).map_err(|e| io_error(&token_path, e))?;
481 let mut out = BufWriter::new(file);
482 let mut offset = 0u64;
483 let mut done = 0u64;
484 let mut failure = None;
485 corpus::read_all(dirs, &options.microsoft, |record| {
486 if failure.is_some() {
487 return;
488 }
489 let Some(&index) = chosen.get(&record.place()) else { return };
490 if messages[index].is_some() {
491 return;
492 }
493 let (words, features) = read_message(&record, trusted, &lexicon);
494 let mut length = 0u64;
495 for line in words.chunks(LINE_WORDS) {
496 let text = line.join(" ");
497 if let Err(e) = out.write_all(text.as_bytes()).and_then(|()| out.write_all(b"\n")) {
498 failure = Some(io_error(&token_path, e));
499 return;
500 }
501 length += text.len() as u64 + 1;
502 }
503 let l = &labeled[index];
504 messages[index] = Some(Message {
505 label: l.label,
506 date: l.date,
507 account: l.place.account.clone(),
508 place: Some(l.place.clone()),
509 evidence: Some(l.evidence),
510 source: None,
511 ordinal: 0,
512 unsettled: detail::unsettled(l.label, l.role, l.date, started_at),
513 known: true,
514 features,
515 offset,
516 length,
517 words: words.len() as u64,
518 });
519 offset += length;
520 done += 1;
521 if done % 500 == 0 {
522 progress(&Progress { stage: Stage::Tokens, done, total: tokens_total, detail: String::new() });
523 }
524 })?;
525 let mut ranks: HashMap<String, u64> = HashMap::new();
527 external::read_all(dirs, |source, message| {
528 let rank = ranks.entry(source.to_string()).or_default();
529 let ordinal = *rank;
530 *rank += 1;
531 if failure.is_some() || yours(&message) {
532 return;
533 }
534 let words = message.words(&lexicon);
535 let mut length = 0u64;
536 for line in words.chunks(LINE_WORDS) {
537 let text = line.join(" ");
538 if let Err(e) = out.write_all(text.as_bytes()).and_then(|()| out.write_all(b"\n")) {
539 failure = Some(io_error(&token_path, e));
540 return;
541 }
542 length += text.len() as u64 + 1;
543 }
544 let source = sources.iter().position(|s| s == source);
545 outside_messages.push(Message {
546 label: message.label,
547 date: message.date,
548 account: String::new(),
549 place: None,
550 evidence: None,
551 source,
552 ordinal,
553 unsettled: false,
554 known: false,
555 features: [0.0; N],
556 offset,
557 length,
558 words: words.len() as u64,
559 });
560 offset += length;
561 done += 1;
562 if done % 500 == 0 {
563 progress(&Progress { stage: Stage::Tokens, done, total: tokens_total, detail: String::new() });
564 }
565 })?;
566 if let Some(e) = failure {
567 return Err(e);
568 }
569 out.flush().map_err(|e| io_error(&token_path, e))?;
570 }
571 let mut messages: Vec<Message> = messages.into_iter().flatten().collect();
573 messages.append(&mut outside_messages);
574 progress(&Progress { stage: Stage::Tokens, done: messages.len() as u64, total: messages.len() as u64, detail: String::new() });
575 check()?;
576
577 if let Model::Supervised { word_ngrams, lr } = options.model {
579 let words = words_of(&token_path, &messages, &(0..messages.len()).collect::<Vec<_>>())?;
580 drop(token_file);
581 let read = Read { messages, words, sources, outside, labels: label_summary, started };
582 return crate::supervised::train(dirs, read, options, word_ngrams, lr, errors, progress, cancel);
583 }
584
585 progress(&Progress { stage: Stage::Language, done: 0, total: 0, detail: String::new() });
587 let args = fasttext::args::Args {
588 input: token_path.clone(),
589 model: fasttext::args::ModelName::SkipGram,
590 loss: fasttext::args::LossName::NegativeSampling,
591 dim: options.dim as i32,
592 epoch: options.epochs as i32,
593 min_count: options.min_count as i32,
594 bucket: options.bucket as i32,
595 minn: options.minn as i32,
596 maxn: options.maxn as i32,
597 thread: options.threads.max(1) as i32,
598 seed: options.seed as i32,
599 verbose: 0,
600 ..fasttext::args::Args::default()
601 };
602 let language = fasttext::FastText::train_with_abort(args, cancel.flag()).map_err(|e| LearnError::Language(e.to_string()))?;
603 check()?;
604 progress(&Progress { stage: Stage::Language, done: 1, total: 1, detail: String::new() });
605
606 let dim = options.dim as usize;
608 let vectors = message_vectors(&language, &token_path, &messages, options.threads.max(1) as usize, progress, cancel)?;
609 check()?;
610
611 let Parts { order, train: train_ids, test: test_ids, held } = parts(&messages, sources.len());
616 let (train_ids, test_ids) = (train_ids.as_slice(), test_ids.as_slice());
617 let test_words = words_of(&token_path, &messages, test_ids)?;
618 let held_words = held.iter().map(|ids| words_of(&token_path, &messages, ids)).collect::<Result<Vec<_>, _>>()?;
619 drop(token_file);
620 let rows = Rows { vectors: &vectors, messages: &messages, dim };
621 let width = rows.width();
622
623 let (mean, deviation) = rows.standardization(train_ids);
626 let fits = (options.costs.len() + 5) as u64;
627 let mut fitted = 0u64;
628 let (c, validation_auc, model, sigmoid, calibration_scores) = {
629 let train_x = rows.standardized(train_ids, &mean, &deviation);
630 let train_y = rows.labels(train_ids);
631 let n_train = train_ids.len();
632 let fit_end = (n_train as f64 * 0.8).round() as usize;
633 let mut validation_auc = BTreeMap::new();
634 let mut best: Option<(f64, f64)> = None;
635 for &c in &options.costs {
636 check()?;
637 let model = fit_prefix(&train_x, &train_y, width, fit_end, c, options, cancel)?;
638 let scores: Vec<(f64, bool)> = (fit_end..n_train).map(|r| (model.decision(&train_x[r * width..(r + 1) * width]), train_y[r] == SPAM)).collect();
639 let quality = eval::auc(&scores).unwrap_or_else(|| -hinge_loss(&scores, options.ham_weight));
641 validation_auc.insert(format!("{c}"), quality);
642 if best.is_none_or(|(_, q)| quality > q + 1e-9) {
643 best = Some((c, quality));
644 }
645 fitted += 1;
646 progress(&Progress { stage: Stage::Classifier, done: fitted, total: fits, detail: format!("C = {c}") });
647 }
648 let c = best.map_or(0.1, |(c, _)| c);
649 let (model, sigmoid, calibration_scores) = fit_with_platt(&train_x, &train_y, width, c, options, cancel, &mut |detail| {
650 fitted += 1;
651 progress(&Progress { stage: Stage::Classifier, done: fitted, total: fits, detail: detail.to_string() });
652 })?;
653 (c, validation_auc, model, sigmoid, calibration_scores)
654 };
655 check()?;
656
657 progress(&Progress { stage: Stage::Evaluation, done: 0, total: test_ids.len() as u64, detail: String::new() });
659 let (split, accounts) = counted(&messages, train_ids, test_ids);
660 let table = Table::from_bytes(&fold(&language, &model, &mean, &deviation, sigmoid, options, &split).to_bytes()).map_err(LearnError::Fold)?;
662 let test_x = rows.standardized(test_ids, &mean, &deviation);
663 let fold_error = fold_error(&table, &model, &test_x, width, &test_words, test_ids, &messages)?;
664 let new_scored: Vec<(f64, bool)> = test_ids.iter().enumerate().map(|(row, &i)| (f64::from(table.score(&test_words[row], &messages[i].features).p), messages[i].label == Label::Spam)).collect();
665 drop(test_x);
666 let test = eval::evaluate(&new_scored, options.threshold_spam, options.threshold_unsure);
667 let settled: Vec<(f64, bool)> = test_ids.iter().zip(&new_scored).filter(|&(&i, _)| !messages[i].unsettled).map(|(_, &s)| s).collect();
669 let test_settled = Some(eval::evaluate(&settled, options.threshold_spam, options.threshold_unsure));
670 let lacking = lacking(&table);
672 let mut tested: Vec<Tested> = test_ids.iter().zip(&new_scored).map(|(&i, &(p, _))| tested_of(&messages[i], p, &sources)).collect();
673 for (s, name) in sources.iter().enumerate() {
674 let scored: Vec<(f64, bool)> = held[s].iter().zip(&held_words[s]).map(|(&i, words)| (f64::from(table.score(words, &lacking).p), messages[i].label == Label::Spam)).collect();
675 tested.extend(held[s].iter().zip(&scored).map(|(&i, &(p, _))| tested_of(&messages[i], p, &sources)));
676 held_out(outside.entry(name.clone()).or_default(), &scored, options);
677 }
678 let detail = detail::of(dirs, &options.microsoft, &tested, options.threshold_spam, options.threshold_unsure, errors)?;
680 drop(tested);
681 progress(&Progress { stage: Stage::Evaluation, done: test_ids.len() as u64, total: test_ids.len() as u64, detail: String::new() });
682
683 let (current_numbers, compared, replaced, reason) = against_current(dirs, test_ids, &test_words, &messages, &new_scored, options)?;
685 check()?;
686
687 let replaced = replaced && options.replace;
690 let mut table_bytes = table.to_bytes().len() as u64;
691 let refit = if replaced {
692 let refits = 6u64;
693 let mut done = 0u64;
694 let mut step = |detail: &str| {
695 done += 1;
696 progress(&Progress { stage: Stage::Export, done, total: refits, detail: detail.to_string() });
697 };
698 let (mut written, refit) = refit_all(&language, &rows, &order, test_ids, &test_words, c, options, cancel, &mut step)?;
699 written.meta.metrics = metrics(&test);
700 check()?;
701 written.write(&dirs.table()).map_err(|e| io_error(&dirs.table(), e))?;
703 table_bytes = std::fs::metadata(dirs.table()).map_or(table_bytes, |m| m.len());
704 let fresh = dirs.language().with_extension("bin.new");
705 language.save_model(&fresh).map_err(|e| io_error(&fresh, e))?;
706 std::fs::rename(&fresh, dirs.language()).map_err(|e| io_error(&dirs.language(), e))?;
707 step("");
708 Some(refit)
709 } else {
710 progress(&Progress { stage: Stage::Export, done: 1, total: 1, detail: String::new() });
711 None
712 };
713 let summary = Summary {
714 trained_at: now(),
715 seconds: started.elapsed().as_secs_f64(),
716 tokenizer: spam::TOKENIZER,
717 features: spam::FEATURES,
718 replaced,
719 reason: reason.to_string(),
720 threshold_spam: options.threshold_spam,
721 threshold_unsure: options.threshold_unsure,
722 labels: label_summary,
723 split,
724 accounts,
725 model: ModelSummary {
726 kind: "centroid".to_string(),
727 word_ngrams: 0,
728 lr: 0.0,
729 dim: options.dim,
730 vocabulary: language.dict().nwords().max(1) as u64 - 1,
731 bucket: options.bucket,
732 minn: options.minn,
733 maxn: options.maxn,
734 epochs: options.epochs,
735 min_count: options.min_count,
736 threads: options.threads,
737 costs: options.costs.clone(),
738 c,
739 validation_auc,
740 ham_weight: options.ham_weight,
741 platt_a: sigmoid.a,
742 platt_b: sigmoid.b,
743 calibration_scores,
744 fold_error,
745 table_bytes,
746 header_weights: (0..N).map(|h| (spam::NAMES[h].to_string(), model.w[dim + h])).collect(),
747 },
748 test,
749 test_settled,
750 current: current_numbers,
751 compared,
752 refit,
753 outside,
754 trial: !options.replace,
755 };
756 if options.replace {
758 let text = toml::to_string(&summary).map_err(|e| io_error(&dirs.trained(), e))?;
759 corpus::write_whole(&dirs.trained(), text.as_bytes())?;
760 }
761 Ok((summary, detail))
762}
763
764pub(crate) struct Read {
766 pub(crate) messages: Vec<Message>,
767 pub(crate) words: Vec<Vec<String>>,
769 pub(crate) sources: Vec<String>,
770 pub(crate) outside: BTreeMap<String, Outside>,
771 pub(crate) labels: labels::Summary,
772 pub(crate) started: std::time::Instant,
773}
774
775pub(crate) struct Parts {
779 pub(crate) order: Vec<usize>,
780 pub(crate) train: Vec<usize>,
781 pub(crate) test: Vec<usize>,
782 pub(crate) held: Vec<Vec<usize>>,
783}
784
785pub(crate) fn parts(messages: &[Message], sources: usize) -> Parts {
789 let by_date = |ids: &mut Vec<usize>| ids.sort_by_key(|&i| (messages[i].date, i));
790 let mut order: Vec<usize> = (0..messages.len()).collect();
791 by_date(&mut order);
792 let newest_fifth = |source: Option<usize>| -> (Vec<usize>, Vec<usize>) {
793 let ids: Vec<usize> = order.iter().copied().filter(|&i| messages[i].source == source).collect();
794 let (mut learned, mut tested) = newest_fifths(&ids, |i| (messages[i].account.as_str(), messages[i].label == Label::Spam));
795 by_date(&mut learned);
796 by_date(&mut tested);
797 (learned, tested)
798 };
799 let (mut train, test) = newest_fifth(None);
800 let mut held: Vec<Vec<usize>> = Vec::with_capacity(sources);
801 for s in 0..sources {
802 let (learned, held_out) = newest_fifth(Some(s));
803 train.extend(learned);
804 held.push(held_out);
805 }
806 by_date(&mut train);
807 Parts { order, train, test, held }
808}
809
810pub(crate) fn counted(messages: &[Message], train: &[usize], test: &[usize]) -> (Split, BTreeMap<String, Counts>) {
812 let mut split = Split::default();
813 let mut accounts: BTreeMap<String, Counts> = BTreeMap::new();
814 for &i in train {
815 if messages[i].source.is_some() {
816 continue;
817 }
818 let counts = accounts.entry(messages[i].account.clone()).or_default();
819 match messages[i].label {
820 Label::Ham => (split.train_ham += 1, counts.train_ham += 1),
821 Label::Spam => (split.train_spam += 1, counts.train_spam += 1),
822 };
823 }
824 for &i in test {
825 let counts = accounts.entry(messages[i].account.clone()).or_default();
826 match messages[i].label {
827 Label::Ham => (split.test_ham += 1, counts.test_ham += 1),
828 Label::Spam => (split.test_spam += 1, counts.test_spam += 1),
829 };
830 }
831 split.test_from = test.first().map_or(0, |&i| messages[i].date);
832 (split, accounts)
833}
834
835pub(crate) fn held_out(counts: &mut Outside, scored: &[(f64, bool)], options: &Options) {
837 counts.held_ham = scored.iter().filter(|(_, spam)| !spam).count() as u64;
838 counts.held_spam = scored.len() as u64 - counts.held_ham;
839 counts.train_ham -= counts.held_ham;
840 counts.train_spam -= counts.held_spam;
841 counts.baseline = (!scored.is_empty()).then(|| eval::evaluate(scored, options.threshold_spam, options.threshold_unsure));
842}
843
844pub(crate) fn tested_of(message: &Message, p: f64, sources: &[String]) -> Tested {
846 let origin = match (&message.place, message.source) {
847 (Some(place), _) => Origin::Own { place: place.clone(), evidence: message.evidence.unwrap_or(Evidence::Folder) },
848 (None, source) => Origin::Outside { source: source.and_then(|s| sources.get(s)).cloned().unwrap_or_default(), ordinal: message.ordinal },
849 };
850 Tested { p, label: message.label, date: message.date, origin, unsettled: message.unsettled }
851}
852
853pub(crate) fn newest_fifths<'a>(ids: &[usize], group: impl Fn(usize) -> (&'a str, bool)) -> (Vec<usize>, Vec<usize>) {
858 let mut groups: BTreeMap<(&str, bool), Vec<usize>> = BTreeMap::new();
859 for &i in ids {
860 groups.entry(group(i)).or_default().push(i);
861 }
862 let (mut learned, mut tested) = (Vec::new(), Vec::new());
863 for (_, mut ids) in groups {
864 let newest = ids.split_off(ids.len() - (ids.len() as f64 * 0.2).round() as usize);
865 learned.extend(ids);
866 tested.extend(newest);
867 }
868 (learned, tested)
869}
870
871pub(crate) fn metrics(test: &Numbers) -> BTreeMap<String, f64> {
873 let mut metrics = BTreeMap::from([
874 ("threshold_spam".to_string(), test.at_spam.threshold),
875 ("threshold_unsure".to_string(), test.at_unsure.threshold),
876 ("ham_called_spam".to_string(), test.at_spam.ham_called_spam.fraction()),
877 ("spam_caught".to_string(), test.at_spam.spam_caught.fraction()),
878 ("ham_called_unsure".to_string(), test.at_unsure.ham_called_spam.fraction()),
879 ("spam_caught_unsure".to_string(), test.at_unsure.spam_caught.fraction()),
880 ("unsure".to_string(), test.unsure.fraction()),
881 ]);
882 if let Some(auc) = test.auc {
883 metrics.insert("auc".to_string(), auc);
884 }
885 metrics
886}
887
888pub(crate) fn against_current(dirs: &Dirs, test_ids: &[usize], test_words: &[Vec<String>], messages: &[Message], new_scored: &[(f64, bool)], options: &Options) -> Result<(Option<Numbers>, Option<Compared>, bool, &'static str), LearnError> {
900 let current = match Table::read(&dirs.table()) {
901 Ok(current) => Ok(Some(current)),
902 Err(_) if !dirs.table().exists() => Ok(None),
903 Err(e) => Err(e),
904 };
905 let (current_numbers, compared) = match ¤t {
906 Ok(Some(current)) => {
907 let since = current.meta.trained_at;
908 let mut unseen: Vec<usize> = (0..test_ids.len()).filter(|&row| messages[test_ids[row]].date > since).collect();
909 let lexicon = current.lexicon();
910 let own_words: Option<HashMap<usize, Vec<String>>> = if lexicon.words == options.lexicon || unseen.is_empty() {
911 None
912 } else {
913 let wanted: HashMap<Place, usize> = unseen.iter().filter_map(|&row| Some((messages[test_ids[row]].place.clone()?, row))).collect();
914 let mut read: HashMap<usize, Vec<String>> = HashMap::new();
915 corpus::read_all(dirs, &options.microsoft, |record| {
916 if let Some(&row) = wanted.get(&record.place()) {
917 read.entry(row).or_insert_with(|| words_of_record(&record, &lexicon));
918 }
919 })?;
920 unseen.retain(|row| read.contains_key(row));
921 Some(read)
922 };
923 let words = |row: usize| own_words.as_ref().and_then(|own| own.get(&row)).unwrap_or(&test_words[row]);
924 let label = |row: usize| messages[test_ids[row]].label == Label::Spam;
925 let theirs: Vec<(f64, bool)> = unseen.iter().map(|&row| (f64::from(current.score(words(row), &messages[test_ids[row]].features).p), label(row))).collect();
926 let ours: Vec<(f64, bool)> = unseen.iter().map(|&row| new_scored[row]).collect();
927 let compared = Compared { since, ham: theirs.iter().filter(|(_, s)| !s).count() as u64, spam: theirs.iter().filter(|(_, s)| *s).count() as u64, new_ham_lost: eval::evaluate(&ours, options.threshold_spam, options.threshold_unsure).at_spam.ham_called_spam.count, current_ham_lost: 0 };
928 let numbers = eval::evaluate(&theirs, options.threshold_spam, options.threshold_unsure);
929 (Some(numbers.clone()), Some(Compared { current_ham_lost: numbers.at_spam.ham_called_spam.count, ..compared }))
930 }
931 _ => (None, None),
932 };
933 let (replaced, reason) = match (¤t, &compared) {
934 (Err(_), _) => (true, "unreadable"),
935 (Ok(None), _) => (true, "no-table"),
936 (Ok(Some(_)), Some(c)) => replacement(c.new_ham_lost, c.current_ham_lost, c.ham + c.spam),
937 (Ok(Some(_)), None) => (true, "no-table"),
938 };
939 Ok((current_numbers, compared, replaced, reason))
940}
941
942pub(crate) fn replacement(new_ham_lost: u64, current_ham_lost: u64, compared: u64) -> (bool, &'static str) {
947 if compared == 0 {
948 (true, "nothing-new")
949 } else if new_ham_lost <= current_ham_lost {
950 (true, "no-worse")
951 } else {
952 (false, "worse")
953 }
954}
955
956pub(crate) fn read_message(record: &Record, trusted: &BTreeMap<String, Vec<String>>, lexicon: &Lexicon) -> (Vec<String>, [f32; N]) {
963 let Some(card) = spam::card_of(record) else { return (Vec::new(), [0.0; N]) };
964 let own = record.checked.as_ref().and_then(|c| c.reading(record.date, sioul_core::spam::features::signed(&card.headers)));
965 features_of(&card, trusted.get(&record.account).map_or(&[][..], Vec::as_slice), (record.date > 0).then_some(record.date), own, lexicon)
966}
967
968pub(crate) fn features_of(card: &Card, trusted: &[String], internal_date: Option<i64>, own: Option<sioul_core::trust::AuthResults>, lexicon: &Lexicon) -> (Vec<String>, [f32; N]) {
973 let tokens = spam::tokens_with(lexicon, &card.subject, &card.excerpt);
974 let auth = own.or_else(|| sioul_core::spam::features::auth_results(&card.headers, trusted));
975 let features = spam::features(card, auth.as_ref(), internal_date, &tokens.links);
976 (kept(tokens.words), features)
977}
978
979fn words_of_record(record: &Record, lexicon: &Lexicon) -> Vec<String> {
983 spam::card_of(record).map(|card| kept(spam::tokens_with(lexicon, &card.subject, &card.excerpt).words)).unwrap_or_default()
984}
985
986fn kept(words: Vec<String>) -> Vec<String> {
988 words.into_iter().filter(|w| !w.is_empty() && !w.contains(char::is_whitespace)).collect()
989}
990
991struct Rows<'a> {
993 vectors: &'a [f64],
994 messages: &'a [Message],
995 dim: usize,
996}
997
998impl Rows<'_> {
999 fn width(&self) -> usize {
1000 self.dim + N
1001 }
1002
1003 fn raw(&self, i: usize) -> impl Iterator<Item = f64> + '_ {
1004 self.vectors[i * self.dim..(i + 1) * self.dim].iter().copied().chain(self.messages[i].features.iter().map(|&x| f64::from(x)))
1005 }
1006
1007 fn standardization(&self, ids: &[usize]) -> (Vec<f64>, Vec<f64>) {
1011 standardization(|k| self.raw(ids[k]).collect(), |k| self.messages[ids[k]].known, ids.len(), self.width(), self.dim)
1012 }
1013
1014 fn standardized(&self, ids: &[usize], mean: &[f64], deviation: &[f64]) -> Vec<f32> {
1018 ids.iter()
1019 .flat_map(|&i| {
1020 let known = self.messages[i].known;
1021 self.raw(i)
1022 .zip(mean)
1023 .zip(deviation)
1024 .enumerate()
1025 .map(move |(c, ((x, m), s))| if *s > 0.0 && (known || c < self.dim) && !x.is_nan() { ((x - m) / s) as f32 } else { 0.0 })
1026 .collect::<Vec<f32>>()
1027 })
1028 .collect()
1029 }
1030
1031 fn labels(&self, ids: &[usize]) -> Vec<i8> {
1032 ids.iter().map(|&i| if self.messages[i].label == Label::Spam { SPAM } else { HAM }).collect()
1033 }
1034}
1035
1036fn standardization(row: impl Fn(usize) -> Vec<f64>, known: impl Fn(usize) -> bool, n: usize, width: usize, dim: usize) -> (Vec<f64>, Vec<f64>) {
1041 let counted = |column: usize, k: usize, x: f64| (column < dim || known(k)) && !x.is_nan();
1042 let mut counts = vec![0usize; width];
1043 let mut mean = vec![0.0; width];
1044 for k in 0..n {
1045 for (c, ((m, x), count)) in mean.iter_mut().zip(row(k)).zip(counts.iter_mut()).enumerate() {
1046 if counted(c, k, x) {
1047 *m += x;
1048 *count += 1;
1049 }
1050 }
1051 }
1052 let counts: Vec<f64> = counts.iter().map(|&c| c.max(1) as f64).collect();
1053 mean.iter_mut().zip(&counts).for_each(|(m, n)| *m /= n);
1054 let mut deviation = vec![0.0; width];
1055 for k in 0..n {
1056 for (c, ((d, x), m)) in deviation.iter_mut().zip(row(k)).zip(&mean).enumerate() {
1057 if counted(c, k, x) {
1058 *d += (x - m) * (x - m);
1059 }
1060 }
1061 }
1062 deviation.iter_mut().zip(&counts).for_each(|(d, n)| {
1063 *d = (*d / n).sqrt();
1064 if *d < 1e-12 {
1065 *d = 0.0;
1066 }
1067 });
1068 (mean, deviation)
1069}
1070
1071fn lacking(_table: &Table) -> [f32; N] {
1075 [f32::NAN; N]
1076}
1077
1078fn fit_prefix(x: &[f32], y: &[i8], width: usize, end: usize, c: f64, options: &Options, cancel: &Cancel) -> Result<svm::Model, LearnError> {
1080 let params = svm::Params { c, ham_weight: options.ham_weight, seed: options.seed, ..svm::Params::default() };
1081 svm::train(&Problem { x: &x[..end * width], dim: width, y: &y[..end] }, ¶ms, &|| cancel.cancelled()).map_err(|_| LearnError::Cancelled)
1082}
1083
1084fn fit_with_platt(x: &[f32], y: &[i8], width: usize, c: f64, options: &Options, cancel: &Cancel, step: &mut dyn FnMut(&str)) -> Result<(svm::Model, Platt, u64), LearnError> {
1090 let n = y.len();
1091 let scores = |model: &svm::Model, rows: std::ops::Range<usize>| -> Vec<(f64, bool)> { rows.map(|r| (model.decision(&x[r * width..(r + 1) * width]), y[r] == SPAM)).collect() };
1092 let mut calibration = Vec::new();
1093 for k in 1..5 {
1094 let (start, end) = (n * k / 5, n * (k + 1) / 5);
1095 if y[..start].contains(&SPAM) && y[..start].contains(&HAM) && end > start {
1096 let model = fit_prefix(x, y, width, start, c, options, cancel)?;
1097 calibration.extend(scores(&model, start..end));
1098 }
1099 step("Platt");
1100 }
1101 let model = fit_prefix(x, y, width, n, c, options, cancel)?;
1102 step(&format!("C = {c}"));
1103 let calibration = if platt::fit(&calibration).is_some() { calibration } else { scores(&model, 0..n) };
1104 let sigmoid = platt::fit(&calibration).unwrap_or(Platt { a: -1.0, b: 0.0 });
1105 Ok((model, sigmoid, calibration.len() as u64))
1106}
1107
1108fn fold_error(table: &Table, model: &svm::Model, x: &[f32], width: usize, words: &[Vec<String>], ids: &[usize], messages: &[Message]) -> Result<f64, LearnError> {
1111 let mut error = 0f64;
1112 for (row, &i) in ids.iter().enumerate() {
1113 let full = model.decision(&x[row * width..(row + 1) * width]);
1114 let score = table.score(&words[row], &messages[i].features);
1115 error = error.max((f64::from(score.f) - full).abs());
1116 }
1117 if error > 1e-4 {
1118 return Err(LearnError::Fold(format!("the table's score differs from the model's by {error:e}")));
1119 }
1120 Ok(error)
1121}
1122
1123#[allow(clippy::too_many_arguments)]
1129fn refit_all(
1130 language: &fasttext::FastText,
1131 rows: &Rows,
1132 order: &[usize],
1133 test_ids: &[usize],
1134 test_words: &[Vec<String>],
1135 c: f64,
1136 options: &Options,
1137 cancel: &Cancel,
1138 step: &mut dyn FnMut(&str),
1139) -> Result<(Table, Refit), LearnError> {
1140 let width = rows.width();
1141 let (mean, deviation) = rows.standardization(order);
1142 let (model, sigmoid, calibration_scores) = {
1143 let x = rows.standardized(order, &mean, &deviation);
1144 let y = rows.labels(order);
1145 fit_with_platt(&x, &y, width, c, options, cancel, step)?
1146 };
1147 let ham_of = |ids: &[usize]| ids.iter().filter(|&&i| rows.messages[i].label == Label::Ham).count() as u64;
1149 let all = Split {
1150 train_ham: ham_of(order),
1151 train_spam: order.len() as u64 - ham_of(order),
1152 test_ham: ham_of(test_ids),
1153 test_spam: test_ids.len() as u64 - ham_of(test_ids),
1154 test_from: test_ids.first().map_or(0, |&i| rows.messages[i].date),
1155 };
1156 let table = Table::from_bytes(&fold(language, &model, &mean, &deviation, sigmoid, options, &all).to_bytes()).map_err(LearnError::Fold)?;
1157 let test_x = rows.standardized(test_ids, &mean, &deviation);
1158 let fold_error = fold_error(&table, &model, &test_x, width, test_words, test_ids, rows.messages)?;
1159 Ok((table, Refit { ham: all.train_ham, spam: all.train_spam, platt_a: sigmoid.a, platt_b: sigmoid.b, calibration_scores, fold_error }))
1160}
1161
1162fn hinge_loss(scores: &[(f64, bool)], ham_weight: f64) -> f64 {
1164 let total: f64 = scores
1165 .iter()
1166 .map(|&(f, spam)| {
1167 let (y, weight) = if spam { (1.0, 1.0) } else { (-1.0, ham_weight) };
1168 weight * (1.0 - y * f).max(0.0).powi(2)
1169 })
1170 .sum();
1171 total / scores.len().max(1) as f64
1172}
1173
1174fn message_vectors(language: &fasttext::FastText, path: &Path, messages: &[Message], threads: usize, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Vec<f64>, LearnError> {
1177 let dim = language.get_dimension() as usize;
1178 let nwords = language.dict().nwords() as usize;
1180 let mut known = vec![0f32; nwords * dim];
1181 std::thread::scope(|s| {
1182 for (t, chunk) in known.chunks_mut(nwords.div_ceil(threads).max(1) * dim).enumerate() {
1183 s.spawn(move || {
1184 let first = t * nwords.div_ceil(threads).max(1);
1185 for (k, out) in chunk.chunks_mut(dim).enumerate() {
1186 language.get_word_vector_into(language.dict().get_word((first + k) as i32), out);
1187 }
1188 });
1189 }
1190 });
1191 let mut vectors = vec![0f64; messages.len() * dim];
1192 let per = messages.len().div_ceil(threads).max(1);
1193 let done = std::sync::atomic::AtomicU64::new(0);
1194 let failed: std::sync::Mutex<Option<LearnError>> = std::sync::Mutex::new(None);
1195 std::thread::scope(|s| {
1196 let mut workers = Vec::new();
1197 for (t, out) in vectors.chunks_mut(per * dim).enumerate() {
1198 let (known, done, failed) = (&known, &done, &failed);
1199 workers.push(s.spawn(move || {
1200 let first = t * per;
1201 let mut file = match std::fs::File::open(path) {
1202 Ok(file) => file,
1203 Err(e) => {
1204 *failed.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(io_error(path, e));
1205 return;
1206 }
1207 };
1208 let mut word = vec![0f32; dim];
1209 for (k, vector) in out.chunks_mut(dim).enumerate() {
1210 if cancel.cancelled() {
1211 return;
1212 }
1213 let message = &messages[first + k];
1214 if message.words == 0 {
1215 continue;
1216 }
1217 let mut text = vec![0u8; message.length as usize];
1218 if let Err(e) = file.seek(std::io::SeekFrom::Start(message.offset)).and_then(|_| std::io::Read::read_exact(&mut file, &mut text)) {
1219 *failed.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(io_error(path, e));
1220 return;
1221 }
1222 let mut count = 0u64;
1223 for token in String::from_utf8_lossy(&text).split_ascii_whitespace() {
1224 let row = match language.get_word_id(token) {
1225 Some(id) => &known[id as usize * dim..(id as usize + 1) * dim],
1226 None => {
1227 language.get_word_vector_into(token, &mut word);
1228 &word[..]
1229 }
1230 };
1231 for (v, &x) in vector.iter_mut().zip(row) {
1232 *v += f64::from(x);
1233 }
1234 count += 1;
1235 }
1236 if count > 0 {
1237 vector.iter_mut().for_each(|v| *v /= count as f64);
1238 }
1239 done.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1240 }
1241 }));
1242 }
1243 loop {
1245 let finished = workers.iter().all(|w| w.is_finished());
1246 progress(&Progress { stage: Stage::Features, done: done.load(std::sync::atomic::Ordering::Relaxed), total: messages.len() as u64, detail: String::new() });
1247 if finished {
1248 break;
1249 }
1250 std::thread::sleep(std::time::Duration::from_millis(100));
1251 }
1252 });
1253 if let Some(e) = failed.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner) {
1254 return Err(e);
1255 }
1256 if cancel.cancelled() {
1257 return Err(LearnError::Cancelled);
1258 }
1259 Ok(vectors)
1260}
1261
1262pub(crate) fn words_of(path: &Path, messages: &[Message], ids: &[usize]) -> Result<Vec<Vec<String>>, LearnError> {
1264 let file = std::fs::File::open(path).map_err(|e| io_error(path, e))?;
1265 let mut reader = BufReader::new(file);
1266 ids.iter()
1267 .map(|&i| {
1268 let message = &messages[i];
1269 let mut text = vec![0u8; message.length as usize];
1270 reader.seek(std::io::SeekFrom::Start(message.offset)).and_then(|_| std::io::Read::read_exact(&mut reader, &mut text)).map_err(|e| io_error(path, e))?;
1271 Ok(String::from_utf8_lossy(&text).split_ascii_whitespace().map(str::to_string).collect())
1272 })
1273 .collect()
1274}
1275
1276fn fold(language: &fasttext::FastText, model: &svm::Model, mean: &[f64], deviation: &[f64], sigmoid: Platt, options: &Options, split: &Split) -> Table {
1281 let dim = options.dim as usize;
1282 let u: Vec<f64> = (0..dim).map(|d| if deviation[d] > 0.0 { model.w[d] / deviation[d] } else { 0.0 }).collect();
1283 let dot = |row: &[f32]| -> f64 { u.iter().zip(row).map(|(a, &b)| a * f64::from(b)).sum() };
1284 let (vocabulary, _) = language.get_vocab();
1285 let mut vector = vec![0f32; dim];
1286 let words: Vec<(u64, f32)> = vocabulary
1287 .iter()
1288 .filter(|w| w.as_str() != fasttext::dictionary::EOS)
1289 .map(|w| {
1290 language.get_word_vector_into(w, &mut vector);
1291 (spam::word_hash(w), dot(&vector) as f32)
1292 })
1293 .collect();
1294 let nwords = i64::from(language.dict().nwords());
1295 let input = language.input_matrix();
1296 let buckets: Vec<f32> = (0..i64::from(options.bucket)).map(|b| dot(input.row(nwords + b)) as f32).collect();
1297 let text_mean = u.iter().zip(&mean[..dim]).map(|(a, m)| a * m).sum::<f64>();
1298 let weights: Vec<f32> = (0..N).map(|h| if deviation[dim + h] > 0.0 { (model.w[dim + h] / deviation[dim + h]) as f32 } else { 0.0 }).collect();
1299 let means: Vec<f32> = (0..N).map(|h| mean[dim + h] as f32).collect();
1300 Table {
1301 tokenizer: spam::TOKENIZER,
1302 features: spam::FEATURES,
1303 dim: options.dim,
1304 minn: options.minn,
1305 maxn: options.maxn,
1306 bucket: options.bucket,
1307 words,
1308 buckets,
1309 weights,
1310 means,
1311 bias: model.b as f32,
1312 text_mean: text_mean as f32,
1313 platt_a: sigmoid.a as f32,
1314 platt_b: sigmoid.b as f32,
1315 meta: spam::Meta {
1316 trained_at: now(),
1317 ham: split.train_ham,
1318 spam: split.train_spam,
1319 test_ham: split.test_ham,
1320 test_spam: split.test_spam,
1321 metrics: BTreeMap::new(),
1322 device: sioul_sync::lease::host_name(),
1323 lexicon: Some(options.lexicon.clone()),
1324 },
1325 }
1326}
1327
1328#[derive(Debug, Clone, PartialEq)]
1330pub struct Evaluation {
1331 pub labels: labels::Summary,
1332 pub split: Split,
1334 pub numbers: Numbers,
1335 pub table: spam::Meta,
1337 pub outside: BTreeMap<String, Numbers>,
1339 pub unseen: Option<Unseen>,
1344}
1345
1346#[derive(Debug, Clone, PartialEq)]
1348pub struct Unseen {
1349 pub since: i64,
1351 pub numbers: Numbers,
1352}
1353
1354pub fn evaluate(dirs: &Dirs, trusted: &BTreeMap<String, Vec<String>>, options: &Options, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<Evaluation, LearnError> {
1358 evaluate_with(dirs, trusted, options, 0, progress, cancel).map(|(evaluation, _)| evaluation)
1359}
1360
1361pub fn evaluate_with(dirs: &Dirs, trusted: &BTreeMap<String, Vec<String>>, options: &Options, errors: usize, progress: &mut dyn FnMut(&Progress), cancel: &Cancel) -> Result<(Evaluation, Detail), LearnError> {
1364 let table = Table::read(&dirs.table()).map_err(LearnError::NoTable)?;
1365 let lexicon = table.lexicon();
1367 let mut copies = Vec::new();
1368 corpus::read_all(dirs, &options.microsoft, |record| copies.push(Copy::of(&record)))?;
1369 let (mut labeled, label_summary) = labels::decide(copies, &labels::read_log(dirs), &labels::read_moved(dirs), &labels::read_flagged(dirs));
1370 progress(&Progress { stage: Stage::Labels, done: label_summary.records, total: label_summary.records, detail: String::new() });
1371 if cancel.cancelled() {
1372 return Err(LearnError::Cancelled);
1373 }
1374 labeled.sort_by(|a, b| (a.date, &a.key).cmp(&(b.date, &b.key)));
1376 let all: Vec<usize> = (0..labeled.len()).collect();
1377 let (_, newest) = newest_fifths(&all, |i| (labeled[i].place.account.as_str(), labeled[i].label == Label::Spam));
1378 let newest: HashSet<usize> = newest.into_iter().collect();
1379 let (tested_labels, labeled): (Vec<(usize, labels::Labeled)>, Vec<(usize, labels::Labeled)>) = labeled.into_iter().enumerate().partition(|(i, _)| newest.contains(i));
1380 let (tested_labels, labeled): (Vec<labels::Labeled>, Vec<labels::Labeled>) = (tested_labels.into_iter().map(|(_, l)| l).collect(), labeled.into_iter().map(|(_, l)| l).collect());
1381 let wanted: HashMap<Place, usize> = tested_labels.iter().enumerate().map(|(i, l)| (l.place.clone(), i)).collect();
1382 let mut scored: Vec<Option<(f64, bool)>> = vec![None; tested_labels.len()];
1383 let mut done = 0u64;
1384 corpus::read_all(dirs, &options.microsoft, |record| {
1385 let Some(&i) = wanted.get(&record.place()) else { return };
1386 if scored[i].is_some() || cancel.cancelled() {
1387 return;
1388 }
1389 let (words, features) = read_message(&record, trusted, &lexicon);
1390 scored[i] = Some((f64::from(table.score(&words, &features).p), tested_labels[i].label == Label::Spam));
1391 done += 1;
1392 if done % 500 == 0 {
1393 progress(&Progress { stage: Stage::Evaluation, done, total: tested_labels.len() as u64, detail: String::new() });
1394 }
1395 })?;
1396 if cancel.cancelled() {
1397 return Err(LearnError::Cancelled);
1398 }
1399 let since = table.meta.trained_at;
1401 let mut tested: Vec<Tested> = tested_labels
1402 .iter()
1403 .zip(&scored)
1404 .filter_map(|(l, scored)| scored.map(|(p, _)| Tested { p, label: l.label, date: l.date, origin: Origin::Own { place: l.place.clone(), evidence: l.evidence }, unsettled: detail::unsettled(l.label, l.role, l.date, now()) }))
1405 .collect();
1406 let unseen: Vec<(f64, bool)> = tested.iter().filter(|t| t.date > since).map(|t| (t.p, t.label == Label::Spam)).collect();
1407 let unseen = (!unseen.is_empty()).then(|| Unseen { since, numbers: eval::evaluate(&unseen, options.threshold_spam, options.threshold_unsure) });
1408 let scored: Vec<(f64, bool)> = scored.into_iter().flatten().collect();
1409 let split = Split {
1410 test_ham: scored.iter().filter(|(_, s)| !s).count() as u64,
1411 test_spam: scored.iter().filter(|(_, s)| *s).count() as u64,
1412 test_from: tested_labels.first().map_or(0, |l| l.date),
1413 ..Split::default()
1414 };
1415 let own_keys: HashSet<String> = labeled.iter().chain(&tested_labels).map(|l| l.key.clone()).collect();
1417 let mut sources: BTreeMap<String, Vec<(u64, external::Message)>> = BTreeMap::new();
1418 let mut ranks: HashMap<String, u64> = HashMap::new();
1419 external::read_all(dirs, |source, message| {
1420 let rank = ranks.entry(source.to_string()).or_default();
1421 let ordinal = *rank;
1422 *rank += 1;
1423 if !message.message_id().is_some_and(|id| own_keys.contains(&id)) {
1424 sources.entry(source.to_string()).or_default().push((ordinal, message));
1425 }
1426 })?;
1427 let lacking = lacking(&table);
1428 let mut outside = BTreeMap::new();
1429 for (name, mut messages) in sources {
1430 messages.sort_by_key(|(_, m)| m.date);
1432 let all: Vec<usize> = (0..messages.len()).collect();
1433 let (_, newest) = newest_fifths(&all, |i| ("", messages[i].1.label == Label::Spam));
1434 let newest: HashSet<usize> = newest.into_iter().collect();
1435 let held: Vec<(u64, external::Message)> = messages.into_iter().enumerate().filter(|(i, _)| newest.contains(i)).map(|(_, m)| m).collect();
1436 let scored: Vec<(f64, bool)> = held.iter().map(|(_, m)| (f64::from(table.score(&m.words(&lexicon), &lacking).p), m.label == Label::Spam)).collect();
1437 tested.extend(held.iter().zip(&scored).map(|((ordinal, m), &(p, _))| Tested { p, label: m.label, date: m.date, origin: Origin::Outside { source: name.clone(), ordinal: *ordinal }, unsettled: false }));
1438 outside.insert(name, eval::evaluate(&scored, options.threshold_spam, options.threshold_unsure));
1439 }
1440 let detail = detail::of(dirs, &options.microsoft, &tested, options.threshold_spam, options.threshold_unsure, errors)?;
1441 let numbers = eval::evaluate(&scored, options.threshold_spam, options.threshold_unsure);
1442 Ok((Evaluation { labels: label_summary, split, numbers, table: table.meta, outside, unseen }, detail))
1443}
1444
1445#[derive(Debug, Clone, PartialEq)]
1448pub struct Why {
1449 pub p: f64,
1451 pub f: f64,
1452 pub words: Vec<(String, f64)>,
1454 pub headers: Vec<(String, f64)>,
1456}
1457
1458pub fn why(dirs: &Dirs, raw: &[u8], trusted: &[String], internal_date: Option<i64>) -> Result<Why, String> {
1462 let table = Table::read(&dirs.table())?;
1463 let card = Card::from_bytes(raw).ok_or_else(|| "not a message".to_string())?;
1464 let (words, features) = features_of(&card, trusted, internal_date, None, &table.lexicon());
1465 let score = table.score(&words, &features);
1466 let words = score.words.iter().filter(|(_, s)| *s > 0.0).take(5).map(|(w, s)| (w.clone(), f64::from(*s))).collect();
1468 let headers = score.signs.iter().filter(|(_, s)| *s > 0.0).take(3).map(|&(h, s)| (spam::NAMES[h].to_string(), f64::from(s))).collect();
1469 Ok(Why { p: f64::from(score.p), f: f64::from(score.f), words, headers })
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474 use super::*;
1475 use crate::synthetic;
1476
1477 fn scratch(name: &str) -> PathBuf {
1478 let dir = std::env::temp_dir().join(format!("sioul-learn-train-{name}-{}", std::process::id()));
1479 let _ = std::fs::remove_dir_all(&dir);
1480 std::fs::create_dir_all(&dir).unwrap();
1481 dir
1482 }
1483
1484 fn small() -> Options {
1487 Options { dim: 16, min_count: 2, bucket: 2000, threads: 1, seed: 7, ..Options::default() }
1488 }
1489
1490 fn centroid() -> Options {
1492 Options { epochs: Model::Centroid.epochs(), model: Model::Centroid, ..small() }
1493 }
1494
1495 #[test]
1497 fn options_read_your_words() {
1498 let config: Config = toml::from_str("[words]\nlanguages = [\"en\"]\n[words.spam.provider_tags]\nadd = [\"verdächtig\"]\n").unwrap();
1499 let lexicon = Options::of(&config).lexicon;
1500 assert!(lexicon.provider_tags.iter().any(|t| t == "verdächtig"), "{lexicon:?}");
1501 assert!(lexicon.elisions.is_empty(), "no French elisions in English alone");
1502 assert_eq!(Options::default().lexicon, sioul_core::words::Words::builtin_ref().spam);
1503 }
1504
1505 fn trusted() -> BTreeMap<String, Vec<String>> {
1506 ["home", "work"].into_iter().map(|a| (a.to_string(), vec!["mx.example.net".to_string()])).collect()
1507 }
1508
1509 fn records(mail: &[synthetic::Mail]) -> Vec<Record> {
1510 mail.iter().enumerate().map(|(i, m)| synthetic::record_of(m, 1, i as u32 + 1)).collect()
1511 }
1512
1513 fn compared_with_current(dirs: &Dirs, test_ids: &[usize], test_words: &[Vec<String>], messages: &[Message], new_scored: &[(f64, bool)], options: &Options) -> (Option<Numbers>, Option<Compared>, bool, &'static str) {
1515 against_current(dirs, test_ids, test_words, messages, new_scored, options).unwrap()
1516 }
1517
1518 #[test]
1523 fn the_table_in_place_reads_with_its_own_words() {
1524 let root = scratch("own-words");
1525 let dirs = Dirs::under(&root);
1526 let mail = |i: u32| {
1528 let spam = i % 2 == 0;
1529 let (subject, body) = if spam { ("lottery jackpot", "You won the lottery jackpot, claim the jackpot prize now") } else { ("rehearsal notes", "The rehearsal notes for the concert, see you at the rehearsal") };
1530 let date = 1_790_000_000 + i64::from(i) * 3600;
1531 let stamp = jiff::Timestamp::from_second(date).unwrap().strftime("%a, %d %b %Y %H:%M:%S +0000").to_string();
1532 let raw = format!("From: Someone <s{i}@example.org>\r\nTo: owner@example.org\r\nSubject: {subject}\r\nDate: {stamp}\r\nMessage-ID: <{i}@example.org>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{body}\r\n");
1533 let (folder, role) = if spam { ("Junk", sioul_core::folders::Role::Junk) } else { ("INBOX", sioul_core::folders::Role::Inbox) };
1534 synthetic::Mail { account: "home", folder, role, flags: Vec::new(), date, raw: raw.into_bytes() }
1535 };
1536 let mails: Vec<synthetic::Mail> = (0..20).map(mail).collect();
1537 let records = records(&mails);
1538 corpus::store(&dirs, &records).unwrap();
1539 let builtin = Lexicon::builtin();
1541 let word = |w: &str| spam::word_hash(&spam::tokens_with(&builtin, "", w).words[0]);
1542 let mut table = Table {
1543 tokenizer: spam::TOKENIZER,
1544 features: spam::FEATURES,
1545 dim: 16,
1546 minn: 3,
1547 maxn: 6,
1548 bucket: 8,
1549 words: vec![(word("lottery"), 400.0), (word("jackpot"), 400.0), (word("rehearsal"), -400.0)],
1550 buckets: vec![0.0; 8],
1551 weights: Vec::new(),
1552 means: Vec::new(),
1553 bias: 0.0,
1554 text_mean: 0.0,
1555 platt_a: -1.0,
1556 platt_b: 0.0,
1557 meta: spam::Meta { trained_at: 1_780_000_000, ..spam::Meta::default() },
1558 };
1559 table.sort();
1560 table.write(&dirs.table()).unwrap();
1561 let mut lexicon = Options::default().lexicon;
1563 lexicon.stop_words.extend(["lottery".to_string(), "jackpot".to_string()]);
1564 let options = Options { lexicon: lexicon.clone(), ..small() };
1565 let ours = Lexicon::cached(&lexicon);
1566 let trusted = BTreeMap::new();
1567 let messages: Vec<Message> = records
1568 .iter()
1569 .zip(&mails)
1570 .map(|(record, m)| Message {
1571 label: if m.role == sioul_core::folders::Role::Junk { Label::Spam } else { Label::Ham },
1572 date: m.date,
1573 account: record.account.clone(),
1574 place: Some(record.place()),
1575 evidence: None,
1576 source: None,
1577 ordinal: 0,
1578 unsettled: false,
1579 known: true,
1580 features: read_message(record, &trusted, &builtin).1,
1581 offset: 0,
1582 length: 0,
1583 words: 0,
1584 })
1585 .collect();
1586 let test_ids: Vec<usize> = (0..messages.len()).collect();
1587 let test_words: Vec<Vec<String>> = records.iter().map(|r| read_message(r, &trusted, &ours).0).collect();
1588 let new_scored: Vec<(f64, bool)> = messages.iter().map(|m| (0.0, m.label == Label::Spam)).collect();
1589 let read_with = |lexicon: &Lexicon| {
1591 let scored: Vec<(f64, bool)> = records.iter().zip(&messages).map(|(r, m)| (f64::from(table.score(&read_message(r, &trusted, lexicon).0, &m.features).p), m.label == Label::Spam)).collect();
1592 eval::evaluate(&scored, options.threshold_spam, options.threshold_unsure)
1593 };
1594 let (own, misread) = (read_with(&builtin), read_with(&ours));
1595 assert_eq!(own.at_spam.spam_caught.count, 10, "its own words: every spam caught");
1596 assert_ne!(own, misread, "the new words would make it look otherwise");
1597 let (current, compared, _, _) = compared_with_current(&dirs, &test_ids, &test_words, &messages, &new_scored, &options);
1598 assert_eq!(current.as_ref(), Some(&own), "the table in place read with its own words");
1599 assert_eq!(compared.map(|c| (c.ham, c.spam)), Some((10, 10)));
1600 let _ = std::fs::remove_dir_all(&root);
1601 }
1602
1603 #[test]
1607 fn learns_invented_spam_apart_from_ham() {
1608 learns_apart(&small(), "classifier", &[Stage::Labels, Stage::Tokens, Stage::Language, Stage::Evaluation, Stage::Export], 100);
1609 learns_apart(¢roid(), "centroid", &[Stage::Labels, Stage::Tokens, Stage::Language, Stage::Features, Stage::Classifier, Stage::Evaluation, Stage::Export], 600);
1610 }
1611
1612 fn learns_apart(options: &Options, kind: &str, steps: &[Stage], refit_calibration: u64) {
1613 let root = scratch(&format!("learns-{kind}"));
1614 let dirs = Dirs::under(&root);
1615 let mail = synthetic::mailbox(11, 500, 300);
1616 corpus::store(&dirs, &records(&mail)).unwrap();
1617 let mut stages = Vec::new();
1618 let summary = train(&dirs, &trusted(), options, &mut |p| stages.push(p.stage), &Cancel::new()).unwrap();
1619 assert_eq!(summary.model.kind, kind);
1620 assert_eq!((summary.labels.ham, summary.labels.spam), (500, 300), "{:?}", summary.labels);
1621 assert_eq!(summary.split.train_ham + summary.split.train_spam + summary.split.test_ham + summary.split.test_spam, 800);
1622 assert_eq!(summary.split.test_ham + summary.split.test_spam, 160, "the newest fifth");
1623 let test = &summary.test;
1624 assert!(test.auc.is_some_and(|a| a > 0.97), "{test:?}");
1625 assert!(test.at_unsure.spam_caught.fraction() > 0.9, "{test:?}");
1626 assert!(test.at_spam.ham_called_spam.fraction() < 0.02, "{test:?}");
1627 assert!(summary.model.platt_a < 0.0, "Platt rises with the score: {:?}", summary.model);
1628 assert!(summary.model.fold_error < 1e-4, "{:?}", summary.model);
1629 assert!(summary.model.vocabulary > 50 && summary.model.calibration_scores >= 100, "{:?}", summary.model);
1630 assert!(summary.replaced && summary.reason == "no-table");
1631 assert!(dirs.table().exists() && dirs.language().exists() && !dirs.previous_table().exists());
1632 assert_eq!(last(&dirs).as_ref(), Some(&summary), "trained.toml says it all");
1633 let refit = summary.refit.as_ref().expect("replaced: refit");
1635 assert_eq!(refit.ham + refit.spam, 800);
1636 assert_eq!(summary.split.train_ham + summary.split.train_spam, 640, "the numbers' model learned from the oldest 80 %");
1637 assert!(refit.fold_error < 1e-4 && refit.platt_a < 0.0 && refit.calibration_scores >= refit_calibration, "{refit:?}");
1638 let written = Table::read(&dirs.table()).unwrap();
1639 assert_eq!(written.kind(), if kind == "classifier" { sioul_core::spam::table::Kind::Rows } else { sioul_core::spam::table::Kind::Centroid });
1640 assert_eq!((written.meta.ham + written.meta.spam, written.meta.test_ham + written.meta.test_spam), (800, 160));
1641 assert_eq!(written.meta.metrics.get("auc").copied(), summary.test.auc);
1642 assert!(!written.meta.device.is_empty());
1643 assert_eq!(written.meta.lexicon.as_ref(), Some(&options.lexicon), "the table keeps the words it read with");
1644 let firsts: Vec<Stage> = stages.iter().fold(Vec::new(), |mut seen, s| {
1646 if seen.last() != Some(s) {
1647 seen.push(*s);
1648 }
1649 seen
1650 });
1651 assert_eq!(firsts, steps);
1652 assert_eq!(std::fs::read_dir(&dirs.cache).unwrap().count(), 0);
1653 let spam = synthetic::message(&mut crate::Rng::new(99), true, 9999, 1_790_000_000);
1655 let why = why(&dirs, &spam, &[], None).unwrap();
1656 assert!(why.p > 0.5 && why.f > 0.0, "{why:?}");
1657 assert_eq!(why.words.len(), 5);
1658 assert!(why.words.iter().chain(&why.headers).all(|(_, share)| *share > 0.0) && why.headers.len() <= 3, "{why:?}");
1659 let _ = std::fs::remove_dir_all(root);
1660 }
1661
1662 #[test]
1666 fn a_worse_table_never_replaces_a_better_one() {
1667 let root = scratch("replace");
1668 let dirs = Dirs::under(&root);
1669 let mut mail = synthetic::mailbox(5, 400, 250);
1670 let mut rng = crate::Rng::new(5);
1672 let newest = mail.iter().map(|m| m.date).max().unwrap();
1673 for k in 0..15 {
1674 let date = newest + 3600 * (k + 1);
1675 mail.push(synthetic::Mail { account: "home", folder: "INBOX", role: sioul_core::folders::Role::Inbox, flags: vec![], date, raw: synthetic::message(&mut rng, true, 50_000 + k as u32, date) });
1676 }
1677 corpus::store(&dirs, &records(&mail)).unwrap();
1678 let first = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1679 assert!(first.replaced && first.reason == "no-table" && first.refit.is_some());
1680 assert!(first.test.at_spam.ham_called_spam.count > 0, "the spam-like ham is lost: {:?}", first.test);
1681 let first_table = std::fs::read(dirs.table()).unwrap();
1682 let second = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1684 assert!(second.replaced && second.reason == "nothing-new", "{} {:?}", second.reason, second.compared);
1685 assert_eq!(second.compared.map(|c| c.ham + c.spam), Some(0));
1686 assert_eq!(std::fs::read(dirs.previous_table()).unwrap(), first_table);
1687 let midway = mail.iter().map(|m| m.date).max().unwrap() - 3600 * 40;
1690 let mut never = Table::read(&dirs.table()).unwrap();
1691 never.platt_b = 1000.0;
1692 never.meta.trained_at = midway;
1693 never.write(&dirs.table()).unwrap();
1694 let kept = std::fs::read(dirs.table()).unwrap();
1695 let third = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1696 assert!(!third.replaced && third.reason == "worse" && third.refit.is_none(), "{} {:?}", third.reason, third.compared);
1697 let compared = third.compared.unwrap();
1698 assert_eq!(compared.since, midway);
1699 assert!(compared.ham >= 15 && compared.current_ham_lost == 0 && compared.new_ham_lost > 0, "{compared:?}");
1700 assert_eq!(third.current.as_ref().map(|c| c.ham + c.spam), Some(compared.ham + compared.spam), "the same messages");
1701 assert_eq!(std::fs::read(dirs.table()).unwrap(), kept, "the table in place stays");
1702 assert_eq!(last(&dirs).map(|s| s.reason), Some("worse".to_string()));
1703 let mut always = Table::read(&dirs.table()).unwrap();
1705 always.platt_b = -1000.0;
1706 always.write(&dirs.table()).unwrap();
1707 let fourth = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1708 assert!(fourth.replaced && fourth.reason == "no-worse", "{} {:?}", fourth.reason, fourth.compared);
1709 std::fs::write(dirs.table(), b"not a table").unwrap();
1711 let fifth = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1712 assert!(fifth.replaced && fifth.reason == "unreadable");
1713 let _ = std::fs::remove_dir_all(root);
1714 }
1715
1716 #[test]
1719 fn the_newest_month_is_in_the_table_that_runs() {
1720 let root = scratch("newest");
1721 let dirs = Dirs::under(&root);
1722 let mut mail = synthetic::mailbox(17, 600, 300);
1723 let newest = mail.iter().map(|m| m.date).max().unwrap();
1724 let mut rng = crate::Rng::new(17);
1725 for k in 0..90u32 {
1726 let date = newest + 3600 * (i64::from(k) + 1);
1727 mail.push(synthetic::Mail { account: "work", folder: "Junk", role: sioul_core::folders::Role::Junk, flags: vec![], date, raw: synthetic::campaign(&mut rng, 60_000 + k, date) });
1728 }
1729 corpus::store(&dirs, &records(&mail)).unwrap();
1730 let summary = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1731 let refit = summary.refit.expect("replaced: refit");
1732 assert_eq!(refit.ham + refit.spam, 990, "every message, the campaign included");
1733 let fresh = synthetic::campaign(&mut crate::Rng::new(99), 70_000, newest + 86_400 * 3);
1735 let why = why(&dirs, &fresh, &[], None).unwrap();
1736 assert!(why.p >= 0.95, "{why:?}");
1737 let _ = std::fs::remove_dir_all(root);
1738 }
1739
1740 #[test]
1747 fn outside_material_teaches_words_and_gives_a_baseline() {
1748 let root = scratch("outside");
1749 let dirs = Dirs::under(&root);
1750 corpus::store(&dirs, &records(&synthetic::mailbox(23, 300, 150))).unwrap();
1751 let mut rng = crate::Rng::new(23);
1753 let lines: Vec<String> = (0..500)
1754 .map(|k| {
1755 let spam = k % 3 == 0;
1756 let date = 1_640_995_200 + 3600 * i64::from(k);
1757 let card = Card::from_bytes(&synthetic::message(&mut rng, spam, 90_000 + k, date)).unwrap();
1758 serde_json::json!({ "label": if spam { "spam" } else { "ham" }, "date": date, "subject": card.subject, "text": card.excerpt }).to_string()
1759 })
1760 .collect();
1761 let file = root.join("old-filter.jsonl");
1762 std::fs::write(&file, lines.join("\n")).unwrap();
1763 let imported = external::import(&dirs, &file, None).unwrap();
1764 assert_eq!((imported.sources["old-filter"].ham, imported.sources["old-filter"].spam), (333, 167));
1765 let summary = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1766 assert_eq!(summary.split.test_ham + summary.split.test_spam, 90);
1768 assert_eq!(summary.split.train_ham + summary.split.train_spam, 360, "your own mail's oldest 80 %");
1769 let outside = &summary.outside["old-filter"];
1771 assert_eq!((outside.train_ham + outside.train_spam, outside.held_ham + outside.held_spam), (400, 100), "{outside:?}");
1772 let baseline = outside.baseline.as_ref().expect("held out: measured");
1773 assert!(baseline.auc.is_some_and(|a| a > 0.9) && baseline.ham + baseline.spam == 100, "{baseline:?}");
1774 assert_eq!(summary.refit.as_ref().map(|r| r.ham + r.spam), Some(450 + 500));
1776 let evaluation = evaluate(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1778 assert_eq!(evaluation.outside["old-filter"].ham + evaluation.outside["old-filter"].spam, 100);
1779 external::remove(&dirs, "old-filter").unwrap();
1781 let without = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1782 assert!(without.outside.is_empty() && without.refit.map(|r| r.ham + r.spam) == Some(450));
1783 let _ = std::fs::remove_dir_all(root);
1784 }
1785
1786 #[test]
1791 fn a_trial_changes_nothing_and_names_its_errors() {
1792 let root = scratch("trial");
1793 let dirs = Dirs::under(&root);
1794 corpus::store(&dirs, &records(&synthetic::mailbox(31, 300, 200))).unwrap();
1795 let trial = Options { replace: false, ..small() };
1797 let (summary, detail) = train_with(&dirs, &trusted(), &trial, 5, &mut |_| {}, &Cancel::new()).unwrap();
1798 assert!(summary.trial && !summary.replaced && summary.reason == "no-table" && summary.refit.is_none(), "{} {}", summary.reason, summary.replaced);
1799 assert!(!dirs.table().exists() && !dirs.language().exists() && !dirs.trained().exists() && last(&dirs).is_none());
1800 assert_eq!(std::fs::read_dir(&dirs.cache).unwrap().count(), 0, "the tokenized corpus is gone");
1801 assert_eq!((summary.model.threads, summary.model.min_count, summary.model.epochs, summary.model.word_ngrams), (1, 2, 25, 1), "how it was asked to learn");
1802 assert!(summary.model.costs.is_empty() && summary.model.kind == "classifier");
1803 let tested: u64 = detail.by_account.values().map(|c| c.ham.total() + c.spam.total()).sum();
1804 assert_eq!(tested, summary.split.test_ham + summary.split.test_spam);
1805 assert_eq!(detail.grid.len(), detail::GRID.len());
1806 assert!(detail.errors.ham_called_spam.len() <= 5 && detail.errors.spam_missed.len() <= 5);
1807 for w in detail.errors.ham_called_spam.iter().chain(&detail.errors.spam_missed) {
1808 assert!(w.card.is_some() && !w.outside && !w.account.is_empty(), "{w:?}");
1809 }
1810 assert!(detail.errors.ham_called_spam.windows(2).all(|w| w[0].p >= w[1].p), "the surest first");
1811 assert!(detail.errors.spam_missed.windows(2).all(|w| w[0].p <= w[1].p), "the least sure first");
1812 let kept = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
1814 assert!(kept.replaced && !kept.trial && last(&dirs).is_some_and(|l| !l.trial));
1815 let in_place = std::fs::read(dirs.table()).unwrap();
1816 let (evaluation, detail) = evaluate_with(&dirs, &trusted(), &small(), 3, &mut |_| {}, &Cancel::new()).unwrap();
1818 assert!(evaluation.unseen.is_none(), "{:?}", evaluation.unseen);
1819 assert!(detail.errors.ham_called_spam.len() <= 3 && detail.errors.spam_missed.len() <= 3);
1820 let mut rng = crate::Rng::new(31);
1822 let soon = now() + 3600;
1823 let newer: Vec<Record> = (0..40u32)
1824 .map(|k| {
1825 let spam = k % 2 == 0;
1826 let date = soon + i64::from(k) * 60;
1827 let (folder, role) = if spam { ("Junk", sioul_core::folders::Role::Junk) } else { ("INBOX", sioul_core::folders::Role::Inbox) };
1828 let mail = synthetic::Mail { account: "home", folder, role, flags: vec![], date, raw: synthetic::message(&mut rng, spam, 80_000 + k, date) };
1829 synthetic::record_of(&mail, 1, 20_000 + k)
1830 })
1831 .collect();
1832 corpus::store(&dirs, &newer).unwrap();
1833 let (evaluation, _) = evaluate_with(&dirs, &trusted(), &small(), 0, &mut |_| {}, &Cancel::new()).unwrap();
1834 let unseen = evaluation.unseen.expect("mail since the training");
1835 assert_eq!(unseen.since, Table::read(&dirs.table()).unwrap().meta.trained_at);
1836 assert_eq!(unseen.numbers.ham + unseen.numbers.spam, 40);
1837 let (second, _) = train_with(&dirs, &trusted(), &trial, 0, &mut |_| {}, &Cancel::new()).unwrap();
1839 assert!(second.trial && !second.replaced && second.compared.is_some(), "{second:?}");
1840 assert_eq!(std::fs::read(dirs.table()).unwrap(), in_place, "the table in place stays");
1841 assert!(!dirs.previous_table().exists());
1842 assert_eq!(last(&dirs).map(|l| l.trained_at), Some(kept.trained_at), "trained.toml stays the last training's");
1843 let _ = std::fs::remove_dir_all(root);
1844 }
1845
1846 #[test]
1850 fn what_outside_material_lacks_weighs_nothing() {
1851 let message = |known: bool, x: f32| Message { label: Label::Ham, date: 0, account: String::new(), place: None, evidence: None, source: (!known).then_some(0), ordinal: 0, unsettled: false, known, features: [x; N], offset: 0, length: 0, words: 0 };
1852 let messages = vec![message(true, 1.0), message(true, 3.0), message(false, 1000.0)];
1853 let vectors = vec![1.0, 2.0, 3.0];
1854 let rows = Rows { vectors: &vectors, messages: &messages, dim: 1 };
1855 let ids = [0, 1, 2];
1856 let (mean, deviation) = rows.standardization(&ids);
1857 assert_eq!((mean[1], deviation[1]), (2.0, 1.0), "your own mail's alone");
1858 assert_eq!((mean[0], deviation[0]), (2.0, (2.0f64 / 3.0).sqrt()), "the words' over all");
1859 let x = rows.standardized(&ids, &mean, &deviation);
1860 let width = rows.width();
1861 assert!(x[2 * width + 1..3 * width].iter().all(|v| *v == 0.0), "outside: 0 for every header feature");
1862 assert_ne!(x[2 * width], 0.0, "its words count");
1863 assert_eq!((x[width + 1], x[1]), (1.0, -1.0));
1864 }
1865
1866 #[test]
1867 fn too_few_or_stopped() {
1868 let root = scratch("few");
1869 let dirs = Dirs::under(&root);
1870 corpus::store(&dirs, &records(&synthetic::mailbox(3, 100, 5))).unwrap();
1871 assert!(matches!(train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()), Err(LearnError::TooFew { spam: 5, .. })));
1872 corpus::store(&dirs, &records(&synthetic::mailbox(4, 60, 60))).unwrap();
1873 let cancel = Cancel::new();
1874 cancel.cancel();
1875 assert_eq!(train(&dirs, &trusted(), &small(), &mut |_| {}, &cancel).err(), Some(LearnError::Cancelled));
1876 assert!(!dirs.table().exists());
1877 let _ = std::fs::remove_dir_all(root);
1878 }
1879
1880 #[test]
1885 fn the_phones_hashing_is_fasttexts() {
1886 let root = scratch("hashing");
1887 let text = root.join("text.txt");
1888 let mut rng = crate::Rng::new(1);
1889 let lines: Vec<String> = (0..300)
1890 .map(|_| {
1891 let spam = rng.unit() < 0.5;
1892 String::from_utf8(synthetic::message(&mut rng, spam, 1, 1_750_000_000)).unwrap().replace("\r\n", " ")
1893 })
1894 .collect();
1895 std::fs::write(&text, lines.join("\n") + "\n réunion naïve été\n").unwrap();
1896 let (minn, maxn, bucket) = (3, 6, 1000);
1897 let args = fasttext::args::Args {
1898 input: text.clone(),
1899 dim: 8,
1900 epoch: 1,
1901 min_count: 1,
1902 bucket: bucket as i32,
1903 minn: minn as i32,
1904 maxn: maxn as i32,
1905 thread: 1,
1906 ..fasttext::args::Args::default()
1907 };
1908 let model = fasttext::FastText::train(args).unwrap();
1909 let nwords = model.dict().nwords();
1910 let mut checked = (0, 0);
1911 let long = "z".repeat(40);
1912 let words = ["lottery", "bonjour", "réunion", "naïve", "zzqxv", "loterrie", "é", "a", "ab", "_URL_", "élève", "🙂ok", &long];
1913 for word in words {
1914 let ids = model.dict().get_subwords_for_string(word);
1915 let expected: Vec<i32> = spam::ngram_buckets(word, minn, maxn, bucket).iter().map(|&b| nwords + b as i32).collect();
1916 match model.get_word_id(word) {
1917 Some(id) => {
1918 assert_eq!(ids[0], id, "{word}");
1919 assert_eq!(ids[1..], expected[..], "{word}");
1920 checked.0 += 1;
1921 }
1922 None => {
1923 assert_eq!(ids, expected, "{word}");
1924 let vector = model.get_word_vector(word);
1925 let input = model.input_matrix();
1926 for d in 0..8 {
1927 let mean = expected.iter().map(|&id| f64::from(input.row(i64::from(id))[d])).sum::<f64>() / expected.len() as f64;
1928 assert!((mean - f64::from(vector[d])).abs() < 1e-6, "{word}[{d}]");
1929 }
1930 checked.1 += 1;
1931 }
1932 }
1933 }
1934 assert!(checked.0 >= 4 && checked.1 >= 5, "in and out of the vocabulary: {checked:?}");
1935 let _ = std::fs::remove_dir_all(root);
1936 }
1937
1938 #[test]
1942 #[ignore]
1943 fn measure_the_spec_sizes() {
1944 let root = scratch("measure");
1945 let dirs = Dirs::under(&root);
1946 let mail = synthetic::mailbox(21, 6000, 3000);
1947 corpus::store(&dirs, &records(&mail)).unwrap();
1948 let started = std::time::Instant::now();
1949 let summary = train(&dirs, &trusted(), &Options::default(), &mut |_| {}, &Cancel::new()).unwrap();
1950 let seconds = started.elapsed().as_secs_f64();
1951 let bytes = std::fs::metadata(dirs.table()).unwrap().len();
1952 let language = std::fs::metadata(dirs.language()).unwrap().len();
1953 let corpus: u64 = std::fs::read_dir(dirs.corpus().join("home")).unwrap().chain(std::fs::read_dir(dirs.corpus().join("work")).unwrap()).filter_map(Result::ok).map(|e| e.metadata().unwrap().len()).sum();
1954 println!(
1955 "messages {}, vocabulary {}, table {bytes} bytes, language model {language} bytes, corpus {corpus} bytes, {seconds:.1} s, fold error {:e}, AUC {:?}, C {}",
1956 summary.labels.ham + summary.labels.spam,
1957 summary.model.vocabulary,
1958 summary.model.fold_error,
1959 summary.test.auc,
1960 summary.model.c
1961 );
1962 assert!(summary.model.fold_error < 1e-4);
1963 let _ = std::fs::remove_dir_all(root);
1964 }
1965
1966 #[test]
1973 #[ignore]
1974 fn write_an_invented_profile() {
1975 let Some(root) = std::env::var_os("SIOUL_LEARN_DEMO").map(PathBuf::from) else { return };
1976 let config = "[[account]]\nid = \"home\"\nkind = \"imap\"\naddress = \"owner@example.org\"\nhost = \"imap.example.org\"\ntrusted_authserv_ids = [\"mx.example.org\"]\n\n[[account]]\nid = \"work\"\nkind = \"imap\"\naddress = \"owner@example.com\"\nhost = \"imap.example.com\"\n\n[spam]\nthreshold_spam = 0.9\nthreshold_unsure = 0.5\n";
1977 std::fs::create_dir_all(root.join("config/sioul")).unwrap();
1978 std::fs::write(root.join("config/sioul/config.toml"), config).unwrap();
1979 let dirs = Dirs { data: root.join("data/sioul/spam"), state: root.join("state/sioul/spam"), cache: root.join("cache/sioul/spam") };
1980 corpus::store(&dirs, &records(&synthetic::mailbox(13, 3000, 1500))).unwrap();
1981 std::fs::write(root.join("spam.eml"), synthetic::message(&mut crate::Rng::new(77), true, 77_777, 1_790_000_000)).unwrap();
1982 }
1983
1984 #[test]
1990 fn training_and_sorting_read_the_same_checks() {
1991 let mut mail = synthetic::mailbox(41, 3, 3).swap_remove(0);
1992 mail.raw = [b"DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=s1; bh=x; b=y\r\n".as_slice(), &mail.raw].concat();
1994 let mut record = synthetic::record_of(&mail, 1, 1);
1995 let stamp = "Authentication-Results: sioul-0123456789ab.invalid;\r\n\tdkim=pass header.d=example.org header.s=s1 header.b=AbCdEf;\r\n\tspf=fail (sioul-0123456789ab.invalid: domain of example.org does not designate 192.0.2.7 as permitted sender) smtp.mailfrom=example.org;\r\n\tdmarc=pass header.from=example.org policy.dmarc=reject\r\n";
1996 record.checked = Some(corpus::Checked { at: record.date + 3600, results: stamp.into(), whole: true, gone: false });
1997 let ids = vec!["sioul-0123456789ab.invalid".to_string(), "mx.example.net".to_string()];
1998 let trusted: BTreeMap<String, Vec<String>> = [(record.account.clone(), ids.clone())].into();
1999 let lexicon = Lexicon::builtin();
2000 let (words, trained) = read_message(&record, &trusted, &lexicon);
2001 let stored = Card::from_bytes(&[stamp.as_bytes(), &mail.raw].concat()).unwrap();
2002 let (sorted_words, sorted) = features_of(&stored, &ids, Some(record.date), None, &lexicon);
2003 assert_eq!(words, sorted_words, "the same words");
2004 assert_eq!(trained.map(f32::to_bits), sorted.map(f32::to_bits), "the same header features");
2005 let value = |x: &[f32; N], name: &str| x[spam::NAMES.iter().position(|n| *n == name).unwrap()];
2006 assert_eq!((value(&trained, "spf_fail"), value(&trained, "dkim_pass"), value(&trained, "dmarc_pass")), (1.0, 1.0, 1.0));
2007 let unchecked = read_message(&Record { checked: None, ..record.clone() }, &trusted, &lexicon).1;
2009 assert!(value(&unchecked, "spf_fail").is_nan() && value(&unchecked, "dkim_pass").is_nan());
2010 record.checked.as_mut().unwrap().at = record.date + 365 * 86_400;
2012 let (_, old) = read_message(&record, &trusted, &lexicon);
2013 assert_eq!((value(&old, "spf_fail"), value(&old, "spf_pass"), value(&old, "dkim_pass"), value(&old, "dmarc_pass")), (0.0, 0.0, 1.0, 1.0));
2014 let unsigned = Record { checked: record.checked.clone(), ..synthetic::record_of(&synthetic::mailbox(41, 3, 3)[0], 1, 1) };
2016 assert_eq!(value(&read_message(&unsigned, &trusted, &lexicon).1, "spf_fail"), 1.0);
2017 record.checked = Some(corpus::Checked { at: record.date + 3600, gone: true, ..corpus::Checked::default() });
2019 assert!(value(&read_message(&record, &trusted, &lexicon).1, "dkim_pass").is_nan());
2020 }
2021
2022 #[test]
2028 fn the_filters_word_is_learned_until_you_say() {
2029 use sioul_core::spam::Class;
2030 use sioul_core::spam::labels::{self as logs, Entry, Moved, Source};
2031 let root = scratch("filters-word");
2032 let dirs = Dirs::under(&root);
2033 let records = records(&synthetic::mailbox(13, 200, 120));
2034 corpus::store(&dirs, &records).unwrap();
2035 let id = |r: &Record| labels::message_id(&r.header_bytes()).expect("invented mail has its Message-ID");
2036 let judged = |r: &Record, class: Class| Moved { at: r.date + 60, account: r.account.clone(), folder: r.folder.clone(), uidvalidity: r.uidvalidity, uid: r.uid, message_id: Some(id(r)), class };
2037 let inbox: Vec<&Record> = records.iter().filter(|r| r.role == sioul_core::folders::Role::Inbox && !r.flags.iter().any(|f| f == "$Junk")).take(2).collect();
2039 let junk: Vec<&Record> = records.iter().filter(|r| r.role == sioul_core::folders::Role::Junk && r.flags.is_empty()).take(2).collect();
2040 let state = &dirs.state;
2041 logs::append_to(&logs::own_flagged_log(state, "desk"), &judged(inbox[0], Class::Spam)).unwrap();
2042 logs::append_to(&logs::own_flagged_log(state, "phone"), &judged(inbox[1], Class::Unsure)).unwrap();
2043 for r in &junk {
2044 logs::append_to(&logs::own_moved_log(state, "phone"), &Moved { folder: "INBOX".into(), uid: r.uid + 10_000, ..judged(r, Class::Spam) }).unwrap();
2045 }
2046 let said = Entry { at: junk[1].date + 3600, account: junk[1].account.clone(), folder: junk[1].folder.clone(), uidvalidity: 1, uid: junk[1].uid, message_id: Some(id(junk[1])), label: Label::Ham, source: Source::NotSpam };
2047 logs::append_to(&logs::own_log(state, "desk"), &said).unwrap();
2048 let summary = train(&dirs, &trusted(), &small(), &mut |_| {}, &Cancel::new()).unwrap();
2049 let l = &summary.labels;
2050 assert_eq!((l.ham, l.spam), (199, 120), "{l:?}");
2052 assert_eq!((l.by_filter, l.unsure, l.filter_moved, l.by_log), (1, 1, 1, 1), "{l:?}");
2053 assert_eq!(summary.split.train_ham + summary.split.train_spam + summary.split.test_ham + summary.split.test_spam, 319, "the doubt left out");
2054 let _ = std::fs::remove_dir_all(root);
2055 }
2056
2057 #[test]
2058 fn the_table_is_replaced_only_when_no_worse() {
2059 assert_eq!(replacement(0, 0, 0), (true, "nothing-new"));
2060 assert_eq!(replacement(3, 3, 40), (true, "no-worse"));
2061 assert_eq!(replacement(2, 3, 40), (true, "no-worse"));
2062 assert_eq!(replacement(4, 3, 40), (false, "worse"));
2063 }
2064
2065 #[test]
2066 fn standardization_of_columns() {
2067 let rows = [vec![1.0, 5.0], vec![3.0, 5.0]];
2068 let (mean, deviation) = standardization(|k| rows[k].clone(), |_| true, rows.len(), 2, 2);
2069 assert_eq!(mean, vec![2.0, 5.0]);
2070 assert_eq!(deviation, vec![1.0, 0.0], "a column that never changes has no deviation");
2071 let rows = [vec![1.0, 4.0], vec![3.0, 6.0], vec![5.0, 0.0]];
2073 let (mean, deviation) = standardization(|k| rows[k].clone(), |k| k < 2, rows.len(), 2, 1);
2074 assert_eq!((mean, deviation), (vec![3.0, 5.0], vec![(8.0f64 / 3.0).sqrt(), 1.0]));
2075 let rows = [vec![1.0, 4.0], vec![3.0, f64::NAN], vec![5.0, 6.0]];
2077 let (mean, deviation) = standardization(|k| rows[k].clone(), |_| true, rows.len(), 2, 1);
2078 assert_eq!((mean[1], deviation[1]), (5.0, 1.0));
2079 }
2080}