diff --git a/Cargo.lock b/Cargo.lock index cfa6a2106..14870eea2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1901,9 +1901,9 @@ dependencies = [ [[package]] name = "fsrs" -version = "1.1.5" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5b4e9d166a106007cc88e2ec7c01b107cf4999bbef71d74d32142cdf5277802" +checksum = "8a6f86707f3588ae410917427aa986e8b281fd0fad96480afd150f3e694c0d76" dependencies = [ "burn", "itertools 0.12.1", diff --git a/Cargo.toml b/Cargo.toml index 8486f1e03..1b04985e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ git = "https://github.com/ankitects/linkcheck.git" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] -version = "1.1.5" +version = "1.2.0" # 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 88c33dda4..0ccc259cb 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -1252,7 +1252,7 @@ }, { "name": "fsrs", - "version": "1.1.5", + "version": "1.2.0", "authors": "Open Spaced Repetition", "repository": "https://github.com/open-spaced-repetition/fsrs-rs", "license": "BSD-3-Clause", diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 113afb34b..b45bb2937 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -335,7 +335,7 @@ deck-config-which-deck = Which deck would you like to display options for? ## Messages related to the FSRS scheduler deck-config-updating-cards = Updating cards: { $current_cards_count }/{ $total_cards_count }... -deck-config-invalid-weights = Parameters must be either left blank to use the defaults, or must be 17 comma-separated numbers. +deck-config-invalid-parameters = The provided FSRS parameters are invalid. Leave them blank to use the default parameters. deck-config-not-enough-history = Insufficient review history to perform this operation. deck-config-unable-to-determine-desired-retention = Unable to determine a minimum recommended retention. @@ -512,3 +512,4 @@ deck-config-compute-optimal-retention-tooltip3 = time for a greater recall rate. Setting your desired retention lower than the minimum is not recommended, as it will lead to a higher workload, because of the high forgetting rate. deck-config-seconds-to-show-question-tooltip-2 = When auto advance is activated, the number of seconds to wait before revealing the answer. Set to 0 to disable. +deck-config-invalid-weights = Parameters must be either left blank to use the defaults, or must be 17 comma-separated numbers. diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 72d07a632..267b516ea 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -13,6 +13,7 @@ use anki_proto::deck_config::deck_configs_for_update::CurrentDeck; use anki_proto::deck_config::UpdateDeckConfigsMode; use anki_proto::decks::deck::normal::DayLimit; use fsrs::DEFAULT_PARAMETERS; +use fsrs::FSRS; use crate::config::I32ConfigKey; use crate::config::StringKey; @@ -158,23 +159,13 @@ impl Collection { // add/update provided configs for conf in &mut req.configs { - let weight_len = conf.inner.fsrs_weights.len(); - 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 { + // we can remove this once https://github.com/open-spaced-repetition/fsrs-rs/pull/217/files + // makes it into an FSRS release + if conf.inner.fsrs_weights.iter().any(|&w| !w.is_finite()) { return Err(AnkiError::FsrsWeightsInvalid); } + // check the provided parameters are valid before we save them + FSRS::new(Some(&conf.inner.fsrs_weights))?; self.add_or_update_deck_config(conf)?; configs_after_update.insert(conf.id, conf.clone()); } diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs index cb4adedfe..416690e8b 100644 --- a/rslib/src/error/mod.rs +++ b/rslib/src/error/mod.rs @@ -181,7 +181,7 @@ impl AnkiError { AnkiError::FsrsInsufficientReviews { count } => { tr.deck_config_must_have_400_reviews(*count).into() } - AnkiError::FsrsWeightsInvalid => tr.deck_config_invalid_weights().into(), + AnkiError::FsrsWeightsInvalid => tr.deck_config_invalid_parameters().into(), AnkiError::SchedulerUpgradeRequired => { tr.scheduling_update_required().replace("V2", "v3") } diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index b41d98d76..9b1c0d072 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -5,7 +5,6 @@ use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; use fsrs::simulate; use fsrs::SimulatorConfig; -use fsrs::DEFAULT_PARAMETERS; use itertools::Itertools; use crate::card::CardQueue; @@ -48,19 +47,6 @@ impl Collection { learn_limit: req.new_limit as usize, review_limit: req.review_limit as usize, }; - let parameters = if req.weights.is_empty() { - DEFAULT_PARAMETERS.to_vec() - } else if req.weights.len() != 19 { - if req.weights.len() == 17 { - let mut parameters = req.weights.to_vec(); - parameters.extend_from_slice(&[0.0, 0.0]); - parameters - } else { - return Err(AnkiError::FsrsWeightsInvalid); - } - } else { - req.weights.to_vec() - }; let ( accumulated_knowledge_acquisition, daily_review_count, @@ -68,11 +54,11 @@ impl Collection { daily_time_cost, ) = simulate( &config, - ¶meters, + &req.weights, req.desired_retention, None, Some(converted_cards), - ); + )?; Ok(SimulateFsrsReviewResponse { accumulated_knowledge_acquisition: accumulated_knowledge_acquisition.to_vec(), daily_review_count: daily_review_count.iter().map(|x| *x as u32).collect_vec(), diff --git a/rslib/src/scheduler/states/learning.rs b/rslib/src/scheduler/states/learning.rs index b26774131..435a3bb04 100644 --- a/rslib/src/scheduler/states/learning.rs +++ b/rslib/src/scheduler/states/learning.rs @@ -29,32 +29,64 @@ impl LearnState { pub(crate) fn next_states(self, ctx: &StateContext) -> SchedulingStates { SchedulingStates { current: self.into(), - again: self.answer_again(ctx).into(), - hard: self.answer_hard(ctx).into(), + again: self.answer_again(ctx), + hard: self.answer_hard(ctx), good: self.answer_good(ctx), easy: self.answer_easy(ctx).into(), } } - fn answer_again(self, ctx: &StateContext) -> LearnState { - LearnState { - remaining_steps: ctx.steps.remaining_for_failed(), - scheduled_secs: ctx.steps.again_delay_secs_learn(), - elapsed_secs: 0, - memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into()), + fn answer_again(self, ctx: &StateContext) -> CardState { + let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into()); + if let Some(again_delay) = ctx.steps.again_delay_secs_learn() { + LearnState { + remaining_steps: ctx.steps.remaining_for_failed(), + scheduled_secs: again_delay, + elapsed_secs: 0, + memory_state, + } + .into() + } else { + let (minimum, maximum) = ctx.min_and_max_review_intervals(1); + let interval = if let Some(states) = &ctx.fsrs_next_states { + states.again.interval + } else { + ctx.graduating_interval_good + }; + ReviewState { + scheduled_days: ctx.with_review_fuzz(interval as f32, minimum, maximum), + ease_factor: ctx.initial_ease_factor, + memory_state, + ..Default::default() + } + .into() } } - fn answer_hard(self, ctx: &StateContext) -> LearnState { - LearnState { - scheduled_secs: ctx - .steps - .hard_delay_secs(self.remaining_steps) - // user has 0 learning steps, which the UI doesn't allow - .unwrap_or(60), - elapsed_secs: 0, - memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()), - ..self + fn answer_hard(self, ctx: &StateContext) -> CardState { + let memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()); + if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) { + LearnState { + scheduled_secs: hard_delay, + elapsed_secs: 0, + memory_state, + ..self + } + .into() + } else { + let (minimum, maximum) = ctx.min_and_max_review_intervals(1); + let interval = if let Some(states) = &ctx.fsrs_next_states { + states.hard.interval + } else { + ctx.graduating_interval_good + }; + ReviewState { + scheduled_days: ctx.with_review_fuzz(interval as f32, minimum, maximum), + ease_factor: ctx.initial_ease_factor, + memory_state, + ..Default::default() + } + .into() } } diff --git a/rslib/src/scheduler/states/relearning.rs b/rslib/src/scheduler/states/relearning.rs index 5972a6f39..e3d628045 100644 --- a/rslib/src/scheduler/states/relearning.rs +++ b/rslib/src/scheduler/states/relearning.rs @@ -36,7 +36,7 @@ impl RelearnState { fn answer_again(self, ctx: &StateContext) -> CardState { let (scheduled_days, memory_state) = self.review.failing_review_interval(ctx); - if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() { + if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_learn() { RelearnState { learning: LearnState { remaining_steps: ctx.relearn_steps.remaining_for_failed(), diff --git a/rslib/src/scheduler/states/review.rs b/rslib/src/scheduler/states/review.rs index 2fa7d2b16..67a6380ed 100644 --- a/rslib/src/scheduler/states/review.rs +++ b/rslib/src/scheduler/states/review.rs @@ -104,7 +104,7 @@ impl ReviewState { memory_state, }; - if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() { + if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_learn() { RelearnState { learning: LearnState { remaining_steps: ctx.relearn_steps.remaining_for_failed(), diff --git a/rslib/src/scheduler/states/steps.rs b/rslib/src/scheduler/states/steps.rs index 00f9cb4cc..1dd70532c 100644 --- a/rslib/src/scheduler/states/steps.rs +++ b/rslib/src/scheduler/states/steps.rs @@ -1,7 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -const DEFAULT_SECS_IF_MISSING: u32 = 60; const DAY: u32 = 60 * 60 * 24; #[derive(Clone, Copy, Debug, PartialEq)] @@ -32,12 +31,7 @@ impl<'a> LearningSteps<'a> { self.steps.get(index).copied().map(to_secs) } - /// Cards in learning must always have at least one learning step. - pub(crate) fn again_delay_secs_learn(&self) -> u32 { - self.secs_at_index(0).unwrap_or(DEFAULT_SECS_IF_MISSING) - } - - pub(crate) fn again_delay_secs_relearn(&self) -> Option { + pub(crate) fn again_delay_secs_learn(&self) -> Option { self.secs_at_index(0) } @@ -118,16 +112,22 @@ mod test { #[test] fn delay_secs() { // if no other step, hard delay is 50% above again secs - assert_delay_secs!([10.0], 1, 600, Some(900), None); + assert_delay_secs!([10.0], 1, Some(600), Some(900), None); // but at most one day more than again secs - assert_delay_secs!([(3 * DAY / 60) as f32], 1, 3 * DAY, Some(4 * DAY), None); + assert_delay_secs!( + [(3 * DAY / 60) as f32], + 1, + Some(3 * DAY), + Some(4 * DAY), + None + ); - assert_delay_secs!([1.0, 10.0], 2, 60, Some(330), Some(600)); - assert_delay_secs!([1.0, 10.0], 1, 60, Some(600), None); + assert_delay_secs!([1.0, 10.0], 2, Some(60), Some(330), Some(600)); + assert_delay_secs!([1.0, 10.0], 1, Some(60), Some(600), None); - assert_delay_secs!([1.0, 10.0, 100.0], 3, 60, Some(330), Some(600)); - assert_delay_secs!([1.0, 10.0, 100.0], 2, 60, Some(600), Some(6000)); - assert_delay_secs!([1.0, 10.0, 100.0], 1, 60, Some(6000), None); + assert_delay_secs!([1.0, 10.0, 100.0], 3, Some(60), Some(330), Some(600)); + assert_delay_secs!([1.0, 10.0, 100.0], 2, Some(60), Some(600), Some(6000)); + assert_delay_secs!([1.0, 10.0, 100.0], 1, Some(60), Some(6000), None); } #[test]