Skip to main content

ipfrs_semantic/
personalizer.rs

1//! Semantic Personalizer — per-user interest profile management
2//!
3//! Maintains per-user interest profiles built from interaction history, biasing
4//! search results toward user preferences via category-level and result-level boosts.
5
6use std::collections::HashMap;
7
8/// Type of interaction a user had with a search result.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum InteractionType {
11    /// User viewed the result (weak positive signal).
12    View,
13    /// User explicitly liked the result (strong positive signal).
14    Like,
15    /// User explicitly disliked the result (strong negative signal).
16    Dislike,
17    /// User saved the result for later (moderate positive signal).
18    Save,
19    /// User shared the result with others (moderate positive signal).
20    Share,
21}
22
23impl InteractionType {
24    /// Signed weight assigned to this interaction type.
25    ///
26    /// Positive weights increase interest; negative weights decrease it.
27    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/// A single interaction event between a user and a search result.
39#[derive(Clone, Debug)]
40pub struct InteractionRecord {
41    /// Unique identifier for the search result.
42    pub result_id: u64,
43    /// The type of interaction.
44    pub interaction: InteractionType,
45    /// Content category tag associated with the result.
46    pub category: String,
47    /// Unix timestamp (seconds) at which the interaction occurred.
48    pub timestamp_secs: u64,
49}
50
51/// Per-user interest profile derived from interaction history.
52#[derive(Clone, Debug)]
53pub struct UserProfile {
54    /// Identifier of the user this profile belongs to.
55    pub user_id: u64,
56    /// Accumulated weighted scores per content category.
57    ///
58    /// Positive scores indicate interest; negative scores indicate aversion.
59    pub category_scores: HashMap<String, f64>,
60    /// Result IDs that have a net positive score across all interactions.
61    pub liked_ids: Vec<u64>,
62    /// Result IDs that have a net negative score across all interactions.
63    pub disliked_ids: Vec<u64>,
64    /// Total number of interactions recorded for this user.
65    pub interaction_count: u64,
66    /// Net score per result ID, used internally to maintain liked/disliked lists.
67    pub(crate) result_scores: HashMap<u64, f64>,
68}
69
70impl UserProfile {
71    /// Create a new, empty profile for the given user.
72    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    /// Returns categories with a score above `0.5`, sorted by score descending.
84    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    /// Returns categories with a score below `-0.5`, sorted by score ascending (most averse first).
96    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/// Score multipliers that bias search results for a specific user.
109#[derive(Clone, Debug)]
110pub struct PersonalizationBias {
111    /// Per-category score multiplier (`1.5` for preferred, `0.5` for aversion categories).
112    pub category_boost: HashMap<String, f64>,
113    /// Per-result direct score multiplier (`1.2` for liked IDs, `0.8` for disliked IDs).
114    pub id_boost: HashMap<u64, f64>,
115}
116
117/// Manages per-user interest profiles and applies personalization to search results.
118///
119/// # Example
120///
121/// ```rust
122/// use ipfrs_semantic::personalizer::{
123///     InteractionRecord, InteractionType, SemanticPersonalizer,
124/// };
125///
126/// let mut personalizer = SemanticPersonalizer::new(0.99);
127///
128/// personalizer.record_interaction(
129///     1,
130///     InteractionRecord {
131///         result_id: 42,
132///         interaction: InteractionType::Like,
133///         category: "rust".to_string(),
134///         timestamp_secs: 1_700_000_000,
135///     },
136/// );
137///
138/// let results = vec![(42_u64, 0.8_f64, "rust".to_string())];
139/// let biased = personalizer.apply_bias(1, &results);
140/// assert!(!biased.is_empty());
141/// ```
142pub struct SemanticPersonalizer {
143    /// Map from user ID to that user's interest profile.
144    pub profiles: HashMap<u64, UserProfile>,
145    /// Multiplicative decay applied to existing category scores on each new interaction.
146    ///
147    /// A value of `0.99` means each prior interaction contributes slightly less over time.
148    pub decay_rate: f64,
149}
150
151impl SemanticPersonalizer {
152    /// Create a new `SemanticPersonalizer` with the given decay rate.
153    ///
154    /// `decay_rate` should be in `(0.0, 1.0]`. A typical value is `0.99`.
155    pub fn new(decay_rate: f64) -> Self {
156        Self {
157            profiles: HashMap::new(),
158            decay_rate,
159        }
160    }
161
162    /// Record a user interaction and update the corresponding interest profile.
163    ///
164    /// Steps performed:
165    /// 1. Upsert the user profile.
166    /// 2. Apply decay to all existing `category_scores`.
167    /// 3. Add the interaction weight to `category_scores[category]`.
168    /// 4. Update `result_scores` and synchronise `liked_ids` / `disliked_ids`.
169    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        // Apply temporal decay to all existing category scores.
178        for score in profile.category_scores.values_mut() {
179            *score *= decay;
180        }
181
182        // Add the weighted signal for this interaction's category.
183        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        // Update net score for this specific result.
190        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        // Synchronise liked / disliked ID lists based on net score.
199        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            // Net score exactly zero: remove from both lists.
211            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    /// Compute personalization bias for a user.
219    ///
220    /// Returns `None` if the user has no profile.
221    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    /// Apply personalization bias to a slice of `(result_id, score, category)` tuples.
247    ///
248    /// Each result's score is multiplied by its category boost and its ID boost.
249    /// Results are returned sorted by adjusted score descending.
250    ///
251    /// If the user has no profile, results are returned sorted by the original scores.
252    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    /// Retrieve an immutable reference to a user's profile.
275    pub fn profile(&self, user_id: u64) -> Option<&UserProfile> {
276        self.profiles.get(&user_id)
277    }
278
279    /// Return aggregate statistics: `(user_count, total_interactions)`.
280    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// ─── Tests ────────────────────────────────────────────────────────────────────
288
289#[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 1: record_interaction creates a profile ──────────────────────────
308
309    #[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 2: Like increases category score ─────────────────────────────────
318
319    #[test]
320    fn test_like_increases_category_score() {
321        let mut p = SemanticPersonalizer::new(1.0); // no decay for clarity
322        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 3: Dislike decreases category score ──────────────────────────────
336
337    #[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 4: View adds small positive weight ───────────────────────────────
354
355    #[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 5: Save weight is 0.8 ────────────────────────────────────────────
369
370    #[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 6: Share weight is 0.5 ───────────────────────────────────────────
384
385    #[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 7: preferred_categories threshold > 0.5 ─────────────────────────
399
400    #[test]
401    fn test_preferred_categories_threshold() {
402        let mut p = SemanticPersonalizer::new(1.0);
403        // Score = 1.0 (Like) — above threshold
404        p.record_interaction(1, make_record(1, InteractionType::Like, "rust", 0));
405        // Score = 0.1 (View) — below threshold
406        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 8: aversion_categories threshold < -0.5 ─────────────────────────
417
418    #[test]
419    fn test_aversion_categories_threshold() {
420        let mut p = SemanticPersonalizer::new(1.0);
421        // Score = -1.0 (Dislike) — below threshold
422        p.record_interaction(1, make_record(1, InteractionType::Dislike, "spam", 0));
423        // Score = -0.1 — above threshold (View weight is 0.1, after Dislike we add View)
424        // Use View to end up at -0.9 — still below threshold, so let's pick a neutral one
425        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 9: aversion_categories ordering (most negative first) ───────────
436
437    #[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        // terrible_cat has score -2.0, bad_cat has score -1.0 → terrible_cat first
455        assert_eq!(aversions[0], "terrible_cat");
456        assert_eq!(aversions[1], "bad_cat");
457    }
458
459    // ── Test 10: liked_ids populated after Like ───────────────────────────────
460
461    #[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 11: disliked_ids populated after Dislike ────────────────────────
471
472    #[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 12: compute_bias returns correct multipliers ─────────────────────
482
483    #[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 13: compute_bias returns None for unknown user ───────────────────
497
498    #[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 14: apply_bias re-ranks results ─────────────────────────────────
505
506    #[test]
507    fn test_apply_bias_reranks() {
508        let mut p = SemanticPersonalizer::new(1.0);
509        // User likes result 10 in "rust" category
510        p.record_interaction(1, make_record(10, InteractionType::Like, "rust", 0));
511        // User dislikes result 20 in "java" category
512        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()), // originally highest
516            (10_u64, 0.7_f64, "rust".to_string()), // boosted: 0.7 * 1.5 * 1.2 = 1.26
517        ];
518
519        let biased = p.apply_bias(1, &results);
520        // result 10 should now rank first after bias
521        assert_eq!(
522            biased[0].0, 10,
523            "result 10 should be ranked first after bias"
524        );
525    }
526
527    // ── Test 15: apply_bias with no profile returns sorted-by-score ──────────
528
529    #[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); // higher original score first
538    }
539
540    // ── Test 16: decay applied on subsequent interaction ──────────────────────
541
542    #[test]
543    fn test_decay_applied() {
544        let mut p = SemanticPersonalizer::new(0.5); // aggressive decay
545        p.record_interaction(1, make_record(1, InteractionType::Like, "topic", 0));
546        // After first: score = 1.0
547        p.record_interaction(1, make_record(2, InteractionType::Like, "topic", 1));
548        // Before adding second Like, existing score decays: 1.0 * 0.5 = 0.5
549        // After second: 0.5 + 1.0 = 1.5
550        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 17: stats totals ─────────────────────────────────────────────────
560
561    #[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 18: result migrates from liked to disliked when net goes negative ─
574
575    #[test]
576    fn test_result_id_migration_liked_to_disliked() {
577        let mut p = SemanticPersonalizer::new(1.0);
578        // First Like: net = 1.0 → liked
579        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        // Two Dislikes: net = 1.0 - 1.0 - 1.0 = -1.0 → disliked
586        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 19: interaction_count increments correctly ───────────────────────
594
595    #[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 20: preferred_categories sorted by score descending ─────────────
610
611    #[test]
612    fn test_preferred_categories_sorted() {
613        let mut p = SemanticPersonalizer::new(1.0);
614        // "rust": 0.8 (Save)
615        p.record_interaction(1, make_record(1, InteractionType::Save, "rust", 0));
616        // "science": 1.0 (Like) — higher
617        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        // science (1.0) should come before rust (0.8)
624        assert_eq!(prefs[0], "science");
625        assert_eq!(prefs[1], "rust");
626    }
627}