1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ImportanceLevel {
10 Critical,
12 Important,
14 Normal,
16 Low,
18 Noise,
20}
21
22impl ImportanceLevel {
23 pub fn as_str(&self) -> &'static str {
25 match self {
26 Self::Critical => "critical",
27 Self::Important => "important",
28 Self::Normal => "normal",
29 Self::Low => "low",
30 Self::Noise => "noise",
31 }
32 }
33
34 pub fn from_score(score: f32) -> Self {
36 if score >= 0.8 {
37 Self::Critical
38 } else if score >= 0.5 {
39 Self::Important
40 } else if score >= 0.2 {
41 Self::Normal
42 } else if score >= 0.0 {
43 Self::Low
44 } else {
45 Self::Noise
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
52pub struct ImportanceSignals {
53 pub is_mutual_contact: bool,
55 pub is_direct_recipient: bool,
57 pub is_reply_to_my_email: bool,
59 pub has_action_items: bool,
61 pub is_vip_sender: bool,
63 pub is_bulk_sender: bool,
65 pub is_mailing_list: bool,
67 pub is_automated: bool,
69 pub has_tracking_pixel: bool,
71 pub is_template_heavy: bool,
73 pub text_to_html_ratio: f32,
75 pub link_count: usize,
77 pub contact_importance_bias: f32,
79}
80
81impl Default for ImportanceSignals {
82 fn default() -> Self {
83 Self {
84 is_mutual_contact: false,
85 is_direct_recipient: false,
86 is_reply_to_my_email: false,
87 has_action_items: false,
88 is_vip_sender: false,
89 is_bulk_sender: false,
90 is_mailing_list: false,
91 is_automated: false,
92 has_tracking_pixel: false,
93 is_template_heavy: false,
94 text_to_html_ratio: 1.0,
95 link_count: 0,
96 contact_importance_bias: 0.0,
97 }
98 }
99}
100
101pub fn calculate_importance(signals: &ImportanceSignals) -> (ImportanceLevel, f32) {
103 let mut score: f32 = 0.3; if signals.is_mutual_contact {
106 score += 0.3;
107 }
108 if signals.is_direct_recipient {
109 score += 0.1;
110 }
111 if signals.is_reply_to_my_email {
112 score += 0.3;
113 }
114 if signals.has_action_items {
115 score += 0.2;
116 }
117 if signals.is_vip_sender {
118 score += 0.4;
119 }
120
121 if signals.is_bulk_sender {
122 score -= 0.3;
123 }
124 if signals.is_mailing_list {
125 score -= 0.2;
126 }
127 if signals.is_automated {
128 score -= 0.3;
129 }
130 if signals.has_tracking_pixel {
131 score -= 0.1;
132 }
133 if signals.is_template_heavy {
134 score -= 0.2;
135 }
136 if signals.link_count > 20 {
137 score -= 0.1;
138 }
139
140 score += signals.contact_importance_bias;
141
142 score = score.clamp(-0.5, 1.0);
143
144 let level = ImportanceLevel::from_score(score);
145 (level, score)
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn baseline_score_is_normal() {
154 let signals = ImportanceSignals::default();
155 let (level, score) = calculate_importance(&signals);
156 assert_eq!(level, ImportanceLevel::Normal);
157 assert!((score - 0.3).abs() < 0.001);
158 }
159
160 #[test]
161 fn mutual_contact_direct_is_important() {
162 let signals = ImportanceSignals {
163 is_mutual_contact: true,
164 is_direct_recipient: true,
165 ..Default::default()
166 };
167 let (level, _) = calculate_importance(&signals);
168 assert!(level == ImportanceLevel::Important || level == ImportanceLevel::Critical);
169 }
170
171 #[test]
172 fn vip_reply_is_critical() {
173 let signals = ImportanceSignals {
174 is_vip_sender: true,
175 is_reply_to_my_email: true,
176 is_mutual_contact: true,
177 ..Default::default()
178 };
179 let (level, score) = calculate_importance(&signals);
180 assert_eq!(level, ImportanceLevel::Critical);
181 assert!(score >= 0.8);
182 }
183
184 #[test]
185 fn bulk_automated_is_low_or_noise() {
186 let signals = ImportanceSignals {
187 is_bulk_sender: true,
188 is_automated: true,
189 is_mailing_list: true,
190 has_tracking_pixel: true,
191 ..Default::default()
192 };
193 let (level, _) = calculate_importance(&signals);
194 assert!(level == ImportanceLevel::Low || level == ImportanceLevel::Noise);
195 }
196
197 #[test]
198 fn template_heavy_marketing_is_low() {
199 let signals = ImportanceSignals {
200 is_bulk_sender: true,
201 has_tracking_pixel: true,
202 is_template_heavy: true,
203 link_count: 30,
204 ..Default::default()
205 };
206 let (level, _) = calculate_importance(&signals);
207 assert!(level == ImportanceLevel::Low || level == ImportanceLevel::Noise);
208 }
209
210 #[test]
211 fn user_bias_positive_boosts() {
212 let signals = ImportanceSignals {
213 contact_importance_bias: 0.5,
214 ..Default::default()
215 };
216 let (level, _) = calculate_importance(&signals);
217 assert_eq!(level, ImportanceLevel::Critical);
218 }
219
220 #[test]
221 fn user_bias_negative_demotes() {
222 let signals = ImportanceSignals {
223 is_mutual_contact: true,
224 contact_importance_bias: -0.5,
225 ..Default::default()
226 };
227 let (level, _) = calculate_importance(&signals);
228 assert!(level == ImportanceLevel::Normal || level == ImportanceLevel::Low);
229 }
230
231 #[test]
232 fn score_clamped_to_range() {
233 let signals = ImportanceSignals {
234 is_vip_sender: true,
235 is_mutual_contact: true,
236 is_reply_to_my_email: true,
237 is_direct_recipient: true,
238 has_action_items: true,
239 contact_importance_bias: 1.0,
240 ..Default::default()
241 };
242 let (_, score) = calculate_importance(&signals);
243 assert!(score <= 1.0);
244
245 let signals2 = ImportanceSignals {
246 is_bulk_sender: true,
247 is_automated: true,
248 is_mailing_list: true,
249 has_tracking_pixel: true,
250 is_template_heavy: true,
251 contact_importance_bias: -1.0,
252 ..Default::default()
253 };
254 let (_, score2) = calculate_importance(&signals2);
255 assert!(score2 >= -0.5);
256 }
257
258 #[test]
259 fn importance_level_as_str() {
260 assert_eq!(ImportanceLevel::Critical.as_str(), "critical");
261 assert_eq!(ImportanceLevel::Important.as_str(), "important");
262 assert_eq!(ImportanceLevel::Normal.as_str(), "normal");
263 assert_eq!(ImportanceLevel::Low.as_str(), "low");
264 assert_eq!(ImportanceLevel::Noise.as_str(), "noise");
265 }
266
267 #[test]
268 fn importance_level_from_score_boundaries() {
269 assert_eq!(ImportanceLevel::from_score(1.0), ImportanceLevel::Critical);
270 assert_eq!(ImportanceLevel::from_score(0.8), ImportanceLevel::Critical);
271 assert_eq!(
272 ImportanceLevel::from_score(0.79),
273 ImportanceLevel::Important
274 );
275 assert_eq!(ImportanceLevel::from_score(0.5), ImportanceLevel::Important);
276 assert_eq!(ImportanceLevel::from_score(0.49), ImportanceLevel::Normal);
277 assert_eq!(ImportanceLevel::from_score(0.2), ImportanceLevel::Normal);
278 assert_eq!(ImportanceLevel::from_score(0.19), ImportanceLevel::Low);
279 assert_eq!(ImportanceLevel::from_score(0.0), ImportanceLevel::Low);
280 assert_eq!(ImportanceLevel::from_score(-0.01), ImportanceLevel::Noise);
281 }
282
283 #[test]
286 fn default_signals_have_text_to_html_ratio_one() {
287 let d = ImportanceSignals::default();
289 assert!((d.text_to_html_ratio - 1.0).abs() < f32::EPSILON);
290 assert_eq!(d.link_count, 0);
291 assert!((d.contact_importance_bias).abs() < f32::EPSILON);
292 }
293
294 #[test]
295 fn link_count_threshold_exactly_20_does_not_penalize() {
296 let s = ImportanceSignals {
298 link_count: 20,
299 ..Default::default()
300 };
301 let (_, score) = calculate_importance(&s);
302 assert!((score - 0.3).abs() < 0.001);
304 }
305
306 #[test]
307 fn link_count_above_threshold_penalizes() {
308 let s = ImportanceSignals {
309 link_count: 21,
310 ..Default::default()
311 };
312 let (_, score) = calculate_importance(&s);
313 assert!((score - 0.2).abs() < 0.001);
315 }
316
317 #[test]
318 fn from_score_negative_clamp_lower_bound() {
319 assert_eq!(ImportanceLevel::from_score(-0.5), ImportanceLevel::Noise);
321 assert_eq!(ImportanceLevel::from_score(-100.0), ImportanceLevel::Noise);
322 assert_eq!(
323 ImportanceLevel::from_score(f32::NEG_INFINITY),
324 ImportanceLevel::Noise
325 );
326 }
327
328 #[test]
329 fn from_score_above_one_still_critical() {
330 assert_eq!(ImportanceLevel::from_score(2.0), ImportanceLevel::Critical);
332 assert_eq!(
333 ImportanceLevel::from_score(f32::INFINITY),
334 ImportanceLevel::Critical
335 );
336 }
337
338 #[test]
339 fn from_score_nan_behavior_is_noise() {
340 assert_eq!(
342 ImportanceLevel::from_score(f32::NAN),
343 ImportanceLevel::Noise
344 );
345 }
346
347 #[test]
348 fn signals_with_all_positives_capped_at_one() {
349 let s = ImportanceSignals {
351 is_mutual_contact: true,
352 is_direct_recipient: true,
353 is_reply_to_my_email: true,
354 has_action_items: true,
355 is_vip_sender: true,
356 contact_importance_bias: 10.0, ..Default::default()
358 };
359 let (level, score) = calculate_importance(&s);
360 assert!((score - 1.0).abs() < f32::EPSILON);
361 assert_eq!(level, ImportanceLevel::Critical);
362 }
363
364 #[test]
365 fn signals_with_all_negatives_capped_at_minus_half() {
366 let s = ImportanceSignals {
368 is_bulk_sender: true,
369 is_mailing_list: true,
370 is_automated: true,
371 has_tracking_pixel: true,
372 is_template_heavy: true,
373 link_count: 100,
374 contact_importance_bias: -10.0,
375 ..Default::default()
376 };
377 let (level, score) = calculate_importance(&s);
378 assert!((score - (-0.5)).abs() < f32::EPSILON);
379 assert_eq!(level, ImportanceLevel::Noise);
380 }
381
382 #[test]
383 fn direct_recipient_alone_does_not_promote_to_important() {
384 let s = ImportanceSignals {
386 is_direct_recipient: true,
387 ..Default::default()
388 };
389 let (level, score) = calculate_importance(&s);
390 assert!((score - 0.4).abs() < 0.001);
391 assert_eq!(level, ImportanceLevel::Normal);
392 }
393
394 #[test]
395 fn signals_cloning_preserves_all_fields() {
396 let s = ImportanceSignals {
397 is_mutual_contact: true,
398 is_direct_recipient: true,
399 is_vip_sender: true,
400 text_to_html_ratio: 0.5,
401 link_count: 7,
402 contact_importance_bias: 0.25,
403 ..Default::default()
404 };
405 let c = s.clone();
406 assert_eq!(c.is_mutual_contact, s.is_mutual_contact);
407 assert_eq!(c.is_direct_recipient, s.is_direct_recipient);
408 assert_eq!(c.is_vip_sender, s.is_vip_sender);
409 assert!((c.text_to_html_ratio - s.text_to_html_ratio).abs() < f32::EPSILON);
410 assert_eq!(c.link_count, s.link_count);
411 assert!((c.contact_importance_bias - s.contact_importance_bias).abs() < f32::EPSILON);
412 }
413
414 #[test]
415 fn importance_level_copy_trait() {
416 let l = ImportanceLevel::Critical;
418 let copy = l;
419 assert_eq!(l, copy);
421 assert_eq!(copy.as_str(), "critical");
422 }
423
424 #[test]
425 fn calculate_importance_returns_consistent_level_and_score() {
426 let cases = [
428 ImportanceSignals::default(),
429 ImportanceSignals {
430 is_vip_sender: true,
431 ..Default::default()
432 },
433 ImportanceSignals {
434 is_bulk_sender: true,
435 contact_importance_bias: -0.1,
436 ..Default::default()
437 },
438 ];
439 for s in cases {
440 let (level, score) = calculate_importance(&s);
441 assert_eq!(
442 level,
443 ImportanceLevel::from_score(score),
444 "level and score must agree (score={score})",
445 );
446 }
447 }
448}