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}