Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Easy days: revisited #3661

Merged
merged 3 commits into from
Jan 8, 2025
Merged
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 87 additions & 52 deletions rslib/src/scheduler/states/load_balancer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ const MAX_LOAD_BALANCE_INTERVAL: usize = 90;
// problems
const LOAD_BALANCE_DAYS: usize = (MAX_LOAD_BALANCE_INTERVAL as f32 * 1.1) as usize;
const SIBLING_PENALTY: f32 = 0.001;
// this is a non-zero value so if all days are minimum, the load balancer will
// proceed as normal
const EASY_DAYS_MINIMUM_LOAD: f32 = 0.0001;
const EASY_DAYS_NORMAL_LOAD: f32 = 1.0;
const EASY_DAYS_REDUCED_THRESHOLD: f32 = 0.5;

#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum EasyDay {
Minimum,
Reduced,
Normal,
}

impl From<f32> for EasyDay {
fn from(other: f32) -> EasyDay {
match other {
1.0 => EasyDay::Normal,
0.0 => EasyDay::Minimum,
_ => EasyDay::Reduced,
}
}
}

#[derive(Debug, Default)]
struct LoadBalancerDay {
Expand Down Expand Up @@ -84,7 +106,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<DeckConfigId, [LoadBalancerDay; LOAD_BALANCE_DAYS]>,
easy_days_percentages_by_preset: HashMap<DeckConfigId, [f32; 7]>,
easy_days_percentages_by_preset: HashMap<DeckConfigId, [EasyDay; 7]>,
next_day_at: TimestampSecs,
}

Expand Down Expand Up @@ -133,21 +155,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::<Result<HashMap<_, [EasyDay; 7]>, AnkiError>>()?;

Ok(LoadBalancer {
days_by_preset,
Expand Down Expand Up @@ -220,22 +247,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::<Vec<_>>();
check_review_distribution(&review_counts, &percentages)
};
.map(|&weekday| match easy_days_load[weekday] {
EasyDay::Normal => EASY_DAYS_NORMAL_LOAD,
EasyDay::Minimum => EASY_DAYS_MINIMUM_LOAD,
EasyDay::Reduced => EASY_DAYS_REDUCED_THRESHOLD,
})
.sum();
let easy_days_modifier = weekdays
.iter()
.zip(review_counts.iter())
.map(|(&weekday, &review_count)| match easy_days_load[weekday] {
EasyDay::Normal => EASY_DAYS_NORMAL_LOAD,
EasyDay::Minimum => EASY_DAYS_MINIMUM_LOAD,
EasyDay::Reduced => {
let other_days_review_total = (total_review_count - review_count) as f32;
let other_days_percent_total = total_percents - EASY_DAYS_REDUCED_THRESHOLD;
let normalized_count = review_count as f32 / EASY_DAYS_REDUCED_THRESHOLD;
let reduced_day_threshold = other_days_review_total / other_days_percent_total;
if normalized_count > reduced_day_threshold {
EASY_DAYS_MINIMUM_LOAD
} else {
EASY_DAYS_NORMAL_LOAD
}
}
})
.collect::<Vec<_>>();

// calculate params for each day
let intervals_and_params = interval_days
Expand All @@ -259,14 +310,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::<Vec<_>>();

Expand Down Expand Up @@ -303,19 +354,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<f32> {
if percentages.iter().sum::<f32>() == 0.0 {
return vec![1.0; actual_reviews.len()];
}
let total_actual = actual_reviews.iter().sum::<usize>() as f32;
let expected_distribution: Vec<f32> = percentages
.iter()
.map(|&p| p * (total_actual / percentages.iter().sum::<f32>()))
.collect();
expected_distribution
.iter()
.zip(actual_reviews.iter())
.map(|(&e, &a)| (e - a as f32).max(0.0))
.collect()
}