use anyhow::{Result, anyhow};
use chrono::Utc;
use crate::data::{ExerciseTrial, ExerciseType};
pub trait ExerciseScorer {
fn score(&self, exercise_type: ExerciseType, previous_trials: &[ExerciseTrial]) -> Result<f32>;
fn velocity(&self, previous_trials: &[ExerciseTrial]) -> Option<f32>;
}
const TARGET_RETRIEVABILITY_AT_STABILITY: f32 = 0.9;
const DECLARATIVE_CURVE_DECAY: f32 = -0.5;
const PROCEDURAL_CURVE_DECAY: f32 = -0.3;
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 DIFFICULTY_FACTOR: f32 = 30.0;
const DIFFICULTY_GRADE_ADJUSTMENT_SCALE: f32 = 0.6;
const DIFFICULTY_REVERSION_WEIGHT: f32 = 0.1;
const PERFORMANCE_BASELINE_SCORE: f32 = 3.0;
const STABILITY_COEFFICIENT: f32 = 2.1;
const GRADE_MIN: f32 = 1.0;
const GRADE_MAX: f32 = 5.0;
const DIFFICULTY_OFFSET: f32 = 1.0;
const DIFFICULTY_SCALE: f32 = 9.0;
const SECONDS_PER_DAY: f32 = 86400.0;
const PERFORMANCE_WEIGHT_DECAY: f32 = 0.98;
const CONFIDENCE_PENALTY_COEFFICIENT: f32 = 0.2;
const PERFORMANCE_WEIGHT_MIN: f32 = 0.1;
const GRADE_RANGE: f32 = GRADE_MAX - GRADE_MIN;
const EASE_NUMERATOR_OFFSET: f32 = 11.0;
const EASE_DENOMINATOR: f32 = 5.0;
const SPACING_EFFECT_WEIGHT: f32 = 0.7;
const STABILITY_DAMPING_EXP: f32 = 0.1;
const MIN_LAPSE_DROP: f32 = 0.0;
const MAX_LAPSE_DROP: f32 = 0.85;
const LAPSE_BASE_DROP: f32 = 0.30;
const LAPSE_DIFFICULTY_WEIGHT: f32 = 0.25;
const LAPSE_RETRIEVABILITY_WEIGHT: f32 = 0.30;
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;
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 = DIFFICULTY_OFFSET + failure_rate * DIFFICULTY_SCALE;
difficulty.clamp(MIN_DIFFICULTY, MAX_DIFFICULTY)
}
fn compute_weighted_avg_and_variance(previous_trials: &[ExerciseTrial]) -> (f32, f32) {
if previous_trials.is_empty() {
return (0.0, 0.0);
}
let newest_timestamp = previous_trials[0].timestamp;
let mut sum_weighted = 0.0;
let mut sum_weighted_sq = 0.0;
let mut sum_weights = 0.0;
for trial in previous_trials {
let elapsed_days = ((newest_timestamp.saturating_sub(trial.timestamp)) as f32
/ SECONDS_PER_DAY)
.max(0.0);
let weight = PERFORMANCE_WEIGHT_DECAY
.powf(elapsed_days)
.max(PERFORMANCE_WEIGHT_MIN);
sum_weighted += weight * trial.score;
sum_weighted_sq += weight * trial.score * trial.score;
sum_weights += weight;
}
let mean = sum_weighted / sum_weights;
let variance = sum_weighted_sq / sum_weights - mean * mean;
(mean, variance)
}
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_spacing_retrievability(
exercise_type: &ExerciseType,
days_since_previous_review: 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_previous_review / 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_spacing_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 is_lapse(trial_score: f32) -> bool {
trial_score < PERFORMANCE_BASELINE_SCORE
}
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 compute_lapse_drop(difficulty: f32, pre_review_retrievability: f32) -> f32 {
let difficulty_adjust =
((difficulty - MIN_DIFFICULTY) / (MAX_DIFFICULTY - MIN_DIFFICULTY)).clamp(0.0, 1.0);
let pre_review_retrievability = pre_review_retrievability.clamp(0.0, 1.0);
(LAPSE_BASE_DROP
+ LAPSE_DIFFICULTY_WEIGHT * difficulty_adjust
+ LAPSE_RETRIEVABILITY_WEIGHT * pre_review_retrievability)
.clamp(MIN_LAPSE_DROP, MAX_LAPSE_DROP)
}
fn apply_stability_transition(
exercise_type: &ExerciseType,
stability: f32,
difficulty: f32,
score: f32,
days_since_previous_review: f32,
is_first_review: bool,
) -> f32 {
let p = (score - GRADE_MIN) / GRADE_RANGE - 0.5;
let e = (EASE_NUMERATOR_OFFSET - difficulty) / EASE_DENOMINATOR;
let pre_review_retrievability = Self::compute_spacing_retrievability(
exercise_type,
days_since_previous_review,
stability,
);
if Self::is_lapse(score) && !is_first_review {
let lapse_drop = Self::compute_lapse_drop(difficulty, pre_review_retrievability);
(stability * (1.0 - lapse_drop)).clamp(MIN_STABILITY, MAX_STABILITY)
} else {
let spacing_gain =
Self::compute_spacing_gain(exercise_type, days_since_previous_review, stability, p);
let stability_damping = Self::compute_stability_damping(stability);
let growth_term = STABILITY_COEFFICIENT * p * e * spacing_gain * stability_damping;
(stability * (1.0 + growth_term)).clamp(MIN_STABILITY, MAX_STABILITY)
}
}
fn compute_stability_and_difficulty(
exercise_type: &ExerciseType,
previous_trials: &[ExerciseTrial],
base_difficulty: f32,
) -> (f32, f32) {
let mut stability = DEFAULT_STABILITY;
let mut difficulty = base_difficulty;
let mut previous_timestamp = None;
for trial in previous_trials.iter().rev() {
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,
previous_timestamp.is_none(),
);
difficulty = Self::update_difficulty(difficulty, BASE_DIFFICULTY, trial.score);
previous_timestamp = Some(trial.timestamp);
}
(stability, difficulty)
}
fn compute_stability_damping(stability: f32) -> f32 {
stability.max(MIN_STABILITY).powf(-STABILITY_DAMPING_EXP)
}
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)
}
fn apply_old_good_retrievability_floor(
adjusted_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
{
adjusted_retrievability.max(OLD_GOOD_FLOOR)
} else {
adjusted_retrievability
}
}
}
impl ExerciseScorer for PowerLawScorer {
fn score(&self, exercise_type: ExerciseType, previous_trials: &[ExerciseTrial]) -> Result<f32> {
if previous_trials.is_empty() {
return Ok(0.0);
}
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, final_difficulty) = Self::compute_stability_and_difficulty(
&exercise_type,
previous_trials,
base_difficulty,
);
let days_since_last = ((Utc::now()
.timestamp()
.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 difficulty_exponent =
MIN_DIFFICULTY + (final_difficulty - MIN_DIFFICULTY) / DIFFICULTY_FACTOR;
let adjusted_retrievability = retrievability.powf(difficulty_exponent);
let (weighted_score, weighted_variance) =
Self::compute_weighted_avg_and_variance(previous_trials);
let effective_retrievability = Self::apply_old_good_retrievability_floor(
adjusted_retrievability,
weighted_score,
days_since_last,
previous_trials.len(),
);
let raw_score = effective_retrievability * weighted_score;
let confidence_penalty = CONFIDENCE_PENALTY_COEFFICIENT * weighted_variance
/ (previous_trials.len() as f32).sqrt();
Ok((raw_score - confidence_penalty).clamp(0.0, 5.0))
}
fn velocity(&self, 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)
}
}
#[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
}
#[test]
fn estimate_difficulty() {
assert_eq!(PowerLawScorer::estimate_difficulty(&[]), BASE_DIFFICULTY);
let easy_trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(2),
},
];
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),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
},
];
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),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(2),
},
];
let medium_difficulty = PowerLawScorer::estimate_difficulty(&medium_trials);
assert!(medium_difficulty >= 4.0 && medium_difficulty < 7.0);
let mixed_trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(4),
},
];
let mixed_difficulty = PowerLawScorer::estimate_difficulty(&mixed_trials);
assert!(mixed_difficulty > 4.0 && mixed_difficulty < 6.0);
}
#[test]
fn no_previous_trials() {
assert_eq!(0.0, SCORER.score(ExerciseType::Declarative, &[]).unwrap());
}
#[test]
fn score_trials() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(3),
},
];
let score = SCORER.score(ExerciseType::Declarative, &trials).unwrap();
assert!(score > 0.0 && score <= 5.0);
assert!(score > 2.0); }
#[test]
fn invalid_timestamp() -> Result<()> {
let score = SCORER.score(
ExerciseType::Declarative,
&[ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(1e10 as i64),
}],
)?;
assert!(score >= 0.0 && score <= 5.0);
assert!(score < 1.0); Ok(())
}
#[test]
fn extreme_timestamp_gap_does_not_overflow() -> Result<()> {
let score = SCORER.score(
ExerciseType::Declarative,
&[
ExerciseTrial {
score: 5.0,
timestamp: i64::MAX,
},
ExerciseTrial {
score: 1.0,
timestamp: i64::MIN,
},
],
)?;
assert!(score >= 0.0 && score <= 5.0);
Ok(())
}
#[test]
fn compute_stability() {
let difficulty = BASE_DIFFICULTY;
let trials = vec![
ExerciseTrial {
score: 1.0, timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 5.0, timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 3.0, timestamp: generate_timestamp(1),
},
];
let stability = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&trials,
difficulty,
)
.0;
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),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(3),
},
];
let long_spacing_trials = vec![
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(10),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(30),
},
];
let short_spacing_stability = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&short_spacing_trials,
difficulty,
)
.0;
let long_spacing_stability = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&long_spacing_trials,
difficulty,
)
.0;
assert!(long_spacing_stability > short_spacing_stability);
}
#[test]
fn stability_lapse_reduces_more_than_hard_success() {
let difficulty = BASE_DIFFICULTY;
let success_trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
},
];
let lapse_trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
];
let success_stability = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&success_trials,
difficulty,
)
.0;
let lapse_stability = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&lapse_trials,
difficulty,
)
.0;
assert!(success_stability > MIN_STABILITY);
assert!(lapse_stability < success_stability);
assert!(lapse_stability >= MIN_STABILITY);
}
#[test]
fn stability_growth_saturates_at_high_s() {
let difficulty = BASE_DIFFICULTY;
let exercise_type = ExerciseType::Declarative;
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 base_growth_term = STABILITY_COEFFICIENT * p * e * spacing_gain;
let low_stability = MIN_STABILITY;
let high_stability = 50.0;
let low_stability_damping = PowerLawScorer::compute_stability_damping(low_stability);
let high_stability_damping = PowerLawScorer::compute_stability_damping(high_stability);
let low_effective_growth = base_growth_term * low_stability_damping;
let high_effective_growth = base_growth_term * high_stability_damping;
assert!(low_effective_growth > high_effective_growth);
}
#[test]
fn multiple_lapses_bounded() {
let difficulty = BASE_DIFFICULTY;
let lapses = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
];
let stability = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&lapses,
difficulty,
)
.0;
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 base_growth_term = STABILITY_COEFFICIENT * p * e * spacing_gain;
let max_damping = PowerLawScorer::compute_stability_damping(MIN_STABILITY);
let mut stability = MIN_STABILITY;
for _ in 0..25 {
let stability_damping = PowerLawScorer::compute_stability_damping(stability);
let effective_growth = base_growth_term * stability_damping;
let next_stability =
(stability * (1.0 + effective_growth)).clamp(MIN_STABILITY, MAX_STABILITY);
let relative_gain = (next_stability - stability) / stability;
assert!(relative_gain >= 0.0);
assert!(relative_gain <= base_growth_term * max_damping + f32::EPSILON);
stability = next_stability;
}
assert!(stability <= MAX_STABILITY);
assert!(stability >= MIN_STABILITY);
}
#[test]
fn difficulty_trend_improves_with_successes() {
let base_difficulty = MAX_DIFFICULTY;
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
];
let (_stability, adjusted_difficulty) = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&trials,
base_difficulty,
);
assert!(adjusted_difficulty < base_difficulty);
assert!(adjusted_difficulty >= MIN_DIFFICULTY);
}
#[test]
fn difficulty_trend_worsens_with_failures() {
let base_difficulty = MIN_DIFFICULTY;
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(0),
},
];
let (_stability, adjusted_difficulty) = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&trials,
base_difficulty,
);
assert!(adjusted_difficulty > base_difficulty);
assert!(adjusted_difficulty < MAX_DIFFICULTY);
}
#[test]
fn difficulty_mean_reversion_prevents_runaway() {
let failures = (0..20)
.map(|days| ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(days),
})
.collect::<Vec<_>>();
let (_stability, adjusted_difficulty) = PowerLawScorer::compute_stability_and_difficulty(
&ExerciseType::Declarative,
&failures,
BASE_DIFFICULTY,
);
assert!(adjusted_difficulty > BASE_DIFFICULTY);
assert!(adjusted_difficulty < MAX_DIFFICULTY - 1.0);
}
#[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.25);
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_retrievability() {
let stability = DEFAULT_STABILITY;
let recent_declarative = PowerLawScorer::compute_spacing_retrievability(
&ExerciseType::Declarative,
0.0,
stability,
);
let old_declarative = PowerLawScorer::compute_spacing_retrievability(
&ExerciseType::Declarative,
30.0,
stability,
);
let old_procedural = PowerLawScorer::compute_spacing_retrievability(
&ExerciseType::Procedural,
30.0,
stability,
);
assert!((recent_declarative - 1.0).abs() < 1e-6);
assert!(old_declarative >= 0.0 && old_declarative <= 1.0);
assert!(old_procedural >= 0.0 && old_procedural <= 1.0);
assert!(recent_declarative > old_declarative);
assert!(old_procedural > old_declarative);
}
#[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!(short_interval_gain >= 1.0 && short_interval_gain <= 1.0 + SPACING_EFFECT_WEIGHT);
assert!(long_interval_gain > short_interval_gain);
assert_eq!(neutral_gain, 1.0);
assert_eq!(failure_gain, 1.0);
}
#[test]
fn compute_weighted_avg_and_variance() {
assert_eq!(
PowerLawScorer::compute_weighted_avg_and_variance(&[]),
(0.0, 0.0)
);
let single_trial = vec![ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
}];
let (mean, var) = PowerLawScorer::compute_weighted_avg_and_variance(&single_trial);
assert!((mean - 5.0).abs() < 1e-6);
assert!(var.abs() < 1e-6);
let multi_trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
];
let (weighted, var) = PowerLawScorer::compute_weighted_avg_and_variance(&multi_trials);
assert!((weighted - 4.013).abs() < 0.001);
assert!(var > 0.0);
let dense_low_tail = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(2),
},
];
let sparse_low_tail = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(30),
},
];
let (dense_weighted, dense_variance) =
PowerLawScorer::compute_weighted_avg_and_variance(&dense_low_tail);
let (sparse_weighted, sparse_variance) =
PowerLawScorer::compute_weighted_avg_and_variance(&sparse_low_tail);
assert!(sparse_weighted > dense_weighted);
assert!(sparse_variance > dense_variance);
let compact = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
];
let with_ancient = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(365),
},
];
let (compact_weighted, compact_variance) =
PowerLawScorer::compute_weighted_avg_and_variance(&compact);
let (ancient_weighted, ancient_variance) =
PowerLawScorer::compute_weighted_avg_and_variance(&with_ancient);
assert!(ancient_weighted < compact_weighted);
assert!(ancient_weighted > 4.0);
assert!(ancient_variance > compact_variance);
}
#[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() -> Result<()> {
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(7),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(10),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(13),
},
];
let score = SCORER.score(ExerciseType::Declarative, &trials)?;
assert!(score < 2.0);
Ok(())
}
#[test]
fn score_mixed_performance() -> Result<()> {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(4),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(5),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(6),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(7),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(10),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(14),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(18),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(21),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(25),
},
];
let score = SCORER.score(ExerciseType::Declarative, &trials)?;
assert!(score > 1.0 && score < 4.0);
Ok(())
}
#[test]
fn score_unsorted_trials() {
let result = SCORER.score(
ExerciseType::Declarative,
&[
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
],
);
assert!(result.is_err());
}
#[test]
fn score_old_timestamp() -> Result<()> {
let score = SCORER.score(
ExerciseType::Declarative,
&[ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(100),
}],
)?;
assert!(score < 3.0);
Ok(())
}
#[test]
fn score_multiple_good() -> Result<()> {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(4),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(5),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(6),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(7),
},
];
let score = SCORER.score(ExerciseType::Declarative, &trials)?;
assert!(score > 4.0);
Ok(())
}
#[test]
fn score_multiple_bad() -> Result<()> {
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(4),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(6),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(9),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(15),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(16),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(27),
},
];
let score = SCORER.score(ExerciseType::Declarative, &trials)?;
assert!(score < 2.0);
Ok(())
}
#[test]
fn score_old_good_trials() -> Result<()> {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(200),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(210),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(213),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(248),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(256),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(270),
},
];
let score = SCORER.score(ExerciseType::Procedural, &trials)?;
assert!(score >= 3.5);
let score = SCORER.score(ExerciseType::Declarative, &trials)?;
assert!(score >= 3.5);
Ok(())
}
#[test]
fn score_very_good_old_trialsl() -> Result<()> {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(400),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(410),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(411),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(420),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(430),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(431),
},
];
let score = SCORER.score(ExerciseType::Procedural, &trials)?;
assert!(score >= 3.5);
let score = SCORER.score(ExerciseType::Declarative, &trials)?;
assert!(score >= 3.5);
Ok(())
}
#[test]
fn confidence_penalty_reduces_high_variance_score() -> Result<()> {
let high_var = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
];
let low_var = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
},
];
let high_var_score = SCORER.score(ExerciseType::Declarative, &high_var)?;
let low_var_score = SCORER.score(ExerciseType::Declarative, &low_var)?;
assert!(low_var_score > high_var_score);
Ok(())
}
#[test]
fn confidence_penalty_diminishes_with_trials() -> Result<()> {
let few = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
];
let many = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(4),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(5),
},
];
let few_score = SCORER.score(ExerciseType::Declarative, &few)?;
let many_score = SCORER.score(ExerciseType::Declarative, &many)?;
assert!(many_score > few_score,);
Ok(())
}
#[test]
fn confidence_penalty_zero_for_single_trial() {
let single = vec![ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(0),
}];
let (_, var) = PowerLawScorer::compute_weighted_avg_and_variance(&single);
assert!(var.abs() < 1e-6);
}
#[test]
fn confidence_penalty_zero_for_consistent_trials() {
let consistent = vec![
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(2),
},
];
let (mean, var) = PowerLawScorer::compute_weighted_avg_and_variance(&consistent);
assert!((mean - 4.0).abs() < 1e-6);
assert!(var.abs() < 1e-5);
}
#[test]
fn weighted_variance_correctness() {
let now = generate_timestamp(0);
let trials = vec![
ExerciseTrial {
score: 4.0,
timestamp: now,
},
ExerciseTrial {
score: 2.0,
timestamp: now,
},
];
let (mean, var) = PowerLawScorer::compute_weighted_avg_and_variance(&trials);
assert!((mean - 3.0).abs() < 1e-6);
assert!((var - 1.0).abs() < 1e-6);
}
#[test]
fn velocity_empty_trials() {
assert_eq!(SCORER.velocity(&[]), None);
let trials = vec![ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
}];
assert_eq!(SCORER.velocity(&trials), None);
}
#[test]
fn velocity_improving_scores() {
let trials = vec![
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(4),
},
];
let velocity = SCORER.velocity(&trials).unwrap();
assert!(velocity > 0.0);
}
#[test]
fn velocity_worsening_scores() {
let trials = vec![
ExerciseTrial {
score: 1.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 2.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
ExerciseTrial {
score: 4.0,
timestamp: generate_timestamp(3),
},
ExerciseTrial {
score: 5.0,
timestamp: generate_timestamp(4),
},
];
let velocity = SCORER.velocity(&trials).unwrap();
assert!(velocity < 0.0);
}
#[test]
fn velocity_constant_scores() {
let trials = vec![
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(0),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(1),
},
ExerciseTrial {
score: 3.0,
timestamp: generate_timestamp(2),
},
];
let velocity = SCORER.velocity(&trials).unwrap();
assert!(velocity.abs() < 1e-6);
}
}