use anyhow::{Result, anyhow};
use chrono::Utc;
use rand::{
RngExt,
distr::{Distribution, weighted::WeightedIndex},
};
use std::path::{Path, PathBuf};
use ustr::{Ustr, UstrMap};
use walkdir::WalkDir;
use crate::{
Trane,
course_library::CourseLibrary,
data::{MasteryScore, SchedulerOptions},
scheduler::ExerciseScheduler,
};
pub type PerformanceProbs = [f32; 5];
pub fn verify_probs(probs: &PerformanceProbs) -> Result<()> {
let sum: f32 = probs.iter().sum();
if (sum - 1.0).abs() < 1e-4 {
Ok(())
} else {
Err(anyhow!("Probabilities must sum up to 1.0 instead of {sum}"))
}
}
#[derive(Clone, Debug)]
pub struct StudentProfile {
pub session_frequency: u32,
pub exercises_per_session: u32,
pub initial_performance: PerformanceProbs,
pub trials_before_stable: u32,
pub stable_performance: PerformanceProbs,
pub lapse_rate: f32,
}
#[derive(Clone, Debug)]
pub struct StudentResult {
pub days_to_mastery: Option<u32>,
pub sessions_run: u32,
pub exercises_practiced: u32,
}
#[derive(Clone, Debug)]
#[allow(missing_docs)]
pub struct BenchmarkResult {
pub remedial_result: StudentResult,
pub below_median_result: StudentResult,
pub median_result: StudentResult,
pub above_median_result: StudentResult,
pub excellent_result: StudentResult,
}
pub struct Benchmark {
pub library_dir: PathBuf,
pub scheduler_opts: SchedulerOptions,
pub remedial_profile: StudentProfile,
pub below_median_profile: StudentProfile,
pub median_profile: StudentProfile,
pub above_median_profile: StudentProfile,
pub excellent_profile: StudentProfile,
pub advanced_course: Ustr,
pub mastery_threshold: f32,
pub max_sessions: u32,
}
impl Default for Benchmark {
fn default() -> Self {
Benchmark {
library_dir: PathBuf::from("placeholder_library_dir"),
scheduler_opts: SchedulerOptions::default(),
remedial_profile: StudentProfile {
session_frequency: 4,
exercises_per_session: 25,
initial_performance: [0.3, 0.2, 0.25, 0.15, 0.1],
trials_before_stable: 5,
stable_performance: [0.03, 0.02, 0.1, 0.2, 0.65],
lapse_rate: 0.1,
},
below_median_profile: StudentProfile {
session_frequency: 3,
exercises_per_session: 25,
initial_performance: [0.2, 0.25, 0.3, 0.15, 0.1],
trials_before_stable: 5,
stable_performance: [0.02, 0.03, 0.1, 0.2, 0.65],
lapse_rate: 0.08,
},
median_profile: StudentProfile {
session_frequency: 2,
exercises_per_session: 30,
initial_performance: [0.15, 0.25, 0.3, 0.18, 0.12],
trials_before_stable: 4,
stable_performance: [0.02, 0.03, 0.1, 0.15, 0.7],
lapse_rate: 0.07,
},
above_median_profile: StudentProfile {
session_frequency: 1,
exercises_per_session: 40,
initial_performance: [0.1, 0.15, 0.4, 0.2, 0.15],
trials_before_stable: 4,
stable_performance: [0.01, 0.03, 0.06, 0.15, 0.75],
lapse_rate: 0.06,
},
excellent_profile: StudentProfile {
session_frequency: 1,
exercises_per_session: 50,
initial_performance: [0.08, 0.12, 0.4, 0.2, 0.2],
trials_before_stable: 3,
stable_performance: [0.0, 0.02, 0.03, 0.15, 0.8],
lapse_rate: 0.05,
},
advanced_course: Ustr::from("placeholder_advanced_course"),
mastery_threshold: 4.3,
max_sessions: 2000,
}
}
}
impl Benchmark {
fn session_timestamp(anchor: i64, session: u32, session_frequency: u32) -> i64 {
anchor + i64::from(session) * i64::from(session_frequency) * 86400
}
fn exercise_timestamp(session_start: i64, exercise_index: u32) -> i64 {
session_start + i64::from(exercise_index)
}
fn copy_library_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in WalkDir::new(src).into_iter().filter_map(Result::ok) {
let path = entry.path();
let relative = path.strip_prefix(src).unwrap();
let dst_path = dst.join(relative);
if path.is_dir() {
std::fs::create_dir_all(&dst_path)?;
} else {
std::fs::copy(path, &dst_path)?;
}
}
Ok(())
}
fn interpolate_performance(profile: &StudentProfile, trial_num: u32) -> PerformanceProbs {
let weight = (trial_num as f32 / profile.trials_before_stable as f32).min(1.0);
std::array::from_fn(|i| {
profile.initial_performance[i] * (1.0 - weight) + profile.stable_performance[i] * weight
})
}
fn get_score(profile: &StudentProfile, trial_num: u32) -> MasteryScore {
let is_lapse = rand::rng().random_range(0.0..1.0) < profile.lapse_rate;
if is_lapse && trial_num > profile.trials_before_stable {
return MasteryScore::Two;
}
let performance = Self::interpolate_performance(profile, trial_num);
let choice = WeightedIndex::new(performance).unwrap();
match choice.sample(&mut rand::rng()) {
0 => MasteryScore::One,
1 => MasteryScore::Two,
2 => MasteryScore::Three,
3 => MasteryScore::Four,
4 => MasteryScore::Five,
_ => unreachable!(), }
}
fn check_mastery(
trane: &Trane,
advanced_course: Ustr,
mastery_threshold: f32,
all_courses: &[Ustr],
) -> bool {
if let Ok(Some(adv_score)) = trane.get_unit_score(advanced_course)
&& adv_score >= mastery_threshold
{
return all_courses.iter().all(|course_id| {
trane
.get_unit_score(*course_id)
.ok()
.flatten()
.is_some_and(|s| s >= mastery_threshold)
});
}
false
}
fn simulate_student(&self, profile: &StudentProfile) -> Result<StudentResult> {
let temp_dir = tempfile::TempDir::new()?;
let temp_path = temp_dir.path().to_path_buf();
Self::copy_library_dir(&self.library_dir, &temp_path)?;
let mut trane = Trane::new_local(&temp_path, &temp_path)?;
trane.set_scheduler_options(self.scheduler_opts.clone());
let anchor = Utc::now().timestamp();
let all_courses = trane.get_course_ids();
let mut trial_counts: UstrMap<u32> = UstrMap::default();
let mut days_to_mastery = None;
let mut sessions_run = 0;
let mut exercises_practiced = 0;
'session_loop: for session in 0..self.max_sessions {
let session_start = Self::session_timestamp(anchor, session, profile.session_frequency);
let mut exercises_in_session = 0u32;
while exercises_in_session < profile.exercises_per_session {
let batch_ts = Self::exercise_timestamp(session_start, exercises_in_session);
trane.override_current_timestamp(Some(batch_ts));
let batch = trane.get_exercise_batch(None)?;
if batch.is_empty() {
break 'session_loop; }
for exercise in batch {
if exercises_in_session >= profile.exercises_per_session {
break;
}
let trial_count = trial_counts.entry(exercise.id).or_insert(0);
let score = Self::get_score(profile, *trial_count);
let timestamp = Self::exercise_timestamp(session_start, exercises_in_session);
trane.score_exercise(exercise.id, score, timestamp)?;
*trial_count += 1;
exercises_practiced += 1;
exercises_in_session += 1;
}
}
sessions_run = session + 1;
let end_of_session_ts = Self::exercise_timestamp(session_start, exercises_in_session);
trane.override_current_timestamp(Some(end_of_session_ts));
if Self::check_mastery(
&trane,
self.advanced_course,
self.mastery_threshold,
&all_courses,
) {
days_to_mastery = Some(session * profile.session_frequency);
}
if days_to_mastery.is_some() {
break;
}
}
Ok(StudentResult {
days_to_mastery,
sessions_run,
exercises_practiced,
})
}
pub fn verify(&self) -> Result<()> {
self.scheduler_opts.verify()?;
for profile in [
&self.remedial_profile,
&self.below_median_profile,
&self.median_profile,
&self.above_median_profile,
&self.excellent_profile,
] {
verify_probs(&profile.initial_performance)?;
verify_probs(&profile.stable_performance)?;
}
Ok(())
}
pub fn run_benchmark(&self) -> Result<BenchmarkResult> {
let results = std::thread::scope(|s| {
let h1 = s.spawn(|| self.simulate_student(&self.remedial_profile));
let h2 = s.spawn(|| self.simulate_student(&self.below_median_profile));
let h3 = s.spawn(|| self.simulate_student(&self.median_profile));
let h4 = s.spawn(|| self.simulate_student(&self.above_median_profile));
let h5 = s.spawn(|| self.simulate_student(&self.excellent_profile));
(
h1.join()
.map_err(|_| anyhow::anyhow!("remedial thread panicked"))
.and_then(|r| r),
h2.join()
.map_err(|_| anyhow::anyhow!("below_median thread panicked"))
.and_then(|r| r),
h3.join()
.map_err(|_| anyhow::anyhow!("median thread panicked"))
.and_then(|r| r),
h4.join()
.map_err(|_| anyhow::anyhow!("above_median thread panicked"))
.and_then(|r| r),
h5.join()
.map_err(|_| anyhow::anyhow!("excellent thread panicked"))
.and_then(|r| r),
)
});
Ok(BenchmarkResult {
remedial_result: results.0?,
below_median_result: results.1?,
median_result: results.2?,
above_median_result: results.3?,
excellent_result: results.4?,
})
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod tests {
use super::*;
#[test]
fn performance_probs_validate_valid() {
assert!(verify_probs(&[0.2, 0.2, 0.2, 0.2, 0.2]).is_ok());
}
#[test]
fn performance_probs_validate_invalid() {
assert!(verify_probs(&[0.5, 0.4, 0.0, 0.0, 0.0]).is_err());
}
fn test_profile() -> StudentProfile {
StudentProfile {
session_frequency: 1,
exercises_per_session: 5,
initial_performance: [0.5, 0.3, 0.1, 0.05, 0.05],
trials_before_stable: 10,
stable_performance: [0.0, 0.1, 0.2, 0.3, 0.4],
lapse_rate: 0.1,
}
}
#[test]
fn interpolate_performance_initial() {
let perf = Benchmark::interpolate_performance(&test_profile(), 0);
let expected = [0.5, 0.3, 0.1, 0.05, 0.05];
for (a, b) in perf.iter().zip(expected.iter()) {
assert!((a - b).abs() < f32::EPSILON);
}
}
#[test]
fn interpolate_performance_stable() {
let perf = Benchmark::interpolate_performance(&test_profile(), 10);
let expected = [0.0, 0.1, 0.2, 0.3, 0.4];
for (a, b) in perf.iter().zip(expected.iter()) {
assert!((a - b).abs() < f32::EPSILON);
}
}
#[test]
fn interpolate_performance_blend() {
let perf = Benchmark::interpolate_performance(&test_profile(), 5);
let expected = [0.25, 0.2, 0.15, 0.175, 0.225];
for (a, b) in perf.iter().zip(expected.iter()) {
assert!((a - b).abs() < f32::EPSILON);
}
}
#[test]
fn session_timestamp() {
let anchor = 1_000_000;
assert_eq!(Benchmark::session_timestamp(anchor, 0, 1), anchor);
assert_eq!(Benchmark::session_timestamp(anchor, 1, 1), anchor + 86400);
assert_eq!(Benchmark::session_timestamp(anchor, 1, 2), anchor + 172_800);
assert_eq!(Benchmark::session_timestamp(anchor, 7, 1), anchor + 604_800);
}
#[test]
fn exercise_timestamp() {
assert_eq!(Benchmark::exercise_timestamp(0, 0), 0);
assert_eq!(Benchmark::exercise_timestamp(0, 5), 5);
assert_eq!(Benchmark::exercise_timestamp(86400, 0), 86400);
assert_eq!(Benchmark::exercise_timestamp(86400, 3), 86403);
}
#[test]
fn get_score_deterministic() {
let benchmark = Benchmark {
remedial_profile: StudentProfile {
session_frequency: 1,
exercises_per_session: 5,
initial_performance: [0.0, 0.0, 0.0, 0.0, 1.0],
trials_before_stable: 1,
stable_performance: [0.0, 0.0, 0.0, 0.0, 1.0],
lapse_rate: 0.0,
},
..Default::default()
};
let profile = &benchmark.remedial_profile;
for _ in 0..10 {
let score = Benchmark::get_score(profile, 0);
assert_eq!(score, MasteryScore::Five);
}
}
#[test]
fn get_score_with_lapses() {
let profile = StudentProfile {
session_frequency: 1,
exercises_per_session: 5,
initial_performance: [0.0, 0.0, 0.0, 0.0, 1.0],
trials_before_stable: 3,
stable_performance: [0.0, 0.0, 0.0, 0.0, 1.0],
lapse_rate: 0.5,
};
let mut saw_two = false;
let mut saw_five = false;
for _ in 0..10 {
let score = Benchmark::get_score(&profile, 5);
if score == MasteryScore::Two {
saw_two = true;
}
if score == MasteryScore::Five {
saw_five = true;
}
}
assert!(saw_two);
assert!(saw_five);
}
#[test]
fn verify_default_benchmark() {
let benchmark = Benchmark::default();
assert!(benchmark.verify().is_ok());
}
#[test]
fn run_benchmark() {
let benchmark = Benchmark {
library_dir: PathBuf::from("tests/small_test_library"),
advanced_course: Ustr::from("trane::music::improvise_for_real::sing_the_numbers::3"),
max_sessions: 50,
..Benchmark::default()
};
let result = benchmark.run_benchmark();
assert!(result.is_ok());
let benchmark_result = result.unwrap();
assert!(benchmark_result.remedial_result.exercises_practiced > 0);
assert!(benchmark_result.remedial_result.sessions_run > 0);
assert!(benchmark_result.below_median_result.exercises_practiced > 0);
assert!(benchmark_result.below_median_result.sessions_run > 0);
assert!(benchmark_result.median_result.exercises_practiced > 0);
assert!(benchmark_result.median_result.sessions_run > 0);
assert!(benchmark_result.above_median_result.exercises_practiced > 0);
assert!(benchmark_result.above_median_result.sessions_run > 0);
assert!(benchmark_result.excellent_result.exercises_practiced > 0);
assert!(benchmark_result.excellent_result.sessions_run > 0);
assert!(
benchmark_result
.above_median_result
.days_to_mastery
.is_some()
);
assert!(benchmark_result.excellent_result.days_to_mastery.is_some());
}
}