mirror of
https://github.com/ankitects/anki.git
synced 2025-11-10 14:47:12 -05:00
Feat/support load balance and easy days in rescheduling (#3815)
* Feat/support load balance and easy days in rescheduling * ./ninja fix:minilints * apply clippy * reuse calculate_easy_days_modifiers() * consider LoadBalancerEnabled * move calculate_easy_days_modifiers out of struct * improve naming & add comments * apply clippy * reschedule if easy days settings are changed * Minor simplification * refactor to share code between load balancer and rescheduler * intervals_and_params -> intervals_and_weights * find_best_interval -> select_weighted_interval * cargo clippy * add warning about easyDaysChanged * compare arrays directly * Don't show warning if fsrs+rescehdule is already enabled --------- Co-authored-by: Damien Elmes <gpg@ankiweb.net> Co-authored-by: Jake Probst <jake.probst@gmail.com>
This commit is contained in:
parent
8fc822a6e7
commit
59e143ec25
12 changed files with 423 additions and 105 deletions
|
|
@ -293,6 +293,7 @@ deck-config-easy-days-normal = Normal
|
||||||
deck-config-easy-days-reduced = Reduced
|
deck-config-easy-days-reduced = Reduced
|
||||||
deck-config-easy-days-minimum = Minimum
|
deck-config-easy-days-minimum = Minimum
|
||||||
deck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'.
|
deck-config-easy-days-no-normal-days = At least one day should be set to '{ deck-config-easy-days-normal }'.
|
||||||
|
deck-config-easy-days-change = Existing reviews will not be rescheduled unless '{ deck-config-reschedule-cards-on-change }' is enabled in the FSRS options.
|
||||||
|
|
||||||
## Adding/renaming
|
## Adding/renaming
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,7 @@ impl Collection {
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let previous_params = previous_config.map(|c| c.fsrs_params());
|
let previous_params = previous_config.map(|c| c.fsrs_params());
|
||||||
let previous_retention = previous_config.map(|c| c.inner.desired_retention);
|
let previous_retention = previous_config.map(|c| c.inner.desired_retention);
|
||||||
|
let previous_easy_days = previous_config.map(|c| &c.inner.easy_days_percentages);
|
||||||
|
|
||||||
// if a selected (sub)deck, or its old config was removed, update deck to point
|
// if a selected (sub)deck, or its old config was removed, update deck to point
|
||||||
// to new config
|
// to new config
|
||||||
|
|
@ -242,9 +243,11 @@ impl Collection {
|
||||||
// if params differ, memory state needs to be recomputed
|
// if params differ, memory state needs to be recomputed
|
||||||
let current_params = current_config.map(|c| c.fsrs_params());
|
let current_params = current_config.map(|c| c.fsrs_params());
|
||||||
let current_retention = current_config.map(|c| c.inner.desired_retention);
|
let current_retention = current_config.map(|c| c.inner.desired_retention);
|
||||||
|
let current_easy_days = current_config.map(|c| &c.inner.easy_days_percentages);
|
||||||
if fsrs_toggled
|
if fsrs_toggled
|
||||||
|| previous_params != current_params
|
|| previous_params != current_params
|
||||||
|| previous_retention != current_retention
|
|| previous_retention != current_retention
|
||||||
|
|| (req.fsrs_reschedule && previous_easy_days != current_easy_days)
|
||||||
{
|
{
|
||||||
decks_needing_memory_recompute
|
decks_needing_memory_recompute
|
||||||
.entry(current_config_id)
|
.entry(current_config_id)
|
||||||
|
|
|
||||||
|
|
@ -608,7 +608,7 @@ impl Card {
|
||||||
/// Return a consistent seed for a given card at a given number of reps.
|
/// Return a consistent seed for a given card at a given number of reps.
|
||||||
/// If for_reschedule is true, we use card.reps - 1 to match the previous
|
/// If for_reschedule is true, we use card.reps - 1 to match the previous
|
||||||
/// review.
|
/// review.
|
||||||
fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option<u64> {
|
pub(crate) fn get_fuzz_seed(card: &Card, for_reschedule: bool) -> Option<u64> {
|
||||||
let reps = if for_reschedule {
|
let reps = if for_reschedule {
|
||||||
card.reps.saturating_sub(1)
|
card.reps.saturating_sub(1)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ use fsrs::FSRS;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
|
||||||
use super::params::ignore_revlogs_before_ms_from_config;
|
use super::params::ignore_revlogs_before_ms_from_config;
|
||||||
|
use super::rescheduler::Rescheduler;
|
||||||
use crate::card::CardType;
|
use crate::card::CardType;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use crate::revlog::RevlogEntry;
|
use crate::revlog::RevlogEntry;
|
||||||
use crate::revlog::RevlogReviewKind;
|
use crate::revlog::RevlogReviewKind;
|
||||||
|
use crate::scheduler::answering::get_fuzz_seed;
|
||||||
use crate::scheduler::fsrs::params::reviews_for_fsrs;
|
use crate::scheduler::fsrs::params::reviews_for_fsrs;
|
||||||
use crate::scheduler::fsrs::params::Params;
|
use crate::scheduler::fsrs::params::Params;
|
||||||
use crate::scheduler::states::fuzz::with_review_fuzz;
|
use crate::scheduler::states::fuzz::with_review_fuzz;
|
||||||
|
|
@ -69,6 +71,10 @@ impl Collection {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let mut rescheduler = self
|
||||||
|
.get_config_bool(BoolKey::LoadBalancerEnabled)
|
||||||
|
.then(|| Rescheduler::new(self))
|
||||||
|
.transpose()?;
|
||||||
let fsrs = FSRS::new(req.as_ref().map(|w| &w.params[..]).or(Some([].as_slice())))?;
|
let fsrs = FSRS::new(req.as_ref().map(|w| &w.params[..]).or(Some([].as_slice())))?;
|
||||||
let historical_retention = req.as_ref().map(|w| w.historical_retention);
|
let historical_retention = req.as_ref().map(|w| w.historical_retention);
|
||||||
let items = fsrs_items_for_memory_states(
|
let items = fsrs_items_for_memory_states(
|
||||||
|
|
@ -99,6 +105,10 @@ impl Collection {
|
||||||
if let Some(state) = &card.memory_state {
|
if let Some(state) = &card.memory_state {
|
||||||
// or in (re)learning
|
// or in (re)learning
|
||||||
if card.ctype == CardType::Review {
|
if card.ctype == CardType::Review {
|
||||||
|
let deck = self
|
||||||
|
.get_deck(card.original_or_current_deck_id())?
|
||||||
|
.or_not_found(card.original_or_current_deck_id())?;
|
||||||
|
let deckconfig_id = deck.config_id().unwrap();
|
||||||
// reschedule it
|
// reschedule it
|
||||||
let original_interval = card.interval;
|
let original_interval = card.interval;
|
||||||
let interval = fsrs.next_interval(
|
let interval = fsrs.next_interval(
|
||||||
|
|
@ -106,19 +116,41 @@ impl Collection {
|
||||||
card.desired_retention.unwrap(),
|
card.desired_retention.unwrap(),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
card.interval = with_review_fuzz(
|
card.interval = rescheduler
|
||||||
|
.as_mut()
|
||||||
|
.and_then(|r| {
|
||||||
|
r.find_interval(
|
||||||
|
interval,
|
||||||
|
1,
|
||||||
|
req.max_interval,
|
||||||
|
days_elapsed as u32,
|
||||||
|
deckconfig_id,
|
||||||
|
get_fuzz_seed(&card, true),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
with_review_fuzz(
|
||||||
card.get_fuzz_factor(true),
|
card.get_fuzz_factor(true),
|
||||||
interval,
|
interval,
|
||||||
1,
|
1,
|
||||||
req.max_interval,
|
req.max_interval,
|
||||||
);
|
)
|
||||||
|
});
|
||||||
let due = if card.original_due != 0 {
|
let due = if card.original_due != 0 {
|
||||||
&mut card.original_due
|
&mut card.original_due
|
||||||
} else {
|
} else {
|
||||||
&mut card.due
|
&mut card.due
|
||||||
};
|
};
|
||||||
*due = (timing.days_elapsed as i32) - days_elapsed
|
let new_due = (timing.days_elapsed as i32) - days_elapsed
|
||||||
+ card.interval as i32;
|
+ card.interval as i32;
|
||||||
|
if let Some(rescheduler) = &mut rescheduler {
|
||||||
|
rescheduler.update_due_cnt_per_day(
|
||||||
|
*due,
|
||||||
|
new_due,
|
||||||
|
deckconfig_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
*due = new_due;
|
||||||
// Add a rescheduled revlog entry if the last entry wasn't
|
// Add a rescheduled revlog entry if the last entry wasn't
|
||||||
// rescheduled
|
// rescheduled
|
||||||
if !last_info.last_revlog_is_rescheduled {
|
if !last_info.last_revlog_is_rescheduled {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
mod error;
|
mod error;
|
||||||
pub mod memory_state;
|
pub mod memory_state;
|
||||||
pub mod params;
|
pub mod params;
|
||||||
|
pub mod rescheduler;
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
pub mod simulator;
|
pub mod simulator;
|
||||||
pub mod try_collect;
|
pub mod try_collect;
|
||||||
|
|
|
||||||
182
rslib/src/scheduler/fsrs/rescheduler.rs
Normal file
182
rslib/src/scheduler/fsrs/rescheduler.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Datelike;
|
||||||
|
|
||||||
|
use crate::prelude::*;
|
||||||
|
use crate::scheduler::states::fuzz::constrained_fuzz_bounds;
|
||||||
|
use crate::scheduler::states::load_balancer::build_easy_days_percentages;
|
||||||
|
use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers;
|
||||||
|
use crate::scheduler::states::load_balancer::select_weighted_interval;
|
||||||
|
use crate::scheduler::states::load_balancer::EasyDay;
|
||||||
|
use crate::scheduler::states::load_balancer::LoadBalancerInterval;
|
||||||
|
|
||||||
|
pub struct Rescheduler {
|
||||||
|
today: i32,
|
||||||
|
next_day_at: TimestampSecs,
|
||||||
|
due_cnt_per_day_by_preset: HashMap<DeckConfigId, HashMap<i32, usize>>,
|
||||||
|
due_today_by_preset: HashMap<DeckConfigId, usize>,
|
||||||
|
reviewed_today_by_preset: HashMap<DeckConfigId, usize>,
|
||||||
|
easy_days_percentages_by_preset: HashMap<DeckConfigId, [EasyDay; 7]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Rescheduler {
|
||||||
|
pub fn new(col: &mut Collection) -> Result<Self> {
|
||||||
|
let timing = col.timing_today()?;
|
||||||
|
let deck_stats = col.storage.get_deck_due_counts()?;
|
||||||
|
let deck_map = col.storage.get_decks_map()?;
|
||||||
|
let did_to_dcid = deck_map
|
||||||
|
.values()
|
||||||
|
.filter_map(|deck| Some((deck.id, deck.config_id()?)))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
|
||||||
|
let mut due_cnt_per_day_by_preset: HashMap<DeckConfigId, HashMap<i32, usize>> =
|
||||||
|
HashMap::new();
|
||||||
|
for (did, due_date, count) in deck_stats {
|
||||||
|
let deck_config_id = did_to_dcid[&did];
|
||||||
|
due_cnt_per_day_by_preset
|
||||||
|
.entry(deck_config_id)
|
||||||
|
.or_default()
|
||||||
|
.entry(due_date)
|
||||||
|
.and_modify(|e| *e += count)
|
||||||
|
.or_insert(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
let today = timing.days_elapsed as i32;
|
||||||
|
let due_today_by_preset = due_cnt_per_day_by_preset
|
||||||
|
.iter()
|
||||||
|
.map(|(deck_config_id, config_dues)| {
|
||||||
|
let due_today = config_dues
|
||||||
|
.iter()
|
||||||
|
.filter(|(&due, _)| due <= today)
|
||||||
|
.map(|(_, &count)| count)
|
||||||
|
.sum();
|
||||||
|
(*deck_config_id, due_today)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let next_day_at = timing.next_day_at;
|
||||||
|
let reviewed_stats = col.storage.studied_today_by_deck(timing.next_day_at)?;
|
||||||
|
let mut reviewed_today_by_preset: HashMap<DeckConfigId, usize> = HashMap::new();
|
||||||
|
for (did, count) in reviewed_stats {
|
||||||
|
if let Some(&deck_config_id) = &did_to_dcid.get(&did) {
|
||||||
|
*reviewed_today_by_preset.entry(deck_config_id).or_default() += count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let easy_days_percentages_by_preset =
|
||||||
|
build_easy_days_percentages(col.storage.get_deck_config_map()?)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
today,
|
||||||
|
next_day_at,
|
||||||
|
due_cnt_per_day_by_preset,
|
||||||
|
due_today_by_preset,
|
||||||
|
reviewed_today_by_preset,
|
||||||
|
easy_days_percentages_by_preset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_due_cnt_per_day(
|
||||||
|
&mut self,
|
||||||
|
due_before: i32,
|
||||||
|
due_after: i32,
|
||||||
|
deck_config_id: DeckConfigId,
|
||||||
|
) {
|
||||||
|
if let Some(counts) = self.due_cnt_per_day_by_preset.get_mut(&deck_config_id) {
|
||||||
|
if let Some(count) = counts.get_mut(&due_before) {
|
||||||
|
*count -= 1;
|
||||||
|
}
|
||||||
|
*counts.entry(due_after).or_default() += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if due_before <= self.today && due_after > self.today {
|
||||||
|
if let Some(count) = self.due_today_by_preset.get_mut(&deck_config_id) {
|
||||||
|
*count -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if due_before > self.today && due_after <= self.today {
|
||||||
|
*self.due_today_by_preset.entry(deck_config_id).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn due_today(&self, deck_config_id: DeckConfigId) -> usize {
|
||||||
|
*self.due_today_by_preset.get(&deck_config_id).unwrap_or(&0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reviewed_today(&self, deck_config_id: DeckConfigId) -> usize {
|
||||||
|
*self
|
||||||
|
.reviewed_today_by_preset
|
||||||
|
.get(&deck_config_id)
|
||||||
|
.unwrap_or(&0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_interval(
|
||||||
|
&self,
|
||||||
|
interval: f32,
|
||||||
|
minimum: u32,
|
||||||
|
maximum: u32,
|
||||||
|
days_elapsed: u32,
|
||||||
|
deckconfig_id: DeckConfigId,
|
||||||
|
fuzz_seed: Option<u64>,
|
||||||
|
) -> Option<u32> {
|
||||||
|
let (before_days, after_days) = constrained_fuzz_bounds(interval, minimum, maximum);
|
||||||
|
|
||||||
|
// Don't reschedule the card when it's overdue
|
||||||
|
if after_days < days_elapsed {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Don't reschedule the card to the past
|
||||||
|
let before_days = before_days.max(days_elapsed);
|
||||||
|
|
||||||
|
// Generate possible intervals and their review counts
|
||||||
|
let possible_intervals: Vec<u32> = (before_days..=after_days).collect();
|
||||||
|
let review_counts: Vec<usize> = possible_intervals
|
||||||
|
.iter()
|
||||||
|
.map(|&ivl| {
|
||||||
|
if ivl > days_elapsed {
|
||||||
|
let check_due = self.today + ivl as i32 - days_elapsed as i32;
|
||||||
|
*self
|
||||||
|
.due_cnt_per_day_by_preset
|
||||||
|
.get(&deckconfig_id)
|
||||||
|
.and_then(|counts| counts.get(&check_due))
|
||||||
|
.unwrap_or(&0)
|
||||||
|
} else {
|
||||||
|
// today's workload is the sum of backlogs, cards due today and cards reviewed
|
||||||
|
// today
|
||||||
|
self.due_today(deckconfig_id) + self.reviewed_today(deckconfig_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let weekdays: Vec<usize> = possible_intervals
|
||||||
|
.iter()
|
||||||
|
.map(|&ivl| {
|
||||||
|
self.next_day_at
|
||||||
|
.adding_secs(days_elapsed as i64 * -86400)
|
||||||
|
.adding_secs((ivl - 1) as i64 * 86400)
|
||||||
|
.local_datetime()
|
||||||
|
.unwrap()
|
||||||
|
.weekday()
|
||||||
|
.num_days_from_monday() as usize
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;
|
||||||
|
let easy_days_modifier =
|
||||||
|
calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);
|
||||||
|
|
||||||
|
let intervals =
|
||||||
|
possible_intervals
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(interval_index, &target_interval)| LoadBalancerInterval {
|
||||||
|
target_interval,
|
||||||
|
review_count: review_counts[interval_index],
|
||||||
|
sibling_modifier: 1.0,
|
||||||
|
easy_days_modifier: easy_days_modifier[interval_index],
|
||||||
|
});
|
||||||
|
|
||||||
|
select_weighted_interval(intervals, fuzz_seed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ impl From<f32> for EasyDay {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EasyDay {
|
impl EasyDay {
|
||||||
fn load_modifier(&self) -> f32 {
|
pub(crate) fn load_modifier(&self) -> f32 {
|
||||||
match self {
|
match self {
|
||||||
// this is a non-zero value so if all days are minimum, the load balancer will
|
// this is a non-zero value so if all days are minimum, the load balancer will
|
||||||
// proceed as normal
|
// proceed as normal
|
||||||
|
|
@ -161,27 +161,7 @@ impl LoadBalancer {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let configs = storage.get_deck_config_map()?;
|
let configs = storage.get_deck_config_map()?;
|
||||||
|
let easy_days_percentages_by_preset = build_easy_days_percentages(configs)?;
|
||||||
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 {
|
Ok(LoadBalancer {
|
||||||
days_by_preset,
|
days_by_preset,
|
||||||
|
|
@ -254,87 +234,30 @@ impl LoadBalancer {
|
||||||
})
|
})
|
||||||
.unzip();
|
.unzip();
|
||||||
|
|
||||||
// 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 easy_days_load = self.easy_days_percentages_by_preset.get(&deckconfig_id)?;
|
||||||
let total_review_count: usize = review_counts.iter().sum();
|
let easy_days_modifier =
|
||||||
let total_percents: f32 = weekdays
|
calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);
|
||||||
.iter()
|
|
||||||
.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::<Vec<_>>();
|
|
||||||
|
|
||||||
// calculate params for each day
|
let intervals = interval_days
|
||||||
let intervals_and_params = interval_days
|
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(interval_index, interval_day)| {
|
.map(|(interval_index, interval_day)| {
|
||||||
let target_interval = interval_index as u32 + before_days;
|
LoadBalancerInterval {
|
||||||
|
target_interval: interval_index as u32 + before_days,
|
||||||
|
review_count: review_counts[interval_index],
|
||||||
// if there is a sibling on this day, give it a very low weight
|
// if there is a sibling on this day, give it a very low weight
|
||||||
let sibling_multiplier = note_id
|
sibling_modifier: note_id
|
||||||
.and_then(|note_id| {
|
.and_then(|note_id| {
|
||||||
interval_day
|
interval_day
|
||||||
.has_sibling(¬e_id)
|
.has_sibling(¬e_id)
|
||||||
.then_some(SIBLING_PENALTY)
|
.then_some(SIBLING_PENALTY)
|
||||||
})
|
})
|
||||||
.unwrap_or(1.0);
|
.unwrap_or(1.0),
|
||||||
|
easy_days_modifier: easy_days_modifier[interval_index],
|
||||||
let weight = match review_counts[interval_index] {
|
|
||||||
0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight
|
|
||||||
card_count => {
|
|
||||||
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
|
|
||||||
* easy_days_modifier[interval_index]
|
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
(target_interval, weight)
|
select_weighted_interval(intervals, fuzz_seed)
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
let mut rng = StdRng::seed_from_u64(fuzz_seed?);
|
|
||||||
|
|
||||||
let weighted_intervals =
|
|
||||||
WeightedIndex::new(intervals_and_params.iter().map(|k| k.1)).ok()?;
|
|
||||||
|
|
||||||
let selected_interval_index = weighted_intervals.sample(&mut rng);
|
|
||||||
Some(intervals_and_params[selected_interval_index].0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) {
|
pub fn add_card(&mut self, cid: CardId, nid: NoteId, dcid: DeckConfigId, interval: u32) {
|
||||||
|
|
@ -354,6 +277,118 @@ impl LoadBalancer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a mapping of deck config IDs to their easy days settings.
|
||||||
|
/// For each deck config, maintains an array of 7 EasyDay values representing
|
||||||
|
/// the load modifier for each day of the week.
|
||||||
|
pub(crate) fn build_easy_days_percentages(
|
||||||
|
configs: HashMap<DeckConfigId, DeckConfig>,
|
||||||
|
) -> Result<HashMap<DeckConfigId, [EasyDay; 7]>> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
pub(crate) fn calculate_easy_days_modifiers(
|
||||||
|
easy_days_load: &[EasyDay; 7],
|
||||||
|
weekdays: &[usize],
|
||||||
|
review_counts: &[usize],
|
||||||
|
) -> Vec<f32> {
|
||||||
|
let total_review_count: usize = review_counts.iter().sum();
|
||||||
|
let total_percents: f32 = weekdays
|
||||||
|
.iter()
|
||||||
|
.map(|&weekday| easy_days_load[weekday].load_modifier())
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoadBalancerInterval {
|
||||||
|
pub target_interval: u32,
|
||||||
|
pub review_count: usize,
|
||||||
|
pub sibling_modifier: f32,
|
||||||
|
pub easy_days_modifier: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_weighted_interval(
|
||||||
|
intervals: impl Iterator<Item = LoadBalancerInterval>,
|
||||||
|
fuzz_seed: Option<u64>,
|
||||||
|
) -> Option<u32> {
|
||||||
|
let intervals_and_weights = intervals
|
||||||
|
.map(|interval| {
|
||||||
|
let weight = match interval.review_count {
|
||||||
|
0 => 1.0, // if theres no cards due on this day, give it the full 1.0 weight
|
||||||
|
card_count => {
|
||||||
|
let card_count_weight = (1.0 / card_count as f32).powi(2);
|
||||||
|
let card_interval_weight = 1.0 / interval.target_interval as f32;
|
||||||
|
|
||||||
|
card_count_weight
|
||||||
|
* card_interval_weight
|
||||||
|
* interval.sibling_modifier
|
||||||
|
* interval.easy_days_modifier
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
(interval.target_interval, weight)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut rng = StdRng::seed_from_u64(fuzz_seed?);
|
||||||
|
|
||||||
|
let weighted_intervals = WeightedIndex::new(intervals_and_weights.iter().map(|k| k.1)).ok()?;
|
||||||
|
|
||||||
|
let selected_interval_index = weighted_intervals.sample(&mut rng);
|
||||||
|
Some(intervals_and_weights[selected_interval_index].0)
|
||||||
|
}
|
||||||
|
|
||||||
fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
|
fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
|
||||||
let target_datetime = next_day_at
|
let target_datetime = next_day_at
|
||||||
.adding_secs((interval - 1) as i64 * 86400)
|
.adding_secs((interval - 1) as i64 * 86400)
|
||||||
|
|
|
||||||
14
rslib/src/storage/card/deck_due_counts.sql
Normal file
14
rslib/src/storage/card/deck_due_counts.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
SELECT CASE
|
||||||
|
WHEN odid == 0 THEN did
|
||||||
|
ELSE odid
|
||||||
|
END AS original_did,
|
||||||
|
CASE
|
||||||
|
WHEN odid == 0 THEN due
|
||||||
|
ELSE odue
|
||||||
|
END AS true_due,
|
||||||
|
COUNT() AS COUNT
|
||||||
|
FROM cards
|
||||||
|
WHERE type = 2
|
||||||
|
AND queue != -1
|
||||||
|
GROUP BY original_did,
|
||||||
|
true_due
|
||||||
|
|
@ -624,6 +624,15 @@ impl super::SqliteStorage {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn get_deck_due_counts(&self) -> Result<Vec<(DeckId, i32, usize)>> {
|
||||||
|
self.db
|
||||||
|
.prepare(include_str!("deck_due_counts.sql"))?
|
||||||
|
.query_and_then([], |row| -> Result<_> {
|
||||||
|
Ok((DeckId(row.get(0)?), row.get(1)?, row.get(2)?))
|
||||||
|
})?
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result<CongratsInfo> {
|
pub(crate) fn congrats_info(&self, current: &Deck, today: u32) -> Result<CongratsInfo> {
|
||||||
// NOTE: this line is obsolete in v3 as it's run on queue build, but kept to
|
// NOTE: this line is obsolete in v3 as it's run on queue build, but kept to
|
||||||
// prevent errors for v1/v2 users before they upgrade
|
// prevent errors for v1/v2 users before they upgrade
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,18 @@ impl SqliteStorage {
|
||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn studied_today_by_deck(
|
||||||
|
&self,
|
||||||
|
day_cutoff: TimestampSecs,
|
||||||
|
) -> Result<Vec<(DeckId, usize)>> {
|
||||||
|
let start = day_cutoff.adding_secs(-86_400).as_millis();
|
||||||
|
self.db
|
||||||
|
.prepare_cached(include_str!("studied_today_by_deck.sql"))?
|
||||||
|
.query_and_then([start.0], |row| -> Result<_> {
|
||||||
|
Ok((DeckId(row.get(0)?), row.get(1)?))
|
||||||
|
})?
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> {
|
pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> {
|
||||||
self.db
|
self.db
|
||||||
.execute_batch(include_str!("v2_upgrade.sql"))
|
.execute_batch(include_str!("v2_upgrade.sql"))
|
||||||
|
|
|
||||||
14
rslib/src/storage/revlog/studied_today_by_deck.sql
Normal file
14
rslib/src/storage/revlog/studied_today_by_deck.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
SELECT CASE
|
||||||
|
WHEN c.odid == 0 THEN c.did
|
||||||
|
ELSE c.odid
|
||||||
|
END AS original_did,
|
||||||
|
COUNT(DISTINCT r.cid) AS cnt
|
||||||
|
FROM revlog AS r
|
||||||
|
JOIN cards AS c ON r.cid = c.id
|
||||||
|
WHERE r.id > ?
|
||||||
|
AND r.ease > 0
|
||||||
|
AND (
|
||||||
|
r.type < 3
|
||||||
|
OR r.factor != 0
|
||||||
|
)
|
||||||
|
GROUP BY original_did
|
||||||
|
|
@ -13,17 +13,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let state: DeckOptionsState;
|
export let state: DeckOptionsState;
|
||||||
export let api: Record<string, never>;
|
export let api: Record<string, never>;
|
||||||
|
|
||||||
|
const fsrsEnabled = state.fsrs;
|
||||||
|
const reschedule = state.fsrsReschedule;
|
||||||
const config = state.currentConfig;
|
const config = state.currentConfig;
|
||||||
const defaults = state.defaults;
|
const defaults = state.defaults;
|
||||||
|
const prevEasyDaysPercentages = $config.easyDaysPercentages.slice();
|
||||||
|
|
||||||
$: if ($config.easyDaysPercentages.length !== 7) {
|
$: if ($config.easyDaysPercentages.length !== 7) {
|
||||||
$config.easyDaysPercentages = defaults.easyDaysPercentages.slice();
|
$config.easyDaysPercentages = defaults.easyDaysPercentages.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$: easyDaysChanged = $config.easyDaysPercentages.some(
|
||||||
|
(value, index) => value !== prevEasyDaysPercentages[index],
|
||||||
|
);
|
||||||
|
|
||||||
$: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0)
|
$: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0)
|
||||||
? ""
|
? ""
|
||||||
: tr.deckConfigEasyDaysNoNormalDays();
|
: tr.deckConfigEasyDaysNoNormalDays();
|
||||||
|
|
||||||
|
$: rescheduleWarning =
|
||||||
|
easyDaysChanged && !($fsrsEnabled && $reschedule)
|
||||||
|
? tr.deckConfigEasyDaysChange()
|
||||||
|
: "";
|
||||||
|
|
||||||
const easyDays = [
|
const easyDays = [
|
||||||
tr.deckConfigEasyDaysMonday(),
|
tr.deckConfigEasyDaysMonday(),
|
||||||
tr.deckConfigEasyDaysTuesday(),
|
tr.deckConfigEasyDaysTuesday(),
|
||||||
|
|
@ -81,6 +93,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<Item>
|
<Item>
|
||||||
<Warning warning={noNormalDay} />
|
<Warning warning={noNormalDay} />
|
||||||
</Item>
|
</Item>
|
||||||
|
<Item>
|
||||||
|
<Warning warning={rescheduleWarning} />
|
||||||
|
</Item>
|
||||||
</DynamicallySlottable>
|
</DynamicallySlottable>
|
||||||
</TitledContainer>
|
</TitledContainer>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue