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}