use anyhow::{Result, anyhow};
use crate::data::{ExerciseDelta, ExerciseScore, ExerciseTrial, ExerciseType};
pub trait ExerciseScorer {
fn score(
&self,
exercise_type: ExerciseType,
previous_trials: &[ExerciseTrial],
previous_deltas: &[ExerciseDelta],
now: i64,
) -> Result<ExerciseScore>;
}
const PROCEDURAL_CURVE_DECAY: f32 = -0.2;
const DECLARATIVE_CURVE_DECAY: f32 = -0.4;
const STABILITY_COEFFICIENT: f32 = 2.5;
const DIFFICULTY_GRADE_ADJUSTMENT_SCALE: f32 = 1.05;
const DIFFICULTY_REVERSION_WEIGHT: f32 = 0.16;
const PERFORMANCE_WEIGHT_DECAY: f32 = 0.95;
const SPACING_EFFECT_WEIGHT: f32 = 0.65;
const OLD_GOOD_MIN_SCORE: f32 = 4.0;
const OLD_GOOD_MIN_SCORES: usize = 2;
const OLD_GOOD_MIN_AGE: f32 = 50.0;
const OLD_GOOD_FLOOR: f32 = 0.75;
const TARGET_RETRIEVABILITY_AT_STABILITY: f32 = 0.9;
const MIN_STABILITY: f32 = 0.5;
const MAX_STABILITY: f32 = 730.0;
const DEFAULT_STABILITY: f32 = 1.0;
const MIN_DIFFICULTY: f32 = 1.0;
const MAX_DIFFICULTY: f32 = 10.0;
const BASE_DIFFICULTY: f32 = 5.0;
const EASE_NUMERATOR_OFFSET: f32 = 11.0;
const EASE_DENOMINATOR: f32 = 5.0;
const PERFORMANCE_BASELINE_SCORE: f32 = 3.0;
const PERFORMANCE_WEIGHT_MIN: f32 = 0.1;
const GRADE_MIN: f32 = 1.0;
const GRADE_MAX: f32 = 5.0;
const GRADE_RANGE: f32 = GRADE_MAX - GRADE_MIN;
const SECONDS_PER_DAY: f32 = 86400.0;
trait TimestampedValue {
fn value(&self) -> f32;
fn timestamp(&self) -> i64;
}
impl TimestampedValue for ExerciseTrial {
fn value(&self) -> f32 {
self.score
}
fn timestamp(&self) -> i64 {
self.timestamp
}
}
impl TimestampedValue for ExerciseDelta {
fn value(&self) -> f32 {
self.delta
}
fn timestamp(&self) -> i64 {
self.timestamp
}
}
pub struct PowerLawScorer {}
impl PowerLawScorer {
fn estimate_difficulty(previous_trials: &[ExerciseTrial]) -> f32 {
if previous_trials.is_empty() {
return BASE_DIFFICULTY;
}
let failures = previous_trials
.iter()
.filter(|t| t.score < PERFORMANCE_BASELINE_SCORE)
.count() as f32;
let failure_rate = failures / previous_trials.len() as f32;
let difficulty = 1.0 + failure_rate * 9.0;
difficulty.clamp(MIN_DIFFICULTY, MAX_DIFFICULTY)
}
fn compute_weighted_avg<T: TimestampedValue>(entries: &[T]) -> f32 {
if entries.is_empty() {
return 0.0;
}
let newest_timestamp = entries[0].timestamp();
let mut time_sum_weighted = 0.0;
let mut time_sum_weights = 0.0;
for entry in entries {
let elapsed_weeks = ((newest_timestamp.saturating_sub(entry.timestamp())) as f32
/ SECONDS_PER_DAY
/ 7.0)
.max(0.0);
let weight = PERFORMANCE_WEIGHT_DECAY
.powf(elapsed_weeks)
.max(PERFORMANCE_WEIGHT_MIN);
time_sum_weighted += weight * entry.value();
time_sum_weights += weight;
}
let time_avg = time_sum_weighted / time_sum_weights;
let mut pos_sum_weighted = 0.0;
let mut pos_sum_weights = 0.0;
for (i, entry) in entries.iter().enumerate() {
let weight = PERFORMANCE_WEIGHT_DECAY
.powf(i as f32)
.max(PERFORMANCE_WEIGHT_MIN);
pos_sum_weighted += weight * entry.value();
pos_sum_weights += weight;
}
let pos_avg = pos_sum_weighted / pos_sum_weights;
0.8 * time_avg + 0.2 * pos_avg
}
fn get_curve_decay(exercise_type: &ExerciseType) -> f32 {
match exercise_type {
ExerciseType::Declarative => DECLARATIVE_CURVE_DECAY,
ExerciseType::Procedural => PROCEDURAL_CURVE_DECAY,
}
}
fn get_curve_factor(exercise_type: &ExerciseType) -> f32 {
let decay_abs = Self::get_curve_decay(exercise_type).abs().max(f32::EPSILON);
TARGET_RETRIEVABILITY_AT_STABILITY.powf(-1.0 / decay_abs) - 1.0
}
fn compute_retrievability(
exercise_type: &ExerciseType,
days_since_last: f32,
stability: f32,
) -> f32 {
let decay = Self::get_curve_decay(exercise_type);
let factor = Self::get_curve_factor(exercise_type);
(1.0 + factor * days_since_last / stability)
.powf(decay)
.clamp(0.0, 1.0)
}
fn compute_spacing_gain(
exercise_type: &ExerciseType,
days_since_previous_review: f32,
stability: f32,
performance_factor: f32,
) -> f32 {
if performance_factor <= 0.0 {
return 1.0;
}
let pre_review_retrievability =
Self::compute_retrievability(exercise_type, days_since_previous_review, stability);
(1.0 + SPACING_EFFECT_WEIGHT * (1.0 - pre_review_retrievability))
.clamp(1.0, 1.0 + SPACING_EFFECT_WEIGHT)
}
fn update_difficulty(difficulty: f32, base_difficulty: f32, trial_score: f32) -> f32 {
let grade_delta = (PERFORMANCE_BASELINE_SCORE - trial_score) / GRADE_RANGE
* DIFFICULTY_GRADE_ADJUSTMENT_SCALE;
let adjusted_difficulty = (difficulty + grade_delta).clamp(MIN_DIFFICULTY, MAX_DIFFICULTY);
(DIFFICULTY_REVERSION_WEIGHT * base_difficulty
+ (1.0 - DIFFICULTY_REVERSION_WEIGHT) * adjusted_difficulty)
.clamp(MIN_DIFFICULTY, MAX_DIFFICULTY)
}
fn apply_stability_transition(
exercise_type: &ExerciseType,
stability: f32,
difficulty: f32,
score: f32,
days_since_previous_review: f32,
) -> f32 {
let p = (score - GRADE_MIN) / GRADE_RANGE - 0.5;
let e = (EASE_NUMERATOR_OFFSET - difficulty) / EASE_DENOMINATOR;
let spacing_gain =
Self::compute_spacing_gain(exercise_type, days_since_previous_review, stability, p);
let intra_day_damping = days_since_previous_review.min(1.0);
let growth_term = STABILITY_COEFFICIENT * p * e * spacing_gain * intra_day_damping;
(stability * (1.0 + growth_term)).clamp(MIN_STABILITY, MAX_STABILITY)
}
fn compute_stability(
exercise_type: &ExerciseType,
previous_trials: &[ExerciseTrial],
base_difficulty: f32,
) -> f32 {
let mut stability = DEFAULT_STABILITY;
let mut difficulty = base_difficulty;
let mut previous_timestamp = None;
for trial in previous_trials.iter().rev() {
if previous_timestamp.is_none() {
previous_timestamp = Some(trial.timestamp);
continue;
}
let days_since_previous_review = previous_timestamp.map_or(0.0, |timestamp| {
((trial.timestamp.saturating_sub(timestamp)) as f32 / SECONDS_PER_DAY).max(0.0)
});
stability = Self::apply_stability_transition(
exercise_type,
stability,
difficulty,
trial.score,
days_since_previous_review,
);
difficulty = Self::update_difficulty(difficulty, BASE_DIFFICULTY, trial.score);
previous_timestamp = Some(trial.timestamp);
}
stability
}
fn apply_old_good_retrievability_floor(
retrievability: f32,
weighted_score: f32,
days_since_last: f32,
num_scores: usize,
) -> f32 {
if num_scores >= OLD_GOOD_MIN_SCORES
&& weighted_score >= OLD_GOOD_MIN_SCORE
&& days_since_last >= OLD_GOOD_MIN_AGE
{
retrievability.max(OLD_GOOD_FLOOR)
} else {
retrievability
}
}
fn compute_delta(previous_deltas: &[ExerciseDelta], retrievability: f32) -> f32 {
if previous_deltas.len() < 2 {
return 0.0;
}
let avg_delta = Self::compute_weighted_avg(previous_deltas);
avg_delta * retrievability / 4.0
}
fn velocity(previous_trials: &[ExerciseTrial]) -> Option<f32> {
if previous_trials.len() < 2 {
return None;
}
let oldest_timestamp = previous_trials.last().unwrap().timestamp;
let n = previous_trials.len() as f32;
let mut sum_t = 0.0_f32;
let mut sum_scores = 0.0_f32;
let mut sum_t_scores = 0.0_f32;
let mut sum_t_sq = 0.0_f32;
for trial in previous_trials {
let t = (trial.timestamp.saturating_sub(oldest_timestamp)) as f32 / SECONDS_PER_DAY;
sum_t += t;
sum_scores += trial.score;
sum_t_scores += t * trial.score;
sum_t_sq += t * t;
}
let denominator = n * sum_t_sq - sum_t * sum_t;
if denominator.abs() < f32::EPSILON {
return Some(0.0);
}
let slope = (n * sum_t_scores - sum_t * sum_scores) / denominator;
Some(slope)
}
}
impl ExerciseScorer for PowerLawScorer {
fn score(
&self,
exercise_type: ExerciseType,
previous_trials: &[ExerciseTrial],
previous_deltas: &[ExerciseDelta],
now: i64,
) -> Result<ExerciseScore> {
if previous_trials.is_empty() {
return Ok(ExerciseScore {
value: 0.0,
urgency: 1.0,
velocity: None,
});
}
if previous_trials
.windows(2)
.any(|w| w[0].timestamp < w[1].timestamp)
{
return Err(anyhow!(
"Exercise trials not sorted in descending order by timestamp"
));
}
let base_difficulty = Self::estimate_difficulty(previous_trials);
let stability = Self::compute_stability(&exercise_type, previous_trials, base_difficulty);
let days_since_last =
((now.saturating_sub(previous_trials[0].timestamp)) as f32 / SECONDS_PER_DAY).max(0.0);
let retrievability =
Self::compute_retrievability(&exercise_type, days_since_last, stability);
let weighted_score = Self::compute_weighted_avg(previous_trials);
let effective_retrievability = Self::apply_old_good_retrievability_floor(
retrievability,
weighted_score,
days_since_last,
previous_trials.len(),
);
let adjusted_score = effective_retrievability * weighted_score;
let delta = Self::compute_delta(previous_deltas, effective_retrievability);
let final_score = (adjusted_score + delta).clamp(0.0, 5.0);
Ok(ExerciseScore {
value: final_score,
urgency: 1.0 - retrievability,
velocity: Self::velocity(previous_trials),
})
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
use chrono::Utc;
use crate::{data::ExerciseTrial, exercise_scorer::*};
const SCORER: PowerLawScorer = PowerLawScorer {};
fn generate_timestamp(num_days: i64) -> i64 {
let now = Utc::now().timestamp();
now - num_days * SECONDS_PER_DAY as i64
}
fn score_helper(
exercise_type: ExerciseType,
previous_trials: &[ExerciseTrial],
previous_deltas: &[ExerciseDelta],
now: i64,
) -> ExerciseScore {
SCORER
.score(exercise_type, previous_trials, previous_deltas, now)
.unwrap()
}
#[test]
fn estimate_difficulty() {
assert_eq!(PowerLawScorer::estimate_difficulty(&[]), BASE_DIFFICULTY);
let easy_trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let easy_difficulty = PowerLawScorer::estimate_difficulty(&easy_trials);
assert!(easy_difficulty < 3.0);
let hard_trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let hard_difficulty = PowerLawScorer::estimate_difficulty(&hard_trials);
assert!(hard_difficulty > 8.0);
let medium_trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let medium_difficulty = PowerLawScorer::estimate_difficulty(&medium_trials);
assert!((4.0..7.0).contains(&medium_difficulty));
let mixed_trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(4),
..Default::default()
},
];
let mixed_difficulty = PowerLawScorer::estimate_difficulty(&mixed_trials);
assert!(mixed_difficulty > 4.0 && mixed_difficulty < 6.0);
}
#[test]
fn no_previous_trials() {
let score = score_helper(ExerciseType::Declarative, &[], &[], Utc::now().timestamp());
assert_eq!(score.value, 0.0);
assert_eq!(score.urgency, 1.0);
assert_eq!(score.velocity, None);
}
#[test]
fn score_trials() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(3),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value > 0.0 && score.value <= 5.0);
assert!(score.value > 2.0); assert!((0.0..=1.0).contains(&score.urgency));
assert!(score.velocity.unwrap() < 0.0);
}
#[test]
fn invalid_timestamp() {
let score = score_helper(
ExerciseType::Declarative,
&[ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(1e10 as i64),
..Default::default()
}],
&[],
Utc::now().timestamp(),
);
assert!(score.value >= 0.0 && score.value <= 5.0);
assert!(score.value < 1.0); assert!((0.0..=1.0).contains(&score.urgency));
}
#[test]
fn extreme_timestamp_gap_does_not_overflow() {
let score = score_helper(
ExerciseType::Declarative,
&[
ExerciseTrial {
score: 5.0,
timestamp: i64::MAX,
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: i64::MIN,
..Default::default()
},
],
&[],
Utc::now().timestamp(),
);
assert!(score.value >= 0.0 && score.value <= 5.0);
assert!((0.0..=1.0).contains(&score.urgency));
}
#[test]
fn compute_stability() {
let difficulty = BASE_DIFFICULTY;
let trials = vec![
ExerciseTrial {
score: 1.0, timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 5.0, timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 3.0, timestamp: generate_timestamp(1),
..Default::default()
},
];
let stability =
PowerLawScorer::compute_stability(&ExerciseType::Declarative, &trials, difficulty);
assert!(stability > 0.0 && stability < 2.0); }
#[test]
fn compute_stability_spacing_effect() {
let difficulty = BASE_DIFFICULTY;
let short_spacing_trials = vec![
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(3),
..Default::default()
},
];
let long_spacing_trials = vec![
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(10),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(30),
..Default::default()
},
];
let short_spacing_stability = PowerLawScorer::compute_stability(
&ExerciseType::Declarative,
&short_spacing_trials,
difficulty,
);
let long_spacing_stability = PowerLawScorer::compute_stability(
&ExerciseType::Declarative,
&long_spacing_trials,
difficulty,
);
assert!(long_spacing_stability > short_spacing_stability);
}
#[test]
fn bad_score_reduces_stability() {
let difficulty = BASE_DIFFICULTY;
let success_trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(3),
..Default::default()
},
];
let lapse_trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(3),
..Default::default()
},
];
let success_stability = PowerLawScorer::compute_stability(
&ExerciseType::Declarative,
&success_trials,
difficulty,
);
let lapse_stability = PowerLawScorer::compute_stability(
&ExerciseType::Declarative,
&lapse_trials,
difficulty,
);
assert!(success_stability > MIN_STABILITY);
assert!(lapse_stability < success_stability);
assert!(lapse_stability >= MIN_STABILITY);
}
#[test]
fn multiple_lapses_bounded() {
let difficulty = BASE_DIFFICULTY;
let lapses = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
..Default::default()
},
];
let stability =
PowerLawScorer::compute_stability(&ExerciseType::Declarative, &lapses, difficulty);
assert!(stability >= MIN_STABILITY);
assert!(stability <= DEFAULT_STABILITY);
}
#[test]
fn high_stability_does_not_explode() {
let exercise_type = ExerciseType::Declarative;
let difficulty = BASE_DIFFICULTY;
let p = (5.0 - GRADE_MIN) / GRADE_RANGE - 0.5;
let e = (EASE_NUMERATOR_OFFSET - difficulty) / EASE_DENOMINATOR;
let spacing_gain =
PowerLawScorer::compute_spacing_gain(&exercise_type, 0.0, MIN_STABILITY, p);
let growth_term = STABILITY_COEFFICIENT * p * e * spacing_gain;
let mut stability = MIN_STABILITY;
for _ in 0..25 {
let next_stability =
(stability * (1.0 + growth_term)).clamp(MIN_STABILITY, MAX_STABILITY);
let relative_gain = (next_stability - stability) / stability;
assert!(relative_gain >= 0.0);
assert!(relative_gain <= growth_term + f32::EPSILON);
stability = next_stability;
}
assert!(stability <= MAX_STABILITY);
assert!(stability >= MIN_STABILITY);
}
#[test]
fn compute_retrievability() {
let stability = DEFAULT_STABILITY;
let recent_declarative =
PowerLawScorer::compute_retrievability(&ExerciseType::Declarative, 0.01, stability);
let recent_procedural =
PowerLawScorer::compute_retrievability(&ExerciseType::Procedural, 0.01, stability);
assert!(recent_declarative > 0.9);
assert!(recent_declarative > recent_procedural);
let old_declarative =
PowerLawScorer::compute_retrievability(&ExerciseType::Declarative, 10.0, stability);
let old_procedural =
PowerLawScorer::compute_retrievability(&ExerciseType::Procedural, 10.0, stability);
assert!(old_declarative < 0.6 && old_declarative > 0.4);
assert!(old_declarative < old_procedural);
let very_old_declarative =
PowerLawScorer::compute_retrievability(&ExerciseType::Declarative, 100.0, stability);
let very_old_procedural =
PowerLawScorer::compute_retrievability(&ExerciseType::Procedural, 100.0, stability);
assert!(very_old_declarative < 0.26);
assert!(very_old_declarative < very_old_procedural);
}
#[test]
fn retrievability_at_stability_is_ninety_percent() {
let declarative = PowerLawScorer::compute_retrievability(
&ExerciseType::Declarative,
DEFAULT_STABILITY,
DEFAULT_STABILITY,
);
let procedural = PowerLawScorer::compute_retrievability(
&ExerciseType::Procedural,
DEFAULT_STABILITY,
DEFAULT_STABILITY,
);
assert!((declarative - TARGET_RETRIEVABILITY_AT_STABILITY).abs() < 1e-6);
assert!((procedural - TARGET_RETRIEVABILITY_AT_STABILITY).abs() < 1e-6);
}
#[test]
fn compute_spacing_gain() {
let stability = DEFAULT_STABILITY;
let short_interval_gain =
PowerLawScorer::compute_spacing_gain(&ExerciseType::Declarative, 0.0, stability, 0.25);
let long_interval_gain =
PowerLawScorer::compute_spacing_gain(&ExerciseType::Declarative, 10.0, stability, 0.25);
let neutral_gain =
PowerLawScorer::compute_spacing_gain(&ExerciseType::Declarative, 10.0, stability, 0.0);
let failure_gain =
PowerLawScorer::compute_spacing_gain(&ExerciseType::Declarative, 10.0, stability, -0.5);
assert!((1.0..=1.0 + SPACING_EFFECT_WEIGHT).contains(&short_interval_gain));
assert!(long_interval_gain > short_interval_gain);
assert_eq!(neutral_gain, 1.0);
assert_eq!(failure_gain, 1.0);
}
#[test]
fn intra_day_damping() {
let difficulty = BASE_DIFFICULTY;
let score = 5.0;
let half_day = PowerLawScorer::apply_stability_transition(
&ExerciseType::Declarative,
DEFAULT_STABILITY,
difficulty,
score,
0.5,
);
let one_day = PowerLawScorer::apply_stability_transition(
&ExerciseType::Declarative,
DEFAULT_STABILITY,
difficulty,
score,
1.0,
);
let two_days = PowerLawScorer::apply_stability_transition(
&ExerciseType::Declarative,
DEFAULT_STABILITY,
difficulty,
score,
2.0,
);
assert!(half_day > DEFAULT_STABILITY);
assert!(one_day > DEFAULT_STABILITY);
assert!(two_days > DEFAULT_STABILITY);
assert!(half_day < one_day);
}
#[test]
fn compute_weighted_avg() {
assert_eq!(
PowerLawScorer::compute_weighted_avg::<ExerciseTrial>(&[]),
0.0
);
let single_trial = vec![ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
}];
let mean = PowerLawScorer::compute_weighted_avg(&single_trial);
assert!((mean - 5.0).abs() < 1e-6);
let multi_trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let weighted = PowerLawScorer::compute_weighted_avg(&multi_trials);
assert!((weighted - 4.017).abs() < 0.01);
let dense_low_tail = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let sparse_low_tail = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(30),
..Default::default()
},
];
let dense_weighted = PowerLawScorer::compute_weighted_avg(&dense_low_tail);
let sparse_weighted = PowerLawScorer::compute_weighted_avg(&sparse_low_tail);
assert!(sparse_weighted > dense_weighted);
let compact = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
];
let with_ancient = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(365),
..Default::default()
},
];
let compact_weighted = PowerLawScorer::compute_weighted_avg(&compact);
let ancient_weighted = PowerLawScorer::compute_weighted_avg(&with_ancient);
assert!(ancient_weighted < compact_weighted);
assert!(ancient_weighted > 4.0);
}
#[test]
fn apply_old_good_retrievability_floor() {
assert_eq!(
PowerLawScorer::apply_old_good_retrievability_floor(0.2, 4.0, 80.0, 3),
OLD_GOOD_FLOOR
);
assert_eq!(
PowerLawScorer::apply_old_good_retrievability_floor(0.95, 4.0, 80.0, 3),
0.95
);
assert_eq!(
PowerLawScorer::apply_old_good_retrievability_floor(0.2, 3.4, 80.0, 3),
0.2
);
assert_eq!(
PowerLawScorer::apply_old_good_retrievability_floor(0.2, 4.0, 49.0, 3),
0.2
);
assert_eq!(
PowerLawScorer::apply_old_good_retrievability_floor(0.2, 4.0, 80.0, 1),
0.2
);
}
#[test]
fn score_bad_recent() {
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(7),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(10),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(13),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value < 2.0);
}
#[test]
fn score_mixed_performance() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(4),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(5),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(6),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(7),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(10),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(14),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(18),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(21),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(25),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value > 1.0 && score.value < 4.0);
}
#[test]
fn score_unsorted_trials() {
let result = SCORER.score(
ExerciseType::Declarative,
&[
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
],
&[],
Utc::now().timestamp(),
);
assert!(result.is_err());
}
#[test]
fn score_old_timestamp() {
let score = score_helper(
ExerciseType::Declarative,
&[ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(100),
..Default::default()
}],
&[],
Utc::now().timestamp(),
);
assert!(score.value < 3.0);
}
#[test]
fn score_multiple_good() {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(4),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(5),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(6),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(7),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value > 4.0);
}
#[test]
fn score_multiple_bad() {
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(4),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(6),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(9),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(15),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(16),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(27),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value < 2.0);
}
#[test]
fn score_old_good_trials() {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(200),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(210),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(213),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(248),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(256),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(270),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Procedural,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value >= 3.5);
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value >= 3.5);
}
#[test]
fn score_very_good_old_trials() {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(400),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(410),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(411),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(420),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(430),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(431),
..Default::default()
},
];
let score = score_helper(
ExerciseType::Procedural,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value >= 3.5);
let score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
assert!(score.value >= 3.5);
}
#[test]
fn urgency_increases_with_elapsed_time() {
let recent_trials = vec![ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(1),
..Default::default()
}];
let old_trials = vec![ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(30),
..Default::default()
}];
let recent = score_helper(
ExerciseType::Declarative,
&recent_trials,
&[],
Utc::now().timestamp(),
);
let old = score_helper(
ExerciseType::Declarative,
&old_trials,
&[],
Utc::now().timestamp(),
);
assert!(old.urgency > recent.urgency);
}
#[test]
fn velocity_empty_trials() {
assert_eq!(PowerLawScorer::velocity(&[]), None);
let trials = vec![ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
..Default::default()
}];
assert_eq!(PowerLawScorer::velocity(&trials), None);
}
#[test]
fn velocity_improving_scores() {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(4),
..Default::default()
},
];
let velocity = PowerLawScorer::velocity(&trials).unwrap();
assert!(velocity > 0.0);
}
#[test]
fn velocity_worsening_scores() {
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(3),
..Default::default()
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(4),
..Default::default()
},
];
let velocity = PowerLawScorer::velocity(&trials).unwrap();
assert!(velocity < 0.0);
}
#[test]
fn velocity_constant_scores() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let velocity = PowerLawScorer::velocity(&trials).unwrap();
assert!(velocity.abs() < 1e-6);
}
#[test]
fn score_with_positive_deltas() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let deltas = vec![
ExerciseDelta {
delta: 0.5,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseDelta {
delta: 1.2,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let base_score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
let delta_score = score_helper(
ExerciseType::Declarative,
&trials,
&deltas,
Utc::now().timestamp(),
);
assert!(delta_score.value > base_score.value);
}
#[test]
fn score_with_negative_deltas() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let deltas = vec![
ExerciseDelta {
delta: -0.5,
timestamp: generate_timestamp(1),
..Default::default()
},
ExerciseDelta {
delta: -0.8,
timestamp: generate_timestamp(2),
..Default::default()
},
];
let base_score = score_helper(
ExerciseType::Declarative,
&trials,
&[],
Utc::now().timestamp(),
);
let delta_score = score_helper(
ExerciseType::Declarative,
&trials,
&deltas,
Utc::now().timestamp(),
);
assert!(delta_score.value < base_score.value);
}
}