From 2e329b6317ec23f379cd163c0ad4f82cc9fcdd10 Mon Sep 17 00:00:00 2001 From: miro Date: Sun, 6 Oct 2024 14:49:13 +0800 Subject: [PATCH] Feat/long-term scheduler (#28) Co-authored-by: Asuka Minato Co-authored-by: Jarrett Ye --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 13 +- src/algo.rs | 227 +++---------------------------- src/lib.rs | 13 +- src/models.rs | 86 +----------- src/parameters.rs | 108 +++++++++++++++ src/scheduler.rs | 57 ++++++++ src/scheduler_basic.rs | 274 ++++++++++++++++++++++++++++++++++++++ src/scheduler_longterm.rs | 251 ++++++++++++++++++++++++++++++++++ src/tests.rs | 107 +++++++++++---- 11 files changed, 816 insertions(+), 324 deletions(-) create mode 100644 src/parameters.rs create mode 100644 src/scheduler.rs create mode 100644 src/scheduler_basic.rs create mode 100644 src/scheduler_longterm.rs diff --git a/Cargo.lock b/Cargo.lock index fc50d92..8854206 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,7 +67,7 @@ checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "fsrs" -version = "0.3.0" +version = "0.4.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index 22da5ac..2a58730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fsrs" -version = "0.3.0" +version = "1.0.0" edition = "2021" [dependencies] diff --git a/README.md b/README.md index 914bc21..bcea7b3 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,18 @@ Quickstart: ```rust use chrono::Utc; -use fsrs::{FSRS, Card, Rating::Easy}; +use fsrs::{FSRS, Card, Rating}; fn main() { let fsrs = FSRS::default(); let card = Card::new(); - - let scheduled_cards = fsrs.schedule(card, Utc::now()); - let updated_card = scheduled_cards.select_card(Easy); - - println!("{:?}", updated_card.log); + let scheduleing_card = fsrs.repeat(card, Utc::now()); + for rating in Rating::iter() { + let item = scheduleing_card.get(rating).unwrap().to_owned(); + println!("{:?}", item.card); + println!("{:?}", item.review_log); + } } ``` diff --git a/src/algo.rs b/src/algo.rs index fc03f5a..03ca9f7 100644 --- a/src/algo.rs +++ b/src/algo.rs @@ -1,223 +1,34 @@ -use crate::models::Rating; -use crate::models::Rating::{Again, Easy, Good, Hard}; -use crate::models::State::{Learning, New, Relearning, Review}; -use crate::models::*; -use chrono::{DateTime, Duration, Utc}; -use std::cmp; +use crate::models::{Card, Rating, RecordLog, SchedulingInfo}; +use crate::parameters::Parameters; +use crate::scheduler_basic::BasicScheduler; +use crate::scheduler_longterm::LongtermScheduler; +use crate::ImplScheduler; + +use chrono::{DateTime, Utc}; #[derive(Debug, Default, Clone, Copy)] pub struct FSRS { - params: Parameters, + parameters: Parameters, } impl FSRS { - pub const fn new(params: Parameters) -> Self { - Self { params } + pub const fn new(parameters: Parameters) -> Self { + Self { parameters } } - pub fn schedule(&self, mut card: Card, now: DateTime) -> ScheduledCards { - card.reps += 1; - card.previous_state = card.state; - - card.elapsed_days = match card.state { - New => 0, - _ => (now - card.last_review).num_days(), - }; - card.last_review = now; - - let mut output_cards = ScheduledCards::new(&card, now); - - match card.state { - New => { - self.init_difficulty_stability(&mut output_cards); - - self.set_due(&mut output_cards, Again, Duration::minutes(1)); - self.set_due(&mut output_cards, Hard, Duration::minutes(5)); - self.set_due(&mut output_cards, Good, Duration::minutes(10)); - - let easy_interval = self.next_interval(&mut output_cards, Easy).unwrap(); - self.set_scheduled_days(&mut output_cards, Easy, easy_interval); - self.set_due(&mut output_cards, Easy, Duration::days(easy_interval)); - } - Learning | Relearning => { - self.next_stability(&mut output_cards, card.state); - self.next_difficulty(&mut output_cards); - - self.set_scheduled_days(&mut output_cards, Again, 0); - self.set_due(&mut output_cards, Again, Duration::minutes(5)); - - self.set_scheduled_days(&mut output_cards, Hard, 0); - self.set_due(&mut output_cards, Hard, Duration::minutes(10)); - - let good_interval = self.next_interval(&mut output_cards, Good).unwrap(); - - let easy_interval = - (good_interval + 1).max(self.next_interval(&mut output_cards, Easy).unwrap()); - - self.set_scheduled_days(&mut output_cards, Good, good_interval); - self.set_due(&mut output_cards, Good, Duration::days(good_interval)); - - self.set_scheduled_days(&mut output_cards, Easy, easy_interval); - self.set_due(&mut output_cards, Easy, Duration::days(easy_interval)); - } - Review => { - self.next_stability(&mut output_cards, card.state); - self.next_difficulty(&mut output_cards); - - let mut hard_interval = self.next_interval(&mut output_cards, Hard).unwrap(); - let mut good_interval = self.next_interval(&mut output_cards, Good).unwrap(); - let mut easy_interval = self.next_interval(&mut output_cards, Easy).unwrap(); - - hard_interval = cmp::min(hard_interval, good_interval); - good_interval = cmp::max(good_interval, hard_interval + 1); - easy_interval = cmp::max(good_interval + 1, easy_interval); - - self.set_scheduled_days(&mut output_cards, Again, 0); - self.set_due(&mut output_cards, Again, Duration::minutes(5)); - - self.set_scheduled_days(&mut output_cards, Hard, hard_interval); - self.set_due(&mut output_cards, Hard, Duration::days(hard_interval)); - - self.set_scheduled_days(&mut output_cards, Good, good_interval); - self.set_due(&mut output_cards, Good, Duration::days(good_interval)); - - self.set_scheduled_days(&mut output_cards, Easy, easy_interval); - self.set_due(&mut output_cards, Easy, Duration::days(easy_interval)); - } + pub fn scheduler(&self, card: Card, now: DateTime) -> Box { + if self.parameters.enable_short_term { + Box::new(BasicScheduler::new(self.parameters, card, now)) + } else { + Box::new(LongtermScheduler::new(self.parameters, card, now)) } - self.save_logs(&mut output_cards); - output_cards } - fn set_due(&self, output_cards: &mut ScheduledCards, rating: Rating, duration: Duration) { - let Some(card) = output_cards.cards.get_mut(&rating) else { - return; - }; - card.due = output_cards.now + duration; + pub fn repeat(&self, card: Card, now: DateTime) -> RecordLog { + self.scheduler(card, now).preview() } - fn set_scheduled_days(&self, output_cards: &mut ScheduledCards, rating: Rating, interval: i64) { - let Some(card) = output_cards.cards.get_mut(&rating) else { - return; - }; - card.scheduled_days = interval; - } - - fn save_logs(&self, output_cards: &mut ScheduledCards) { - for rating in Rating::iter() { - let Some(card) = output_cards.cards.get_mut(rating) else { - continue; - }; - card.save_log(*rating); - } - } - - fn init_difficulty_stability(&self, output_cards: &mut ScheduledCards) { - for rating in Rating::iter() { - let Some(card) = output_cards.cards.get_mut(rating) else { - continue; - }; - card.difficulty = self.init_difficulty(*rating); - card.stability = self.init_stability(*rating); - } - } - - fn init_difficulty(&self, rating: Rating) -> f32 { - let rating_int: i32 = rating as i32; - - (self.params.w[4] - f32::exp(self.params.w[5] * (rating_int as f32 - 1.0)) + 1.0) - .clamp(1.0, 10.0) - } - - fn init_stability(&self, rating: Rating) -> f32 { - let rating_int: i32 = rating as i32; - self.params.w[(rating_int - 1) as usize].max(0.1) - } - - #[allow(clippy::suboptimal_flops)] - fn next_interval( - &self, - output_cards: &mut ScheduledCards, - rating: Rating, - ) -> Result { - let Some(card) = output_cards.cards.get_mut(&rating) else { - return Err("Failed to retrieve card from output_cards".to_string()); - }; - let new_interval = - card.stability / FACTOR * (self.params.request_retention.powf(1.0 / DECAY) - 1.0); - Ok((new_interval.round() as i64).clamp(1, self.params.maximum_interval as i64)) - } - - fn next_stability(&self, output_cards: &mut ScheduledCards, state: State) { - if state == Learning || state == Relearning { - self.short_term_stability(output_cards) - } else if state == Review { - for rating in Rating::iter() { - if rating == &Again { - self.next_forget_stability(output_cards); - } else { - self.next_recall_stability(output_cards, *rating); - } - } - } - } - - fn next_recall_stability(&self, output_cards: &mut ScheduledCards, rating: Rating) { - let modifier = match rating { - Hard => self.params.w[15], - Easy => self.params.w[16], - _ => 1.0, - }; - - let Some(card) = output_cards.cards.get_mut(&rating) else { - return; - }; - let retrievability = card.get_retrievability(); - card.stability = card.stability - * (((self.params.w[8]).exp() - * (11.0 - card.difficulty) - * card.stability.powf(-self.params.w[9]) - * (((1.0 - retrievability) * self.params.w[10]).exp_m1())) - .mul_add(modifier, 1.0)); - } - - fn next_forget_stability(&self, output_cards: &mut ScheduledCards) { - let Some(card) = output_cards.cards.get_mut(&Again) else { - return; - }; - let retrievability = card.get_retrievability(); - card.stability = self.params.w[11] - * card.difficulty.powf(-self.params.w[12]) - * ((card.stability + 1.0).powf(self.params.w[13]) - 1.0) - * f32::exp((1.0 - retrievability) * self.params.w[14]) - } - - fn next_difficulty(&self, output_cards: &mut ScheduledCards) { - for rating in Rating::iter() { - let rating_int = *rating as i32; - let Some(mut card) = output_cards.cards.remove(rating) else { - continue; - }; - let next_difficulty = - self.params.w[6].mul_add(-(rating_int as f32 - 3.0), card.difficulty); - let mean_reversion = self.mean_reversion(self.init_difficulty(Easy), next_difficulty); - card.difficulty = mean_reversion.clamp(1.0, 10.0); - output_cards.cards.insert(*rating, card); - } - } - - fn mean_reversion(&self, initial: f32, current: f32) -> f32 { - self.params.w[7].mul_add(initial, (1.0 - self.params.w[7]) * current) - } - - fn short_term_stability(&self, output_cards: &mut ScheduledCards) { - for rating in Rating::iter() { - let rating_int = *rating as i32; - let Some(card) = output_cards.cards.get_mut(rating) else { - continue; - }; - card.stability *= - f32::exp(self.params.w[17] * (rating_int as f32 - 3.0 + self.params.w[18])); - } + pub fn next(&self, card: Card, now: DateTime, rating: Rating) -> SchedulingInfo { + self.scheduler(card, now).review(rating) } } diff --git a/src/lib.rs b/src/lib.rs index 21df86e..4bad2ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,17 @@ mod algo; pub use algo::FSRS; +mod scheduler; +pub use scheduler::{ImplScheduler, Scheduler}; + +mod scheduler_basic; +pub use scheduler_basic::BasicScheduler; +mod scheduler_longterm; +pub use scheduler_longterm::LongtermScheduler; + mod models; -pub use models::{Card, Parameters, Rating, ReviewLog, ScheduledCards, State}; +pub use models::{Card, Rating, ReviewLog, SchedulingInfo, State}; + +mod parameters; +pub use crate::parameters::Parameters; mod tests; diff --git a/src/models.rs b/src/models.rs index c417d72..edb9b01 100644 --- a/src/models.rs +++ b/src/models.rs @@ -30,29 +30,12 @@ impl Rating { } #[derive(Debug, Clone)] -pub struct ScheduledCards { - pub cards: HashMap, - pub now: DateTime, +pub struct SchedulingInfo { + pub card: Card, + pub review_log: ReviewLog, } -impl ScheduledCards { - pub fn new(card: &Card, now: DateTime) -> Self { - let mut cards = HashMap::new(); - for rating in Rating::iter() { - cards.insert(*rating, card.clone()); - let Some(card) = cards.get_mut(rating) else { - continue; - }; - card.update_state(*rating); - } - - Self { cards, now } - } - - pub fn select_card(&self, rating: Rating) -> Card { - self.cards.get(&rating).unwrap().clone() - } -} +pub type RecordLog = HashMap; #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -64,44 +47,18 @@ pub struct ReviewLog { pub reviewed_date: DateTime, } -#[derive(Debug, Clone, Copy)] -pub struct Parameters { - pub request_retention: f32, - pub maximum_interval: i32, - pub w: [f32; 19], -} - -pub const DECAY: f32 = -0.5; -/// (9/10) ^ (1 / DECAY) - 1 -pub const FACTOR: f32 = 19f32 / 81f32; - -impl Default for Parameters { - fn default() -> Self { - Self { - request_retention: 0.9, - maximum_interval: 36500, - w: [ - 0.4197, 1.1869, 3.0412, 15.2441, 7.1434, 0.6477, 1.0007, 0.0674, 1.6597, 0.1712, - 1.1178, 2.0225, 0.0904, 0.3025, 2.1214, 0.2498, 2.9466, 0.4891, 0.6468, - ], - } - } -} - -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] pub struct Card { pub due: DateTime, - pub stability: f32, - pub difficulty: f32, + pub stability: f64, + pub difficulty: f64, pub elapsed_days: i64, pub scheduled_days: i64, pub reps: i32, pub lapses: i32, pub state: State, pub last_review: DateTime, - pub previous_state: State, - pub log: Option, } impl Card { @@ -112,33 +69,4 @@ impl Card { ..Default::default() } } - - pub fn get_retrievability(&self) -> f32 { - (1.0 + FACTOR * self.elapsed_days as f32 / self.stability).powf(DECAY) - } - - pub fn save_log(&mut self, rating: Rating) { - self.log = Some(ReviewLog { - rating, - elapsed_days: self.elapsed_days, - scheduled_days: self.scheduled_days, - state: self.previous_state, - reviewed_date: self.last_review, - }); - } - - pub fn update_state(&mut self, rating: Rating) { - match (self.state, rating) { - (State::New, Rating::Easy) - | (State::Learning | State::Relearning, Rating::Good | Rating::Easy) => { - self.state = State::Review - } - (State::New, _) => self.state = State::Learning, - (State::Review, Rating::Again) => { - self.lapses += 1; - self.state = State::Relearning; - } - _ => {} - } - } } diff --git a/src/parameters.rs b/src/parameters.rs new file mode 100644 index 0000000..ca664e4 --- /dev/null +++ b/src/parameters.rs @@ -0,0 +1,108 @@ +use crate::Rating; + +type Weights = [f64; 19]; +const DEFAULT_WEIGHTS: Weights = [ + 0.4197, 1.1869, 3.0412, 15.2441, 7.1434, 0.6477, 1.0007, 0.0674, 1.6597, 0.1712, 1.1178, + 2.0225, 0.0904, 0.3025, 2.1214, 0.2498, 2.9466, 0.4891, 0.6468, +]; + +#[derive(Debug, Clone, Copy)] +pub struct Parameters { + pub request_retention: f64, + pub maximum_interval: i32, + pub w: Weights, + pub decay: f64, + pub factor: f64, + pub enable_short_term: bool, +} + +impl Parameters { + pub const DECAY: f64 = -0.5; + /// (9/10) ^ (1 / DECAY) - 1 + pub const FACTOR: f64 = 19f64 / 81f64; + + pub fn forgeting_curve(&self, elapsed_days: i64, stability: f64) -> f64 { + (1.0 + Self::FACTOR * elapsed_days as f64 / stability).powf(Self::DECAY) + } + + pub fn init_difficulty(&self, rating: Rating) -> f64 { + let rating_int: i32 = rating as i32; + + (self.w[4] - f64::exp(self.w[5] * (rating_int as f64 - 1.0)) + 1.0).clamp(1.0, 10.0) + } + + pub fn init_stability(&self, rating: Rating) -> f64 { + let rating_int: i32 = rating as i32; + self.w[(rating_int - 1) as usize].max(0.1) + } + + #[allow(clippy::suboptimal_flops)] + pub fn next_interval(&self, stability: f64) -> i64 { + let new_interval = + stability / Self::FACTOR * (self.request_retention.powf(1.0 / Self::DECAY) - 1.0); + (new_interval.round() as i64).clamp(1, self.maximum_interval as i64) + } + + pub fn next_difficulty(&self, difficulty: f64, rating: Rating) -> f64 { + let rating_int = rating as i32; + let next_difficulty = self.w[6].mul_add(-(rating_int as f64 - 3.0), difficulty); + let mean_reversion = + self.mean_reversion(self.init_difficulty(Rating::Easy), next_difficulty); + mean_reversion.clamp(1.0, 10.0) + } + + pub fn short_term_stability(&self, stability: f64, rating: Rating) -> f64 { + let rating_int = rating as i32; + stability * f64::exp(self.w[17] * (rating_int as f64 - 3.0 + self.w[18])) + } + + pub fn next_recall_stability( + &self, + difficulty: f64, + stability: f64, + retrievability: f64, + rating: Rating, + ) -> f64 { + let modifier = match rating { + Rating::Hard => self.w[15], + Rating::Easy => self.w[16], + _ => 1.0, + }; + + stability + * (((self.w[8]).exp() + * (11.0 - difficulty) + * stability.powf(-self.w[9]) + * (((1.0 - retrievability) * self.w[10]).exp_m1())) + .mul_add(modifier, 1.0)) + } + + pub fn next_forget_stability( + &self, + difficulty: f64, + stability: f64, + retrievability: f64, + ) -> f64 { + self.w[11] + * difficulty.powf(-self.w[12]) + * ((stability + 1.0).powf(self.w[13]) - 1.0) + * f64::exp((1.0 - retrievability) * self.w[14]) + } + + fn mean_reversion(&self, initial: f64, current: f64) -> f64 { + self.w[7].mul_add(initial, (1.0 - self.w[7]) * current) + } +} + +impl Default for Parameters { + fn default() -> Self { + Self { + request_retention: 0.9, + maximum_interval: 36500, + w: DEFAULT_WEIGHTS, + decay: Self::DECAY, + factor: Self::FACTOR, + enable_short_term: true, + } + } +} diff --git a/src/scheduler.rs b/src/scheduler.rs new file mode 100644 index 0000000..a47848b --- /dev/null +++ b/src/scheduler.rs @@ -0,0 +1,57 @@ +use chrono::{DateTime, Utc}; + +use crate::models::State::*; +use crate::{ + models::{RecordLog, SchedulingInfo}, + Card, Parameters, Rating, ReviewLog, +}; + +#[derive(Debug, Clone)] +pub struct Scheduler { + pub parameters: Parameters, + pub last: Card, + pub current: Card, + pub now: DateTime, + pub next: RecordLog, +} + +impl Scheduler { + pub fn new(parameters: Parameters, card: Card, now: DateTime) -> Self { + let mut current_card: Card = card.clone(); + current_card.elapsed_days = match card.state { + New => 0, + _ => (now - card.last_review).num_days(), + }; + current_card.last_review = now; + current_card.reps += 1; + + Self { + parameters, + last: card, + current: current_card, + now, + next: RecordLog::new(), + } + } + + pub const fn build_log(&self, rating: Rating) -> ReviewLog { + ReviewLog { + rating, + state: self.current.state, + elapsed_days: self.current.elapsed_days, + scheduled_days: self.current.scheduled_days, + reviewed_date: self.now, + } + } +} + +pub trait ImplScheduler { + fn preview(&mut self) -> RecordLog { + let mut log = RecordLog::new(); + for rating in Rating::iter() { + log.insert(*rating, self.review(*rating)); + } + log + } + fn review(&mut self, rating: Rating) -> SchedulingInfo; +} diff --git a/src/scheduler_basic.rs b/src/scheduler_basic.rs new file mode 100644 index 0000000..30a8211 --- /dev/null +++ b/src/scheduler_basic.rs @@ -0,0 +1,274 @@ +use chrono::{DateTime, Duration, Utc}; + +use crate::{scheduler::Scheduler, Card, ImplScheduler, Parameters, Rating, SchedulingInfo}; +use crate::{Rating::*, State::*}; +pub struct BasicScheduler { + pub scheduler: Scheduler, +} + +impl BasicScheduler { + pub fn new(parameters: Parameters, card: Card, now: DateTime) -> Self { + Self { + scheduler: Scheduler::new(parameters, card, now), + } + } + fn new_state(&mut self, rating: Rating) -> SchedulingInfo { + if let Some(exist) = self.scheduler.next.get(&rating) { + return exist.clone(); + } + + let mut next = self.scheduler.current.clone(); + next.difficulty = self.scheduler.parameters.init_difficulty(rating); + next.stability = self.scheduler.parameters.init_stability(rating); + + match rating { + Again => { + next.scheduled_days = 0; + next.due = self.scheduler.now + Duration::minutes(1); + next.state = Learning; + } + Hard => { + next.scheduled_days = 0; + next.due = self.scheduler.now + Duration::minutes(5); + next.state = Learning; + } + Good => { + next.scheduled_days = 0; + next.due = self.scheduler.now + Duration::minutes(10); + next.state = Learning; + } + Easy => { + let easy_interval = self.scheduler.parameters.next_interval(next.stability); + next.scheduled_days = easy_interval; + next.due = self.scheduler.now + Duration::days(easy_interval); + next.state = Review; + } + }; + let item = SchedulingInfo { + card: next, + review_log: self.scheduler.build_log(rating), + }; + + self.scheduler.next.insert(rating, item.clone()); + item + } + + fn learning_state(&mut self, rating: Rating) -> SchedulingInfo { + if let Some(exist) = self.scheduler.next.get(&rating) { + return exist.clone(); + } + + let mut next = self.scheduler.current.clone(); + next.difficulty = self + .scheduler + .parameters + .next_difficulty(self.scheduler.last.difficulty, rating); + next.stability = self + .scheduler + .parameters + .short_term_stability(self.scheduler.last.stability, rating); + + match rating { + Again => { + next.scheduled_days = 0; + next.due = self.scheduler.now + Duration::minutes(5); + next.state = self.scheduler.last.state; + } + Hard => { + next.scheduled_days = 0; + next.due = self.scheduler.now + Duration::minutes(10); + next.state = self.scheduler.last.state; + } + Good => { + let good_interval = self.scheduler.parameters.next_interval(next.stability); + next.scheduled_days = good_interval; + next.due = self.scheduler.now + Duration::days(good_interval); + next.state = Review; + } + Easy => { + let good_stability = self + .scheduler + .parameters + .short_term_stability(self.scheduler.last.stability, Good); + let good_interval = self.scheduler.parameters.next_interval(good_stability); + let easy_interval = self + .scheduler + .parameters + .next_interval(next.stability) + .max(good_interval + 1); + next.scheduled_days = easy_interval; + next.due = self.scheduler.now + Duration::days(easy_interval); + next.state = Review; + } + } + let item = SchedulingInfo { + card: next, + review_log: self.scheduler.build_log(rating), + }; + + self.scheduler.next.insert(rating, item.clone()); + item + } + + fn review_state(&mut self, rating: Rating) -> SchedulingInfo { + if let Some(exist) = self.scheduler.next.get(&rating) { + return exist.clone(); + } + + let next = self.scheduler.current.clone(); + let interval = self.scheduler.current.elapsed_days; + let stability = self.scheduler.last.stability; + let difficulty = self.scheduler.last.difficulty; + let retrievability = self + .scheduler + .parameters + .forgeting_curve(interval, stability); + + let mut next_again = next.clone(); + let mut next_hard = next.clone(); + let mut next_good = next.clone(); + let mut next_easy = next; + + self.next_difficulty_stability( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + difficulty, + stability, + retrievability, + ); + self.next_interval( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + self.next_state( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + next_again.lapses += 1; + + let item_again = SchedulingInfo { + card: next_again, + review_log: self.scheduler.build_log(Again), + }; + let item_hard = SchedulingInfo { + card: next_hard, + review_log: self.scheduler.build_log(Hard), + }; + let item_good = SchedulingInfo { + card: next_good, + review_log: self.scheduler.build_log(Good), + }; + let item_easy = SchedulingInfo { + card: next_easy, + review_log: self.scheduler.build_log(Easy), + }; + + self.scheduler.next.insert(Again, item_again); + self.scheduler.next.insert(Hard, item_hard); + self.scheduler.next.insert(Good, item_good); + self.scheduler.next.insert(Easy, item_easy); + + self.scheduler.next.get(&rating).unwrap().to_owned() + } + + #[allow(clippy::too_many_arguments)] + fn next_difficulty_stability( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + difficulty: f64, + stability: f64, + retrievability: f64, + ) { + next_again.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Again); + next_again.stability = + self.scheduler + .parameters + .next_forget_stability(difficulty, stability, retrievability); + + next_hard.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Hard); + next_hard.stability = self.scheduler.parameters.next_recall_stability( + difficulty, + stability, + retrievability, + Hard, + ); + + next_good.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Good); + next_good.stability = self.scheduler.parameters.next_recall_stability( + difficulty, + stability, + retrievability, + Good, + ); + + next_easy.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Easy); + next_easy.stability = self.scheduler.parameters.next_recall_stability( + difficulty, + stability, + retrievability, + Easy, + ); + } + + fn next_interval( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + ) { + let mut hard_interval = self.scheduler.parameters.next_interval(next_hard.stability); + let mut good_interval = self.scheduler.parameters.next_interval(next_good.stability); + hard_interval = hard_interval.min(good_interval); + good_interval = good_interval.max(hard_interval + 1); + let easy_interval = self + .scheduler + .parameters + .next_interval(next_easy.stability) + .max(good_interval + 1); + + next_again.scheduled_days = 0; + next_again.due = self.scheduler.now + Duration::minutes(5); + + next_hard.scheduled_days = hard_interval; + next_hard.due = self.scheduler.now + Duration::days(hard_interval); + + next_good.scheduled_days = good_interval; + next_good.due = self.scheduler.now + Duration::days(good_interval); + + next_easy.scheduled_days = easy_interval; + next_easy.due = self.scheduler.now + Duration::days(easy_interval); + } + + fn next_state( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + ) { + next_again.state = Relearning; + next_hard.state = Review; + next_good.state = Review; + next_easy.state = Review; + } +} + +impl ImplScheduler for BasicScheduler { + fn review(&mut self, rating: Rating) -> SchedulingInfo { + match self.scheduler.last.state { + New => self.new_state(rating), + Learning | Relearning => self.learning_state(rating), + Review => self.review_state(rating), + } + } +} diff --git a/src/scheduler_longterm.rs b/src/scheduler_longterm.rs new file mode 100644 index 0000000..66b939b --- /dev/null +++ b/src/scheduler_longterm.rs @@ -0,0 +1,251 @@ +use chrono::{DateTime, Duration, Utc}; + +use crate::{Card, ImplScheduler, Parameters, Rating, Scheduler, SchedulingInfo}; +use crate::{Rating::*, State::*}; + +pub struct LongtermScheduler { + scheduler: Scheduler, +} + +impl LongtermScheduler { + pub fn new(parameters: Parameters, card: Card, now: DateTime) -> Self { + Self { + scheduler: Scheduler::new(parameters, card, now), + } + } + + fn new_state(&mut self, rating: Rating) -> SchedulingInfo { + if let Some(exist) = self.scheduler.next.get(&rating) { + return exist.clone(); + } + + let next = self.scheduler.current.clone(); + self.scheduler.current.scheduled_days = 0; + self.scheduler.current.elapsed_days = 0; + + let mut next_again = next.clone(); + let mut next_hard = next.clone(); + let mut next_good = next.clone(); + let mut next_easy = next; + + self.init_difficulty_stability( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + self.next_interval( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + self.next_state( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + self.update_next(&next_again, &next_hard, &next_good, &next_easy); + + self.scheduler.next.get(&rating).unwrap().to_owned() + } + + fn learning_state(&mut self, rating: Rating) -> SchedulingInfo { + self.review_state(rating) + } + + fn review_state(&mut self, rating: Rating) -> SchedulingInfo { + if let Some(exist) = self.scheduler.next.get(&rating) { + return exist.clone(); + } + + let next = self.scheduler.current.clone(); + let interval = self.scheduler.current.elapsed_days; + let stability = self.scheduler.last.stability; + let difficulty = self.scheduler.last.difficulty; + let retrievability = self + .scheduler + .parameters + .forgeting_curve(interval, stability); + + let mut next_again = next.clone(); + let mut next_hard = next.clone(); + let mut next_good = next.clone(); + let mut next_easy = next; + + self.next_difficulty_stability( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + difficulty, + stability, + retrievability, + ); + self.next_interval( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + self.next_state( + &mut next_again, + &mut next_hard, + &mut next_good, + &mut next_easy, + ); + next_again.lapses += 1; + + self.update_next(&next_again, &next_hard, &next_good, &next_easy); + self.scheduler.next.get(&rating).unwrap().to_owned() + } + + fn init_difficulty_stability( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + ) { + next_again.difficulty = self.scheduler.parameters.init_difficulty(Again); + next_again.stability = self.scheduler.parameters.init_stability(Again); + + next_hard.difficulty = self.scheduler.parameters.init_difficulty(Hard); + next_hard.stability = self.scheduler.parameters.init_stability(Hard); + + next_good.difficulty = self.scheduler.parameters.init_difficulty(Good); + next_good.stability = self.scheduler.parameters.init_stability(Good); + + next_easy.difficulty = self.scheduler.parameters.init_difficulty(Easy); + next_easy.stability = self.scheduler.parameters.init_stability(Easy); + } + + #[allow(clippy::too_many_arguments)] + fn next_difficulty_stability( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + difficulty: f64, + stability: f64, + retrievability: f64, + ) { + next_again.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Again); + next_again.stability = + self.scheduler + .parameters + .next_forget_stability(difficulty, stability, retrievability); + + next_hard.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Hard); + next_hard.stability = self.scheduler.parameters.next_recall_stability( + difficulty, + stability, + retrievability, + Hard, + ); + + next_good.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Good); + next_good.stability = self.scheduler.parameters.next_recall_stability( + difficulty, + stability, + retrievability, + Good, + ); + + next_easy.difficulty = self.scheduler.parameters.next_difficulty(difficulty, Easy); + next_easy.stability = self.scheduler.parameters.next_recall_stability( + difficulty, + stability, + retrievability, + Easy, + ); + } + + fn next_interval( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + ) { + let mut again_interval = self + .scheduler + .parameters + .next_interval(next_again.stability); + let mut hard_interval = self.scheduler.parameters.next_interval(next_hard.stability); + let mut good_interval = self.scheduler.parameters.next_interval(next_good.stability); + let mut easy_interval = self.scheduler.parameters.next_interval(next_easy.stability); + + again_interval = again_interval.min(hard_interval); + hard_interval = hard_interval.max(again_interval + 1); + good_interval = good_interval.max(hard_interval + 1); + easy_interval = easy_interval.max(good_interval + 1); + + next_again.scheduled_days = again_interval; + next_again.due = self.scheduler.now + Duration::days(again_interval); + + next_hard.scheduled_days = hard_interval; + next_hard.due = self.scheduler.now + Duration::days(hard_interval); + + next_good.scheduled_days = good_interval; + next_good.due = self.scheduler.now + Duration::days(good_interval); + + next_easy.scheduled_days = easy_interval; + next_easy.due = self.scheduler.now + Duration::days(easy_interval); + } + + fn next_state( + &self, + next_again: &mut Card, + next_hard: &mut Card, + next_good: &mut Card, + next_easy: &mut Card, + ) { + next_again.state = Review; + next_hard.state = Review; + next_good.state = Review; + next_easy.state = Review; + } + + fn update_next( + &mut self, + next_again: &Card, + next_hard: &Card, + next_good: &Card, + next_easy: &Card, + ) { + let item_again = SchedulingInfo { + card: next_again.clone(), + review_log: self.scheduler.build_log(Again), + }; + let item_hard = SchedulingInfo { + card: next_hard.clone(), + review_log: self.scheduler.build_log(Hard), + }; + let item_good = SchedulingInfo { + card: next_good.clone(), + review_log: self.scheduler.build_log(Good), + }; + let item_easy = SchedulingInfo { + card: next_easy.clone(), + review_log: self.scheduler.build_log(Easy), + }; + + self.scheduler.next.insert(Again, item_again); + self.scheduler.next.insert(Hard, item_hard); + self.scheduler.next.insert(Good, item_good); + self.scheduler.next.insert(Easy, item_easy); + } +} + +impl ImplScheduler for LongtermScheduler { + fn review(&mut self, rating: Rating) -> SchedulingInfo { + match self.scheduler.last.state { + New => self.new_state(rating), + Learning | Relearning => self.learning_state(rating), + Review => self.review_state(rating), + } + } +} diff --git a/src/tests.rs b/src/tests.rs index f660a83..52b6953 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -2,9 +2,10 @@ use { crate::{ algo::FSRS, - models::{Card, Parameters, Rating, State}, + models::{Card, Rating, State}, + parameters::Parameters, }, - chrono::{DateTime, Days, TimeZone, Utc}, + chrono::{DateTime, Duration, TimeZone, Utc}, }; #[cfg(test)] @@ -25,7 +26,7 @@ static TEST_RATINGS: [Rating; 13] = [ ]; #[cfg(test)] -static WEIGHTS: [f32; 19] = [ +static WEIGHTS: [f64; 19] = [ 0.4197, 1.1869, 3.0412, 15.2441, 7.1434, 0.6477, 1.0007, 0.0674, 1.6597, 0.1712, 1.1178, 2.0225, 0.0904, 0.3025, 2.1214, 0.2498, 2.9466, 0.4891, 0.6468, ]; @@ -36,8 +37,14 @@ fn string_to_utc(date_string: &str) -> DateTime { Utc.from_local_datetime(&datetime.naive_utc()).unwrap() } +#[cfg(test)] +fn round_float(num: f64, precision: i32) -> f64 { + let multiplier = 10.0_f64.powi(precision); + (num * multiplier).round() / multiplier +} + #[test] -fn test_interval() { +fn test_basic_scheduler_interval() { let params = Parameters { w: WEIGHTS, ..Default::default() @@ -49,9 +56,8 @@ fn test_interval() { let mut interval_history = vec![]; for rating in TEST_RATINGS.iter() { - let scheduled_cards = fsrs.schedule(card, now); - card = scheduled_cards.select_card(*rating); - + let next = fsrs.next(card, now, *rating); + card = next.card; interval_history.push(card.scheduled_days); now = card.due; } @@ -60,7 +66,7 @@ fn test_interval() { } #[test] -fn test_state() { +fn test_basic_scheduler_state() { let params = Parameters { w: WEIGHTS, ..Default::default() @@ -69,25 +75,26 @@ fn test_state() { let fsrs = FSRS::new(params); let mut card = Card::new(); let mut now = string_to_utc("2022-11-29 12:30:00 +0000 UTC"); - let mut state_history = vec![]; + let mut state_list: Vec = vec![]; + let mut scheduling_card = fsrs.repeat(card, now); for rating in TEST_RATINGS.iter() { - state_history.push(card.state); - let scheduled_cards = fsrs.schedule(card, now); - - card = scheduled_cards.select_card(*rating); + card = scheduling_card[rating].card.clone(); + let rev_log = scheduling_card[rating].review_log.clone(); + state_list.push(rev_log.state); now = card.due; + scheduling_card = fsrs.repeat(card, now); } use State::*; let expected = [ New, Learning, Review, Review, Review, Review, Review, Relearning, Relearning, Review, Review, Review, Review, ]; - assert_eq!(state_history, expected); + assert_eq!(state_list, expected); } #[test] -fn test_memo_state() { +fn test_basic_scheduler_memo_state() { let params = Parameters { w: WEIGHTS, ..Default::default() @@ -96,7 +103,7 @@ fn test_memo_state() { let fsrs = FSRS::new(params); let mut card = Card::new(); let mut now = string_to_utc("2022-11-29 12:30:00 +0000 UTC"); - + let mut scheduling_card = fsrs.repeat(card.clone(), now); let ratings = [ Rating::Again, Rating::Good, @@ -106,16 +113,60 @@ fn test_memo_state() { Rating::Good, ]; let intervals = [0, 0, 1, 3, 8, 21]; - let scheduled_cards = ratings.iter().zip(intervals.iter()).fold( - fsrs.schedule(card.clone(), now), - |scheduled_cards, (rating, interval)| { - card = scheduled_cards.select_card(*rating); - now = now.checked_add_days(Days::new(*interval)).unwrap(); - fsrs.schedule(card.clone(), now) - }, - ); - card = scheduled_cards.select_card(Rating::Good); - assert_eq!(card.stability, 71.4554); - // card.difficulty = 5.0976353 - assert!((card.difficulty - 5.0976).abs() < f32::EPSILON * 1000f32) + for (index, rating) in ratings.iter().enumerate() { + card = scheduling_card[rating].card.clone(); + now = now + Duration::days(intervals[index] as i64); + scheduling_card = fsrs.repeat(card.clone(), now); + } + + card = scheduling_card.get(&Rating::Good).unwrap().to_owned().card; + assert_eq!(round_float(card.stability, 4), 71.4554); + assert_eq!(round_float(card.difficulty, 4), 5.0976); +} + +#[test] +fn test_long_term_scheduler() { + let params = Parameters { + w: WEIGHTS, + enable_short_term: false, + ..Default::default() + }; + + let fsrs = FSRS::new(params); + let mut card = Card::new(); + let mut now = string_to_utc("2022-11-29 12:30:00 +0000 UTC"); + let mut interval_history = vec![]; + let mut stability_history = vec![]; + let mut difficulty_history = vec![]; + + for rating in TEST_RATINGS.iter() { + let record = fsrs + .repeat(card.clone(), now) + .get(&rating) + .unwrap() + .to_owned(); + let next = fsrs.next(card, now, *rating); + + assert_eq!(record.card, next.card); + + card = record.card; + interval_history.push(card.scheduled_days); + stability_history.push(round_float(card.stability, 4)); + difficulty_history.push(round_float(card.difficulty, 4)); + now = card.due; + } + + let expected_interval = [3, 13, 48, 155, 445, 1158, 17, 3, 9, 27, 74, 190, 457]; + let expected_stability = [ + 3.0412, 13.0913, 48.1585, 154.9373, 445.0556, 1158.0778, 16.6306, 2.9888, 9.4633, 26.9474, + 73.9723, 189.7037, 457.4379, + ]; + let expected_difficulty = [ + 4.4909, 4.2666, 4.0575, 3.8624, 3.6804, 3.5108, 5.219, 6.8122, 6.4314, 6.0763, 5.7452, + 5.4363, 5.1483, + ]; + + assert_eq!(interval_history, expected_interval); + assert_eq!(stability_history, expected_stability); + assert_eq!(difficulty_history, expected_difficulty); }