Skip to main content

sioul_learn/
lib.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sioul's spam filter, learned on the desktop from your own mail.
5//!
6//! Nothing here runs on the phone: sioul-cli and sioul-app depend on this crate
7//! on desktops only. Nothing here shows a message to an AI agent: the numbers
8//! it gives are aggregates; `sioul spam why` is for you, in your terminal.
9//!
10//! What every device runs (the tokenizer, the header features, the reduced
11//! table that scores both, the label log) is sioul-core's
12//! [`sioul_core::spam`]. This crate trains on your mail, then writes that
13//! table. It reads mail through [sioul-sync](../sioul_sync/index.html); the
14//! command line (`sioul spam`) and the window ("Train now") run it. The
15//! design, its numbers and its reasons: docs/spam-filter.md, also on the
16//! website: <https://sioul.aurelienpierre.com/dev/spam-filter.html>.
17//!
18//! Long jobs report where they stand through a callback ([`Progress`]) and
19//! stop when asked ([`Cancel`]), so the window can run them on a thread.
20//!
21//! # Where to start reading
22//!
23//! - [`train::run`]: what "Train now" and `sioul spam train` do: the corpus
24//!   brought up to date ([`corpus::update`]), then [`train::train`], step by
25//!   step.
26//! - [`labels::decide`]: the one label each message gets, spam or ham.
27//! - [`Dirs`]: where the filter keeps its files.
28//!
29//! # Modules
30//!
31//! - [`consent`]: your consent, address by address, to learning from a Microsoft address's mail: the addresses named at the last press of Train now; never a work or school one's.
32//! - [`corpus`]: what training reads, downloaded from every folder of every account and kept here, junk included after your provider purges it, with Sioul's own checks of each message (its signatures, its sender) made on the whole message.
33//! - [`external`]: outside training material, mail labelled elsewhere, imported once (`sioul spam import`): more words to learn, and a baseline.
34//! - [`labels`]: spam or ham for each message, from its folder, its keywords, your own filter's word and your own actions (every device's logs).
35//! - [`auto`]: training again by itself, once a week, on the computer that made the table in use, when it is plugged in and idle: the rule, and what it knows.
36//! - [`train`]: training on demand: fastText on your mail, the message vectors, the SVM, its calibration, the evaluation, and the reduced table every device reads, replaced only when no worse.
37//! - [`svm`]: the linear SVM, solved as liblinear solves it.
38//! - [`platt`]: the SVM's scores turned into probabilities (Platt scaling).
39//! - [`eval`]: the filter's numbers, aggregates only, each with its interval.
40//! - [`detail`]: what a test said message by message: counts by account and folder, a grid of thresholds, the worst errors (`sioul spam eval --errors`).
41//! - [`diagnose`]: each header feature's mean by account and label (`sioul spam features`): which ones tell accounts apart rather than spam from ham.
42//! - [`spamcore`](spamcore/index.html): the seam to sioul-core's spam module: a corpus record read as the Porch reads the message.
43//! - `synthetic`, for tests only, this crate's and the command line's (so not shown here): invented mail on reserved domains.
44
45pub mod auto;
46pub mod consent;
47pub mod corpus;
48pub mod detail;
49pub mod diagnose;
50pub mod eval;
51pub mod external;
52pub mod labels;
53pub mod platt;
54pub(crate) mod spamcore;
55pub(crate) mod supervised;
56pub mod svm;
57/// Invented mail on reserved domains, for the tests of this crate and of the command line.
58#[doc(hidden)]
59pub mod synthetic;
60pub mod train;
61
62use std::path::PathBuf;
63use std::sync::Arc;
64use std::sync::atomic::{AtomicBool, Ordering};
65
66/// Where the filter keeps its files. `standard()` is Sioul's own folders;
67/// tests give their own.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Dirs {
70    /// `$XDG_DATA_HOME/sioul/spam`: the corpus, outside material, the language model, the table.
71    pub data: PathBuf,
72    /// `$XDG_STATE_HOME/sioul/spam`: every device's label log and log of
73    /// what the filter moved, the last training's summary.
74    pub state: PathBuf,
75    /// `$XDG_CACHE_HOME/sioul/spam`: the tokenized corpus while fastText reads it.
76    pub cache: PathBuf,
77}
78
79impl Dirs {
80    pub fn standard() -> Dirs {
81        use sioul_core::config;
82        Dirs { data: config::data_dir().join("spam"), state: config::state_dir().join("spam"), cache: config::cache_dir().join("spam") }
83    }
84
85    /// Every folder under one root (tests).
86    pub fn under(root: &std::path::Path) -> Dirs {
87        Dirs { data: root.join("data"), state: root.join("state"), cache: root.join("cache") }
88    }
89
90    /// The training corpus: private, never shared, never shown to an AI agent.
91    pub fn corpus(&self) -> PathBuf {
92        self.data.join("corpus")
93    }
94
95    /// The addresses named under Train now at its last press (`consent`): this computer's only, never shared.
96    pub fn consent(&self) -> PathBuf {
97        self.data.join("consent.toml")
98    }
99
100    /// Outside training material (`external`): private, never shared, never shown to an AI agent.
101    pub fn external(&self) -> PathBuf {
102        self.data.join("external")
103    }
104
105    /// The fastText model: private, never shared (it holds the vocabulary in plain text).
106    pub fn language(&self) -> PathBuf {
107        self.data.join("language.bin")
108    }
109
110    /// The reduced table every device reads.
111    pub fn table(&self) -> PathBuf {
112        self.data.join("table.bin")
113    }
114
115    /// The table before the last one replaced it.
116    pub fn previous_table(&self) -> PathBuf {
117        self.data.join("table.prev.bin")
118    }
119
120    /// The label logs, one per device: your junk, not-junk, "Spam", "Not spam" and blocks.
121    pub fn labels(&self) -> PathBuf {
122        self.state.join(sioul_core::spam::labels::FOLDER)
123    }
124
125    /// What your own filter moved into a Junk folder, one log per device:
126    /// learned as all Junk mail is, until you say otherwise.
127    pub fn moved(&self) -> PathBuf {
128        self.state.join(sioul_core::spam::labels::MOVED)
129    }
130
131    /// What your own filter flagged where it is, one log per device: a
132    /// probable spam learned as spam, a maybe spam left out, until you say.
133    pub fn flagged(&self) -> PathBuf {
134        self.state.join(sioul_core::spam::labels::FLAGGED)
135    }
136
137    /// The last training's summary, for the settings.
138    pub fn trained(&self) -> PathBuf {
139        self.state.join("trained.toml")
140    }
141
142    /// Forgets what the corpus keeps of one account (the start of each of its
143    /// messages), when the account is removed with its mail (Microsoft's
144    /// accounts, docs/microsoft.md): once no download of it runs, in any
145    /// process (the window's, the command line's, an AI agent's job), whose
146    /// last batch would make the folder again; one under way is waited for,
147    /// at most `FORGET_WAITS`. True when there was something; an id that is
148    /// not one plain name names nothing.
149    pub fn forget_account(&self, id: &str) -> std::io::Result<bool> {
150        self.forget_account_within(id, FORGET_WAITS)
151    }
152
153    fn forget_account_within(&self, id: &str, within: std::time::Duration) -> std::io::Result<bool> {
154        let plain = matches!(std::path::Path::new(id).components().collect::<Vec<_>>().as_slice(), [std::path::Component::Normal(_)]);
155        let folder = self.corpus().join(id);
156        if !plain || !folder.is_dir() {
157            return Ok(false);
158        }
159        unlocked(&folder.join("lock"), within);
160        std::fs::remove_dir_all(&folder)?;
161        Ok(true)
162    }
163}
164
165/// How long forgetting an account's corpus waits for a download of it under way.
166const FORGET_WAITS: std::time::Duration = std::time::Duration::from_secs(30 * 60);
167
168/// Waits until nobody holds the lock at `path` (an account's download holds
169/// it: `corpus.rs`), at most `within`; then lets it go at once, so that its
170/// folder can be deleted (Windows deletes no open file). No file: none runs.
171fn unlocked(path: &std::path::Path, within: std::time::Duration) {
172    let Ok(file) = std::fs::OpenOptions::new().write(true).open(path) else { return };
173    let until = std::time::Instant::now() + within;
174    while file.try_lock().is_err() && std::time::Instant::now() < until {
175        std::thread::sleep(std::time::Duration::from_millis(100));
176    }
177}
178
179/// The steps of a long job, in order.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
181pub enum Stage {
182    /// Downloading the corpus: `detail` is "account · folder".
183    Corpus,
184    /// Sioul's own checks of the messages kept without them, each fetched
185    /// whole once (`corpus::verify`): `detail` is the account.
186    Verify,
187    /// Reading the corpus and deciding each message's label.
188    Labels,
189    /// Turning each message into words.
190    Tokens,
191    /// fastText learning your mail's words (no count: it says nothing until done).
192    Language,
193    /// Each message's vector and header features.
194    Features,
195    /// The SVM, one cost after another, then the calibration's folds.
196    Classifier,
197    /// The numbers on the newest fifth.
198    Evaluation,
199    /// The table written, or kept.
200    Export,
201}
202
203impl Stage {
204    /// A short name for logs and the command line ("corpus", "language").
205    pub fn as_str(self) -> &'static str {
206        match self {
207            Stage::Corpus => "corpus",
208            Stage::Verify => "verify",
209            Stage::Labels => "labels",
210            Stage::Tokens => "tokens",
211            Stage::Language => "language",
212            Stage::Features => "features",
213            Stage::Classifier => "classifier",
214            Stage::Evaluation => "evaluation",
215            Stage::Export => "export",
216        }
217    }
218}
219
220/// Where a long job stands: `done` of `total` in this stage (`total` 0 when
221/// unknown), and what it is on (an account and a folder; never a message).
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Progress {
224    pub stage: Stage,
225    pub done: u64,
226    pub total: u64,
227    pub detail: String,
228}
229
230/// Asks a long job to stop at its next step; shared between threads.
231#[derive(Debug, Clone, Default)]
232pub struct Cancel(Arc<AtomicBool>);
233
234impl Cancel {
235    pub fn new() -> Cancel {
236        Cancel::default()
237    }
238
239    pub fn cancel(&self) {
240        self.0.store(true, Ordering::Relaxed);
241    }
242
243    pub fn cancelled(&self) -> bool {
244        self.0.load(Ordering::Relaxed)
245    }
246
247    /// The flag itself, for fastText's own training loop.
248    pub(crate) fn flag(&self) -> Arc<AtomicBool> {
249        Arc::clone(&self.0)
250    }
251}
252
253/// What went wrong, said plainly; never a message's content.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum LearnError {
256    /// Stopped when asked.
257    Cancelled,
258    /// The disk would fall under the space kept free.
259    Disk(String),
260    /// Too few messages of one kind to learn from.
261    TooFew { ham: u64, spam: u64 },
262    /// A file could not be read or written.
263    Io(String),
264    /// The language model could not be trained.
265    Language(String),
266    /// The folded table does not give the model's scores: nothing is exported.
267    Fold(String),
268    /// No table to evaluate, or one this Sioul cannot read (why).
269    NoTable(String),
270}
271
272impl std::fmt::Display for LearnError {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        match self {
275            LearnError::Cancelled => write!(f, "stopped"),
276            LearnError::Disk(d) => write!(f, "disk: {d}"),
277            LearnError::TooFew { ham, spam } => write!(f, "too few messages: {ham} ham, {spam} spam"),
278            LearnError::Io(d) => write!(f, "{d}"),
279            LearnError::Language(d) => write!(f, "fastText: {d}"),
280            LearnError::Fold(d) => write!(f, "fold: {d}"),
281            LearnError::NoTable(d) => write!(f, "no table: {d}"),
282        }
283    }
284}
285
286impl std::error::Error for LearnError {}
287
288pub(crate) fn io_error(path: &std::path::Path, e: impl std::fmt::Display) -> LearnError {
289    LearnError::Io(format!("{}: {e}", path.display()))
290}
291
292/// A small, seeded random number generator (splitmix64): the same seed gives
293/// the same visit order, so the same model.
294#[doc(hidden)]
295#[derive(Debug, Clone)]
296pub struct Rng(u64);
297
298impl Rng {
299    pub fn new(seed: u64) -> Rng {
300        Rng(seed)
301    }
302
303    pub(crate) fn next_u64(&mut self) -> u64 {
304        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
305        let mut z = self.0;
306        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
307        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
308        z ^ (z >> 31)
309    }
310
311    /// A number in 0..n (n > 0).
312    pub(crate) fn below(&mut self, n: usize) -> usize {
313        (self.next_u64() % n as u64) as usize
314    }
315
316    /// A number in [0, 1).
317    pub(crate) fn unit(&mut self) -> f64 {
318        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
319    }
320}
321
322/// Now, in Unix seconds.
323pub(crate) fn now() -> i64 {
324    jiff::Timestamp::now().as_second()
325}
326
327#[cfg(test)]
328mod forget_tests {
329    use super::*;
330
331    /// One account's corpus forgotten, the others' kept; an odd id names nothing.
332    #[test]
333    fn an_accounts_corpus_is_forgotten_alone() {
334        let root = std::env::temp_dir().join(format!("sioul-learn-forget-{}", std::process::id()));
335        let dirs = Dirs::under(&root);
336        for account in ["work", "home"] {
337            std::fs::create_dir_all(dirs.corpus().join(account)).unwrap();
338            std::fs::write(dirs.corpus().join(account).join("state.toml"), "x").unwrap();
339        }
340        assert!(dirs.forget_account("work").unwrap());
341        assert!(!dirs.corpus().join("work").exists() && dirs.corpus().join("home").is_dir());
342        assert!(!dirs.forget_account("work").unwrap(), "nothing left");
343        assert!(!dirs.forget_account("../corpus").unwrap() && !dirs.forget_account("").unwrap());
344        assert!(dirs.corpus().join("home").is_dir());
345        std::fs::remove_dir_all(&root).unwrap();
346    }
347
348    /// The review of 15 October 2026, L5: a download of the account under
349    /// way (another process's job holds its lock) ends before its corpus is
350    /// forgotten: its last batch never makes the folder again.
351    #[test]
352    fn a_download_under_way_ends_before_its_corpus_goes() {
353        let root = std::env::temp_dir().join(format!("sioul-learn-forget-locked-{}", std::process::id()));
354        let dirs = Dirs::under(&root);
355        let folder = dirs.corpus().join("live");
356        std::fs::create_dir_all(&folder).unwrap();
357        let lock = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(folder.join("lock")).unwrap();
358        lock.lock().unwrap();
359        let (held, wait) = std::sync::mpsc::channel();
360        std::thread::scope(|s| {
361            s.spawn(|| {
362                held.send(()).unwrap();
363                std::thread::sleep(std::time::Duration::from_millis(300));
364                // Its last batch, then the lock let go.
365                std::fs::write(folder.join("INBOX.jsonl.gz"), b"batch").unwrap();
366                drop(lock);
367            });
368            wait.recv().unwrap();
369            let started = std::time::Instant::now();
370            assert!(dirs.forget_account("live").unwrap());
371            assert!(started.elapsed() >= std::time::Duration::from_millis(250), "waited for the download");
372        });
373        assert!(!folder.exists(), "forgotten after its last batch, not before");
374        // Held past the wait: forgotten all the same.
375        std::fs::create_dir_all(&folder).unwrap();
376        let stuck = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(folder.join("lock")).unwrap();
377        stuck.lock().unwrap();
378        #[cfg(unix)]
379        assert!(dirs.forget_account_within("live", std::time::Duration::from_millis(100)).unwrap() && !folder.exists());
380        drop(stuck);
381        let _ = std::fs::remove_dir_all(&root);
382    }
383}