From 52ce6e6a6b3372260add66b69037d02df4288304 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Sun, 21 Jul 2024 22:02:24 +0800 Subject: [PATCH] Feat/FSRS-5 (#3298) * Feat/FSRS-5 * adapt the SimulatorConfig of FSRS-5 * update parameters from FSRS-4.5 * udpate to FSRS-rs v1.1.0 * ./ninja fix:minilints * pass ci * update cargo-deny to 0.14.24 * udpate to FSRS-rs v1.1.1 * update to fsrs-rs v1.1.2 --- Cargo.lock | 4 +- Cargo.toml | 2 +- cargo/licenses.json | 2 +- proto/anki/scheduler.proto | 31 ++-- rslib/src/deckconfig/update.rs | 9 +- rslib/src/scheduler/fsrs/memory_state.rs | 8 +- rslib/src/scheduler/fsrs/retention.rs | 221 +++++------------------ rslib/src/scheduler/fsrs/simulator.rs | 45 ++--- rslib/src/scheduler/service/mod.rs | 22 ++- tools/minilints/src/main.rs | 2 +- 10 files changed, 110 insertions(+), 236 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93470d74b..22c99381e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,9 +1901,9 @@ dependencies = [ [[package]] name = "fsrs" -version = "0.6.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70cec685337af48789e58cea6ef59ee9f01289d1083428b03fe14e76b98c817c" +checksum = "347ee5ad2666861567827d8c8ed8a97c924d943a9af8e50f24b2691dda95cb59" dependencies = [ "burn", "itertools 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index 27eda27d6..94d793fa1 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.6.4" +version = "1.1.2" # 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 5615cb0bc..93d54b011 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1252,7 +1252,7 @@ }, { "name": "fsrs", - "version": "0.6.4", + "version": "1.1.2", "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 bc78100e2..1bacd0d1f 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -402,27 +402,26 @@ message ComputeOptimalRetentionResponse { float optimal_retention = 1; } -message OptimalRetentionParameters { - double recall_secs_hard = 1; - double recall_secs_good = 2; - double recall_secs_easy = 3; - double forget_secs = 4; - double learn_secs = 5; - double first_rating_probability_again = 6; - double first_rating_probability_hard = 7; - double first_rating_probability_good = 8; - double first_rating_probability_easy = 9; - double review_rating_probability_hard = 10; - double review_rating_probability_good = 11; - double review_rating_probability_easy = 12; -} - message GetOptimalRetentionParametersRequest { string search = 1; } message GetOptimalRetentionParametersResponse { - OptimalRetentionParameters params = 1; + uint32 deck_size = 1; + uint32 learn_span = 2; + float max_cost_perday = 3; + float max_ivl = 4; + repeated float learn_costs = 5; + repeated float review_costs = 6; + repeated float first_rating_prob = 7; + repeated float review_rating_prob = 8; + repeated float first_rating_offsets = 9; + repeated float first_session_lens = 10; + float forget_rating_offset = 11; + float forget_session_len = 12; + float loss_aversion = 13; + uint32 learn_limit = 14; + uint32 review_limit = 15; } message EvaluateWeightsRequest { diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 38197bca6..72d07a632 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -159,12 +159,19 @@ impl Collection { // add/update provided configs for conf in &mut req.configs { let weight_len = conf.inner.fsrs_weights.len(); - if weight_len == 17 { + if weight_len == 19 { + for i in 0..19 { + if !conf.inner.fsrs_weights[i].is_finite() { + return Err(AnkiError::FsrsWeightsInvalid); + } + } + } else if weight_len == 17 { for i in 0..17 { if !conf.inner.fsrs_weights[i].is_finite() { return Err(AnkiError::FsrsWeightsInvalid); } } + conf.inner.fsrs_weights.extend_from_slice(&[0.0, 0.0]) } else if weight_len != 0 { return Err(AnkiError::FsrsWeightsInvalid); } diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 4fe4c5c4a..ed8560a62 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -374,7 +374,7 @@ mod tests { item.starting_state.map(Into::into), Some(FsrsMemoryState { stability: 99.999954, - difficulty: 5.899495, + difficulty: 5.6932373, }), ); let mut card = Card { @@ -385,8 +385,8 @@ mod tests { assert_int_eq( card.memory_state, Some(FsrsMemoryState { - stability: 248.64981, - difficulty: 5.872157, + stability: 248.64305, + difficulty: 5.7909784, }), ); // but if there's only a single revlog entry, we'll fall back on current card @@ -411,7 +411,7 @@ mod tests { card.memory_state, Some(FsrsMemoryState { stability: 122.99994, - difficulty: 7.5038733, + difficulty: 7.334526, }), ); Ok(()) diff --git a/rslib/src/scheduler/fsrs/retention.rs b/rslib/src/scheduler/fsrs/retention.rs index d0c271afa..60506c233 100644 --- a/rslib/src/scheduler/fsrs/retention.rs +++ b/rslib/src/scheduler/fsrs/retention.rs @@ -2,14 +2,12 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use anki_proto::scheduler::ComputeOptimalRetentionRequest; -use anki_proto::scheduler::OptimalRetentionParameters; +use fsrs::extract_simulator_config; use fsrs::SimulatorConfig; use fsrs::FSRS; -use itertools::Itertools; use crate::prelude::*; use crate::revlog::RevlogEntry; -use crate::revlog::RevlogReviewKind; use crate::search::SortMode; #[derive(Default, Clone, Copy, Debug)] @@ -42,23 +40,17 @@ impl Collection { &SimulatorConfig { deck_size, 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: req.loss_aversion, + max_cost_perday: f32::MAX, + max_ivl: req.max_interval as f32, + learn_costs: p.learn_costs, + review_costs: p.review_costs, + first_rating_prob: p.first_rating_prob, + review_rating_prob: p.review_rating_prob, + first_rating_offsets: p.first_rating_offsets, + first_session_lens: p.first_session_lens, + forget_rating_offset: p.forget_rating_offset, + forget_session_len: p.forget_session_len, + loss_aversion: req.loss_aversion as f32, learn_limit, review_limit: usize::MAX, }, @@ -71,173 +63,44 @@ impl Collection { .is_ok() }, )? - .clamp(0.75, 0.95) as f32) + .clamp(0.75, 0.95)) } pub fn get_optimal_retention_parameters( &mut self, revlogs: Vec, - ) -> Result { - let mut first_rating_count = revlogs - .iter() - .group_by(|r| r.cid) - .into_iter() - .map(|(_cid, group)| { - group - .into_iter() - .find(|r| r.review_kind == RevlogReviewKind::Learning && r.button_chosen >= 1) - }) - .filter(|r| r.is_some()) - .counts_by(|r| r.unwrap().button_chosen); - for button_chosen in 1..=4 { - first_rating_count.entry(button_chosen).or_insert(0); - } - let total_first = first_rating_count.values().sum::() as f64; - let weight = total_first / (50.0 + total_first); - const DEFAULT_FIRST_RATING_PROB: [f64; 4] = [0.256, 0.084, 0.483, 0.177]; - let first_rating_prob = if total_first > 0.0 { - let mut arr = DEFAULT_FIRST_RATING_PROB; - first_rating_count - .iter() - .for_each(|(&button_chosen, &count)| { - let index = button_chosen as usize - 1; - arr[index] = (count as f64 / total_first) * weight - + DEFAULT_FIRST_RATING_PROB[index] * (1.0 - weight); - }); - arr - } else { - DEFAULT_FIRST_RATING_PROB - }; - - let mut review_rating_count = revlogs - .iter() - .filter(|r| r.review_kind == RevlogReviewKind::Review && r.button_chosen != 1) - .counts_by(|r| r.button_chosen); - for button_chosen in 2..=4 { - review_rating_count.entry(button_chosen).or_insert(0); - } - let total_reviews = review_rating_count.values().sum::() as f64; - let weight = total_reviews / (50.0 + total_reviews); - const DEFAULT_REVIEW_RATING_PROB: [f64; 3] = [0.224, 0.632, 0.144]; - let review_rating_prob = if total_reviews > 0.0 { - let mut arr = DEFAULT_REVIEW_RATING_PROB; - review_rating_count - .iter() - .filter(|(&button_chosen, ..)| button_chosen >= 2) - .for_each(|(&button_chosen, &count)| { - let index = button_chosen as usize - 2; - arr[index] = (count as f64 / total_reviews) * weight - + DEFAULT_REVIEW_RATING_PROB[index] * (1.0 - weight); - }); - arr - } else { - DEFAULT_REVIEW_RATING_PROB - }; - - let recall_costs = { - const DEFAULT: [f64; 4] = [18.0, 11.8, 7.3, 5.7]; - let mut arr = DEFAULT; - revlogs - .iter() - .filter(|r| { - r.review_kind == RevlogReviewKind::Review - && r.button_chosen > 0 - && r.taken_millis > 0 - && r.taken_millis < 1200000 // 20 minutes - }) - .sorted_by(|a, b| a.button_chosen.cmp(&b.button_chosen)) - .group_by(|r| r.button_chosen) - .into_iter() - .for_each(|(button_chosen, group)| { - let group_vec = group.into_iter().map(|r| r.taken_millis).collect_vec(); - let weight = group_vec.len() as f64 / (50.0 + group_vec.len() as f64); - let index = button_chosen as usize - 1; - arr[index] = median_secs(&group_vec) * weight + DEFAULT[index] * (1.0 - weight); - }); - arr - }; - let learn_cost = { - const DEFAULT: f64 = 22.8; - let revlogs_filter = revlogs - .iter() - .filter(|r| { - r.review_kind == RevlogReviewKind::Learning - && r.button_chosen >= 1 - && r.taken_millis > 0 - && r.taken_millis < 1200000 // 20 minutes - }) - .map(|r| r.taken_millis); - let group_vec = revlogs_filter.collect_vec(); - let weight = group_vec.len() as f64 / (50.0 + group_vec.len() as f64); - median_secs(&group_vec) * weight + DEFAULT * (1.0 - weight) - }; - - let forget_cost = { - const DEFAULT: f64 = 18.0; - let review_kind_to_total_millis = revlogs - .iter() - .filter(|r| { - r.button_chosen > 0 && r.taken_millis > 0 && r.taken_millis < 1200000 - // 20 minutes - }) - .sorted_by(|a, b| a.cid.cmp(&b.cid).then(a.id.cmp(&b.id))) - .group_by(|r| r.review_kind) - /* - for example: - o x x o o x x x o o x x o x - |<->| |<--->| |<->| |<>| - x means forgotten, there are 4 consecutive sets of internal relearning in this card. - So each group is counted separately, and each group is summed up internally.(following code) - Finally averaging all groups, so sort by cid and id. - */ - .into_iter() - .map(|(review_kind, group)| { - let total_millis: u32 = group.into_iter().map(|r| r.taken_millis).sum(); - (review_kind, total_millis) - }) - .collect_vec(); - let mut group_sec_by_review_kind: [Vec<_>; 5] = Default::default(); - for (review_kind, sec) in review_kind_to_total_millis.into_iter() { - group_sec_by_review_kind[review_kind as usize].push(sec) - } - let recall_cost = - median_secs(&group_sec_by_review_kind[RevlogReviewKind::Review as usize]); - let relearn_group = &group_sec_by_review_kind[RevlogReviewKind::Relearning as usize]; - let weight = relearn_group.len() as f64 / (50.0 + relearn_group.len() as f64); - (median_secs(relearn_group) + recall_cost) * weight + DEFAULT * (1.0 - weight) - }; - - let params = OptimalRetentionParameters { - recall_secs_hard: recall_costs[1], - recall_secs_good: recall_costs[2], - recall_secs_easy: recall_costs[3], - forget_secs: forget_cost, - learn_secs: learn_cost, - first_rating_probability_again: first_rating_prob[0], - first_rating_probability_hard: first_rating_prob[1], - first_rating_probability_good: first_rating_prob[2], - first_rating_probability_easy: first_rating_prob[3], - review_rating_probability_hard: review_rating_prob[0], - review_rating_probability_good: review_rating_prob[1], - review_rating_probability_easy: review_rating_prob[2], - }; + ) -> Result { + let fsrs_revlog: Vec = revlogs.into_iter().map(|r| r.into()).collect(); + let params = + extract_simulator_config(fsrs_revlog, self.timing_today()?.next_day_at.into(), true); Ok(params) } } -fn median_secs(group: &[u32]) -> f64 { - let length = group.len(); - if length > 0 { - let mut group_vec = group.to_vec(); - group_vec.sort_unstable(); - let median_millis = if length % 2 == 0 { - let mid = length / 2; - (group_vec[mid - 1] + group_vec[mid]) as f64 / 2.0 - } else { - group_vec[length / 2] as f64 - }; - median_millis / 1000.0 - } else { - 0.0 +impl From for fsrs::RevlogReviewKind { + fn from(kind: crate::revlog::RevlogReviewKind) -> Self { + match kind { + crate::revlog::RevlogReviewKind::Learning => fsrs::RevlogReviewKind::Learning, + crate::revlog::RevlogReviewKind::Review => fsrs::RevlogReviewKind::Review, + crate::revlog::RevlogReviewKind::Relearning => fsrs::RevlogReviewKind::Relearning, + crate::revlog::RevlogReviewKind::Filtered => fsrs::RevlogReviewKind::Filtered, + crate::revlog::RevlogReviewKind::Manual => fsrs::RevlogReviewKind::Manual, + } + } +} + +impl From for fsrs::RevlogEntry { + fn from(entry: crate::revlog::RevlogEntry) -> Self { + fsrs::RevlogEntry { + id: entry.id.into(), + cid: entry.cid.into(), + usn: entry.usn.into(), + button_chosen: entry.button_chosen, + interval: entry.interval, + last_interval: entry.last_interval, + ease_factor: entry.ease_factor, + taken_millis: entry.taken_millis, + review_kind: entry.review_kind.into(), + } } } diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index fd039f7b9..2396f14ac 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -26,22 +26,16 @@ impl Collection { 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, - ], + max_cost_perday: f32::MAX, + max_ivl: req.max_interval as f32, + learn_costs: p.learn_costs, + review_costs: p.review_costs, + first_rating_prob: p.first_rating_prob, + review_rating_prob: p.review_rating_prob, + first_rating_offsets: p.first_rating_offsets, + first_session_lens: p.first_session_lens, + forget_rating_offset: p.forget_rating_offset, + forget_session_len: p.forget_session_len, loss_aversion: 1.0, learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, @@ -54,8 +48,8 @@ impl Collection { daily_time_cost, ) = simulate( &config, - &req.weights.iter().map(|w| *w as f64).collect_vec(), - req.desired_retention as f64, + &req.weights, + req.desired_retention, None, Some( cards @@ -65,13 +59,10 @@ impl Collection { ), ); Ok(SimulateFsrsReviewResponse { - accumulated_knowledge_acquisition: accumulated_knowledge_acquisition - .iter() - .map(|x| *x as f32) - .collect_vec(), + accumulated_knowledge_acquisition: accumulated_knowledge_acquisition.to_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(), + daily_time_cost: daily_time_cost.to_vec(), }) } } @@ -83,10 +74,10 @@ impl Card { 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, + difficulty: state.difficulty, + stability: state.stability, + last_date: (relative_due - card.interval as i32) as f32, + due: relative_due as f32, }) } None => None, diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index 04f4fd90c..9cce0c44e 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -307,10 +307,24 @@ impl crate::services::SchedulerService for Collection { .col .storage .get_revlog_entries_for_searched_cards_in_card_order()?; - self.get_optimal_retention_parameters(revlogs) - .map(|params| GetOptimalRetentionParametersResponse { - params: Some(params), - }) + let simulator_config = self.get_optimal_retention_parameters(revlogs)?; + Ok(GetOptimalRetentionParametersResponse { + deck_size: simulator_config.deck_size as u32, + learn_span: simulator_config.learn_span as u32, + max_cost_perday: simulator_config.max_cost_perday, + max_ivl: simulator_config.max_ivl, + learn_costs: simulator_config.learn_costs.to_vec(), + review_costs: simulator_config.review_costs.to_vec(), + first_rating_prob: simulator_config.first_rating_prob.to_vec(), + review_rating_prob: simulator_config.review_rating_prob.to_vec(), + first_rating_offsets: simulator_config.first_rating_offsets.to_vec(), + first_session_lens: simulator_config.first_session_lens.to_vec(), + forget_rating_offset: simulator_config.forget_rating_offset, + forget_session_len: simulator_config.forget_session_len, + loss_aversion: simulator_config.loss_aversion, + learn_limit: simulator_config.learn_limit as u32, + review_limit: simulator_config.review_limit as u32, + }) } fn compute_memory_state(&mut self, input: cards::CardId) -> Result { diff --git a/tools/minilints/src/main.rs b/tools/minilints/src/main.rs index eb1cfe4cd..d3b384256 100644 --- a/tools/minilints/src/main.rs +++ b/tools/minilints/src/main.rs @@ -209,7 +209,7 @@ fn sveltekit_temp_file(path: &str) -> bool { } fn check_cargo_deny() -> Result<()> { - Command::run("cargo install cargo-deny@0.14.12")?; + Command::run("cargo install cargo-deny@0.14.24")?; Command::run("cargo deny check")?; Ok(()) }