1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
//! Contains the logic to score an exercise based on the results and timestamps of previous trials.

use anyhow::{anyhow, Result};
use chrono::{TimeZone, Utc};

use crate::data::ExerciseTrial;

/// The weight of a score diminishes by the number of days multiplied by this factor.
const WEIGHT_DAY_FACTOR: f32 = 0.1;

/// The weight of a trial is adjusted based on the index of the trial in the list multiplied by this
/// factor. The most recent trial (with index zero) has the highest weight. This prevents scores
/// from the same day to be assigned the same weight.
const WEIGHT_INDEX_FACTOR: f32 = 0.5;

/// The initial weight for a score.
const INITIAL_WEIGHT: f32 = 10.0;

/// The minimum weight of a score. This weight is also assigned when there's an issue calculating
/// the number of days since the trial (e.g., the score's timestamp is after the current timestamp).
const MIN_WEIGHT: f32 = 1.0;

/// The score of a trial diminishes at a faster rate during this number of days.
const SHORT_TERM_LENGTH: f32 = 10.0;

/// The score of a trial diminishes by this factor during the first [SHORT_TERM_LENGTH] days.
const SHORT_TERM_ADJUSTMENT_FACTOR: f32 = 0.025;

/// The score of a trial diminishes by this factor after [SHORT_TERM_LENGTH] days.
const LONG_TERM_ADJUSTMENT_FACTOR: f32 = 0.01;

/// A trait exposing a function to score an exercise based on the results of previous trials.
pub trait ExerciseScorer {
    /// Returns a score (between 0.0 and 5.0) for the exercise based on the results and timestamps
    /// of previous trials.
    fn score(&self, previous_trials: &[ExerciseTrial]) -> Result<f32>;
}

/// A simple scorer that computes a score based on the weighted average of previous scores.
///
/// The score is computed as a weighted average of the previous scores. The weight of each score is
/// based on the number of days since the trial and the index of the score in the list. The score is
/// adjusted based on the number of days to account for skills deteriorating over time.
pub struct SimpleScorer {}

impl SimpleScorer {
    /// Returns the weight of the score based on the number of days since the trial and the index of
    /// of the trial in the list.
    fn weight(num_trials: usize, trial_index: usize, num_days: f32) -> f32 {
        // If the difference is negative, there's been some error. Use the min weight for
        // this trial instead of ignoring it.
        if num_days < 0.0 {
            return MIN_WEIGHT;
        }

        // The weight decreases with the number of days.
        let mut weight = INITIAL_WEIGHT - WEIGHT_DAY_FACTOR * num_days;

        // Give the most recent scores a higher weight. Otherwise, scores from the same day
        // will be given the same weight, which might make initial progress more difficult.
        weight += (num_trials - trial_index) as f32 * WEIGHT_INDEX_FACTOR;

        // Make sure the weight is never less than the min weight.
        weight.max(MIN_WEIGHT)
    }

    /// Returns the adjusted score based on the number of days since the trial. The score decreases
    /// with each passing day to account for skills deteriorating over time.
    fn adjusted_score(score: f32, num_days: f32) -> f32 {
        // If there's an issue with calculating the number of days since the trial, return
        // the score as is.
        if num_days < 0.0 {
            return score;
        }

        // The score decreases with the number of days but is never less than half of the original
        // score. The score decreases faster during the first few days, but then decreases slower.
        // This is to simulate the fact that skills deteriorate faster during the first few days
        // after a trial but then settle into long-term memory.
        if num_days <= SHORT_TERM_LENGTH {
            (score - SHORT_TERM_ADJUSTMENT_FACTOR * num_days).max(score / 2.0)
        } else {
            let long_term_days = num_days - SHORT_TERM_LENGTH.max(0.0);
            let adjusted_score = score - SHORT_TERM_ADJUSTMENT_FACTOR * SHORT_TERM_LENGTH;
            (adjusted_score - LONG_TERM_ADJUSTMENT_FACTOR * long_term_days).max(score / 2.0)
        }
    }

    /// Returns the weighted average of the scores.
    fn weighted_average(scores: &[f32], weights: &[f32]) -> f32 {
        // weighted average = (cross product of scores and their weights) / (sum of weights)
        let cross_product: f32 = scores.iter().zip(weights.iter()).map(|(s, w)| s * *w).sum();
        let weight_sum = weights.iter().sum::<f32>();
        cross_product / weight_sum
    }
}

impl ExerciseScorer for SimpleScorer {
    fn score(&self, previous_trials: &[ExerciseTrial]) -> Result<f32> {
        // An exercise with no previous trials is assigned a score of 0.0.
        if previous_trials.is_empty() {
            return Ok(0.0);
        }

        // Calculate the number of days since each trial.
        let now = Utc::now();
        let days = previous_trials
            .iter()
            .map(|t| -> Result<f32> {
                if let Some(utc_timestame) = Utc.timestamp_opt(t.timestamp, 0).earliest() {
                    Ok((now - utc_timestame).num_days() as f32)
                } else {
                    Err(anyhow!("Invalid timestamp for exercise trial"))
                }
            })
            .collect::<Result<Vec<f32>>>()?;

        // Calculate the weight of each score based on the number of days since the trial.
        let weights: Vec<f32> = previous_trials
            .iter()
            .zip(days.iter())
            .enumerate()
            .map(|(index, (_, num_days))| -> f32 {
                Self::weight(previous_trials.len(), index, *num_days)
            })
            .collect();

        // The score of the trial is adjusted based on the number of days since the trial. The score
        // decreases linearly with the number of days but is never less than half of the original.
        let scores: Vec<f32> = previous_trials
            .iter()
            .zip(days.iter())
            .map(|(t, num_days)| -> f32 { Self::adjusted_score(t.score, *num_days) })
            .collect();

        // Return the weighted average of the scores.
        Ok(Self::weighted_average(&scores, &weights))
    }
}

