-
Notifications
You must be signed in to change notification settings - Fork 15
/
Copy pathscorer.rs
327 lines (283 loc) · 12.1 KB
/
scorer.rs
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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
//! 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 lazy_static::lazy_static;
use crate::data::ExerciseTrial;
/// The initial weight for an individual trial.
const INITIAL_WEIGHT: f32 = 10.0;
/// The weight of a trial is adjusted based on the index of the trial in the list. The first trial
/// has the initial weight, and the weight decreases with each subsequent trial by this factor.
const WEIGHT_INDEX_FACTOR: f32 = 0.8;
/// 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;
// A list of precomputed weights at compile-time to save on computation time.
lazy_static! {
static ref PRECOMPUTED_WEIGHTS: [f32; 11] = [
INITIAL_WEIGHT,
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR,
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(2.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(3.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(4.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(5.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(6.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(7.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(8.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(9.0),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(10.0),
];
}
/// The score of a trial initially diminishes at a faster rate during this number of days.
const INITIAL_TERM_LENGTH: f32 = 10.0;
/// The score of a trial diminishes by this factor during the initial term.
const INITIAL_TERM_ADJUSTMENT_FACTOR: f32 = 0.01;
/// The score of a trial diminishes by this factor after the initial term.
const LONG_TERM_ADJUSTMENT_FACTOR: f32 = 0.005;
/// The adjusted score is never less than this factor of the original score.
const MIN_ADJUSTMENT_FACTOR: f32 = 0.75;
/// 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 index of the trial within 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 the index of the trial in the list.
#[inline]
fn weight(num_trials: usize, trial_index: usize) -> f32 {
// If the index is outside the bounds of the list, return the min weight.
if trial_index >= num_trials {
return MIN_WEIGHT;
}
// If the index is within the bounds of the precomputed weights, return it.
if trial_index < PRECOMPUTED_WEIGHTS.len() {
return PRECOMPUTED_WEIGHTS[trial_index];
}
// Otherwise, compute the weight, making sure it's never less than the min weight.
let weight = INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(trial_index as f32);
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.
#[inline]
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 <= INITIAL_TERM_LENGTH {
(score - INITIAL_TERM_ADJUSTMENT_FACTOR * num_days).max(score * MIN_ADJUSTMENT_FACTOR)
} else {
let long_term_days = num_days - INITIAL_TERM_LENGTH.max(0.0);
let adjusted_score = score - INITIAL_TERM_ADJUSTMENT_FACTOR * INITIAL_TERM_LENGTH;
(adjusted_score - LONG_TERM_ADJUSTMENT_FACTOR * long_term_days)
.max(score * MIN_ADJUSTMENT_FACTOR)
}
}
/// Returns the weighted average of the scores.
#[inline]
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 from each trial to the next, and from the last trial to now.
// This assumes that the trials are sorted by timestamp in descending order.
let days = previous_trials
.iter()
.enumerate()
.map(|(i, t)| -> Result<f32> {
let now = if i == 0 {
Utc::now()
} else {
Utc.timestamp_opt(previous_trials[i - 1].timestamp, 0)
.earliest()
.unwrap_or_default()
};
if let Some(utc_timestamp) = Utc.timestamp_opt(t.timestamp, 0).earliest() {
Ok((now - utc_timestamp).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 each trial to the
// next.
let weights: Vec<f32> = previous_trials
.iter()
.enumerate()
.map(|(index, _)| -> f32 { Self::weight(previous_trials.len(), index) })
.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(&[]).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 * INITIAL_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
- INITIAL_TERM_ADJUSTMENT_FACTOR * INITIAL_TERM_LENGTH
- (days - INITIAL_TERM_LENGTH) * LONG_TERM_ADJUSTMENT_FACTOR
);
}
/// Verifies that the adjusted score is never less than half of the original.
#[test]
fn score_capped_at_min() {
let score = 4.0;
let days = 1000.0;
let adjusted_score = SimpleScorer::adjusted_score(score, days);
assert_eq!(adjusted_score, score * MIN_ADJUSTMENT_FACTOR);
}
/// Verifies that the minimum weight is returned if the trial index is outside the bounds of the
/// list.
#[test]
fn weight_outside_bounds() {
let num_trials = 3;
let trial_index = 4;
assert_eq!(SimpleScorer::weight(num_trials, trial_index), MIN_WEIGHT);
}
/// Verifies that the weight is adjusted based on the index of the score.
#[test]
fn weight_adjusted_by_index() {
let num_trials = 3;
let trial_index = 2;
assert_eq!(
SimpleScorer::weight(num_trials, trial_index),
INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(trial_index as f32)
);
}
/// Verifies that the weight is never less than the minimum weight.
#[test]
fn weight_capped_at_min() {
let num_trials = 100;
let trial_index = 99;
assert_eq!(SimpleScorer::weight(num_trials, trial_index), 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);
let diff = (adjusted_score
- SCORER
.score(&[ExerciseTrial {
score,
timestamp: generate_timestamp(days as i64),
}])
.unwrap())
.abs();
assert!(diff < 1e-6,);
}
/// 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_trials = 2;
let score1 = 2.0;
let days1 = 5.0;
let weight1 = SimpleScorer::weight(num_trials, 0);
let adjusted_score1 = SimpleScorer::adjusted_score(score1, days1);
let score2 = 5.0;
let days2 = 10.0;
let weight2 = SimpleScorer::weight(num_trials, 1);
let adjusted_score2 = SimpleScorer::adjusted_score(score2, days2 - days1);
assert_eq!(
(weight1 * adjusted_score1 + weight2 * adjusted_score2) / (weight1 + weight2),
SCORER
.score(&[
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(&[ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(1e10 as i64)
},])
.is_err());
}
}