From aaf8b4dddb90086a08d1b706e1d56266dac478cd Mon Sep 17 00:00:00 2001 From: Jake Probst Date: Wed, 8 Jan 2025 02:56:27 -0800 Subject: [PATCH] Easy days: revisited (#3661) * new easy days algorithm * take easy day percent totals in account when determining reduced scheduling * Use variant method to avoid repeated mapping to a constant (dae) It was probably not worth the time I took to change this ^_^; --- rslib/src/scheduler/states/load_balancer.rs | 146 +++++++++++++------- 1 file changed, 94 insertions(+), 52 deletions(-) diff --git a/rslib/src/scheduler/states/load_balancer.rs b/rslib/src/scheduler/states/load_balancer.rs index e05feb1e0..77c34dae6 100644 --- a/rslib/src/scheduler/states/load_balancer.rs +++ b/rslib/src/scheduler/states/load_balancer.rs @@ -26,6 +26,35 @@ const MAX_LOAD_BALANCE_INTERVAL: usize = 90; const LOAD_BALANCE_DAYS: usize = (MAX_LOAD_BALANCE_INTERVAL as f32 * 1.1) as usize; const SIBLING_PENALTY: f32 = 0.001; +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum EasyDay { + Minimum, + Reduced, + Normal, +} + +impl From for EasyDay { + fn from(other: f32) -> EasyDay { + match other { + 1.0 => EasyDay::Normal, + 0.0 => EasyDay::Minimum, + _ => EasyDay::Reduced, + } + } +} + +impl EasyDay { + fn load_modifier(&self) -> f32 { + match self { + // this is a non-zero value so if all days are minimum, the load balancer will + // proceed as normal + EasyDay::Minimum => 0.0001, + EasyDay::Reduced => 0.5, + EasyDay::Normal => 1.0, + } + } +} + #[derive(Debug, Default)] struct LoadBalancerDay { cards: Vec<(CardId, NoteId)>, @@ -84,7 +113,7 @@ pub struct LoadBalancer { /// Load balancer operates at the preset level, it only counts /// cards in the same preset as the card being balanced. days_by_preset: HashMap, - easy_days_percentages_by_preset: HashMap, + easy_days_percentages_by_preset: HashMap, next_day_at: TimestampSecs, } @@ -133,21 +162,26 @@ impl LoadBalancer { ); let configs = storage.get_deck_config_map()?; - let mut easy_days_percentages_by_preset = HashMap::with_capacity(configs.len()); - for (dcid, conf) in configs { - let easy_days_percentages = if conf.inner.easy_days_percentages.is_empty() { - [1.0; 7] - } else { - conf.inner.easy_days_percentages.try_into().map_err(|_| { - AnkiError::from(InvalidInputError { - message: "expected 7 days".into(), - source: None, - backtrace: None, - }) - })? - }; - easy_days_percentages_by_preset.insert(dcid, easy_days_percentages); - } + let easy_days_percentages_by_preset = configs + .into_iter() + .map(|(dcid, conf)| { + let easy_days_percentages: [EasyDay; 7] = + if conf.inner.easy_days_percentages.is_empty() { + [EasyDay::Normal; 7] + } else { + TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages) + .map_err(|_| { + AnkiError::from(InvalidInputError { + message: "expected 7 days".into(), + source: None, + backtrace: None, + }) + })? + .map(EasyDay::from) + }; + Ok((dcid, easy_days_percentages)) + }) + .collect::, AnkiError>>()?; Ok(LoadBalancer { days_by_preset, @@ -220,22 +254,46 @@ impl LoadBalancer { }) .unzip(); - let easy_days_percentages = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; - // check if easy days are in effect by seeing if all days have the same - // configuration. If all days are the same, we can skip out on calculating - // the distribution - let easy_days_are_all_the_same = easy_days_percentages + // Determine which days to schedule to with respect to Easy Day settings + // If a day is Normal, it will always be an option to schedule to + // If a day is Minimum, it will almost never be an option to schedule to + // If a day is Reduced, it will look at the amount of cards due in the fuzz + // range to determine if scheduling a card on that day would put it + // above the reduced threshold or not. + // the resulting easy_days_modifier will be a vec of 0.0s and 1.0s, to be + // used when calculating the day's weight. This turns the day on or off. + // Note that it does not actually set it to 0.0, but a small + // 0.0-ish number (see EASY_DAYS_MINIMUM_LOAD) to remove the need to + // handle a handful of zero-related corner cases. + let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?; + let total_review_count: usize = review_counts.iter().sum(); + let total_percents: f32 = weekdays .iter() - .all(|day| easy_days_percentages[0] == *day); - let expected_distribution = if easy_days_are_all_the_same { - vec![1.0; weekdays.len()] - } else { - let percentages = weekdays - .iter() - .map(|&wd| easy_days_percentages[wd]) - .collect::>(); - check_review_distribution(&review_counts, &percentages) - }; + .map(|&weekday| easy_days_load[weekday].load_modifier()) + .sum(); + let easy_days_modifier = weekdays + .iter() + .zip(review_counts.iter()) + .map(|(&weekday, &review_count)| { + let day = match easy_days_load[weekday] { + EasyDay::Reduced => { + const HALF: f32 = 0.5; + let other_days_review_total = (total_review_count - review_count) as f32; + let other_days_percent_total = total_percents - HALF; + let normalized_count = review_count as f32 / HALF; + let reduced_day_threshold = + other_days_review_total / other_days_percent_total; + if normalized_count > reduced_day_threshold { + EasyDay::Minimum + } else { + EasyDay::Normal + } + } + other => other, + }; + day.load_modifier() + }) + .collect::>(); // calculate params for each day let intervals_and_params = interval_days @@ -259,14 +317,14 @@ impl LoadBalancer { let card_count_weight = (1.0 / card_count as f32).powi(2); let card_interval_weight = 1.0 / target_interval as f32; - card_count_weight * card_interval_weight * sibling_multiplier + card_count_weight + * card_interval_weight + * sibling_multiplier + * easy_days_modifier[interval_index] } }; - ( - target_interval, - weight * expected_distribution[interval_index], - ) + (target_interval, weight) }) .collect::>(); @@ -303,19 +361,3 @@ fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize { .unwrap(); target_datetime.weekday().num_days_from_monday() as usize } - -fn check_review_distribution(actual_reviews: &[usize], percentages: &[f32]) -> Vec { - if percentages.iter().sum::() == 0.0 { - return vec![1.0; actual_reviews.len()]; - } - let total_actual = actual_reviews.iter().sum::() as f32; - let expected_distribution: Vec = percentages - .iter() - .map(|&p| p * (total_actual / percentages.iter().sum::())) - .collect(); - expected_distribution - .iter() - .zip(actual_reviews.iter()) - .map(|(&e, &a)| (e - a as f32).max(0.0)) - .collect() -}