relevancy/
ranker.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::cmp::max;
6
7use crate::interest::{Interest, InterestVector};
8
9/// Calculate score for a piece of categorized content based on a user interest vector.
10///
11/// This scoring function is of the following properties:
12///   - The score ranges from 0.0 to 1.0
13///   - The score is monotonically increasing for the accumulated interest count
14///
15/// # Params:
16///   - `interest_vector`: a user interest vector that can be fetched via
17///     `RelevancyStore::user_interest_vector()`.
18///   - `content_categories`: a list of categories (interests) of the give content.
19/// # Return:
20///   - A score ranges in [0, 1].
21#[uniffi::export]
22pub fn score(interest_vector: InterestVector, content_categories: Vec<Interest>) -> f64 {
23    let n = content_categories
24        .iter()
25        .fold(0, |acc, &category| acc + interest_vector[category]);
26
27    // Apply base 10 logarithm to the accumulated count so its hyperbolic tangent is more
28    // evenly distributed in [0, 1]. Note that `max(n, 1)` is used to avoid negative scores.
29    (max(n, 1) as f64).log10().tanh()
30}
31
32#[cfg(test)]
33mod test {
34    use crate::interest::{Interest, InterestVector};
35
36    use super::*;
37
38    const EPSILON: f64 = 1e-10;
39    const SUBEPSILON: f64 = 1e-6;
40
41    #[test]
42    fn test_score_lower_bound() {
43        // Empty interest vector yields score 0.
44        let s = score(InterestVector::default(), vec![Interest::Food]);
45        let delta = (s - 0_f64).abs();
46
47        assert!(delta < EPSILON);
48
49        // No overlap also yields score 0.
50        let s = score(
51            InterestVector {
52                animals: 10,
53                ..InterestVector::default()
54            },
55            vec![Interest::Food],
56        );
57        let delta = (s - 0_f64).abs();
58
59        assert!(delta < EPSILON);
60    }
61
62    #[test]
63    fn test_score_upper_bound() {
64        let score = score(
65            InterestVector {
66                animals: 1_000_000_000,
67                ..InterestVector::default()
68            },
69            vec![Interest::Animals],
70        );
71        let delta = (score - 1.0_f64).abs();
72
73        // Can get very close to the upper bound 1.0 but not over.
74        assert!(delta < SUBEPSILON);
75    }
76
77    #[test]
78    fn test_score_monotonic() {
79        let l = score(
80            InterestVector {
81                animals: 1,
82                ..InterestVector::default()
83            },
84            vec![Interest::Animals],
85        );
86
87        let r = score(
88            InterestVector {
89                animals: 5,
90                ..InterestVector::default()
91            },
92            vec![Interest::Animals],
93        );
94
95        assert!(l < r);
96    }
97
98    #[test]
99    fn test_score_multi_categories() {
100        let l = score(
101            InterestVector {
102                animals: 100,
103                food: 100,
104                ..InterestVector::default()
105            },
106            vec![Interest::Animals, Interest::Food],
107        );
108
109        let r = score(
110            InterestVector {
111                animals: 200,
112                ..InterestVector::default()
113            },
114            vec![Interest::Animals],
115        );
116        let delta = (l - r).abs();
117
118        assert!(delta < EPSILON);
119    }
120}