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://aurelienpierre.github.io/sioul/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//! - [`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.
32//! - [`external`]: outside training material, mail labelled elsewhere, imported once (`sioul spam import`): more words to learn, and a baseline.
33//! - [`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).
34//! - [`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.
35//! - [`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.
36//! - [`svm`]: the linear SVM, solved as liblinear solves it.
37//! - [`platt`]: the SVM's scores turned into probabilities (Platt scaling).
38//! - [`eval`]: the filter's numbers, aggregates only, each with its interval.
39//! - [`detail`]: what a test said message by message: counts by account and folder, a grid of thresholds, the worst errors (`sioul spam eval --errors`).
40//! - [`diagnose`]: each header feature's mean by account and label (`sioul spam features`): which ones tell accounts apart rather than spam from ham.
41//! - [`spamcore`](spamcore/index.html): the seam to sioul-core's spam module: a corpus record read as the Porch reads the message.
42//! - `synthetic`, for tests only, this crate's and the command line's (so not shown here): invented mail on reserved domains.
43
44pub mod auto;
45pub mod corpus;
46pub mod detail;
47pub mod diagnose;
48pub mod eval;
49pub mod external;
50pub mod labels;
51pub mod platt;
52pub(crate) mod spamcore;
53pub(crate) mod supervised;
54pub mod svm;
55/// Invented mail on reserved domains, for the tests of this crate and of the command line.
56#[doc(hidden)]
57pub mod synthetic;
58pub mod train;
59
60use std::path::PathBuf;
61use std::sync::Arc;
62use std::sync::atomic::{AtomicBool, Ordering};
63
64/// Where the filter keeps its files. `standard()` is Sioul's own folders;
65/// tests give their own.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Dirs {
68    /// `$XDG_DATA_HOME/sioul/spam`: the corpus, outside material, the language model, the table.
69    pub data: PathBuf,
70    /// `$XDG_STATE_HOME/sioul/spam`: every device's label log and log of
71    /// what the filter moved, the last training's summary.
72    pub state: PathBuf,
73    /// `$XDG_CACHE_HOME/sioul/spam`: the tokenized corpus while fastText reads it.
74    pub cache: PathBuf,
75}
76
77impl Dirs {
78    pub fn standard() -> Dirs {
79        use sioul_core::config;
80        Dirs { data: config::data_dir().join("spam"), state: config::state_dir().join("spam"), cache: config::cache_dir().join("spam") }
81    }
82
83    /// Every folder under one root (tests).
84    pub fn under(root: &std::path::Path) -> Dirs {
85        Dirs { data: root.join("data"), state: root.join("state"), cache: root.join("cache") }
86    }
87
88    /// The training corpus: private, never shared, never shown to an AI agent.
89    pub fn corpus(&self) -> PathBuf {
90        self.data.join("corpus")
91    }
92
93    /// Outside training material (`external`): private, never shared, never shown to an AI agent.
94    pub fn external(&self) -> PathBuf {
95        self.data.join("external")
96    }
97
98    /// The fastText model: private, never shared (it holds the vocabulary in plain text).
99    pub fn language(&self) -> PathBuf {
100        self.data.join("language.bin")
101    }
102
103    /// The reduced table every device reads.
104    pub fn table(&self) -> PathBuf {
105        self.data.join("table.bin")
106    }
107
108    /// The table before the last one replaced it.
109    pub fn previous_table(&self) -> PathBuf {
110        self.data.join("table.prev.bin")
111    }
112
113    /// The label logs, one per device: your junk, not-junk, "Spam", "Not spam" and blocks.
114    pub fn labels(&self) -> PathBuf {
115        self.state.join(sioul_core::spam::labels::FOLDER)
116    }
117
118    /// What your own filter moved into a Junk folder, one log per device:
119    /// learned as all Junk mail is, until you say otherwise.
120    pub fn moved(&self) -> PathBuf {
121        self.state.join(sioul_core::spam::labels::MOVED)
122    }
123
124    /// What your own filter flagged where it is, one log per device: a
125    /// probable spam learned as spam, a maybe spam left out, until you say.
126    pub fn flagged(&self) -> PathBuf {
127        self.state.join(sioul_core::spam::labels::FLAGGED)
128    }
129
130    /// The last training's summary, for the settings.
131    pub fn trained(&self) -> PathBuf {
132        self.state.join("trained.toml")
133    }
134}
135
136/// The steps of a long job, in order.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub enum Stage {
139    /// Downloading the corpus: `detail` is "account · folder".
140    Corpus,
141    /// Sioul's own checks of the messages kept without them, each fetched
142    /// whole once (`corpus::verify`): `detail` is the account.
143    Verify,
144    /// Reading the corpus and deciding each message's label.
145    Labels,
146    /// Turning each message into words.
147    Tokens,
148    /// fastText learning your mail's words (no count: it says nothing until done).
149    Language,
150    /// Each message's vector and header features.
151    Features,
152    /// The SVM, one cost after another, then the calibration's folds.
153    Classifier,
154    /// The numbers on the newest fifth.
155    Evaluation,
156    /// The table written, or kept.
157    Export,
158}
159
160impl Stage {
161    /// A short name for logs and the command line ("corpus", "language").
162    pub fn as_str(self) -> &'static str {
163        match self {
164            Stage::Corpus => "corpus",
165            Stage::Verify => "verify",
166            Stage::Labels => "labels",
167            Stage::Tokens => "tokens",
168            Stage::Language => "language",
169            Stage::Features => "features",
170            Stage::Classifier => "classifier",
171            Stage::Evaluation => "evaluation",
172            Stage::Export => "export",
173        }
174    }
175}
176
177/// Where a long job stands: `done` of `total` in this stage (`total` 0 when
178/// unknown), and what it is on (an account and a folder; never a message).
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Progress {
181    pub stage: Stage,
182    pub done: u64,
183    pub total: u64,
184    pub detail: String,
185}
186
187/// Asks a long job to stop at its next step; shared between threads.
188#[derive(Debug, Clone, Default)]
189pub struct Cancel(Arc<AtomicBool>);
190
191impl Cancel {
192    pub fn new() -> Cancel {
193        Cancel::default()
194    }
195
196    pub fn cancel(&self) {
197        self.0.store(true, Ordering::Relaxed);
198    }
199
200    pub fn cancelled(&self) -> bool {
201        self.0.load(Ordering::Relaxed)
202    }
203
204    /// The flag itself, for fastText's own training loop.
205    pub(crate) fn flag(&self) -> Arc<AtomicBool> {
206        Arc::clone(&self.0)
207    }
208}
209
210/// What went wrong, said plainly; never a message's content.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum LearnError {
213    /// Stopped when asked.
214    Cancelled,
215    /// The disk would fall under the space kept free.
216    Disk(String),
217    /// Too few messages of one kind to learn from.
218    TooFew { ham: u64, spam: u64 },
219    /// A file could not be read or written.
220    Io(String),
221    /// The language model could not be trained.
222    Language(String),
223    /// The folded table does not give the model's scores: nothing is exported.
224    Fold(String),
225    /// No table to evaluate, or one this Sioul cannot read (why).
226    NoTable(String),
227}
228
229impl std::fmt::Display for LearnError {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            LearnError::Cancelled => write!(f, "stopped"),
233            LearnError::Disk(d) => write!(f, "disk: {d}"),
234            LearnError::TooFew { ham, spam } => write!(f, "too few messages: {ham} ham, {spam} spam"),
235            LearnError::Io(d) => write!(f, "{d}"),
236            LearnError::Language(d) => write!(f, "fastText: {d}"),
237            LearnError::Fold(d) => write!(f, "fold: {d}"),
238            LearnError::NoTable(d) => write!(f, "no table: {d}"),
239        }
240    }
241}
242
243impl std::error::Error for LearnError {}
244
245pub(crate) fn io_error(path: &std::path::Path, e: impl std::fmt::Display) -> LearnError {
246    LearnError::Io(format!("{}: {e}", path.display()))
247}
248
249/// A small, seeded random number generator (splitmix64): the same seed gives
250/// the same visit order, so the same model.
251#[doc(hidden)]
252#[derive(Debug, Clone)]
253pub struct Rng(u64);
254
255impl Rng {
256    pub fn new(seed: u64) -> Rng {
257        Rng(seed)
258    }
259
260    pub(crate) fn next_u64(&mut self) -> u64 {
261        self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
262        let mut z = self.0;
263        z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
264        z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
265        z ^ (z >> 31)
266    }
267
268    /// A number in 0..n (n > 0).
269    pub(crate) fn below(&mut self, n: usize) -> usize {
270        (self.next_u64() % n as u64) as usize
271    }
272
273    /// A number in [0, 1).
274    pub(crate) fn unit(&mut self) -> f64 {
275        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
276    }
277}
278
279/// Now, in Unix seconds.
280pub(crate) fn now() -> i64 {
281    jiff::Timestamp::now().as_second()
282}