use rand::{rng, seq::IndexedRandom};
use ustr::{UstrMap, UstrSet};
use crate::{
data::{MasteryWindow, SchedulerOptions},
scheduler::{Candidate, SchedulerData, review_knocker::KnockoutResult},
};
const MIN_WEIGHT: f32 = 100.0;
const EXERCISE_SCORE_WEIGHT_FACTOR: f32 = 200.0;
const LESSON_SCORE_WEIGHT_FACTOR: f32 = 100.0;
const COURSE_SCORE_WEIGHT_FACTOR: f32 = 50.0;
const MAX_ENCOMPASSED_WEIGHT: f32 = 1000.0;
const DEPTH_WEIGHT_FACTOR: f32 = 25.0;
const DEAD_WEIGHT_FACTOR: f32 = 1000.0;
const MAX_DEPTH_WEIGHT: f32 = 1000.0;
const MAX_SCHEDULED_WEIGHT: f32 = 1000.0;
const SCHEDULED_FACTOR: f32 = 0.5;
const MAX_NUM_TRIALS_WEIGHT: f32 = 1000.0;
const NUM_TRIALS_FACTOR: f32 = 0.75;
const LAST_SEEN_WEIGHT_PER_DAY: f32 = 10.0;
const MAX_LAST_SEEN_WEIGHT: f32 = 1000.0;
const MAX_LESSON_FREQUENCY_WEIGHT: f32 = 1000.0;
const MAX_COURSE_FREQUENCY_WEIGHT: f32 = 1000.0;
const MIN_DYNAMIC_BATCH_SIZE: usize = 10;
const VELOCITY_WEIGHT_FACTOR: f32 = 250.0;
const STAGNANT_VELOCITY_WEIGHT: f32 = 2000.0;
const STAGNANT_VELOCITY_PENALTY: f32 = -2000.0;
const STAGNANT_VELOCITY_THRESHOLD: f32 = 0.2;
const MASTERED_SCORE_THRESHOLD: f32 = 4.0;
pub(super) struct CandidateFilter {
data: SchedulerData,
}
impl CandidateFilter {
pub fn new(data: SchedulerData) -> Self {
Self { data }
}
fn candidates_in_window(
candidates: &[Candidate],
encompassed_set: &UstrSet,
window_opts: &MasteryWindow,
) -> Vec<Candidate> {
candidates
.iter()
.filter(|c| window_opts.in_window(c.exercise_score))
.filter(|c| !encompassed_set.contains(&c.exercise_id))
.cloned()
.collect()
}
fn count_lesson_frequency(candidates: &[Candidate]) -> UstrMap<u32> {
let mut lesson_frequency = UstrMap::default();
for candidate in candidates {
*lesson_frequency.entry(candidate.lesson_id).or_default() += 1;
}
lesson_frequency
}
fn count_course_frequency(candidates: &[Candidate]) -> UstrMap<u32> {
let mut course_frequency = UstrMap::default();
for candidate in candidates {
*course_frequency.entry(candidate.course_id).or_default() += 1;
}
course_frequency
}
fn candidate_weight(
c: &Candidate,
encompassed_freq: u32,
lesson_freq: u32,
course_freq: u32,
) -> f32 {
let mut weight = EXERCISE_SCORE_WEIGHT_FACTOR * (5.0 - c.exercise_score).max(0.0);
weight += LESSON_SCORE_WEIGHT_FACTOR * (5.0 - c.lesson_score).max(0.0);
weight += COURSE_SCORE_WEIGHT_FACTOR * (5.0 - c.course_score).max(0.0);
weight += MAX_ENCOMPASSED_WEIGHT / (encompassed_freq.max(1) as f32);
weight += (DEPTH_WEIGHT_FACTOR * c.depth).clamp(0.0, MAX_DEPTH_WEIGHT);
weight += MAX_SCHEDULED_WEIGHT * SCHEDULED_FACTOR.powf(c.frequency as f32);
weight += MAX_NUM_TRIALS_WEIGHT * NUM_TRIALS_FACTOR.powf(c.num_trials as f32);
weight += (LAST_SEEN_WEIGHT_PER_DAY * c.last_seen).clamp(0.0, MAX_LAST_SEEN_WEIGHT);
weight += MAX_LESSON_FREQUENCY_WEIGHT / lesson_freq.max(1) as f32;
weight += MAX_COURSE_FREQUENCY_WEIGHT / course_freq.max(1) as f32;
if c.dead_end {
weight += DEAD_WEIGHT_FACTOR;
}
if let Some(velocity) = c.score_velocity {
weight += VELOCITY_WEIGHT_FACTOR * velocity.abs();
if velocity.abs() < STAGNANT_VELOCITY_THRESHOLD {
if c.exercise_score >= MASTERED_SCORE_THRESHOLD {
weight += STAGNANT_VELOCITY_PENALTY;
} else {
weight += STAGNANT_VELOCITY_WEIGHT;
}
}
}
weight.max(MIN_WEIGHT)
}
fn select_candidates(
candidates: &[Candidate],
frequency_map: &UstrMap<u32>,
num_to_select: usize,
) -> (Vec<Candidate>, Vec<Candidate>) {
if candidates.len() <= num_to_select {
return (candidates.to_vec(), vec![]);
}
let lesson_freq = Self::count_lesson_frequency(candidates);
let course_freq = Self::count_course_frequency(candidates);
let mut rng = rng();
let selected: Vec<Candidate> = candidates
.sample_weighted(&mut rng, num_to_select, |c| {
let encompassed_frequency = frequency_map.get(&c.exercise_id).copied().unwrap_or(0);
Self::candidate_weight(
c,
encompassed_frequency,
lesson_freq.get(&c.lesson_id).copied().unwrap_or(0),
course_freq.get(&c.course_id).copied().unwrap_or(0),
)
})
.unwrap()
.cloned()
.collect();
let selected_ids: UstrSet = selected.iter().map(|c| c.exercise_id).collect();
let remainder = candidates
.iter()
.filter(|c| !selected_ids.contains(&c.exercise_id))
.cloned()
.collect();
(selected, remainder)
}
fn add_remainder(
batch_size: usize,
final_candidates: &mut Vec<Candidate>,
remainder: &[Candidate],
frequency_map: &UstrMap<u32>,
max_added: Option<usize>,
) {
if final_candidates.len() >= batch_size * 3 / 4 {
return;
}
let num_remainder = batch_size - final_candidates.len();
let num_added = match max_added {
None => num_remainder,
Some(max) => num_remainder.min(max),
};
let (remainder_candidates, _) =
Self::select_candidates(remainder, frequency_map, num_added);
final_candidates.extend(remainder_candidates);
}
fn dynamic_batch_size(batch_size: usize, num_candidates: usize) -> usize {
if batch_size < MIN_DYNAMIC_BATCH_SIZE {
return batch_size;
}
if num_candidates < batch_size * 3 {
return (num_candidates / 3).max(MIN_DYNAMIC_BATCH_SIZE);
}
batch_size
}
fn adjusted_mastery_windows(options: &SchedulerOptions, success_rate: f32) -> SchedulerOptions {
let mut adjusted_options = options.clone();
let shift = if success_rate > 0.90 {
0.05_f32
} else if (0.75..=0.90).contains(&success_rate) {
return adjusted_options;
} else if (0.50..0.75).contains(&success_rate) {
-0.05_f32
} else {
-0.10_f32
};
let clamp = |p: f32| p.clamp(0.05, 0.50);
adjusted_options.new_window_opts.percentage =
clamp(options.new_window_opts.percentage + shift);
adjusted_options.target_window_opts.percentage =
clamp(options.target_window_opts.percentage + shift);
adjusted_options.easy_window_opts.percentage =
clamp(options.easy_window_opts.percentage - shift);
adjusted_options.mastered_window_opts.percentage =
clamp(options.mastered_window_opts.percentage - shift);
let sum = adjusted_options.new_window_opts.percentage
+ adjusted_options.target_window_opts.percentage
+ adjusted_options.easy_window_opts.percentage
+ adjusted_options.mastered_window_opts.percentage;
adjusted_options.current_window_opts.percentage = (1.0_f32 - sum).max(0.05);
adjusted_options
}
pub fn filter_candidates(&self, result: KnockoutResult) -> Vec<Candidate> {
let candidates = &result.candidates;
let options =
Self::adjusted_mastery_windows(&self.data.options, self.data.get_success_rate());
let batch_size = Self::dynamic_batch_size(options.batch_size, candidates.len());
let batch_size_float = batch_size as f32;
let encompassed_set: UstrSet = result
.highly_encompassed
.iter()
.map(|c| c.exercise_id)
.collect();
let mut mastered_candidates =
Self::candidates_in_window(candidates, &encompassed_set, &options.mastered_window_opts);
let easy_candidates =
Self::candidates_in_window(candidates, &encompassed_set, &options.easy_window_opts);
let current_candidates =
Self::candidates_in_window(candidates, &encompassed_set, &options.current_window_opts);
let target_candidates =
Self::candidates_in_window(candidates, &encompassed_set, &options.target_window_opts);
let new_candidates =
Self::candidates_in_window(candidates, &encompassed_set, &options.new_window_opts);
mastered_candidates.extend(result.highly_encompassed);
let mut final_candidates = Vec::with_capacity(batch_size);
let num_mastered =
(batch_size_float * options.mastered_window_opts.percentage).max(1.0) as usize;
let frequency_map = &result.frequency_map;
let (mastered_selected, mastered_remainder) =
Self::select_candidates(&mastered_candidates, frequency_map, num_mastered);
final_candidates.extend(mastered_selected);
let num_easy = (batch_size_float * options.easy_window_opts.percentage).max(1.0) as usize;
let (easy_selected, easy_remainder) =
Self::select_candidates(&easy_candidates, frequency_map, num_easy);
final_candidates.extend(easy_selected);
let num_current =
(batch_size_float * options.current_window_opts.percentage).max(1.0) as usize;
let (current_selected, current_remainder) =
Self::select_candidates(¤t_candidates, frequency_map, num_current);
final_candidates.extend(current_selected);
let num_target =
(batch_size_float * options.target_window_opts.percentage).max(1.0) as usize;
let (target_selected, target_remainder) =
Self::select_candidates(&target_candidates, frequency_map, num_target);
final_candidates.extend(target_selected);
let num_new = (batch_size_float * options.new_window_opts.percentage).max(1.0) as usize;
let (new_selected, new_remainder) =
Self::select_candidates(&new_candidates, frequency_map, num_new);
final_candidates.extend(new_selected);
let base_remainder = (batch_size / 10).max(1);
Self::add_remainder(
batch_size,
&mut final_candidates,
¤t_remainder,
frequency_map,
None,
);
Self::add_remainder(
batch_size,
&mut final_candidates,
&new_remainder,
frequency_map,
Some(5 * base_remainder),
);
Self::add_remainder(
batch_size,
&mut final_candidates,
&target_remainder,
frequency_map,
Some(3 * base_remainder),
);
Self::add_remainder(
batch_size,
&mut final_candidates,
&easy_remainder,
frequency_map,
None,
);
Self::add_remainder(
batch_size,
&mut final_candidates,
&mastered_remainder,
frequency_map,
None,
);
final_candidates
}
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
use ustr::Ustr;
use super::*;
use crate::scheduler::Candidate;
#[test]
fn dynamic_batch_size() {
assert_eq!(CandidateFilter::dynamic_batch_size(5, 10), 5);
assert_eq!(CandidateFilter::dynamic_batch_size(50, 70), 70 / 3);
assert_eq!(
CandidateFilter::dynamic_batch_size(50, 10),
MIN_DYNAMIC_BATCH_SIZE
);
assert_eq!(CandidateFilter::dynamic_batch_size(50, 150), 50);
assert_eq!(CandidateFilter::dynamic_batch_size(50, 200), 50);
}
#[test]
fn count_lesson_frequency() {
let candidates = vec![
Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise3"),
lesson_id: Ustr::from("lesson2"),
course_id: Ustr::from("course1"),
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise4"),
course_id: Ustr::from("course1"),
..Default::default()
},
];
let lesson_frequency = CandidateFilter::count_lesson_frequency(&candidates);
assert_eq!(lesson_frequency.len(), 3);
assert_eq!(lesson_frequency.get(&Ustr::from("lesson1")), Some(&2));
assert_eq!(lesson_frequency.get(&Ustr::from("lesson2")), Some(&1));
assert_eq!(lesson_frequency.get(&Ustr::from("")), Some(&1));
}
#[test]
fn candidates_in_window() {
let candidates = vec![
Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 2.1,
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 3.0,
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise3"),
lesson_id: Ustr::from("lesson2"),
course_id: Ustr::from("course1"),
exercise_score: 3.7,
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise4"),
course_id: Ustr::from("course1"),
exercise_score: 1.0,
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise5"),
course_id: Ustr::from("course1"),
exercise_score: 3.5,
..Default::default()
},
];
let window_opts = MasteryWindow {
percentage: 1.0,
range: (2.0, 4.0),
};
let encompassed_set =
UstrSet::from_iter([Ustr::from("exercise1"), Ustr::from("exercise5")]);
let candidates_in_window =
CandidateFilter::candidates_in_window(&candidates, &encompassed_set, &window_opts);
assert_eq!(candidates_in_window.len(), 2);
assert!(
candidates_in_window
.iter()
.any(|c| c.exercise_id == Ustr::from("exercise2"))
);
assert!(
candidates_in_window
.iter()
.any(|c| c.exercise_id == Ustr::from("exercise3"))
);
}
#[test]
fn add_remainder() {
let batch_size = 10;
let mut final_candidates = vec![Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
}];
let remainder = vec![
Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson2"),
course_id: Ustr::from("course2"),
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise3"),
lesson_id: Ustr::from("lesson3"),
course_id: Ustr::from("course3"),
..Default::default()
},
Candidate {
exercise_id: Ustr::from("exercise4"),
lesson_id: Ustr::from("lesson4"),
course_id: Ustr::from("course4"),
..Default::default()
},
];
let frequency_map = UstrMap::default();
let initial_len = final_candidates.len();
CandidateFilter::add_remainder(
batch_size,
&mut final_candidates,
&remainder.clone(),
&frequency_map,
None,
);
assert!(final_candidates.len() > initial_len);
assert!(final_candidates.len() < batch_size);
let mut final_candidates_full = (0..batch_size * 2 / 3 + 1)
.map(|i| Candidate {
exercise_id: Ustr::from(&format!("exercise{}", i)),
lesson_id: Ustr::from(&format!("lesson{}", i)),
course_id: Ustr::from(&format!("course{}", i)),
..Default::default()
})
.collect::<Vec<_>>();
let initial_len_full = final_candidates_full.len();
CandidateFilter::add_remainder(
batch_size,
&mut final_candidates_full,
&remainder.clone(),
&frequency_map,
None,
);
assert_eq!(final_candidates_full.len(), initial_len_full);
let mut final_candidates_limited = vec![Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
}];
let max_added = 1;
CandidateFilter::add_remainder(
batch_size,
&mut final_candidates_limited,
&remainder,
&frequency_map,
Some(max_added),
);
assert_eq!(final_candidates_limited.len(), 2);
}
#[test]
fn more_hops_more_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
depth: 10.0,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn higher_exercise_score_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 5.0,
lesson_score: 5.0,
course_score: 5.0,
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 1.0,
lesson_score: 1.0,
course_score: 1.0,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn higher_lesson_score_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
lesson_score: 5.0,
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
lesson_score: 1.0,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn higher_course_score_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
course_score: 5.0,
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
course_score: 1.0,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn more_scheduled_frequency_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
frequency: 5,
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
frequency: 1,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn fewer_trials_more_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
num_trials: 5,
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
num_trials: 1,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn more_days_since_last_seen_more_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
last_seen: 1.0,
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson2"),
course_id: Ustr::from("course2"),
last_seen: 20.0,
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 1)
< CandidateFilter::candidate_weight(&c2, 0, 1, 1)
);
}
#[test]
fn higher_lesson_frequency_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson2"),
course_id: Ustr::from("course1"),
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 10, 1)
< CandidateFilter::candidate_weight(&c2, 0, 3, 1)
);
}
#[test]
fn adjusted_mastery_windows() {
let options = SchedulerOptions::default();
let adjusted = CandidateFilter::adjusted_mastery_windows(&options, 0.85);
assert_eq!(
adjusted.new_window_opts.percentage,
options.new_window_opts.percentage
);
assert_eq!(
adjusted.target_window_opts.percentage,
options.target_window_opts.percentage
);
assert_eq!(
adjusted.current_window_opts.percentage,
options.current_window_opts.percentage
);
assert_eq!(
adjusted.easy_window_opts.percentage,
options.easy_window_opts.percentage
);
assert_eq!(
adjusted.mastered_window_opts.percentage,
options.mastered_window_opts.percentage
);
let adjusted_low = CandidateFilter::adjusted_mastery_windows(&options, 0.75);
assert_eq!(
adjusted_low.new_window_opts.percentage,
options.new_window_opts.percentage
);
let adjusted_high = CandidateFilter::adjusted_mastery_windows(&options, 0.90);
assert_eq!(
adjusted_high.new_window_opts.percentage,
options.new_window_opts.percentage
);
let adjusted = CandidateFilter::adjusted_mastery_windows(&options, 0.95);
assert!(adjusted.new_window_opts.percentage > options.new_window_opts.percentage);
assert!(adjusted.target_window_opts.percentage > options.target_window_opts.percentage);
assert!(adjusted.easy_window_opts.percentage < options.easy_window_opts.percentage);
assert!(adjusted.mastered_window_opts.percentage < options.mastered_window_opts.percentage);
let adjusted = CandidateFilter::adjusted_mastery_windows(&options, 0.60);
assert!(adjusted.new_window_opts.percentage < options.new_window_opts.percentage);
assert!(adjusted.target_window_opts.percentage < options.target_window_opts.percentage);
assert!(adjusted.easy_window_opts.percentage > options.easy_window_opts.percentage);
assert!(adjusted.mastered_window_opts.percentage > options.mastered_window_opts.percentage);
let adjusted_very_hard = CandidateFilter::adjusted_mastery_windows(&options, 0.30);
let adjusted_hard = CandidateFilter::adjusted_mastery_windows(&options, 0.60);
assert!(
adjusted_very_hard.easy_window_opts.percentage
> adjusted_hard.easy_window_opts.percentage
);
assert!(
adjusted_very_hard.mastered_window_opts.percentage
> adjusted_hard.mastered_window_opts.percentage
);
assert!(
adjusted_very_hard.new_window_opts.percentage
< adjusted_hard.new_window_opts.percentage
);
assert!(
adjusted_very_hard.target_window_opts.percentage
< adjusted_hard.target_window_opts.percentage
);
for rate in [0.0, 0.30, 0.60, 0.80, 0.95, 1.0] {
let adj = CandidateFilter::adjusted_mastery_windows(&options, rate);
let sum = adj.new_window_opts.percentage
+ adj.target_window_opts.percentage
+ adj.current_window_opts.percentage
+ adj.easy_window_opts.percentage
+ adj.mastered_window_opts.percentage;
assert!((sum - 1.0).abs() < 1e-6);
}
}
#[test]
fn higher_course_frequency_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson2"),
course_id: Ustr::from("course2"),
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 0, 1, 10)
< CandidateFilter::candidate_weight(&c2, 0, 1, 3)
);
}
#[test]
fn higher_encompassed_frequency_less_weight() {
let c1 = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
};
let c2 = Candidate {
exercise_id: Ustr::from("exercise2"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
};
assert!(
CandidateFilter::candidate_weight(&c1, 10, 1, 1)
< CandidateFilter::candidate_weight(&c2, 3, 1, 1)
);
}
#[test]
fn dead_end_fixed_weight_bonus() {
let base = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
..Default::default()
};
let dead_end = Candidate {
dead_end: true,
..base.clone()
};
let base_weight = CandidateFilter::candidate_weight(&base, 0, 1, 1);
let dead_end_weight = CandidateFilter::candidate_weight(&dead_end, 0, 1, 1);
assert_eq!(dead_end_weight - base_weight, DEAD_WEIGHT_FACTOR);
}
#[test]
fn minimum_weight() {
let c = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 5.0,
lesson_score: 5.0,
course_score: 5.0,
num_trials: 1000,
frequency: 1000,
..Default::default()
};
assert_eq!(
CandidateFilter::candidate_weight(&c, 100, 1000, 1000),
MIN_WEIGHT
);
}
#[test]
fn higher_velocity_more_weight() {
let base = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 2.0,
score_velocity: Some(1.0),
..Default::default()
};
let low_velocity = Candidate {
score_velocity: Some(0.5),
..base.clone()
};
assert!(
CandidateFilter::candidate_weight(&base, 0, 1, 1)
> CandidateFilter::candidate_weight(&low_velocity, 0, 1, 1)
);
}
#[test]
fn negative_velocity_boosts_weight() {
let base = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 2.0,
..Default::default()
};
let negative = Candidate {
score_velocity: Some(-1.0),
..base.clone()
};
assert!(
CandidateFilter::candidate_weight(&negative, 0, 1, 1)
> CandidateFilter::candidate_weight(&base, 0, 1, 1)
);
}
#[test]
fn stagnant_low_score_gets_bonus() {
let base = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 2.0,
..Default::default()
};
let stagnant = Candidate {
score_velocity: Some(0.05),
..base.clone()
};
let base_weight = CandidateFilter::candidate_weight(&base, 0, 1, 1);
let stagnant_weight = CandidateFilter::candidate_weight(&stagnant, 0, 1, 1);
assert!(stagnant_weight > base_weight + STAGNANT_VELOCITY_WEIGHT - 100.0);
}
#[test]
fn stagnant_high_score_gets_penalty() {
let base = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 4.5,
..Default::default()
};
let stagnant = Candidate {
score_velocity: Some(0.05),
..base.clone()
};
assert!(
CandidateFilter::candidate_weight(&stagnant, 0, 1, 1)
< CandidateFilter::candidate_weight(&base, 0, 1, 1)
);
}
#[test]
fn non_stagnant_velocity_no_bonus_or_penalty() {
let base = Candidate {
exercise_id: Ustr::from("exercise1"),
lesson_id: Ustr::from("lesson1"),
course_id: Ustr::from("course1"),
exercise_score: 2.0,
..Default::default()
};
let active = Candidate {
score_velocity: Some(0.5),
..base.clone()
};
let base_weight = CandidateFilter::candidate_weight(&base, 0, 1, 1);
let active_weight = CandidateFilter::candidate_weight(&active, 0, 1, 1);
let expected_diff = VELOCITY_WEIGHT_FACTOR * 0.5;
assert!((active_weight - base_weight - expected_diff).abs() < 1.0);
}
}