/// An implementation of [Send] for [SimpleScorer]. This implementation is safe because
/// [SimpleScorer] stores no state.
unsafe impl Send for SimpleScorer {}

/// An implementation of [Sync] for [SimpleScorer]. This implementation is safe because
/// [SimpleScorer] stores no state.
unsafe impl Sync for SimpleScorer {}

#[cfg(test)]
mod test {
    use chrono::Utc;

    use crate::{data::ExerciseTrial, scorer::*};

    const SECONDS_IN_DAY: i64 = 60 * 60 * 24;
    const SCORER: SimpleScorer = SimpleScorer {};

    /// Generates a timestamp equal to the timestamp from `num_days` ago.
    fn generate_timestamp(num_days: i64) -> i64 {
        let now = Utc::now().timestamp();
        now - num_days * SECONDS_IN_DAY
    }

    /// Verifies the score for an exercise with no previous trials is 0.0.
    #[test]
    fn no_previous_trials() {
        assert_eq!(0.0, SCORER.score(&vec![]).unwrap());
    }

    /// Verifies that the score is not changed if the number of days since the trial is negative.
    #[test]
    fn negative_days() {
        let score = 4.0;
        assert_eq!(score, SimpleScorer::adjusted_score(score, -1.0));
    }

    /// Verifies that recent scores decrease faster.
    #[test]
    fn recent_scores_decrease_faster() {
        let score = 4.0;
        let days = 2.0;
        let adjusted_score = SimpleScorer::adjusted_score(score, days);
        assert_eq!(adjusted_score, score - days * SHORT_TERM_ADJUSTMENT_FACTOR);
    }

    /// Verifies that older scores decrease slower.
    #[test]
    fn older_scores_decrease_slower() {
        let score = 4.0;
        let days = 20.0;
        let adjusted_score = SimpleScorer::adjusted_score(score, days);
        assert_eq!(
            adjusted_score,
            score
                - SHORT_TERM_ADJUSTMENT_FACTOR * SHORT_TERM_LENGTH
                - (days - SHORT_TERM_LENGTH) * LONG_TERM_ADJUSTMENT_FACTOR
        );
    }

    /// Verifies that the adjusted score is never less than half of the original.
    #[test]
    fn score_capped_at_half() {
        let score = 4.0;
        let days = 1000.0;
        let adjusted_score = SimpleScorer::adjusted_score(score, days);
        assert_eq!(adjusted_score, score / 2.0);
    }

    /// Verifies that the minimum weight is returned if the number of days is negative.
    #[test]
    fn negative_days_weight() {
        let num_scores = 2;
        let index = 0;
        let days = -1.0;
        assert_eq!(SimpleScorer::weight(num_scores, index, days), MIN_WEIGHT,);
    }

    /// Verifiest that the weight is adjusted based on the index of the score and the number of days
    /// since the trial.
    #[test]
    fn weight_adjusted_by_day_and_index() {
        let num_scores = 2;
        let index = 2;
        let num_trials = 2;
        let days = 4.0;
        assert_eq!(
            SimpleScorer::weight(num_scores, index, days),
            INITIAL_WEIGHT - days * WEIGHT_DAY_FACTOR
                + (num_trials - index) as f32 * WEIGHT_INDEX_FACTOR
        );
    }

    /// Verifies that the weight is never less than the minimum weight.
    #[test]
    fn weight_capped_at_min() {
        let num_scores = 2;
        let index = 0;
        let days = 1000.0;
        assert_eq!(SimpleScorer::weight(num_scores, index, days), MIN_WEIGHT,);
    }

    /// Verifies the expected score for an exercise with a single trial.
    #[test]
    fn single_trial() {
        let score = 4.0;
        let days = 1.0;
        let adjusted_score = SimpleScorer::adjusted_score(score, days);

        assert_eq!(
            adjusted_score,
            SCORER
                .score(&vec![ExerciseTrial {
                    score: score,
                    timestamp: generate_timestamp(days as i64)
                }])
                .unwrap()
        );
    }

    /// Verifies the expected score for an exercise with multiple trials.
    #[test]
    fn multiple_trials() {
        // Both scores are from a few days ago. Calculate their weight and adjusted scores based on
        // the formula.
        let num_scores = 2;
        let score1 = 2.0;
        let days1 = 5.0;
        let weight1 = SimpleScorer::weight(num_scores, 0, days1);
        let adjusted_score1 = SimpleScorer::adjusted_score(score1, days1);

        let score2 = 5.0;
        let days2 = 10.0;
        let weight2 = SimpleScorer::weight(num_scores, 1, days2);
        let adjusted_score2 = SimpleScorer::adjusted_score(score2, days2);

        assert_eq!(
            (weight1 * adjusted_score1 + weight2 * adjusted_score2) / (weight1 + weight2),
            SCORER
                .score(&vec![
                    ExerciseTrial {
                        score: score1,
                        timestamp: generate_timestamp(days1 as i64)
                    },
                    ExerciseTrial {
                        score: score2,
                        timestamp: generate_timestamp(days2 as i64)
                    },
                ])
                .unwrap()
        );
    }

    /// Verify scoring an exercise with an invalid timestamp fails.
    #[test]
    fn invalid_timestamp() {
        // The timestamp is before the Unix epoch.
        assert!(SCORER
            .score(&vec![ExerciseTrial {
                score: 5.0,
                timestamp: generate_timestamp(1e10 as i64)
            },])
            .is_err());
    }
}