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());
}
}