Skip to main content

sioul_learn/
train.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Training on demand: "Train now" in the mail settings, and
5//! `sioul spam train` (docs/spam-filter.md, "Training on demand").
6//!
7//! The model learns in one of two ways (`Model`). The filter's, by default,
8//! is fastText's classifier (`supervised`, which says its own steps after
9//! the second): a message's words and its header facts as words, two
10//! labels; it ranked a real mailbox's mail better than the centroid did,
11//! and gave the same numbers from one training to the next. The centroid's,
12//! on request, is the one below, the filter's before features version 3:
13//!
14//! 1. The corpus is brought up to date (`corpus::update`, by `run`).
15//! 2. Each message gets one label (`labels`), and its words and header
16//!    features from sioul-core's spam module, read as the Porch reads mail.
17//! 3. fastText learns your mail's words: skip-gram, 100 dimensions,
18//!    character n-grams of 3 to 6, 200 000 buckets, words seen 5 times.
19//! 4. A message's vector is the plain mean of its words' vectors (fastText's
20//!    `get_word_vector`), never normalized, so that the model folds; the
21//!    header features follow it; each dimension is standardized with the
22//!    training messages' mean and deviation.
23//! 5. Oldest 80 % to learn, newest 20 % to test, by INTERNALDATE. The SVM's
24//!    cost is chosen among 0.01, 0.1, 1 and 10 on the newest fifth of the
25//!    learning part; ham weighs 5. Outside material (`external`), when
26//!    imported, is split alike, source by source, by its own dates: its
27//!    oldest 80 % joins the learning part at its dates, with its header
28//!    features at the learning part's mean (0 once standardized: only its
29//!    words weigh); its newest 20 % is held out, the baseline.
30//! 6. Platt's A and B are fitted on scores each message got from an SVM
31//!    trained on older mail only (forward folds).
32//! 7. The numbers, on the newest 20 % of your own mail: aggregates only
33//!    (`eval`); the same numbers on each outside source's held-out part,
34//!    apart: the baseline.
35//! 8. The model is folded into the reduced table (one scalar per word and per
36//!    bucket, the header weights, the bias); the table must give the model's
37//!    score within 1e-4 on every test message, or nothing is exported. It
38//!    replaces `table.bin` only if it loses no more ham on the newest 20 %, at
39//!    your spam threshold, than the table in place, on those of them the
40//!    table in place never learned from (it learned from all mail before its
41//!    training: on that mail it would be judged on what it was taught); the
42//!    one before is kept as `table.prev.bin`. `trained.toml` says what
43//!    happened, for the settings.
44//! 9. Kept, the model is learned again, with the same cost, from every
45//!    message, the newest month included (spam changes, and the filter that
46//!    runs must have seen this month's): standardized anew, calibrated by
47//!    Platt on forward folds over all of it (each fifth scored by an SVM that
48//!    learned the fifths before it), folded and tested the same way. That is
49//!    the table written; the numbers said are those of steps 5 to 7.
50//!
51//! A trial (`Options::replace` false: `sioul spam train --no-replace`, the
52//! MCP's spam_train unless asked) stops after step 8's comparison: it says
53//! what the table in place would become, and changes nothing of the filter,
54//! the table, the one before, the language model nor `trained.toml`.
55//! `train_with` also says what the test said message by message (`detail`).
56
57use 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/// How training goes; `Default` holds the values of docs/spam-filter.md.
77#[derive(Debug, Clone, PartialEq)]
78pub struct Options {
79    /// fastText: the vectors' dimension.
80    pub dim: u32,
81    /// fastText: passes over the corpus (the classifier's 25; the centroid's
82    /// language model, 5: `Model::epochs`).
83    pub epochs: u32,
84    /// fastText: words seen fewer times are left out of the vocabulary.
85    pub min_count: u32,
86    /// fastText: the character n-grams' hash buckets.
87    pub bucket: u32,
88    /// fastText: character n-grams from `minn` to `maxn` characters.
89    pub minn: u32,
90    pub maxn: u32,
91    /// Threads for fastText and the vectors; the computer's cores by default.
92    pub threads: u32,
93    /// The same seed and one thread give the same model.
94    pub seed: u64,
95    /// The SVM: ham's weight (calling ham spam costs this many times more).
96    pub ham_weight: f64,
97    /// The SVM's costs to choose from.
98    pub costs: Vec<f64>,
99    /// Your thresholds (`spam.threshold_spam`, `spam.threshold_unsure`).
100    pub threshold_spam: f64,
101    pub threshold_unsure: f64,
102    /// Fewer messages of a kind than this: no training.
103    pub least_of_each: u64,
104    /// The table in place may be replaced (when no worse). False: a trial,
105    /// which changes nothing of the filter and says what it would do.
106    pub replace: bool,
107    /// Which model learns.
108    pub model: Model,
109    /// The tokenizer's words (the spam lists of the languages in use and your
110    /// changes, `[words]`): it reads the messages with them, and the table
111    /// keeps them (`spam::Meta::lexicon`).
112    pub lexicon: SpamWords,
113    /// The configuration's Microsoft accounts: a folder of the corpus that
114    /// recorded nothing is read only when it is not one of theirs (`corpus::read_all`).
115    pub microsoft: MicrosoftIds,
116}
117
118/// Which model learns: fastText's classifier (the filter's), or the
119/// language model's centroid and the SVM (the filter's before features
120/// version 3, on request).
121#[derive(Debug, Clone, Copy, PartialEq)]
122pub enum Model {
123    /// The words and the header facts as words, two labels; `lr` fastText's
124    /// learning rate; word pairs (`word_ngrams` above 1) only in a trial:
125    /// the table reads none.
126    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    /// Its passes over the corpus by default: the classifier 25, the centroid's language model 5.
138    pub fn epochs(&self) -> u32 {
139        match self {
140            Model::Supervised { .. } => 25,
141            Model::Centroid => 5,
142        }
143    }
144
145    /// Its name in the settings and on the command line: "supervised", "centroid".
146    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    /// The defaults, with your thresholds (`[spam]`, read as the Porch reads
180    /// them) and the tokenizer's words of your languages and changes (`[words]`).
181    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/// The last training, as `trained.toml` keeps it for the settings: numbers only.
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct Summary {
191    /// When it ended (Unix seconds), and how long it took.
192    pub trained_at: i64,
193    pub seconds: f64,
194    pub tokenizer: u32,
195    pub features: u32,
196    /// The table took this training's model.
197    pub replaced: bool,
198    /// Why: "no-table" (none yet), "unreadable" (the one in place was made by
199    /// another version, or damaged), "nothing-new" (no test message came after
200    /// the one in place was trained), "no-worse", "worse" (kept).
201    pub reason: String,
202    pub threshold_spam: f64,
203    pub threshold_unsure: f64,
204    pub labels: labels::Summary,
205    pub split: Split,
206    /// Per account: ham and spam learned from, ham and spam tested on.
207    pub accounts: BTreeMap<String, Counts>,
208    pub model: ModelSummary,
209    /// The numbers, on the newest 20 %, of the model learned from the oldest
210    /// 80 % (`model`): what decided the replacement.
211    pub test: Numbers,
212    /// The same, ham an inbox holds since less than `detail::UNSETTLED_DAYS`
213    /// left out: its label may not be settled (spam nobody looked at yet).
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub test_settled: Option<Numbers>,
216    /// The table in place before, on the test messages it never learned from
217    /// (those after its training: `compared`).
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub current: Option<Numbers>,
220    /// What the replacement compared.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub compared: Option<Compared>,
223    /// The table written, when replaced: the same model learned again from every message.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub refit: Option<Refit>,
226    /// Outside material learned from, by source (`external`): what each gave, and its baseline.
227    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
228    pub outside: BTreeMap<String, Outside>,
229    /// A trial (`Options::replace` false): nothing was replaced nor written;
230    /// `reason` says what the comparison found ("worse": the table in place
231    /// would have stayed).
232    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
233    pub trial: bool,
234}
235
236/// An outside source in a training: its oldest 80 % learned from, its
237/// newest 20 % held out, and the numbers there (the baseline).
238#[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    /// The numbers of the model learned from the oldest 80 % (yours and
245    /// every source's), on this source's held-out part; none when nothing is held out.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub baseline: Option<Numbers>,
248}
249
250/// The replacement's comparison: the test messages the table in place never
251/// learned from, and the ham each table sets aside among them.
252#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
253pub struct Compared {
254    /// When the table in place was trained: only later messages are compared.
255    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/// The table written: the model learned again, with the same cost, from
263/// every labelled message, the newest included.
264#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
265pub struct Refit {
266    pub ham: u64,
267    pub spam: u64,
268    /// Platt's A and B, from forward folds over every message: each fifth
269    /// scored by an SVM learned from the fifths before it.
270    pub platt_a: f64,
271    pub platt_b: f64,
272    pub calibration_scores: u64,
273    /// The largest difference between the table's score and the model's on the test messages.
274    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    /// INTERNALDATE of the oldest test message (Unix seconds).
284    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/// The model learned from the oldest 80 %, whose numbers `test` gives, and
296/// how it was asked to learn (`Options`: the command line's overrides too).
297#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
298pub struct ModelSummary {
299    /// Which model learned: "classifier" (fastText's, the filter's) or "centroid".
300    #[serde(default, skip_serializing_if = "String::is_empty")]
301    pub kind: String,
302    /// The classifier's word pairs (1: none) and learning rate.
303    #[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    /// Words fastText kept.
309    pub vocabulary: u64,
310    pub bucket: u32,
311    pub minn: u32,
312    pub maxn: u32,
313    pub epochs: u32,
314    /// Words seen fewer times were left out.
315    #[serde(default)]
316    pub min_count: u32,
317    /// fastText's threads.
318    #[serde(default)]
319    pub threads: u32,
320    /// The SVM's costs it chose from.
321    #[serde(default, skip_serializing_if = "Vec::is_empty")]
322    pub costs: Vec<f64>,
323    /// The SVM's cost chosen, and the validation AUC each cost had.
324    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    /// Scores the calibration was fitted on.
330    pub calibration_scores: u64,
331    /// The largest difference between the table's score and the model's on the test messages.
332    pub fold_error: f64,
333    /// The size of the table written (the refit), or that would have been.
334    pub table_bytes: u64,
335    /// The classifier's weight on each header feature, standardized (its
336    /// pull for one deviation of the feature): what the header facts learned.
337    #[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
349/// The last training's summary; none before the first.
350pub 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
354/// "Train now": the corpus brought up to date from every account (each
355/// password as the sync uses it; a Microsoft one only with your consent,
356/// `consent`, which the window keeps first), then `train` with your
357/// thresholds. `here`: whether an account is still in the configuration
358/// (`corpus::update`). For the window: run it on a thread of its own;
359/// `progress` says each step, `cancel` stops it at the next.
360pub 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
369/// The authserv-ids each account trusts, as the Porch reads them (Sioul's
370/// own first, then the provider's: `Config::mail_sources`).
371pub 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
375/// One message on its way to the classifier.
376pub(crate) struct Message {
377    pub(crate) label: Label,
378    pub(crate) date: i64,
379    pub(crate) account: String,
380    /// Your own mail: the copy learned from, and what decided its label.
381    pub(crate) place: Option<Place>,
382    pub(crate) evidence: Option<Evidence>,
383    /// Outside material: its source (an index into the training's sources); none for your own mail.
384    pub(crate) source: Option<usize>,
385    /// Outside material: its rank in its source, as `external::read_all` reads it (`detail`).
386    pub(crate) ordinal: u64,
387    /// Ham in an inbox, too young for its label to be settled (`detail::unsettled`).
388    pub(crate) unsettled: bool,
389    /// Its header features are known (your own mail); outside material's are
390    /// not: the centroid sets them to the mean, the classifier reads no word for them.
391    pub(crate) known: bool,
392    pub(crate) features: [f32; N],
393    /// Where its words are in the tokenized corpus.
394    pub(crate) offset: u64,
395    pub(crate) length: u64,
396    pub(crate) words: u64,
397}
398
399/// The tokenized corpus, removed whatever happens.
400struct TokenFile(PathBuf);
401
402impl Drop for TokenFile {
403    fn drop(&mut self) {
404        let _ = std::fs::remove_file(&self.0);
405    }
406}
407
408/// fastText reads lines of at most 1024 words: longer messages are cut in lines of this many.
409const LINE_WORDS: usize = 1000;
410
411/// Trains on the corpus as it is (see the module's steps). The table is
412/// replaced only when no worse; `trained.toml` says what happened.
413pub 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
417/// `train`, and what its test said message by message (`detail`): the test
418/// messages counted by account and folder, the grid of thresholds, and up
419/// to `errors` errors of each kind, your own mail's and each outside
420/// source's, as the model learned from the oldest 80 % judged them.
421pub 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    // Labels: a first pass over the corpus, keeping only what deciding needs.
427    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    // Outside material, by source: a message also in your own mail is learned from yours.
439    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    // Room: the tokenized corpus (at most the texts' size) and the language model.
462    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    // Words and header features: a second pass, for the copies learned from.
471    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    // Your own messages, then the outside material's.
477    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        // Outside material after your own mail, in the same file: its words teach the language model too.
526        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    // A copy that could not be read again (the corpus changed meanwhile) is left out.
572    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    // fastText's classifier, the filter's model: its own steps (`supervised`).
578    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    // fastText.
586    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    // Message vectors, read back from the tokenized corpus.
607    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    // Your own mail: each account's ham, and its spam, oldest 80 % to learn,
612    // newest 20 % to test (`newest_fifths`); each outside source alike, its
613    // ham and its spam, by their own dates, the newest 20 % held out (the
614    // baseline). The test and held-out messages' words kept for their scores.
615    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    // The model of the oldest 80 %: its cost chosen on the newest fifth of
624    // those, Platt on forward folds over them, then learned from all of them.
625    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            // AUC first; without both kinds in the validation, the weighted hinge loss.
640            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    // Its table, and the fold's test on every test message; its numbers.
658    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    // The table as every device will read it: its bytes, read back (scores in f32, words sorted by hash).
661    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    // The same, ham an inbox holds since less than a month left out: it may be spam nobody has looked at.
668    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    // Each outside source's held-out part: the baseline, its header features at the table's means (they weigh nothing).
671    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    // Message by message: counted by account and folder, the grid, and the worst errors, read again.
679    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    // The table in place, on the test messages it never learned from.
684    let (current_numbers, compared, replaced, reason) = against_current(dirs, test_ids, &test_words, &messages, &new_scored, options)?;
685    check()?;
686
687    // Kept: the same model learned again from every message, the newest month included, and written.
688    // A trial stops here: nothing of the filter changes.
689    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 whole or not at all; the table it replaces is kept as table.prev.bin.
702        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    // What the settings say of the last training: never a trial's.
757    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
764/// What the corpus and the outside material gave, read once, for the classifier's own steps (`supervised`).
765pub(crate) struct Read {
766    pub(crate) messages: Vec<Message>,
767    /// Each message's words, in its order.
768    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
775/// The messages' parts: every message in time order; the learning part
776/// (time order); your own mail's test part; each outside source's held-out
777/// part (`newest_fifths`).
778pub(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
785/// Your own mail: each account's ham, and its spam, oldest 80 % to learn,
786/// newest 20 % to test; each outside source alike, its ham and its spam, by
787/// their own dates, the newest 20 % held out (the baseline).
788pub(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
810/// What was learned from and tested on, in all and per account: your own mail's.
811pub(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
835/// An outside source's held-out part, scored: its counts and its numbers, the baseline.
836pub(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
844/// A test message as `detail` takes it: where it is, its label, how likely spam.
845pub(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
853/// The oldest 80 % and the newest 20 % of each group of `ids` (in time
854/// order): an account's ham, its spam; an outside source's. Every group is
855/// on both sides, so that a group whose mail all came late (a provider that
856/// keeps spam a month) is learned from too, and none is told apart by when it came.
857pub(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
871/// The test's numbers the table carries, for the settings of every device.
872pub(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
888/// The table in place, on the same test messages: those of them it never
889/// learned from (it learned from every message before its training), so
890/// that both are judged on mail they never saw; then whether the new table
891/// takes its place (`replacement`), and why. `test_words` and `new_scored`
892/// are in `test_ids`' order, read with this training's words
893/// (`Options::lexicon`). Each table reads with its own words: when those of
894/// the table in place (`Table::lexicon`: its own, or the built-in French and
895/// English ones when it keeps none) are not this training's, its test
896/// messages are read again from the corpus with them, and one that can no
897/// longer be read is left out for both tables; a change of words never makes
898/// the table in place look worse than it is.
899pub(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 &current {
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 (&current, &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
942/// Whether the new table replaces the one in place, and why, from the ham
943/// each sets aside among the `compared` test messages the one in place never
944/// learned from. None of them (nothing came since it was trained): nothing
945/// says it is better; the new one learned from the same mail, and replaces it.
946pub(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
956/// A message's words and header features, from its corpus record, as the
957/// Porch reads the message itself: Sioul's own checks of it first (made on
958/// the whole message when it was fetched, as the Porch reads the stamp the
959/// sync writes on it; beside a signature, their failures unknown when made
960/// long after it came: `corpus::Checked::reading`), your provider's second.
961/// The words are read with `lexicon` (a training's, or the table's own).
962pub(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
968/// A card's words and header features; `own`, Sioul's checks kept apart
969/// from it (a corpus record's), else those its headers carry (Sioul's stamp
970/// first, your provider's second: `features::auth_results`); its words read
971/// with `lexicon`.
972pub(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
979/// A record's words alone, read with `lexicon` as `read_message` reads them:
980/// what the table in place reads of a test message when its words are not
981/// the training's (`against_current`).
982fn 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
986/// The words fastText can learn: it splits on whitespace, so a word never holds any.
987fn kept(words: Vec<String>) -> Vec<String> {
988    words.into_iter().filter(|w| !w.is_empty() && !w.contains(char::is_whitespace)).collect()
989}
990
991/// The classifier's rows: each message's vector, then its header features.
992struct 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    /// The mean and the deviation of each column over some messages; the
1008    /// header features' over those that have them (your own mail), each
1009    /// over the messages where it is known (not missing: `f32::NAN`).
1010    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    /// Some messages' rows, standardized, one after the other; a message's
1015    /// header features it does not have (outside material), or that are
1016    /// missing, at the mean: 0.
1017    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
1036/// The mean and the deviation (population) of `width` columns over `n` rows,
1037/// in two passes, rows made one at a time; 0 for a column that never changes.
1038/// The columns from `dim` on (the header features) only over the rows that
1039/// have them (`known`); without any, 0. A missing value (NaN) counts in neither.
1040fn 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
1071/// What outside material, which has no headers, is scored with: every
1072/// header feature missing, which weighs nothing in either kind of table (the
1073/// classifier reads no word for it, the centroid stands it at its mean).
1074fn lacking(_table: &Table) -> [f32; N] {
1075    [f32::NAN; N]
1076}
1077
1078/// The SVM learned from the first `end` rows of `x` (time order), cost `c`.
1079fn 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] }, &params, &|| cancel.cancelled()).map_err(|_| LearnError::Cancelled)
1082}
1083
1084/// The SVM learned from every row (time order), cost `c`, and Platt's
1085/// sigmoid from forward folds over them: each fifth scored by an SVM learned
1086/// from the fifths before it. When the folds give none (too few of a kind
1087/// early on), the model's own scores, which make it surer than it is.
1088/// `step` is told after each fit; the scores the sigmoid was fitted on are counted.
1089fn 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
1108/// The largest difference between a table's score and its model's on some
1109/// messages (`x`, their standardized rows); over 1e-4, the fold is broken.
1110fn 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/// The model learned again, cost `c`, from every message (`order`, time
1124/// order), the newest included: standardized anew, Platt on forward folds over
1125/// all of it, folded; the fold tested on the test messages. The table to
1126/// write (its metadata counts what it learned from; its metrics are the
1127/// caller's), and what to say of it.
1128#[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    // What it learned from: every message, outside material included; tested on your newest.
1148    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
1162/// The mean weighted squared hinge loss of scores (lower is better).
1163fn 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
1174/// Every message's vector: the plain mean of its words' fastText vectors,
1175/// computed on `threads` threads, in f64, from the tokenized corpus.
1176fn 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    // The vocabulary's vectors once (most words are in it); the others on the way.
1179    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        // Progress from this thread while the others work.
1244        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
1262/// The words of some messages, read back from the tokenized corpus.
1263pub(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
1276/// The model folded into the reduced table (spam-core-api.md): with
1277/// `u = w/σ` over the vector's dimensions, each vocabulary word's scalar is
1278/// `u · get_word_vector(word)`, each bucket's `u · its input row`; the
1279/// standardization's means become `text_mean = u · μ` and the header means.
1280fn 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/// The table in place, tested on the newest 20 % of the corpus as it is now.
1329#[derive(Debug, Clone, PartialEq)]
1330pub struct Evaluation {
1331    pub labels: labels::Summary,
1332    /// The test messages' counts (`train_*` stay 0).
1333    pub split: Split,
1334    pub numbers: Numbers,
1335    /// When the table was trained (Unix seconds), and on how much.
1336    pub table: spam::Meta,
1337    /// Each outside source's newest 20 %, the baseline, apart.
1338    pub outside: BTreeMap<String, Numbers>,
1339    /// The test messages that came after the table was trained: it never
1340    /// learned from them. The table a training writes learned from every
1341    /// message before it, the newest fifth included: on the others, it is
1342    /// judged on what it was taught. None when none came since.
1343    pub unseen: Option<Unseen>,
1344}
1345
1346/// The numbers on the test messages the table in place never learned from.
1347#[derive(Debug, Clone, PartialEq)]
1348pub struct Unseen {
1349    /// When the table was trained: only later messages count.
1350    pub since: i64,
1351    pub numbers: Numbers,
1352}
1353
1354/// Tests the table in place on the newest fifth of the corpus as it is now,
1355/// without training: aggregate numbers only (`sioul spam eval`). New mail
1356/// since the training makes it a test of how the table holds up.
1357pub 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
1361/// `evaluate`, and what the test said message by message (`detail`), with
1362/// up to `errors` errors of each kind (`sioul spam eval --errors`).
1363pub 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    // Every message read with the table's own words.
1366    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    // The newest fifth of each account's ham and of its spam, as a training tests on (`newest_fifths`).
1375    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    // Message by message, for the detail; then the numbers, and those of the messages it never learned from.
1400    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    // Each outside source's newest fifth, as a training holds it out: the baseline.
1416    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        // Its ham and its spam, each its newest fifth, as a training holds them out.
1431        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/// What weighed in one message's verdict. It names words of the message: for
1446/// you, in your terminal (`sioul spam why`), never through MCP.
1447#[derive(Debug, Clone, PartialEq)]
1448pub struct Why {
1449    /// The probability of spam, and the score it comes from (positive for spam).
1450    pub p: f64,
1451    pub f: f64,
1452    /// Up to five words or placeholders that pushed toward spam, the most first, with their part of the score.
1453    pub words: Vec<(String, f64)>,
1454    /// Up to three header features that pushed toward spam, the most first, by name.
1455    pub headers: Vec<(String, f64)>,
1456}
1457
1458/// The table's verdict on a whole message (a file's bytes), and why.
1459/// `trusted` are its account's authserv-ids; `internal_date` when it reached
1460/// the server (a Maildir file's modification time).
1461pub 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    // Each word's and each feature's share of the score, the most toward spam first, as the Porch says why (`spam::Why::of`).
1467    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    /// Small enough to train in a second; one thread and a seed: the same
1485    /// model each time. The filter's model, fastText's classifier.
1486    fn small() -> Options {
1487        Options { dim: 16, min_count: 2, bucket: 2000, threads: 1, seed: 7, ..Options::default() }
1488    }
1489
1490    /// The same, the centroid's: its language model, the SVM.
1491    fn centroid() -> Options {
1492        Options { epochs: Model::Centroid.epochs(), model: Model::Centroid, ..small() }
1493    }
1494
1495    /// Your words reach the training: the spam lists of your languages, and your changes.
1496    #[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    /// The comparison with the table in place, as a training makes it.
1514    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    /// The table in place is judged on the newest messages read with its own
1519    /// words (`Table::lexicon`), never with the new training's: a change of
1520    /// words would make it look worse than it is. Here the new training's
1521    /// words drop the very words the table in place knows spam by.
1522    #[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        // Ten ham and ten spam, all after the table in place was trained.
1527        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        // The table in place, made before the words existed (none kept: the built-in ones).
1540        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        // The new training's words: "lottery" and "jackpot" dropped, as stop words.
1562        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        // What the table in place says of them, each read with its own words, and with the new ones.
1590        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    /// Invented ham and spam: each model tells them apart, Platt rises, the
1604    /// table gives the model's scores, and the files are where the settings
1605    /// read them.
1606    #[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(&centroid(), "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        // The table written learned again from every message, the newest included; the numbers are the 80 % model's.
1634        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        // In order, every stage; the tokenized corpus is gone.
1645        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        // Why, for a spam it never saw: its spam words weigh most.
1654        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    /// The table is replaced when the new one loses no more ham than the one
1663    /// in place, on the test messages that one never learned from; kept when
1664    /// it would lose more; the one before is kept as table.prev.bin.
1665    #[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        // Ham that reads like spam, the newest of all: a table that calls them spam loses them.
1671        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        // Nothing came since: the table in place learned from all of it; replaced, the first kept as the previous.
1683        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        // A table in place trained before the newest messages, which never calls anything spam:
1688        // it loses no ham among those; the new one loses the spam-like ham, and is kept out.
1689        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        // One that calls everything spam loses every such ham: the new one is no worse, replaced.
1704        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        // A table made for another version cannot be compared: replaced, and said so.
1710        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    /// Spam changes: a campaign of the newest weeks, its words never seen
1717    /// before, is in the table that runs, learned again from every message.
1718    #[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        // A message of that campaign, never seen: spam to the table that runs.
1734        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    /// Outside material: its words join the language model's and the
1741    /// classifier's learning, its header features at the mean (0 once
1742    /// standardized: no pull on the header weights); split in time source by
1743    /// source, its newest fifth held out and measured apart, the baseline;
1744    /// your own mail tested as before. It alone is not enough of yours to
1745    /// test on, but enough to learn.
1746    #[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        // Two years older than your own mail, from an older filter's dump: subject and text, no headers.
1752        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        // Your own mail tested as before, on its newest fifth.
1767        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        // The outside source split by its own dates: 400 learned from, 100 held out and measured.
1770        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        // The table in use learned from all of it.
1775        assert_eq!(summary.refit.as_ref().map(|r| r.ham + r.spam), Some(450 + 500));
1776        // `sioul spam eval`: the same baseline on the table in place.
1777        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        // Removed: the next training knows nothing of it.
1780        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    /// A trial (`--no-replace`) says what the table would become and changes
1787    /// nothing of the filter; asked for errors, it gives the worst of each
1788    /// kind with their cards read again. `evaluate_with` says the same of the
1789    /// table in place, and apart the numbers of the messages it never learned from.
1790    #[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        // No table yet: a trial would write one, and writes nothing.
1796        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        // Trained for good: written, and trained.toml says it.
1813        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        // The newest fifth was learned from by the table in place: nothing unseen.
1817        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        // Mail since the table's training: tested apart.
1821        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        // A trial with a table in place: compared, never replaced.
1838        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    /// Outside material's header features, which it does not have, stand at
1847    /// the mean of your own mail's: standardized, exactly 0, whatever is
1848    /// written there; the mean and the deviation are your own mail's alone.
1849    #[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    /// The phone's n-gram hashing (sioul-core) is fastText's: the same rows,
1881    /// in the same order, for words in the vocabulary and out of it, accents
1882    /// included (fastText's sign-extended bytes); and an unknown word's
1883    /// vector is the mean of those rows.
1884    #[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    /// The sizes of docs/spam-filter.md (dimension 100, 200 000 buckets, words seen
1939    /// 5 times) on a larger invented mailbox: how big the table is, how long
1940    /// training takes. `cargo test --release -p sioul-learn -- --ignored measure --nocapture`.
1941    #[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    /// Lays out an invented profile for trying `sioul spam` by hand, in the
1967    /// folder `SIOUL_LEARN_DEMO` names: a configuration with two invented
1968    /// accounts (IMAP: never fetch with them, `--no-fetch`; no password is
1969    /// kept), an invented corpus, one invented spam (`spam.eml`). Point
1970    /// XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME and XDG_CACHE_HOME at its
1971    /// config, data, state and cache folders.
1972    #[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    /// The same message stored by the sync with Sioul's stamp on top (as the
1985    /// Porch sorts it) and kept in the corpus with the same checks beside it
1986    /// (as training reads it): the same words, the same header features.
1987    /// Checked long after it came, its failure reads unknown beside its
1988    /// signature, its passes kept; unsigned, the failure holds.
1989    #[test]
1990    fn training_and_sorting_read_the_same_checks() {
1991        let mut mail = synthetic::mailbox(41, 3, 3).swap_remove(0);
1992        // Signed, as its checks say.
1993        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        // Without checks of its own (none made yet): the provider's, none here: missing.
2008        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        // Checked a year after it came: the failure unknown (no word), the passes as they were.
2011        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        // The same check of an unsigned message: nothing of its own expired, its failure holds.
2015        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        // Its server no longer had it: nothing of Sioul's, the provider's (none here).
2018        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    /// A training on invented mail, your filter's word in its logs as your
2023    /// devices write them: a message it moved into a Junk folder and one it
2024    /// flagged as probably spam are learned as spam, one it flagged as maybe
2025    /// spam is left out, and one it moved that you said is not spam is
2026    /// learned as ham.
2027    #[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        // Ham in an inbox, as its folder says; spam in a Junk folder, no keyword.
2038        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        // By their folders 200 ham and 120 spam; then a ham flagged probably spam, a ham flagged maybe spam, a moved spam you said is not.
2051        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        // A header column (from 1 on) over the rows that have it: the third row's says nothing.
2072        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        // A missing value counts in neither: the mean and deviation of those known.
2076        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}