Skip to main content

sioul_learn/
auto.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Training again by itself (docs/spam-filter.md, "Training by itself"): on
5//! the computer that made the table in use, once a week at most after the
6//! last training (by hand or by itself), when it is on mains power, not
7//! saving power, idle for 15 minutes or locked, and no focus session runs;
8//! on a metered connection, without downloading first. A job run apart
9//! (`sioul spam train --by-itself`), at the lowest priority, on half the
10//! cores, with the usual rule for replacing the table; stopped through its
11//! stop file when the computer is unplugged or starts saving power, to try
12//! again later. Never a notification: the settings say when it last trained
13//! by itself, and whether its table replaced the one in use.
14//!
15//! [`decide`] is the rule, a pure function of what is known ([`Facts`]); an
16//! unknown fact counts as not holding. [`facts`] reads them, the cheap ones
17//! first: the system is asked about its power and its session only when
18//! nothing else holds a training back. What the last run by itself did is
19//! kept beside the last training's summary ([`Run`], `auto.toml`). The
20//! window asks at its minute, `sioul watch` at its own; the command line
21//! starts the job.
22
23use crate::Dirs;
24use serde::{Deserialize, Serialize};
25use sioul_sync::power::Power;
26use std::path::PathBuf;
27use std::time::{Duration, Instant};
28
29/// The least time between two trainings, by hand or by itself.
30pub const WEEK: i64 = 7 * 86_400;
31/// After a run by itself stopped (unplugged, saving power): the soonest it tries again.
32pub const AFTER_STOP: i64 = 15 * 60;
33/// After one that failed, or whose process vanished: the soonest it tries again.
34pub const AFTER_FAILURE: i64 = 86_400;
35/// How often a run by itself asks whether its power still allows it.
36pub const SUPPLY_EVERY: Duration = Duration::from_secs(30);
37
38/// Who made the table in use.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Trainer {
41    /// This computer: it trains by itself.
42    Here,
43    /// Another device, by its name: that one does.
44    Elsewhere(String),
45    /// No table yet, or none this Sioul can read: a training by hand comes first.
46    Nobody,
47}
48
49/// How a run by itself ended.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Outcome {
53    /// Trained: its table replaced the one in use.
54    Replaced,
55    /// Trained: the table in use stayed (the new one no better, or nothing new).
56    Kept,
57    /// Stopped: unplugged, saving power, or asked to.
58    Stopped,
59    /// Failed: too few messages, the disk, an error.
60    Failed,
61}
62
63/// The last run by itself, as `auto.toml` keeps it.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Run {
66    /// When it started (Unix seconds), and its job's id.
67    pub started: i64,
68    pub job: String,
69    /// It downloaded first (a connection not metered).
70    #[serde(default)]
71    pub fetched: bool,
72    /// When it ended, and how; none while it runs, or when its process vanished.
73    #[serde(default)]
74    pub ended: Option<i64>,
75    #[serde(default)]
76    pub outcome: Option<Outcome>,
77}
78
79impl Run {
80    fn path(dirs: &Dirs) -> PathBuf {
81        dirs.state.join("auto.toml")
82    }
83
84    /// The last run by itself, if one was.
85    pub fn load(dirs: &Dirs) -> Option<Run> {
86        toml::from_str(&std::fs::read_to_string(Run::path(dirs)).ok()?).ok()
87    }
88
89    /// Kept, written whole.
90    pub fn save(&self, dirs: &Dirs) -> Result<(), String> {
91        std::fs::create_dir_all(&dirs.state).map_err(|e| format!("{}: {e}", dirs.state.display()))?;
92        let text = toml::to_string(self).map_err(|e| e.to_string())?;
93        crate::corpus::write_whole(&Run::path(dirs), text.as_bytes()).map_err(|e| e.to_string())
94    }
95
96    /// The end of the run of `job`, kept: when, and how.
97    pub fn finish(dirs: &Dirs, job: &str, outcome: Outcome, now: i64) -> Result<(), String> {
98        let Some(run) = Run::load(dirs).filter(|r| r.job == job) else { return Ok(()) };
99        Run { ended: Some(now), outcome: Some(outcome), ..run }.save(dirs)
100    }
101}
102
103/// What is known now.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Facts {
106    /// The setting: "Train again by itself" (`[spam] train_by_itself`, on unless said).
107    pub enabled: bool,
108    pub trainer: Trainer,
109    /// When the last training ended, by hand or by itself (`trained.toml`).
110    pub last_trained: Option<i64>,
111    /// The last run by itself.
112    pub last_run: Option<Run>,
113    /// A training runs: the window's, or a job's (`a_job_runs`).
114    pub running: bool,
115    /// A focus session runs.
116    pub focus: Option<bool>,
117    /// What the computer says of itself (`sioul_sync::power`).
118    pub power: Power,
119}
120
121/// Why it does not train now.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum Wait {
124    /// The setting is off.
125    Off,
126    /// No table yet: a training by hand comes first.
127    NoTable,
128    /// Another computer made the table in use: that one trains.
129    Elsewhere,
130    /// A training runs already.
131    Running,
132    /// Trained less than a week ago.
133    Recent,
134    /// A run by itself stopped or failed not long ago.
135    Retry,
136    /// A focus session runs, or it cannot tell.
137    Focus,
138    /// On its battery, or it cannot tell.
139    Battery,
140    /// Saving power, or it cannot tell.
141    PowerSaver,
142    /// Someone at it, or it cannot tell.
143    InUse,
144}
145
146/// What to do now.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum Decision {
149    /// Train, downloading first or not (a metered connection, or one it cannot tell).
150    Train { fetch: bool },
151    Wait(Wait),
152}
153
154/// The rule: every condition must hold, an unknown one counting as not
155/// holding; the cheap ones are looked at first (`facts` asks the system
156/// only when a training waits on its power alone).
157pub fn decide(f: &Facts, now: i64) -> Decision {
158    let wait = |why| Decision::Wait(why);
159    if !f.enabled {
160        return wait(Wait::Off);
161    }
162    match f.trainer {
163        Trainer::Nobody => return wait(Wait::NoTable),
164        Trainer::Elsewhere(_) => return wait(Wait::Elsewhere),
165        Trainer::Here => {}
166    }
167    if f.running {
168        return wait(Wait::Running);
169    }
170    if f.last_trained.is_some_and(|at| now - at < WEEK) {
171        return wait(Wait::Recent);
172    }
173    // A run by itself that ended without a training since (stopped, failed, vanished): not again too soon.
174    if let Some(run) = &f.last_run
175        && !f.last_trained.is_some_and(|at| at >= run.started)
176    {
177        let pause = if run.outcome == Some(Outcome::Stopped) { AFTER_STOP } else { AFTER_FAILURE };
178        if now - run.ended.unwrap_or(run.started) < pause {
179            return wait(Wait::Retry);
180        }
181    }
182    if f.focus != Some(false) {
183        return wait(Wait::Focus);
184    }
185    if f.power.on_mains != Some(true) {
186        return wait(Wait::Battery);
187    }
188    if f.power.power_saver != Some(false) {
189        return wait(Wait::PowerSaver);
190    }
191    if f.power.idle != Some(true) {
192        return wait(Wait::InUse);
193    }
194    Decision::Train { fetch: f.power.metered == Some(false) }
195}
196
197/// What is known now: the setting (`enabled`), the table in use and the
198/// files of the last trainings, the jobs, a focus session (`focus`:
199/// `sioul_core::timelog::running`); then, only when nothing else holds a
200/// training back, what the system says (`power`, asked once). `here`: this
201/// computer's name, as a table records it (`sioul_sync::lease::host_name`);
202/// `window_trains`: the window's own training runs ("Train now").
203pub fn facts(dirs: &Dirs, enabled: bool, here: &str, window_trains: bool, focus: bool, now: i64, power: impl FnOnce() -> Power) -> Facts {
204    let mut facts = Facts {
205        enabled,
206        trainer: trainer(dirs, here),
207        last_trained: crate::train::last(dirs).map(|s| s.trained_at).filter(|at| *at > 0),
208        last_run: Run::load(dirs),
209        running: window_trains || a_job_runs(dirs),
210        focus: Some(focus),
211        power: Power::default(),
212    };
213    if decide(&facts, now) == Decision::Wait(Wait::Battery) {
214        facts.power = power();
215    }
216    facts
217}
218
219/// Who made the table in use (the Porch's: this one, else the one before
220/// when this one is refused), by the name it records.
221pub fn trainer(dirs: &Dirs, here: &str) -> Trainer {
222    match sioul_core::spam::table::Table::cached(&dirs.table()) {
223        None => Trainer::Nobody,
224        Some(table) if table.meta.device.is_empty() => Trainer::Nobody,
225        Some(table) if table.meta.device == here => Trainer::Here,
226        Some(table) => Trainer::Elsewhere(table.meta.device.clone()),
227    }
228}
229
230/// Whether a job of the spam filter runs, a download or a training: its
231/// process holds its lock (`$XDG_STATE_HOME/sioul/spam/jobs/<id>.lock`).
232pub fn a_job_runs(dirs: &Dirs) -> bool {
233    let jobs = dirs.state.join("jobs");
234    std::fs::read_dir(&jobs).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "lock")).any(|path| {
235        let Ok(lock) = std::fs::OpenOptions::new().read(true).open(&path) else { return false };
236        match lock.try_lock() {
237            Ok(()) => {
238                let _ = lock.unlock();
239                false
240            }
241            Err(_) => true,
242        }
243    })
244}
245
246/// fastText's threads for a run by itself: half the cores, one at least.
247pub fn threads() -> u32 {
248    std::thread::available_parallelism().map_or(1, |n| u32::try_from(n.get() / 2).unwrap_or(1).max(1))
249}
250
251/// While a run by itself goes on: its power asked every `every` (`supply`);
252/// unplugged or saving power, or twice in a row unable to tell, `stop` is
253/// called once and the watch ends; it ends too as soon as `done` says the
254/// run did.
255pub fn watch_supply(supply: impl Fn() -> Power, stop: impl FnOnce(), done: impl Fn() -> bool, every: Duration) {
256    let mut unknown = false;
257    loop {
258        if done() {
259            return;
260        }
261        let power = supply();
262        let away = power.on_mains == Some(false) || power.power_saver == Some(true);
263        let unsure = !away && !power.supplied();
264        if away || (unsure && unknown) {
265            stop();
266            return;
267        }
268        unknown = unsure;
269        let until = Instant::now() + every;
270        while Instant::now() < until {
271            if done() {
272                return;
273            }
274            std::thread::sleep(every.min(Duration::from_millis(200)));
275        }
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::cell::{Cell, RefCell};
283
284    const NOW: i64 = 1_791_360_000;
285
286    fn power(on_mains: Option<bool>, power_saver: Option<bool>, idle: Option<bool>, metered: Option<bool>) -> Power {
287        Power { on_mains, power_saver, idle, metered }
288    }
289
290    /// Everything allows it: the training computer, set on, a week and more
291    /// since the last training, nothing running, no focus, on mains power,
292    /// not saving power, idle, a connection not metered.
293    fn ready() -> Facts {
294        Facts {
295            enabled: true,
296            trainer: Trainer::Here,
297            last_trained: Some(NOW - WEEK - 1),
298            last_run: None,
299            running: false,
300            focus: Some(false),
301            power: power(Some(true), Some(false), Some(true), Some(false)),
302        }
303    }
304
305    #[test]
306    fn it_trains_when_everything_allows_it() {
307        assert_eq!(decide(&ready(), NOW), Decision::Train { fetch: true });
308        // Never trained here (no `trained.toml`), the table this computer's: it may.
309        assert_eq!(decide(&Facts { last_trained: None, ..ready() }, NOW), Decision::Train { fetch: true });
310    }
311
312    /// Each condition alone holds it back, its reason said.
313    #[test]
314    fn each_condition_holds_it_back() {
315        let cases: Vec<(Facts, Wait)> = vec![
316            (Facts { enabled: false, ..ready() }, Wait::Off),
317            (Facts { trainer: Trainer::Nobody, ..ready() }, Wait::NoTable),
318            (Facts { trainer: Trainer::Elsewhere("desk".into()), ..ready() }, Wait::Elsewhere),
319            (Facts { running: true, ..ready() }, Wait::Running),
320            (Facts { last_trained: Some(NOW - WEEK + 60), ..ready() }, Wait::Recent),
321            (Facts { focus: Some(true), ..ready() }, Wait::Focus),
322            (Facts { power: power(Some(false), Some(false), Some(true), Some(false)), ..ready() }, Wait::Battery),
323            (Facts { power: power(Some(true), Some(true), Some(true), Some(false)), ..ready() }, Wait::PowerSaver),
324            (Facts { power: power(Some(true), Some(false), Some(false), Some(false)), ..ready() }, Wait::InUse),
325        ];
326        for (facts, why) in cases {
327            assert_eq!(decide(&facts, NOW), Decision::Wait(why), "{facts:?}");
328        }
329    }
330
331    /// An unknown fact counts as not holding; a metered connection, or one it
332    /// cannot tell, trains on the corpus as it is.
333    #[test]
334    fn unknown_counts_as_no() {
335        let with = |p: Power| Facts { power: p, ..ready() };
336        assert_eq!(decide(&Facts { focus: None, ..ready() }, NOW), Decision::Wait(Wait::Focus));
337        assert_eq!(decide(&with(power(None, Some(false), Some(true), Some(false))), NOW), Decision::Wait(Wait::Battery));
338        assert_eq!(decide(&with(power(Some(true), None, Some(true), Some(false))), NOW), Decision::Wait(Wait::PowerSaver));
339        assert_eq!(decide(&with(power(Some(true), Some(false), None, Some(false))), NOW), Decision::Wait(Wait::InUse));
340        assert_eq!(decide(&with(power(Some(true), Some(false), Some(true), Some(true))), NOW), Decision::Train { fetch: false });
341        assert_eq!(decide(&with(power(Some(true), Some(false), Some(true), None)), NOW), Decision::Train { fetch: false });
342        assert_eq!(decide(&with(Power::default()), NOW), Decision::Wait(Wait::Battery), "nothing known: no");
343    }
344
345    /// Once a week at most after the last training, by hand or by itself;
346    /// after a run that stopped, a quarter of an hour; after one that failed
347    /// or vanished, a day; a run that trained since says nothing.
348    #[test]
349    fn once_a_week_and_not_again_too_soon() {
350        let run = |started: i64, ended: Option<i64>, outcome: Option<Outcome>| Run { started, job: "j".into(), fetched: true, ended, outcome };
351        let after = |r: Run| decide(&Facts { last_run: Some(r), ..ready() }, NOW);
352        assert_eq!(after(run(NOW - 600, Some(NOW - 300), Some(Outcome::Stopped))), Decision::Wait(Wait::Retry));
353        assert_eq!(after(run(NOW - 3600, Some(NOW - AFTER_STOP - 1), Some(Outcome::Stopped))), Decision::Train { fetch: true });
354        assert_eq!(after(run(NOW - 3600, Some(NOW - 3000), Some(Outcome::Failed))), Decision::Wait(Wait::Retry));
355        assert_eq!(after(run(NOW - AFTER_FAILURE - 1, Some(NOW - AFTER_FAILURE), Some(Outcome::Failed))), Decision::Train { fetch: true });
356        assert_eq!(after(run(NOW - 3600, None, None)), Decision::Wait(Wait::Retry), "vanished: as failed");
357        // Trained since that run started (by hand, or the run itself): the week counts.
358        let trained = Facts { last_trained: Some(NOW - 3000), last_run: Some(run(NOW - 3600, Some(NOW - 3000), Some(Outcome::Replaced))), ..ready() };
359        assert_eq!(decide(&trained, NOW), Decision::Wait(Wait::Recent));
360        let old = Facts { last_trained: Some(NOW - WEEK - 10), last_run: Some(run(NOW - WEEK - 100, Some(NOW - WEEK - 10), Some(Outcome::Kept))), ..ready() };
361        assert_eq!(decide(&old, NOW), Decision::Train { fetch: true });
362    }
363
364    /// The cheap facts first: the system is asked only when a training waits
365    /// on its power alone; then once.
366    #[test]
367    fn the_system_is_asked_last() {
368        let dir = std::env::temp_dir().join(format!("sioul-auto-{}", std::process::id()));
369        let _ = std::fs::remove_dir_all(&dir);
370        let dirs = Dirs::under(&dir);
371        let asked = Cell::new(0);
372        // No table here: nothing to ask.
373        let f = facts(&dirs, true, "desk", false, false, NOW, || {
374            asked.set(asked.get() + 1);
375            Power::default()
376        });
377        assert_eq!((f.trainer.clone(), asked.get(), decide(&f, NOW)), (Trainer::Nobody, 0, Decision::Wait(Wait::NoTable)));
378        // The record of a run kept and read back, its end written by its job.
379        let run = Run { started: NOW - 100, job: "20261007-031200-train-abcd".into(), fetched: false, ended: None, outcome: None };
380        run.save(&dirs).unwrap();
381        Run::finish(&dirs, "another", Outcome::Failed, NOW).unwrap();
382        assert_eq!(Run::load(&dirs), Some(run.clone()), "another job's end is not this run's");
383        Run::finish(&dirs, &run.job, Outcome::Replaced, NOW).unwrap();
384        assert_eq!(Run::load(&dirs).map(|r| (r.ended, r.outcome)), Some((Some(NOW), Some(Outcome::Replaced))));
385        // A job whose process holds its lock runs; one whose lock nobody holds does not.
386        let jobs = dirs.state.join("jobs");
387        std::fs::create_dir_all(&jobs).unwrap();
388        let lock = std::fs::File::create(jobs.join("x.lock")).unwrap();
389        assert!(!a_job_runs(&dirs));
390        lock.lock().unwrap();
391        assert!(a_job_runs(&dirs));
392        drop(lock);
393        assert!(threads() >= 1);
394        let _ = std::fs::remove_dir_all(dir);
395    }
396
397    /// A run by itself stops once unplugged or saving power, or after two
398    /// readings in a row that cannot tell; never while its power allows it,
399    /// and the watch ends with the run.
400    #[test]
401    fn the_run_stops_when_the_power_changes() {
402        let mains = power(Some(true), Some(false), None, None);
403        let watched = |readings: Vec<Power>| {
404            let left = RefCell::new(readings);
405            let (stopped, asked) = (Cell::new(false), Cell::new(0));
406            let next = || {
407                asked.set(asked.get() + 1);
408                let mut left = left.borrow_mut();
409                if left.is_empty() { mains } else { left.remove(0) }
410            };
411            // The run ends after ten readings, unless stopped before.
412            watch_supply(next, || stopped.set(true), || asked.get() >= 10, Duration::from_millis(1));
413            (stopped.get(), asked.get())
414        };
415        assert_eq!(watched(vec![mains; 3]), (false, 10), "plugged in all along");
416        assert_eq!(watched(vec![mains, mains, power(Some(false), Some(false), None, None)]), (true, 3), "unplugged");
417        assert_eq!(watched(vec![mains, power(Some(true), Some(true), None, None)]), (true, 2), "saving power");
418        assert_eq!(watched(vec![mains, Power::default(), mains, Power::default(), Power::default()]), (true, 5), "twice unknown in a row");
419        assert_eq!(watched(vec![Power::default(), mains, Power::default(), mains]), (false, 10), "once unknown, then known again");
420    }
421}