1use crate::corpus::{self, Place};
19use crate::eval::{self, AtThreshold};
20use crate::external;
21use crate::labels::{Evidence, Label};
22use crate::{Dirs, LearnError};
23use serde::Serialize;
24use sioul_core::card::Card;
25use sioul_core::spam::Class;
26use std::collections::{BTreeMap, HashMap, HashSet};
27
28pub const GRID: [f64; 10] = [0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 0.97, 0.98, 0.99];
30
31pub const MOST_ERRORS: usize = 500;
33
34#[derive(Debug, Clone, PartialEq)]
36pub struct Tested {
37 pub p: f64,
39 pub label: Label,
41 pub date: i64,
43 pub origin: Origin,
44 pub unsettled: bool,
47}
48
49pub const UNSETTLED_DAYS: i64 = 30;
52
53pub fn unsettled(label: Label, role: sioul_core::folders::Role, date: i64, now: i64) -> bool {
56 label == Label::Ham && role == sioul_core::folders::Role::Inbox && date > now - UNSETTLED_DAYS * 86_400
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum Origin {
62 Own { place: Place, evidence: Evidence },
64 Outside { source: String, ordinal: u64 },
67}
68
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
72pub struct Row {
73 pub spam: u64,
74 pub unsure: u64,
75 pub ham: u64,
76}
77
78impl Row {
79 pub fn total(&self) -> u64 {
80 self.spam + self.unsure + self.ham
81 }
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
86pub struct Confusion {
87 pub ham: Row,
88 pub spam: Row,
89}
90
91impl Confusion {
92 fn add(&mut self, label: Label, class: Class) {
93 let row = match label {
94 Label::Ham => &mut self.ham,
95 Label::Spam => &mut self.spam,
96 };
97 match class {
98 Class::Spam => row.spam += 1,
99 Class::Unsure => row.unsure += 1,
100 Class::Ham => row.ham += 1,
101 }
102 }
103}
104
105#[derive(Debug, Clone)]
108pub struct Wrong {
109 pub p: f64,
110 pub label: Label,
111 pub class: Class,
112 pub date: i64,
114 pub evidence: &'static str,
117 pub account: String,
119 pub folder: String,
121 pub outside: bool,
122 pub card: Option<Card>,
124}
125
126#[derive(Debug, Clone, Default)]
128pub struct Errors {
129 pub ham_called_spam: Vec<Wrong>,
131 pub spam_missed: Vec<Wrong>,
133}
134
135#[derive(Debug, Clone, Default)]
137pub struct OutsideDetail {
138 pub confusion: Confusion,
139 pub grid: Vec<AtThreshold>,
141 pub scores: Vec<(f64, bool)>,
143 pub errors: Errors,
144}
145
146#[derive(Debug, Clone, Default)]
148pub struct Detail {
149 pub by_account: BTreeMap<String, Confusion>,
151 pub by_folder: BTreeMap<String, BTreeMap<String, Confusion>>,
153 pub grid: Vec<AtThreshold>,
155 pub grid_settled: Vec<AtThreshold>,
157 pub scores: Vec<(f64, bool, bool)>,
160 pub errors: Errors,
161 pub outside: BTreeMap<String, OutsideDetail>,
163}
164
165pub fn evidence_name(evidence: Evidence) -> &'static str {
167 match evidence {
168 Evidence::Folder => "folder",
169 Evidence::Filter => "filter",
170 Evidence::JunkFolder => "junk-folder",
171 Evidence::Keyword => "keyword",
172 Evidence::Log => "log",
173 }
174}
175
176pub fn class_of(p: f64, threshold_spam: f64, threshold_unsure: f64) -> Class {
178 if p >= threshold_spam {
179 Class::Spam
180 } else if p >= threshold_unsure {
181 Class::Unsure
182 } else {
183 Class::Ham
184 }
185}
186
187pub fn of(dirs: &Dirs, tested: &[Tested], threshold_spam: f64, threshold_unsure: f64, errors: usize) -> Result<Detail, LearnError> {
191 let class = |p: f64| class_of(p, threshold_spam, threshold_unsure);
192 let mut detail = Detail::default();
193 let mut own = Vec::new();
194 let mut settled = Vec::new();
195 let mut outside: BTreeMap<&str, Vec<(f64, bool)>> = BTreeMap::new();
196 for t in tested {
197 match &t.origin {
198 Origin::Own { place, .. } => {
199 detail.by_account.entry(place.account.clone()).or_default().add(t.label, class(t.p));
200 detail.by_folder.entry(place.account.clone()).or_default().entry(place.folder.clone()).or_default().add(t.label, class(t.p));
201 own.push((t.p, t.label == Label::Spam));
202 detail.scores.push((t.p, t.label == Label::Spam, t.unsettled));
203 if !t.unsettled {
204 settled.push((t.p, t.label == Label::Spam));
205 }
206 }
207 Origin::Outside { source, .. } => {
208 detail.outside.entry(source.clone()).or_default().confusion.add(t.label, class(t.p));
209 outside.entry(source.as_str()).or_default().push((t.p, t.label == Label::Spam));
210 }
211 }
212 }
213 detail.grid = eval::grid(&own, &GRID);
214 detail.grid_settled = eval::grid(&settled, &GRID);
215 for (source, scored) in outside {
216 let part = detail.outside.entry(source.to_string()).or_default();
217 part.grid = eval::grid(&scored, &GRID);
218 part.scores = scored;
219 }
220 let errors = errors.min(MOST_ERRORS);
221 if errors == 0 {
222 return Ok(detail);
223 }
224 let mut sets: Vec<Option<&str>> = vec![None];
226 sets.extend(detail.outside.keys().map(|s| Some(s.as_str())));
227 let in_set = |t: &&Tested, set: Option<&str>| match (&t.origin, set) {
228 (Origin::Own { .. }, None) => true,
229 (Origin::Outside { source, .. }, Some(set)) => source == set,
230 _ => false,
231 };
232 let mut chosen: Vec<(Option<String>, Vec<&Tested>, Vec<&Tested>)> = Vec::new();
233 for set in sets {
234 let mut ham: Vec<&Tested> = tested.iter().filter(|t| in_set(t, set) && t.label == Label::Ham && class(t.p) != Class::Ham).collect();
235 ham.sort_by(|a, b| b.p.total_cmp(&a.p).then(b.date.cmp(&a.date)));
236 ham.truncate(errors);
237 let mut spam: Vec<&Tested> = tested.iter().filter(|t| in_set(t, set) && t.label == Label::Spam && class(t.p) != Class::Spam).collect();
238 spam.sort_by(|a, b| a.p.total_cmp(&b.p).then(b.date.cmp(&a.date)));
239 spam.truncate(errors);
240 chosen.push((set.map(str::to_string), ham, spam));
241 }
242 let places: HashSet<&Place> = chosen.iter().flat_map(|(_, h, s)| h.iter().chain(s)).filter_map(|t| if let Origin::Own { place, .. } = &t.origin { Some(place) } else { None }).collect();
244 let ranks: HashSet<(&str, u64)> =
245 chosen.iter().flat_map(|(_, h, s)| h.iter().chain(s)).filter_map(|t| if let Origin::Outside { source, ordinal } = &t.origin { Some((source.as_str(), *ordinal)) } else { None }).collect();
246 let mut cards: HashMap<Place, Card> = HashMap::new();
247 if !places.is_empty() {
248 corpus::read_all(dirs, |record| {
249 let place = record.place();
250 if places.contains(&place)
251 && !cards.contains_key(&place)
252 && let Some(card) = crate::spamcore::card_of(&record)
253 {
254 cards.insert(place, card);
255 }
256 })?;
257 }
258 let mut outside_cards: HashMap<(String, u64), Card> = HashMap::new();
259 if !ranks.is_empty() {
260 let mut counted: HashMap<String, u64> = HashMap::new();
261 external::read_all(dirs, |source, message| {
262 let rank = counted.entry(source.to_string()).or_default();
263 let ordinal = *rank;
264 *rank += 1;
265 if ranks.contains(&(source, ordinal))
266 && let Some(card) = message.card()
267 {
268 outside_cards.insert((source.to_string(), ordinal), card);
269 }
270 })?;
271 }
272 let wrong = |t: &Tested| -> Wrong {
273 let (evidence, account, folder, outside, card) = match &t.origin {
274 Origin::Own { place, evidence } => (evidence_name(*evidence), place.account.clone(), place.folder.clone(), false, cards.get(place).cloned()),
275 Origin::Outside { source, ordinal } => ("outside", source.clone(), String::new(), true, outside_cards.get(&(source.clone(), *ordinal)).cloned()),
276 };
277 Wrong { p: t.p, label: t.label, class: class(t.p), date: t.date, evidence, account, folder, outside, card }
278 };
279 for (set, ham, spam) in chosen {
280 let listed = Errors { ham_called_spam: ham.into_iter().map(wrong).collect(), spam_missed: spam.into_iter().map(wrong).collect() };
281 match set {
282 None => detail.errors = listed,
283 Some(source) => detail.outside.entry(source).or_default().errors = listed,
284 }
285 }
286 Ok(detail)
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::synthetic;
293
294 fn scratch(name: &str) -> std::path::PathBuf {
295 let dir = std::env::temp_dir().join(format!("sioul-learn-detail-{name}-{}", std::process::id()));
296 let _ = std::fs::remove_dir_all(&dir);
297 std::fs::create_dir_all(&dir).unwrap();
298 dir
299 }
300
301 #[test]
304 fn the_worst_errors_with_their_cards() {
305 let root = scratch("errors");
306 let dirs = Dirs::under(&root);
307 let mail = synthetic::mailbox(5, 6, 4);
308 let records: Vec<_> = mail.iter().enumerate().map(|(i, m)| synthetic::record_of(m, 1, i as u32 + 1)).collect();
309 corpus::store(&dirs, &records).unwrap();
310 let file = root.join("old.jsonl");
311 std::fs::write(&file, "{\"label\":\"ham\",\"date\":1600000000,\"subject\":\"Lunch tomorrow\",\"text\":\"See you\",\"from\":\"Jane <jane@example.org>\"}\n{\"label\":\"spam\",\"date\":1600000100,\"subject\":\"You won\",\"text\":\"Claim it\"}\n").unwrap();
312 external::import(&dirs, &file, None).unwrap();
313 let mut tested: Vec<Tested> = records
315 .iter()
316 .map(|r| {
317 let label = if r.role == sioul_core::folders::Role::Junk || r.has_flag("$Junk") { Label::Spam } else { Label::Ham };
318 Tested { p: if label == Label::Spam { 0.99 } else { 0.1 }, label, date: r.date, origin: Origin::Own { place: r.place(), evidence: Evidence::Folder }, unsettled: false }
319 })
320 .collect();
321 let hams: Vec<usize> = (0..tested.len()).filter(|&i| tested[i].label == Label::Ham).collect();
322 let spams: Vec<usize> = (0..tested.len()).filter(|&i| tested[i].label == Label::Spam).collect();
323 tested[hams[0]].p = 0.97;
324 tested[hams[1]].p = 0.6;
325 tested[spams[0]].p = 0.2;
326 tested.push(Tested { p: 0.8, label: Label::Ham, date: 1_600_000_000, origin: Origin::Outside { source: "old".into(), ordinal: 0 }, unsettled: false });
327 tested.push(Tested { p: 0.9, label: Label::Spam, date: 1_600_000_100, origin: Origin::Outside { source: "old".into(), ordinal: 1 }, unsettled: false });
328 let detail = of(&dirs, &tested, 0.95, 0.5, 5).unwrap();
329 let counted: u64 = detail.by_account.values().map(|c| c.ham.total() + c.spam.total()).sum();
331 let by_folder: u64 = detail.by_folder.values().flat_map(|f| f.values()).map(|c| c.ham.total() + c.spam.total()).sum();
332 assert_eq!((counted, by_folder), (records.len() as u64, records.len() as u64));
333 assert_eq!(detail.grid.len(), GRID.len());
334 assert_eq!(detail.grid[0].ham_called_spam.count, 2, "two ham from 0.5 on");
335 assert_eq!(detail.grid[6].ham_called_spam.count, 1, "one from 0.95 on");
336 let ham: Vec<f64> = detail.errors.ham_called_spam.iter().map(|w| w.p).collect();
338 assert_eq!(ham, vec![0.97, 0.6]);
339 assert_eq!(detail.errors.ham_called_spam[0].class, Class::Spam);
340 assert_eq!(detail.errors.spam_missed.iter().map(|w| (w.p, w.class)).collect::<Vec<_>>(), vec![(0.2, Class::Ham)]);
341 let card = detail.errors.ham_called_spam[0].card.as_ref().expect("read again");
342 let record = &records[hams[0]];
343 assert_eq!(card.message_id.as_deref().map(sioul_core::mailindex::bare_id), crate::labels::message_id(&record.header_bytes()));
344 assert_eq!(detail.errors.ham_called_spam[0].evidence, "folder");
345 let old = &detail.outside["old"];
347 assert_eq!((old.confusion.ham.unsure, old.confusion.spam.unsure), (1, 1));
348 let outside = &old.errors.ham_called_spam[0];
349 assert!(outside.outside && outside.evidence == "outside" && outside.account == "old");
350 let card = outside.card.as_ref().expect("made of its fields");
351 assert_eq!((card.subject.as_str(), card.from_address.as_deref()), ("Lunch tomorrow", Some("jane@example.org")));
352 assert_eq!(old.errors.spam_missed[0].card.as_ref().map(|c| c.subject.clone()), Some("You won".to_string()));
353 let plain = of(&dirs, &tested, 0.95, 0.5, 0).unwrap();
355 assert!(plain.errors.ham_called_spam.is_empty() && plain.outside["old"].errors.spam_missed.is_empty() && !plain.grid.is_empty());
356 let _ = std::fs::remove_dir_all(root);
357 }
358}