1use std::collections::HashMap;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum InteractionType {
11 View,
13 Like,
15 Dislike,
17 Save,
19 Share,
21}
22
23impl InteractionType {
24 pub fn weight(self) -> f64 {
28 match self {
29 Self::View => 0.1,
30 Self::Like => 1.0,
31 Self::Dislike => -1.0,
32 Self::Save => 0.8,
33 Self::Share => 0.5,
34 }
35 }
36}
37
38#[derive(Clone, Debug)]
40pub struct InteractionRecord {
41 pub result_id: u64,
43 pub interaction: InteractionType,
45 pub category: String,
47 pub timestamp_secs: u64,
49}
50
51#[derive(Clone, Debug)]
53pub struct UserProfile {
54 pub user_id: u64,
56 pub category_scores: HashMap<String, f64>,
60 pub liked_ids: Vec<u64>,
62 pub disliked_ids: Vec<u64>,
64 pub interaction_count: u64,
66 pub(crate) result_scores: HashMap<u64, f64>,
68}
69
70impl UserProfile {
71 pub fn new(user_id: u64) -> Self {
73 Self {
74 user_id,
75 category_scores: HashMap::new(),
76 liked_ids: Vec::new(),
77 disliked_ids: Vec::new(),
78 interaction_count: 0,
79 result_scores: HashMap::new(),
80 }
81 }
82
83 pub fn preferred_categories(&self) -> Vec<String> {
85 let mut preferred: Vec<(String, f64)> = self
86 .category_scores
87 .iter()
88 .filter(|(_, &s)| s > 0.5)
89 .map(|(cat, &s)| (cat.clone(), s))
90 .collect();
91 preferred.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
92 preferred.into_iter().map(|(cat, _)| cat).collect()
93 }
94
95 pub fn aversion_categories(&self) -> Vec<String> {
97 let mut aversions: Vec<(String, f64)> = self
98 .category_scores
99 .iter()
100 .filter(|(_, &s)| s < -0.5)
101 .map(|(cat, &s)| (cat.clone(), s))
102 .collect();
103 aversions.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
104 aversions.into_iter().map(|(cat, _)| cat).collect()
105 }
106}
107
108#[derive(Clone, Debug)]
110pub struct PersonalizationBias {
111 pub category_boost: HashMap<String, f64>,
113 pub id_boost: HashMap<u64, f64>,
115}
116
117pub struct SemanticPersonalizer {
143 pub profiles: HashMap<u64, UserProfile>,
145 pub decay_rate: f64,
149}
150
151impl SemanticPersonalizer {
152 pub fn new(decay_rate: f64) -> Self {
156 Self {
157 profiles: HashMap::new(),
158 decay_rate,
159 }
160 }
161
162 pub fn record_interaction(&mut self, user_id: u64, interaction: InteractionRecord) {
170 let decay = self.decay_rate;
171
172 let profile = self
173 .profiles
174 .entry(user_id)
175 .or_insert_with(|| UserProfile::new(user_id));
176
177 for score in profile.category_scores.values_mut() {
179 *score *= decay;
180 }
181
182 let category_score = profile
184 .category_scores
185 .entry(interaction.category.clone())
186 .or_insert(0.0);
187 *category_score += interaction.interaction.weight();
188
189 let result_score = profile
191 .result_scores
192 .entry(interaction.result_id)
193 .or_insert(0.0);
194 *result_score += interaction.interaction.weight();
195 let net = *result_score;
196 let result_id = interaction.result_id;
197
198 if net > 0.0 {
200 if !profile.liked_ids.contains(&result_id) {
201 profile.liked_ids.push(result_id);
202 }
203 profile.disliked_ids.retain(|&id| id != result_id);
204 } else if net < 0.0 {
205 if !profile.disliked_ids.contains(&result_id) {
206 profile.disliked_ids.push(result_id);
207 }
208 profile.liked_ids.retain(|&id| id != result_id);
209 } else {
210 profile.liked_ids.retain(|&id| id != result_id);
212 profile.disliked_ids.retain(|&id| id != result_id);
213 }
214
215 profile.interaction_count += 1;
216 }
217
218 pub fn compute_bias(&self, user_id: u64) -> Option<PersonalizationBias> {
222 let profile = self.profiles.get(&user_id)?;
223
224 let mut category_boost: HashMap<String, f64> = HashMap::new();
225 for cat in profile.preferred_categories() {
226 category_boost.insert(cat, 1.5);
227 }
228 for cat in profile.aversion_categories() {
229 category_boost.insert(cat, 0.5);
230 }
231
232 let mut id_boost: HashMap<u64, f64> = HashMap::new();
233 for &id in &profile.liked_ids {
234 id_boost.insert(id, 1.2);
235 }
236 for &id in &profile.disliked_ids {
237 id_boost.insert(id, 0.8);
238 }
239
240 Some(PersonalizationBias {
241 category_boost,
242 id_boost,
243 })
244 }
245
246 pub fn apply_bias(&self, user_id: u64, results: &[(u64, f64, String)]) -> Vec<(u64, f64)> {
253 let bias = self.compute_bias(user_id);
254
255 let mut adjusted: Vec<(u64, f64)> = results
256 .iter()
257 .map(|(result_id, score, category)| {
258 let new_score = match &bias {
259 Some(b) => {
260 let cat_mult = b.category_boost.get(category).copied().unwrap_or(1.0);
261 let id_mult = b.id_boost.get(result_id).copied().unwrap_or(1.0);
262 score * cat_mult * id_mult
263 }
264 None => *score,
265 };
266 (*result_id, new_score)
267 })
268 .collect();
269
270 adjusted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
271 adjusted
272 }
273
274 pub fn profile(&self, user_id: u64) -> Option<&UserProfile> {
276 self.profiles.get(&user_id)
277 }
278
279 pub fn stats(&self) -> (usize, u64) {
281 let user_count = self.profiles.len();
282 let total_interactions = self.profiles.values().map(|p| p.interaction_count).sum();
283 (user_count, total_interactions)
284 }
285}
286
287#[cfg(test)]
290mod tests {
291 use super::*;
292
293 fn make_record(
294 result_id: u64,
295 interaction: InteractionType,
296 category: &str,
297 timestamp_secs: u64,
298 ) -> InteractionRecord {
299 InteractionRecord {
300 result_id,
301 interaction,
302 category: category.to_string(),
303 timestamp_secs,
304 }
305 }
306
307 #[test]
310 fn test_record_creates_profile() {
311 let mut p = SemanticPersonalizer::new(0.99);
312 assert!(p.profile(1).is_none());
313 p.record_interaction(1, make_record(10, InteractionType::View, "tech", 0));
314 assert!(p.profile(1).is_some());
315 }
316
317 #[test]
320 fn test_like_increases_category_score() {
321 let mut p = SemanticPersonalizer::new(1.0); p.record_interaction(1, make_record(10, InteractionType::Like, "music", 0));
323 let score = *p
324 .profile(1)
325 .expect("test: profile for user 1 not found")
326 .category_scores
327 .get("music")
328 .expect("test: music category score not found");
329 assert!(
330 (score - 1.0).abs() < 1e-9,
331 "score should be 1.0, got {score}"
332 );
333 }
334
335 #[test]
338 fn test_dislike_decreases_category_score() {
339 let mut p = SemanticPersonalizer::new(1.0);
340 p.record_interaction(1, make_record(20, InteractionType::Dislike, "ads", 0));
341 let score = *p
342 .profile(1)
343 .expect("test: profile for user 1 not found")
344 .category_scores
345 .get("ads")
346 .expect("test: ads category score not found");
347 assert!(
348 (score - (-1.0)).abs() < 1e-9,
349 "score should be -1.0, got {score}"
350 );
351 }
352
353 #[test]
356 fn test_view_weight() {
357 let mut p = SemanticPersonalizer::new(1.0);
358 p.record_interaction(1, make_record(30, InteractionType::View, "news", 0));
359 let score = *p
360 .profile(1)
361 .expect("test: profile for user 1 not found")
362 .category_scores
363 .get("news")
364 .expect("test: news category score not found");
365 assert!((score - 0.1).abs() < 1e-9);
366 }
367
368 #[test]
371 fn test_save_weight() {
372 let mut p = SemanticPersonalizer::new(1.0);
373 p.record_interaction(1, make_record(40, InteractionType::Save, "cooking", 0));
374 let score = *p
375 .profile(1)
376 .expect("test: profile for user 1 not found")
377 .category_scores
378 .get("cooking")
379 .expect("test: cooking category score not found");
380 assert!((score - 0.8).abs() < 1e-9);
381 }
382
383 #[test]
386 fn test_share_weight() {
387 let mut p = SemanticPersonalizer::new(1.0);
388 p.record_interaction(1, make_record(50, InteractionType::Share, "science", 0));
389 let score = *p
390 .profile(1)
391 .expect("test: profile for user 1 not found")
392 .category_scores
393 .get("science")
394 .expect("test: science category score not found");
395 assert!((score - 0.5).abs() < 1e-9);
396 }
397
398 #[test]
401 fn test_preferred_categories_threshold() {
402 let mut p = SemanticPersonalizer::new(1.0);
403 p.record_interaction(1, make_record(1, InteractionType::Like, "rust", 0));
405 p.record_interaction(1, make_record(2, InteractionType::View, "python", 0));
407
408 let prefs = p
409 .profile(1)
410 .expect("test: profile for user 1 not found")
411 .preferred_categories();
412 assert!(prefs.contains(&"rust".to_string()));
413 assert!(!prefs.contains(&"python".to_string()));
414 }
415
416 #[test]
419 fn test_aversion_categories_threshold() {
420 let mut p = SemanticPersonalizer::new(1.0);
421 p.record_interaction(1, make_record(1, InteractionType::Dislike, "spam", 0));
423 p.record_interaction(1, make_record(2, InteractionType::View, "neutral", 0));
426
427 let aversions = p
428 .profile(1)
429 .expect("test: profile for user 1 not found")
430 .aversion_categories();
431 assert!(aversions.contains(&"spam".to_string()));
432 assert!(!aversions.contains(&"neutral".to_string()));
433 }
434
435 #[test]
438 fn test_aversion_categories_ordering() {
439 let mut p = SemanticPersonalizer::new(1.0);
440 p.record_interaction(1, make_record(1, InteractionType::Dislike, "bad_cat", 0));
441 p.record_interaction(
442 1,
443 make_record(2, InteractionType::Dislike, "terrible_cat", 0),
444 );
445 p.record_interaction(
446 1,
447 make_record(3, InteractionType::Dislike, "terrible_cat", 0),
448 );
449
450 let aversions = p
451 .profile(1)
452 .expect("test: profile for user 1 not found")
453 .aversion_categories();
454 assert_eq!(aversions[0], "terrible_cat");
456 assert_eq!(aversions[1], "bad_cat");
457 }
458
459 #[test]
462 fn test_liked_ids_populated() {
463 let mut p = SemanticPersonalizer::new(1.0);
464 p.record_interaction(1, make_record(99, InteractionType::Like, "art", 0));
465 let profile = p.profile(1).expect("test: profile for user 1 not found");
466 assert!(profile.liked_ids.contains(&99));
467 assert!(!profile.disliked_ids.contains(&99));
468 }
469
470 #[test]
473 fn test_disliked_ids_populated() {
474 let mut p = SemanticPersonalizer::new(1.0);
475 p.record_interaction(1, make_record(77, InteractionType::Dislike, "ads", 0));
476 let profile = p.profile(1).expect("test: profile for user 1 not found");
477 assert!(profile.disliked_ids.contains(&77));
478 assert!(!profile.liked_ids.contains(&77));
479 }
480
481 #[test]
484 fn test_compute_bias() {
485 let mut p = SemanticPersonalizer::new(1.0);
486 p.record_interaction(1, make_record(10, InteractionType::Like, "rust", 0));
487 p.record_interaction(1, make_record(20, InteractionType::Dislike, "java", 0));
488
489 let bias = p.compute_bias(1).expect("bias should exist");
490 assert_eq!(bias.category_boost.get("rust").copied(), Some(1.5));
491 assert_eq!(bias.category_boost.get("java").copied(), Some(0.5));
492 assert_eq!(bias.id_boost.get(&10).copied(), Some(1.2));
493 assert_eq!(bias.id_boost.get(&20).copied(), Some(0.8));
494 }
495
496 #[test]
499 fn test_compute_bias_unknown_user() {
500 let p = SemanticPersonalizer::new(0.99);
501 assert!(p.compute_bias(9999).is_none());
502 }
503
504 #[test]
507 fn test_apply_bias_reranks() {
508 let mut p = SemanticPersonalizer::new(1.0);
509 p.record_interaction(1, make_record(10, InteractionType::Like, "rust", 0));
511 p.record_interaction(1, make_record(20, InteractionType::Dislike, "java", 0));
513
514 let results = vec![
515 (20_u64, 0.9_f64, "java".to_string()), (10_u64, 0.7_f64, "rust".to_string()), ];
518
519 let biased = p.apply_bias(1, &results);
520 assert_eq!(
522 biased[0].0, 10,
523 "result 10 should be ranked first after bias"
524 );
525 }
526
527 #[test]
530 fn test_apply_bias_no_profile() {
531 let p = SemanticPersonalizer::new(0.99);
532 let results = vec![
533 (1_u64, 0.5_f64, "tech".to_string()),
534 (2_u64, 0.9_f64, "tech".to_string()),
535 ];
536 let biased = p.apply_bias(999, &results);
537 assert_eq!(biased[0].0, 2); }
539
540 #[test]
543 fn test_decay_applied() {
544 let mut p = SemanticPersonalizer::new(0.5); p.record_interaction(1, make_record(1, InteractionType::Like, "topic", 0));
546 p.record_interaction(1, make_record(2, InteractionType::Like, "topic", 1));
548 let score = *p
551 .profile(1)
552 .expect("test: profile for user 1 not found")
553 .category_scores
554 .get("topic")
555 .expect("test: topic category score not found");
556 assert!((score - 1.5).abs() < 1e-9, "expected 1.5, got {score}");
557 }
558
559 #[test]
562 fn test_stats_totals() {
563 let mut p = SemanticPersonalizer::new(0.99);
564 p.record_interaction(1, make_record(1, InteractionType::View, "a", 0));
565 p.record_interaction(1, make_record(2, InteractionType::Like, "b", 1));
566 p.record_interaction(2, make_record(3, InteractionType::Save, "c", 2));
567
568 let (users, interactions) = p.stats();
569 assert_eq!(users, 2);
570 assert_eq!(interactions, 3);
571 }
572
573 #[test]
576 fn test_result_id_migration_liked_to_disliked() {
577 let mut p = SemanticPersonalizer::new(1.0);
578 p.record_interaction(1, make_record(5, InteractionType::Like, "cat", 0));
580 assert!(p
581 .profile(1)
582 .expect("test: profile for user 1 not found")
583 .liked_ids
584 .contains(&5));
585 p.record_interaction(1, make_record(5, InteractionType::Dislike, "cat", 1));
587 p.record_interaction(1, make_record(5, InteractionType::Dislike, "cat", 2));
588 let profile = p.profile(1).expect("test: profile for user 1 not found");
589 assert!(!profile.liked_ids.contains(&5), "should no longer be liked");
590 assert!(profile.disliked_ids.contains(&5), "should now be disliked");
591 }
592
593 #[test]
596 fn test_interaction_count() {
597 let mut p = SemanticPersonalizer::new(0.99);
598 for i in 0..5 {
599 p.record_interaction(1, make_record(i, InteractionType::View, "x", i));
600 }
601 assert_eq!(
602 p.profile(1)
603 .expect("test: profile for user 1 not found")
604 .interaction_count,
605 5
606 );
607 }
608
609 #[test]
612 fn test_preferred_categories_sorted() {
613 let mut p = SemanticPersonalizer::new(1.0);
614 p.record_interaction(1, make_record(1, InteractionType::Save, "rust", 0));
616 p.record_interaction(1, make_record(2, InteractionType::Like, "science", 0));
618
619 let prefs = p
620 .profile(1)
621 .expect("test: profile for user 1 not found")
622 .preferred_categories();
623 assert_eq!(prefs[0], "science");
625 assert_eq!(prefs[1], "rust");
626 }
627}