From 8d197a1555a7d9db5563f04008d927a31f4f0f7e Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Mon, 18 Mar 2024 21:42:38 +0800 Subject: [PATCH] Feat/fsrs simulator backend part (#3075) * [WIP] FSRS simulator * add desired_retention as input * cargo fmt * fix format * add standard copyright header * support existing cards * fix format * pass days_elapsed into Card::convert & return None --- Cargo.lock | 4 +- Cargo.toml | 2 +- cargo/licenses.json | 2 +- proto/anki/scheduler.proto | 20 ++++++ rslib/src/scheduler/fsrs/mod.rs | 1 + rslib/src/scheduler/fsrs/retention.rs | 15 +++-- rslib/src/scheduler/fsrs/simulator.rs | 95 +++++++++++++++++++++++++++ rslib/src/scheduler/service/mod.rs | 17 ++++- 8 files changed, 144 insertions(+), 12 deletions(-) create mode 100644 rslib/src/scheduler/fsrs/simulator.rs diff --git a/Cargo.lock b/Cargo.lock index 686977a42..68788cca7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1792,9 +1792,9 @@ dependencies = [ [[package]] name = "fsrs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eca50c5f619d6fe0e00962be6f68bf45d67de2fa211a12645882619f5900ff3" +checksum = "84a04c31041078628c5ce7310be96c987bf7f33a3f8815fa0fcdb084eb31feba" dependencies = [ "burn", "itertools 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index ad6132936..5040c89c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] -version = "0.5.4" +version = "0.5.5" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git" # rev = "58ca25ed2bc4bb1dc376208bbcaed7f5a501b941" # path = "../open-spaced-repetition/fsrs-rs" diff --git a/cargo/licenses.json b/cargo/licenses.json index 45fc3648d..bbe1f32e2 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1198,7 +1198,7 @@ }, { "name": "fsrs", - "version": "0.5.4", + "version": "0.5.5", "authors": "Open Spaced Repetition", "repository": "https://github.com/open-spaced-repetition/fsrs-rs", "license": "BSD-3-Clause", diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index a0a853823..a3b993d99 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -51,6 +51,8 @@ service SchedulerService { returns (GetOptimalRetentionParametersResponse); rpc ComputeOptimalRetention(ComputeOptimalRetentionRequest) returns (ComputeOptimalRetentionResponse); + rpc SimulateFsrsReview(SimulateFsrsReviewRequest) + returns (SimulateFsrsReviewResponse); rpc EvaluateWeights(EvaluateWeightsRequest) returns (EvaluateWeightsResponse); rpc ComputeMemoryState(cards.CardId) returns (ComputeMemoryStateResponse); // The number of days the calculated interval was fuzzed by on the previous @@ -371,6 +373,24 @@ message FsrsReview { uint32 delta_t = 2; } +message SimulateFsrsReviewRequest { + repeated float weights = 1; + float desired_retention = 2; + uint32 deck_size = 3; + uint32 days_to_simulate = 4; + uint32 new_limit = 5; + uint32 review_limit = 6; + uint32 max_interval = 7; + string search = 8; +} + +message SimulateFsrsReviewResponse { + repeated float accumulated_knowledge_acquisition = 1; + repeated uint32 daily_review_count = 2; + repeated uint32 daily_new_count = 3; + repeated float daily_time_cost = 4; +} + message ComputeOptimalRetentionRequest { repeated float weights = 1; uint32 days_to_simulate = 2; diff --git a/rslib/src/scheduler/fsrs/mod.rs b/rslib/src/scheduler/fsrs/mod.rs index 71bf19a67..8a0bd51fe 100644 --- a/rslib/src/scheduler/fsrs/mod.rs +++ b/rslib/src/scheduler/fsrs/mod.rs @@ -3,5 +3,6 @@ mod error; pub mod memory_state; pub mod retention; +pub mod simulator; pub mod try_collect; pub mod weights; diff --git a/rslib/src/scheduler/fsrs/retention.rs b/rslib/src/scheduler/fsrs/retention.rs index 3e726573d..9f9f6c44e 100644 --- a/rslib/src/scheduler/fsrs/retention.rs +++ b/rslib/src/scheduler/fsrs/retention.rs @@ -8,6 +8,7 @@ use fsrs::FSRS; use itertools::Itertools; use crate::prelude::*; +use crate::revlog::RevlogEntry; use crate::revlog::RevlogReviewKind; use crate::search::SortMode; @@ -27,7 +28,12 @@ impl Collection { if req.days_to_simulate == 0 { invalid_input!("no days to simulate") } - let p = self.get_optimal_retention_parameters(&req.search)?; + let revlogs = self + .search_cards_into_table(&req.search, SortMode::NoOrder)? + .col + .storage + .get_revlog_entries_for_searched_cards_in_card_order()?; + let p = self.get_optimal_retention_parameters(revlogs)?; let learn_span = req.days_to_simulate as usize; let learn_limit = 10; let deck_size = learn_span * learn_limit; @@ -71,13 +77,8 @@ impl Collection { pub fn get_optimal_retention_parameters( &mut self, - search: &str, + revlogs: Vec, ) -> Result { - let revlogs = self - .search_cards_into_table(search, SortMode::NoOrder)? - .col - .storage - .get_revlog_entries_for_searched_cards_in_card_order()?; let first_rating_count = revlogs .iter() .group_by(|r| r.cid) diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs new file mode 100644 index 000000000..fd039f7b9 --- /dev/null +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -0,0 +1,95 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use anki_proto::scheduler::SimulateFsrsReviewRequest; +use anki_proto::scheduler::SimulateFsrsReviewResponse; +use fsrs::simulate; +use fsrs::SimulatorConfig; +use itertools::Itertools; + +use crate::prelude::*; +use crate::search::SortMode; + +impl Collection { + pub fn simulate_review( + &mut self, + req: SimulateFsrsReviewRequest, + ) -> Result { + let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?; + let revlogs = guard + .col + .storage + .get_revlog_entries_for_searched_cards_in_card_order()?; + let cards = guard.col.storage.all_searched_cards()?; + drop(guard); + let p = self.get_optimal_retention_parameters(revlogs)?; + let config = SimulatorConfig { + deck_size: req.deck_size as usize, + learn_span: req.days_to_simulate as usize, + max_cost_perday: f64::MAX, + max_ivl: req.max_interval as f64, + recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy], + forget_cost: p.forget_secs, + learn_cost: p.learn_secs, + first_rating_prob: [ + p.first_rating_probability_again, + p.first_rating_probability_hard, + p.first_rating_probability_good, + p.first_rating_probability_easy, + ], + review_rating_prob: [ + p.review_rating_probability_hard, + p.review_rating_probability_good, + p.review_rating_probability_easy, + ], + loss_aversion: 1.0, + learn_limit: req.new_limit as usize, + review_limit: req.review_limit as usize, + }; + let days_elapsed = self.timing_today().unwrap().days_elapsed as i32; + let ( + accumulated_knowledge_acquisition, + daily_review_count, + daily_new_count, + daily_time_cost, + ) = simulate( + &config, + &req.weights.iter().map(|w| *w as f64).collect_vec(), + req.desired_retention as f64, + None, + Some( + cards + .into_iter() + .filter_map(|c| Card::convert(c, days_elapsed)) + .collect_vec(), + ), + ); + Ok(SimulateFsrsReviewResponse { + accumulated_knowledge_acquisition: accumulated_knowledge_acquisition + .iter() + .map(|x| *x as f32) + .collect_vec(), + daily_review_count: daily_review_count.iter().map(|x| *x as u32).collect_vec(), + daily_new_count: daily_new_count.iter().map(|x| *x as u32).collect_vec(), + daily_time_cost: daily_time_cost.iter().map(|x| *x as f32).collect_vec(), + }) + } +} + +impl Card { + fn convert(card: Card, days_elapsed: i32) -> Option { + match card.memory_state { + Some(state) => { + let due = card.original_or_current_due(); + let relative_due = due - days_elapsed; + Some(fsrs::Card { + difficulty: state.difficulty as f64, + stability: state.stability as f64, + last_date: (relative_due - card.interval as i32) as f64, + due: relative_due as f64, + }) + } + None => None, + } + } +} diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index e40f49d32..534b8dcaf 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -15,6 +15,8 @@ use anki_proto::scheduler::FsrsBenchmarkResponse; use anki_proto::scheduler::FuzzDeltaRequest; use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse; +use anki_proto::scheduler::SimulateFsrsReviewRequest; +use anki_proto::scheduler::SimulateFsrsReviewResponse; use fsrs::FSRSItem; use fsrs::FSRSReview; use fsrs::FSRS; @@ -24,6 +26,7 @@ use crate::prelude::*; use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::states::CardState; use crate::scheduler::states::SchedulingStates; +use crate::search::SortMode; use crate::stats::studied_today; impl crate::services::SchedulerService for Collection { @@ -264,6 +267,13 @@ impl crate::services::SchedulerService for Collection { ) } + fn simulate_fsrs_review( + &mut self, + input: SimulateFsrsReviewRequest, + ) -> Result { + self.simulate_review(input) + } + fn compute_optimal_retention( &mut self, input: ComputeOptimalRetentionRequest, @@ -292,7 +302,12 @@ impl crate::services::SchedulerService for Collection { &mut self, input: scheduler::GetOptimalRetentionParametersRequest, ) -> Result { - self.get_optimal_retention_parameters(&input.search) + let revlogs = self + .search_cards_into_table(&input.search, SortMode::NoOrder)? + .col + .storage + .get_revlog_entries_for_searched_cards_in_card_order()?; + self.get_optimal_retention_parameters(revlogs) .map(|params| GetOptimalRetentionParametersResponse { params: Some(params), })