mirror of
https://github.com/ankitects/anki.git
synced 2025-11-10 06:37: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-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-change = Existing reviews will not be rescheduled unless '{ deck-config-reschedule-cards-on-change }' is enabled in the FSRS options.
|
||||
|
||||
## Adding/renaming
|
||||
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ impl Collection {
|
|||
.unwrap_or_default();
|
||||
let previous_params = previous_config.map(|c| c.fsrs_params());
|
||||
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
|
||||
// to new config
|
||||
|
|
@ -242,9 +243,11 @@ impl Collection {
|
|||
// if params differ, memory state needs to be recomputed
|
||||
let current_params = current_config.map(|c| c.fsrs_params());
|
||||
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
|
||||
|| previous_params != current_params
|
||||
|| previous_retention != current_retention
|
||||
|| (req.fsrs_reschedule && previous_easy_days != current_easy_days)
|
||||
{
|
||||
decks_needing_memory_recompute
|
||||
.entry(current_config_id)
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@ impl Card {
|
|||
/// 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
|
||||
/// 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 {
|
||||
card.reps.saturating_sub(1)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ use fsrs::FSRS;
|
|||
use itertools::Itertools;
|
||||
|
||||
use super::params::ignore_revlogs_before_ms_from_config;
|
||||
use super::rescheduler::Rescheduler;
|
||||
use crate::card::CardType;
|
||||
use crate::prelude::*;
|
||||
use crate::revlog::RevlogEntry;
|
||||
use crate::revlog::RevlogReviewKind;
|
||||
use crate::scheduler::answering::get_fuzz_seed;
|
||||
use crate::scheduler::fsrs::params::reviews_for_fsrs;
|
||||
use crate::scheduler::fsrs::params::Params;
|
||||
use crate::scheduler::states::fuzz::with_review_fuzz;
|
||||
|
|
@ -69,6 +71,10 @@ impl Collection {
|
|||
} else {
|
||||
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 historical_retention = req.as_ref().map(|w| w.historical_retention);
|
||||
let items = fsrs_items_for_memory_states(
|
||||
|
|
@ -99,6 +105,10 @@ impl Collection {
|
|||
if let Some(state) = &card.memory_state {
|
||||
// or in (re)learning
|
||||
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
|
||||
let original_interval = card.interval;
|
||||
let interval = fsrs.next_interval(
|
||||
|
|
@ -106,19 +116,41 @@ impl Collection {
|
|||
card.desired_retention.unwrap(),
|
||||
0,
|
||||
);
|
||||
card.interval = with_review_fuzz(
|
||||
card.get_fuzz_factor(true),
|
||||
interval,
|
||||
1,
|
||||
req.max_interval,
|
||||
);
|
||||
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),
|
||||
interval,
|
||||
1,
|
||||
req.max_interval,
|
||||
)
|
||||
});
|
||||
let due = if card.original_due != 0 {
|
||||
&mut card.original_due
|
||||
} else {
|
||||
&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;
|
||||
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
|
||||
// rescheduled
|
||||
if !last_info.last_revlog_is_rescheduled {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
mod error;
|
||||
pub mod memory_state;
|
||||
pub mod params;
|
||||
pub mod rescheduler;
|
||||
pub mod retention;
|
||||
pub mod simulator;
|
||||
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 {
|
||||
fn load_modifier(&self) -> f32 {
|
||||
pub(crate) 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
|
||||
|
|
@ -161,27 +161,7 @@ impl LoadBalancer {
|
|||
},
|
||||
);
|
||||
let configs = storage.get_deck_config_map()?;
|
||||
|
||||
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>>()?;
|
||||
let easy_days_percentages_by_preset = build_easy_days_percentages(configs)?;
|
||||
|
||||
Ok(LoadBalancer {
|
||||
days_by_preset,
|
||||
|
|
@ -254,87 +234,30 @@ impl LoadBalancer {
|
|||
})
|
||||
.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 total_review_count: usize = review_counts.iter().sum();
|
||||
let total_percents: f32 = weekdays
|
||||
.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<_>>();
|
||||
let easy_days_modifier =
|
||||
calculate_easy_days_modifiers(easy_days_load, &weekdays, &review_counts);
|
||||
|
||||
// calculate params for each day
|
||||
let intervals_and_params = interval_days
|
||||
let intervals = interval_days
|
||||
.iter()
|
||||
.enumerate()
|
||||
.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
|
||||
sibling_modifier: note_id
|
||||
.and_then(|note_id| {
|
||||
interval_day
|
||||
.has_sibling(¬e_id)
|
||||
.then_some(SIBLING_PENALTY)
|
||||
})
|
||||
.unwrap_or(1.0),
|
||||
easy_days_modifier: easy_days_modifier[interval_index],
|
||||
}
|
||||
});
|
||||
|
||||
// if there is a sibling on this day, give it a very low weight
|
||||
let sibling_multiplier = note_id
|
||||
.and_then(|note_id| {
|
||||
interval_day
|
||||
.has_sibling(¬e_id)
|
||||
.then_some(SIBLING_PENALTY)
|
||||
})
|
||||
.unwrap_or(1.0);
|
||||
|
||||
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)
|
||||
})
|
||||
.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)
|
||||
select_weighted_interval(intervals, fuzz_seed)
|
||||
}
|
||||
|
||||
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 {
|
||||
let target_datetime = next_day_at
|
||||
.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> {
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -201,6 +201,18 @@ impl SqliteStorage {
|
|||
.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<()> {
|
||||
self.db
|
||||
.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 api: Record<string, never>;
|
||||
|
||||
const fsrsEnabled = state.fsrs;
|
||||
const reschedule = state.fsrsReschedule;
|
||||
const config = state.currentConfig;
|
||||
const defaults = state.defaults;
|
||||
const prevEasyDaysPercentages = $config.easyDaysPercentages.slice();
|
||||
|
||||
$: if ($config.easyDaysPercentages.length !== 7) {
|
||||
$config.easyDaysPercentages = defaults.easyDaysPercentages.slice();
|
||||
}
|
||||
|
||||
$: easyDaysChanged = $config.easyDaysPercentages.some(
|
||||
(value, index) => value !== prevEasyDaysPercentages[index],
|
||||
);
|
||||
|
||||
$: noNormalDay = $config.easyDaysPercentages.some((p) => p === 1.0)
|
||||
? ""
|
||||
: tr.deckConfigEasyDaysNoNormalDays();
|
||||
|
||||
$: rescheduleWarning =
|
||||
easyDaysChanged && !($fsrsEnabled && $reschedule)
|
||||
? tr.deckConfigEasyDaysChange()
|
||||
: "";
|
||||
|
||||
const easyDays = [
|
||||
tr.deckConfigEasyDaysMonday(),
|
||||
tr.deckConfigEasyDaysTuesday(),
|
||||
|
|
@ -81,6 +93,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<Item>
|
||||
<Warning warning={noNormalDay} />
|
||||
</Item>
|
||||
<Item>
|
||||
<Warning warning={rescheduleWarning} />
|
||||
</Item>
|
||||
</DynamicallySlottable>
|
||||
</TitledContainer>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue