use anyhow::{Result, anyhow};
use chrono::Utc;
use std::cell::RefCell;
use ustr::{Ustr, UstrMap, UstrSet};
use crate::{
data::{ExerciseType, SchedulerOptions, UnitType},
exercise_scorer::{ExerciseScorer, PowerLawScorer},
reward_scorer::{RewardScorer, WeightedRewardScorer},
scheduler::SchedulerData,
};
#[derive(Clone)]
pub(super) struct CachedScore {
score: f32,
velocity: Option<f32>,
num_trials: usize,
last_seen: f32,
}
pub(super) struct UnitScorer {
exercise_cache: RefCell<UstrMap<CachedScore>>,
lesson_cache: RefCell<UstrMap<Option<f32>>>,
course_cache: RefCell<UstrMap<Option<f32>>>,
lesson_trials_cache: RefCell<UstrMap<Option<f32>>>,
course_trials_cache: RefCell<UstrMap<Option<f32>>>,
data: SchedulerData,
options: SchedulerOptions,
exercise_scorer: Box<dyn ExerciseScorer + Send + Sync>,
reward_scorer: Box<dyn RewardScorer + Send + Sync>,
}
impl UnitScorer {
pub(super) fn new(data: SchedulerData, options: SchedulerOptions) -> Self {
Self {
exercise_cache: RefCell::new(UstrMap::default()),
lesson_cache: RefCell::new(UstrMap::default()),
course_cache: RefCell::new(UstrMap::default()),
lesson_trials_cache: RefCell::new(UstrMap::default()),
course_trials_cache: RefCell::new(UstrMap::default()),
data,
options,
exercise_scorer: Box::new(PowerLawScorer {}),
reward_scorer: Box::new(WeightedRewardScorer {}),
}
}
pub(super) fn invalidate_cached_score(&self, unit_id: Ustr) {
self.exercise_cache.borrow_mut().remove(&unit_id);
self.lesson_cache.borrow_mut().remove(&unit_id);
self.course_cache.borrow_mut().remove(&unit_id);
self.lesson_trials_cache.borrow_mut().remove(&unit_id);
self.course_trials_cache.borrow_mut().remove(&unit_id);
let graph = self.data.unit_graph.read();
match graph.get_unit_type(unit_id) {
Some(UnitType::Exercise) => {
if let Some(lesson_id) = graph.get_exercise_lesson(unit_id) {
self.lesson_cache.borrow_mut().remove(&lesson_id);
self.lesson_trials_cache.borrow_mut().remove(&lesson_id);
if let Some(course_id) = graph.get_lesson_course(lesson_id) {
self.course_cache.borrow_mut().remove(&course_id);
self.course_trials_cache.borrow_mut().remove(&course_id);
}
}
}
Some(UnitType::Lesson) => {
if let Some(course_id) = graph.get_lesson_course(unit_id) {
self.course_cache.borrow_mut().remove(&course_id);
self.course_trials_cache.borrow_mut().remove(&course_id);
}
if let Some(exercise_ids) = graph.get_lesson_exercises(unit_id) {
for exercise_id in exercise_ids.iter() {
self.exercise_cache.borrow_mut().remove(exercise_id);
}
}
}
Some(UnitType::Course) => {
if let Some(lesson_ids) = graph.get_course_lessons(unit_id) {
for lesson_id in lesson_ids.iter() {
self.lesson_cache.borrow_mut().remove(lesson_id);
self.lesson_trials_cache.borrow_mut().remove(lesson_id);
if let Some(exercise_ids) = graph.get_lesson_exercises(*lesson_id) {
for exercise_id in exercise_ids.iter() {
self.exercise_cache.borrow_mut().remove(exercise_id);
}
}
}
}
}
None => {}
}
}
pub(super) fn invalidate_cached_scores_with_prefix(&self, prefix: &str) {
self.exercise_cache
.borrow_mut()
.retain(|unit_id, _| !unit_id.starts_with(prefix));
self.lesson_cache
.borrow_mut()
.retain(|unit_id, _| !unit_id.starts_with(prefix));
self.course_cache
.borrow_mut()
.retain(|unit_id, _| !unit_id.starts_with(prefix));
self.lesson_trials_cache
.borrow_mut()
.retain(|unit_id, _| !unit_id.starts_with(prefix));
self.course_trials_cache
.borrow_mut()
.retain(|unit_id, _| !unit_id.starts_with(prefix));
}
fn get_exercise_score(&self, exercise_id: Ustr) -> Result<f32> {
let cached_score = self
.exercise_cache
.borrow()
.get(&exercise_id)
.map(|c| c.score);
if let Some(score) = cached_score {
return Ok(score);
}
let exercise_type = self
.data
.course_library
.read()
.get_exercise_manifest(exercise_id)
.map_or(ExerciseType::Procedural, |manifest| {
manifest.exercise_type.clone()
});
let scores = self
.data
.practice_stats
.read()
.get_scores(exercise_id, self.options.num_trials)
.unwrap_or_default();
let score = self.exercise_scorer.score(exercise_type, &scores)?;
let graph = self.data.unit_graph.read();
let rewards = self.data.practice_rewards.read();
let lesson_id = graph.get_exercise_lesson(exercise_id).unwrap_or_default();
let lesson_rewards = rewards
.get_rewards(lesson_id, self.options.num_rewards)
.unwrap_or_default();
let course_id = graph.get_lesson_course(lesson_id).unwrap_or_default();
let course_rewards = rewards
.get_rewards(course_id, self.options.num_rewards)
.unwrap_or_default();
let reward = self
.reward_scorer
.score_rewards(&course_rewards, &lesson_rewards)
.unwrap_or_default();
let now = Utc::now().timestamp();
let last_seen = scores.first().map_or(0.0, |trial| {
((now - trial.timestamp) as f32 / 86_400.0).max(0.0)
});
let final_score = if self.reward_scorer.apply_reward(reward, &scores) {
(score + reward).clamp(0.0, 5.0)
} else {
score
};
self.exercise_cache.borrow_mut().insert(
exercise_id,
CachedScore {
score: final_score,
velocity: self.exercise_scorer.velocity(&scores),
num_trials: scores.len(),
last_seen,
},
);
Ok(final_score)
}
pub(super) fn get_exercise_velocity(&self, exercise_id: Ustr) -> Result<Option<f32>> {
let cached_velocity = self
.exercise_cache
.borrow()
.get(&exercise_id)
.and_then(|c| c.velocity);
if let Some(velocity) = cached_velocity {
return Ok(Some(velocity));
}
self.get_exercise_score(exercise_id)?;
let cached_velocity = self
.exercise_cache
.borrow()
.get(&exercise_id)
.and_then(|s| s.velocity);
Ok(cached_velocity)
}
pub(super) fn get_exercise_num_trials(&self, exercise_id: Ustr) -> Result<Option<usize>> {
let cached_num_trials = self
.exercise_cache
.borrow()
.get(&exercise_id)
.map(|c| c.num_trials);
if let Some(num_trials) = cached_num_trials {
return Ok(Some(num_trials));
}
self.get_exercise_score(exercise_id)?;
let cached_num_trials = self
.exercise_cache
.borrow()
.get(&exercise_id)
.map(|s| s.num_trials);
Ok(cached_num_trials)
}
pub(super) fn get_last_seen_days(&self, exercise_id: Ustr) -> Result<Option<f32>> {
let cached_last_seen = self
.exercise_cache
.borrow()
.get(&exercise_id)
.map(|c| c.last_seen);
if let Some(last_seen) = cached_last_seen {
return Ok(Some(last_seen));
}
self.get_exercise_score(exercise_id)?;
let cached_last_seen = self
.exercise_cache
.borrow()
.get(&exercise_id)
.map(|s| s.last_seen);
Ok(cached_last_seen)
}
pub(super) fn all_valid_exercises_have_scores(&self, unit_id: Ustr) -> bool {
let valid_exercises = self.data.all_valid_exercises(unit_id);
if valid_exercises.is_empty() {
return true;
}
let scores: Vec<Result<f32>> = valid_exercises
.into_iter()
.map(|id| self.get_exercise_score(id))
.collect();
scores
.into_iter()
.all(|score| score.is_ok() && score.unwrap() > 0.0)
}
pub(super) fn is_superseded(&self, superseded_id: Ustr, superseding_ids: &UstrSet) -> bool {
if superseding_ids.is_empty() {
return false;
}
if !self.all_valid_exercises_have_scores(superseded_id) {
return false;
}
let scores = superseding_ids
.iter()
.filter_map(|id| self.get_unit_score(*id).unwrap_or_default())
.collect::<Vec<_>>();
scores
.iter()
.all(|score| *score >= self.data.options.superseding_score)
}
fn replace_superseding(&self, superseding_ids: &UstrSet) -> UstrSet {
let mut result = UstrSet::default();
for id in superseding_ids.iter().copied() {
let superseding = self.data.get_superseding(id);
if let Some(superseding) = superseding {
if self.is_superseded(id, &superseding) {
result.extend(self.replace_superseding(&superseding));
} else {
result.insert(id);
}
} else {
result.insert(id);
}
}
result
}
pub(super) fn get_superseding_recursive(&self, unit_id: Ustr) -> Option<UstrSet> {
let superseding_ids = self.data.get_superseding(unit_id);
superseding_ids.map(|ids| self.replace_superseding(&ids))
}
fn get_lesson_score(&self, lesson_id: Ustr) -> Result<Option<f32>> {
let cached_score = self.lesson_cache.borrow().get(&lesson_id).copied();
if let Some(score) = cached_score {
return Ok(score);
}
let blacklist = self.data.blacklist.read();
let blacklisted = blacklist.blacklisted(lesson_id);
if blacklisted.unwrap_or(false) {
self.lesson_cache.borrow_mut().insert(lesson_id, None);
return Ok(None);
}
let superseding_ids = self.get_superseding_recursive(lesson_id);
if let Some(superseding_ids) = superseding_ids
&& self.is_superseded(lesson_id, &superseding_ids)
{
self.lesson_cache.borrow_mut().insert(lesson_id, None);
return Ok(None);
}
let exercises = self.data.unit_graph.read().get_lesson_exercises(lesson_id);
let score = match exercises {
None => {
Ok(None)
}
Some(exercise_ids) => {
let valid_exercises = exercise_ids
.iter()
.copied()
.filter(|exercise_id| {
let blacklisted = blacklist.blacklisted(*exercise_id);
!blacklisted.unwrap_or(false)
})
.collect::<Vec<Ustr>>();
if valid_exercises.is_empty() {
Ok(None)
} else {
let avg_score: f32 = valid_exercises
.iter()
.map(|id| self.get_exercise_score(*id))
.sum::<Result<f32>>()?
/ valid_exercises.len() as f32;
Ok(Some(avg_score))
}
}
};
if let Ok(score) = score {
self.lesson_cache.borrow_mut().insert(lesson_id, score);
}
score
}
fn get_course_score(&self, course_id: Ustr) -> Result<Option<f32>> {
let cached_score = self.course_cache.borrow().get(&course_id).copied();
if let Some(score) = cached_score {
return Ok(score);
}
let blacklisted = self.data.blacklist.read().blacklisted(course_id);
if blacklisted.unwrap_or(false) {
self.course_cache.borrow_mut().insert(course_id, None);
return Ok(None);
}
let superseding_ids = self.get_superseding_recursive(course_id);
if let Some(superseding_ids) = superseding_ids
&& self.is_superseded(course_id, &superseding_ids)
{
self.course_cache.borrow_mut().insert(course_id, None);
return Ok(None);
}
let lessons = self.data.unit_graph.read().get_course_lessons(course_id);
let score = match lessons {
None => {
Ok(None)
}
Some(lesson_ids) => {
let valid_lesson_scores = lesson_ids
.iter()
.copied()
.map(|lesson_id| self.get_lesson_score(lesson_id))
.filter(|score| {
if score.as_ref().unwrap_or(&None).is_none() {
return false;
}
true
})
.collect::<Result<Vec<_>>>()?;
if valid_lesson_scores.is_empty() {
return Ok(None);
}
let avg_score: f32 = valid_lesson_scores
.iter()
.map(|s| s.unwrap_or_default())
.sum::<f32>()
/ valid_lesson_scores.len() as f32;
Ok(Some(avg_score))
}
};
if let Ok(score) = score {
self.course_cache.borrow_mut().insert(course_id, score);
}
score
}
pub(super) fn get_unit_score(&self, unit_id: Ustr) -> Result<Option<f32>> {
let unit_type = self
.data
.unit_graph
.read()
.get_unit_type(unit_id)
.ok_or(anyhow!("missing unit type for unit with ID {unit_id}"))?;
match unit_type {
UnitType::Course => self.get_course_score(unit_id),
UnitType::Lesson => self.get_lesson_score(unit_id),
UnitType::Exercise => self.get_exercise_score(unit_id).map(Some),
}
}
pub(super) fn get_lesson_num_trials(&self, lesson_id: Ustr) -> Option<f32> {
if let Some(cached) = self.lesson_trials_cache.borrow().get(&lesson_id) {
return *cached;
}
let blacklist = self.data.blacklist.read();
let exercise_ids: Vec<Ustr> = self
.data
.unit_graph
.read()
.get_lesson_exercises(lesson_id)?
.iter()
.copied()
.filter(|exercise_id| {
let blacklisted = blacklist.blacklisted(*exercise_id);
!blacklisted.unwrap_or(false)
})
.collect();
let valid_exercise_trials: Vec<usize> = exercise_ids
.iter()
.filter_map(|exercise_id| self.get_exercise_num_trials(*exercise_id).unwrap_or(None))
.collect();
let result = if valid_exercise_trials.is_empty() {
None
} else {
let total_num_trials: usize = valid_exercise_trials.iter().sum();
Some(total_num_trials as f32 / valid_exercise_trials.len() as f32)
};
self.lesson_trials_cache
.borrow_mut()
.insert(lesson_id, result);
result
}
pub(super) fn get_course_num_trials(&self, course_id: Ustr) -> Option<f32> {
if let Some(cached) = self.course_trials_cache.borrow().get(&course_id) {
return *cached;
}
let blacklist = self.data.blacklist.read();
let lesson_ids: Vec<Ustr> = self
.data
.unit_graph
.read()
.get_course_lessons(course_id)
.unwrap_or_default()
.iter()
.copied()
.filter(|lesson_id| {
let superseding_ids = self.get_superseding_recursive(*lesson_id);
let superseded = if let Some(superseding_ids) = superseding_ids {
self.is_superseded(*lesson_id, &superseding_ids)
} else {
false
};
let blacklisted = blacklist.blacklisted(*lesson_id);
!blacklisted.unwrap_or(false) && !superseded
})
.collect();
let valid_lesson_trials: Vec<f32> = lesson_ids
.iter()
.filter_map(|lesson_id| self.get_lesson_num_trials(*lesson_id))
.collect();
let result = if valid_lesson_trials.is_empty() {
None
} else {
let total_num_trials: f32 = valid_lesson_trials.iter().sum();
Some(total_num_trials / valid_lesson_trials.len() as f32)
};
self.course_trials_cache
.borrow_mut()
.insert(course_id, result);
result
}
pub(super) fn get_avg_trials(&self, unit_id: Ustr) -> Option<f32> {
let unit_type = self.data.unit_graph.read().get_unit_type(unit_id);
match unit_type {
Some(UnitType::Course) => self.get_course_num_trials(unit_id),
Some(UnitType::Lesson) => self.get_lesson_num_trials(unit_id),
_ => None, }
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
use anyhow::Result;
use chrono::Utc;
use std::{collections::BTreeMap, sync::LazyLock};
use ustr::Ustr;
use crate::{
blacklist::Blacklist,
data::{MasteryScore, SchedulerOptions},
scheduler::{ExerciseScheduler, UnitScorer, unit_scorer::CachedScore},
test_utils::*,
};
static NUM_EXERCISES: usize = 2;
static TEST_LIBRARY: LazyLock<Vec<TestCourse>> = LazyLock::new(|| {
vec![
TestCourse {
id: TestId(0, None, None),
dependencies: vec![],
superseded: vec![],
encompassed: vec![],
metadata: BTreeMap::default(),
lessons: vec![
TestLesson {
id: TestId(0, Some(0), None),
dependencies: vec![],
superseded: vec![],
encompassed: vec![],
metadata: BTreeMap::default(),
num_exercises: NUM_EXERCISES,
},
TestLesson {
id: TestId(0, Some(1), None),
dependencies: vec![TestId(0, Some(0), None)],
encompassed: vec![],
superseded: vec![],
metadata: BTreeMap::default(),
num_exercises: NUM_EXERCISES,
},
],
},
TestCourse {
id: TestId(1, None, None),
dependencies: vec![TestId(0, None, None)],
encompassed: vec![],
superseded: vec![TestId(0, None, None)],
metadata: BTreeMap::default(),
lessons: vec![
TestLesson {
id: TestId(1, Some(0), None),
dependencies: vec![],
encompassed: vec![],
superseded: vec![],
metadata: BTreeMap::default(),
num_exercises: NUM_EXERCISES,
},
TestLesson {
id: TestId(1, Some(1), None),
dependencies: vec![TestId(1, Some(0), None)],
encompassed: vec![],
superseded: vec![TestId(1, Some(0), None)],
metadata: BTreeMap::default(),
num_exercises: NUM_EXERCISES,
},
],
},
]
});
#[test]
fn blacklisted_course_score() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let mut library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
let scheduler_data = library.get_scheduler_data();
let cache = UnitScorer::new(scheduler_data, SchedulerOptions::default());
let course_id = Ustr::from("0");
library.add_to_blacklist(course_id)?;
assert_eq!(cache.get_course_score(course_id)?, None);
Ok(())
}
#[test]
fn superseded_course_cached() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
let scheduler_data = library.get_scheduler_data();
let cache = UnitScorer::new(scheduler_data, SchedulerOptions::default());
let ts = Utc::now().timestamp();
library.score_exercise(Ustr::from("0::0::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("0::0::1"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("0::1::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("0::1::1"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::0::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::0::1"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::1::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::1::1"), MasteryScore::Five, ts)?;
assert_eq!(cache.get_course_score(Ustr::from("0"))?, None);
assert_eq!(cache.get_course_score(Ustr::from("0"))?, None);
Ok(())
}
#[test]
fn superseded_course_lesson_cached() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
let scheduler_data = library.get_scheduler_data();
let cache = UnitScorer::new(scheduler_data, SchedulerOptions::default());
let ts = Utc::now().timestamp();
library.score_exercise(Ustr::from("0::0::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("0::0::1"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("0::1::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("0::1::1"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::0::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::0::1"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::1::0"), MasteryScore::Five, ts)?;
library.score_exercise(Ustr::from("1::1::1"), MasteryScore::Five, ts)?;
assert_eq!(cache.get_lesson_score(Ustr::from("1::0"))?, None);
assert_eq!(cache.get_lesson_score(Ustr::from("1::0"))?, None);
Ok(())
}
#[test]
fn invalidate_cached_scores() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
let scheduler_data = library.get_scheduler_data();
let cache = UnitScorer::new(scheduler_data, SchedulerOptions::default());
cache.exercise_cache.borrow_mut().insert(
Ustr::from("a"),
CachedScore {
score: 5.0,
velocity: None,
num_trials: 1,
last_seen: 0.0,
},
);
cache.exercise_cache.borrow_mut().insert(
Ustr::from("b::a"),
CachedScore {
score: 5.0,
velocity: None,
num_trials: 1,
last_seen: 0.0,
},
);
cache
.lesson_cache
.borrow_mut()
.insert(Ustr::from("a::a"), Some(5.0));
cache
.lesson_cache
.borrow_mut()
.insert(Ustr::from("c::a"), Some(5.0));
assert_eq!(cache.get_exercise_score(Ustr::from("a"))?, 5.0);
assert_eq!(cache.get_exercise_score(Ustr::from("b::a"))?, 5.0);
assert_eq!(cache.get_lesson_score(Ustr::from("a::a"))?, Some(5.0));
assert_eq!(cache.get_lesson_score(Ustr::from("c::a"))?, Some(5.0));
cache.invalidate_cached_scores_with_prefix("a");
assert_eq!(cache.get_exercise_score(Ustr::from("a"))?, 0.0);
assert_eq!(cache.get_exercise_score(Ustr::from("b::a"))?, 5.0);
assert_eq!(cache.get_lesson_score(Ustr::from("a::a"))?, None);
assert_eq!(cache.get_lesson_score(Ustr::from("c::a"))?, Some(5.0));
cache.invalidate_cached_score(Ustr::from("b::a"));
cache.invalidate_cached_score(Ustr::from("c::a"));
assert_eq!(cache.get_exercise_score(Ustr::from("b::a"))?, 0.0);
assert_eq!(cache.get_lesson_score(Ustr::from("c::a"))?, None);
Ok(())
}
#[test]
fn get_num_trials() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
let scheduler_data = library.get_scheduler_data();
let cache = UnitScorer::new(scheduler_data, SchedulerOptions::default());
let exercise_id = Ustr::from("0::0::0");
library.score_exercise(exercise_id, MasteryScore::Four, 1)?;
library.score_exercise(exercise_id, MasteryScore::Five, 2)?;
assert_eq!(Some(2), cache.get_exercise_num_trials(exercise_id)?);
assert_eq!(Some(2), cache.get_exercise_num_trials(exercise_id)?);
library.score_exercise(exercise_id, MasteryScore::Four, 3)?;
cache.invalidate_cached_score(exercise_id);
assert_eq!(Some(3), cache.get_exercise_num_trials(exercise_id)?);
Ok(())
}
#[test]
fn get_last_seen_days() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
let scheduler_data = library.get_scheduler_data();
let cache = UnitScorer::new(scheduler_data, SchedulerOptions::default());
let exercise_id = Ustr::from("0::0::0");
let two_days_ago = Utc::now().timestamp() - (2 * 86_400);
library.score_exercise(exercise_id, MasteryScore::Three, two_days_ago)?;
let last_seen = cache.get_last_seen_days(exercise_id)?;
assert!((last_seen.unwrap_or_default() - 2.0).abs() < 0.5);
library.score_exercise(exercise_id, MasteryScore::Four, Utc::now().timestamp())?;
cache.invalidate_cached_score(exercise_id);
let last_seen = cache.get_last_seen_days(exercise_id)?;
assert!(last_seen.unwrap_or_default() < 1.0);
Ok(())
}
}