From 072cd37b42b649501d8fa1c8c4862b0933eeee9e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 1 Oct 2023 14:59:12 +1000 Subject: [PATCH] Support rescheduling on weight/retention change --- ftl/core/deck-config.ftl | 1 + proto/anki/deck_config.proto | 4 +- rslib/src/deckconfig/mod.rs | 1 + rslib/src/deckconfig/schema11.rs | 6 ++ rslib/src/deckconfig/update.rs | 48 +++++++------ rslib/src/revlog/mod.rs | 12 +++- rslib/src/scheduler/answering/mod.rs | 8 ++- rslib/src/scheduler/fsrs/memory_state.rs | 87 ++++++++++++++++++++++-- rslib/src/scheduler/new.rs | 2 +- rslib/src/scheduler/reviews.rs | 2 +- rslib/src/scheduler/states/fuzz.rs | 23 +++++-- rslib/src/stats/card.rs | 2 +- rslib/src/timestamp.rs | 2 +- ts/deck-options/FsrsOptions.svelte | 9 +++ 14 files changed, 165 insertions(+), 42 deletions(-) diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index f736b85ca..df084bec5 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -339,6 +339,7 @@ deck-config-fsrs-on-all-clients = not work correctly if one of your clients is older. deck-config-set-optimal-retention = Set desired retention to { $num } deck-config-complete = { $num }% complete. +deck-config-reschedule-cards-on-change = Reschedule cards on change ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index e45e2a682..f5023c7c6 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -139,7 +139,9 @@ message DeckConfig { bool bury_reviews = 28; bool bury_interday_learning = 29; - float desired_retention = 37; // for fsrs + // for fsrs + float desired_retention = 37; + bool reschedule_fsrs_cards = 39; bytes other = 255; } diff --git a/rslib/src/deckconfig/mod.rs b/rslib/src/deckconfig/mod.rs index 0a4e09c8b..c6858906b 100644 --- a/rslib/src/deckconfig/mod.rs +++ b/rslib/src/deckconfig/mod.rs @@ -70,6 +70,7 @@ const DEFAULT_DECK_CONFIG_INNER: DeckConfigInner = DeckConfigInner { fsrs_weights: vec![], desired_retention: 0.9, other: Vec::new(), + reschedule_fsrs_cards: false, }; impl Default for DeckConfig { diff --git a/rslib/src/deckconfig/schema11.rs b/rslib/src/deckconfig/schema11.rs index 8580d83c4..539e92af9 100644 --- a/rslib/src/deckconfig/schema11.rs +++ b/rslib/src/deckconfig/schema11.rs @@ -71,6 +71,8 @@ pub struct DeckConfSchema11 { desired_retention: f32, #[serde(default)] stop_timer_on_answer: bool, + #[serde(default)] + reschedule_fsrs_cards: bool, #[serde(flatten)] other: HashMap, @@ -260,6 +262,7 @@ impl Default for DeckConfSchema11 { bury_interday_learning: false, fsrs_weights: vec![], desired_retention: 0.9, + reschedule_fsrs_cards: false, } } } @@ -331,6 +334,7 @@ impl From for DeckConfig { bury_interday_learning: c.bury_interday_learning, fsrs_weights: c.fsrs_weights, desired_retention: c.desired_retention, + reschedule_fsrs_cards: c.reschedule_fsrs_cards, other: other_bytes, }, } @@ -425,6 +429,7 @@ impl From for DeckConfSchema11 { bury_interday_learning: i.bury_interday_learning, fsrs_weights: i.fsrs_weights, desired_retention: i.desired_retention, + reschedule_fsrs_cards: i.reschedule_fsrs_cards, } } } @@ -449,6 +454,7 @@ static RESERVED_DECKCONF_KEYS: Set<&'static str> = phf_set! { "fsrsWeights", "desiredRetention", "stopTimerOnAnswer", + "rescheduleFsrsCards" }; static RESERVED_DECKCONF_NEW_KEYS: Set<&'static str> = phf_set! { diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 47ead45d7..4427a6188 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -16,7 +16,7 @@ use fsrs::DEFAULT_WEIGHTS; use crate::config::StringKey; use crate::decks::NormalDeck; use crate::prelude::*; -use crate::scheduler::fsrs::memory_state::WeightsAndDesiredRetention; +use crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest; use crate::search::JoinSearches; use crate::search::SearchNode; @@ -123,19 +123,19 @@ impl Collection { .collect()) } - fn update_deck_configs_inner(&mut self, mut input: UpdateDeckConfigsRequest) -> Result<()> { - require!(!input.configs.is_empty(), "config not provided"); + fn update_deck_configs_inner(&mut self, mut req: UpdateDeckConfigsRequest) -> Result<()> { + require!(!req.configs.is_empty(), "config not provided"); let configs_before_update = self.storage.get_deck_config_map()?; let mut configs_after_update = configs_before_update.clone(); // handle removals first - for dcid in &input.removed_config_ids { + for dcid in &req.removed_config_ids { self.remove_deck_config_inner(*dcid)?; configs_after_update.remove(dcid); } // add/update provided configs - for conf in &mut input.configs { + for conf in &mut req.configs { let weight_len = conf.inner.fsrs_weights.len(); if weight_len != 0 && weight_len != 17 { return Err(AnkiError::FsrsWeightsInvalid); @@ -145,11 +145,11 @@ impl Collection { } // get selected deck and possibly children - let selected_deck_ids: HashSet<_> = if input.apply_to_children { + let selected_deck_ids: HashSet<_> = if req.apply_to_children { let deck = self .storage - .get_deck(input.target_deck_id)? - .or_not_found(input.target_deck_id)?; + .get_deck(req.target_deck_id)? + .or_not_found(req.target_deck_id)?; self.storage .child_decks(&deck)? .iter() @@ -157,18 +157,18 @@ impl Collection { .map(|d| d.id) .collect() } else { - [input.target_deck_id].iter().cloned().collect() + [req.target_deck_id].iter().cloned().collect() }; // loop through all normal decks let usn = self.usn()?; let today = self.timing_today()?.days_elapsed; - let selected_config = input.configs.last().unwrap(); + let selected_config = req.configs.last().unwrap(); let mut decks_needing_memory_recompute: HashMap> = Default::default(); - let fsrs_toggled = self.get_config_bool(BoolKey::Fsrs) != input.fsrs; + let fsrs_toggled = self.get_config_bool(BoolKey::Fsrs) != req.fsrs; if fsrs_toggled { - self.set_config_bool_inner(BoolKey::Fsrs, input.fsrs)?; + self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?; } for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { @@ -181,6 +181,7 @@ impl Collection { .map(|c| c.inner.new_card_insert_order()) .unwrap_or_default(); let previous_weights = previous_config.map(|c| &c.inner.fsrs_weights); + let previous_retention = previous_config.map(|c| c.inner.desired_retention); // if a selected (sub)deck, or its old config was removed, update deck to point // to new config @@ -189,7 +190,7 @@ impl Collection { { let mut updated = deck.clone(); updated.normal_mut()?.config_id = selected_config.id.0; - update_deck_limits(updated.normal_mut()?, &input.limits, today); + update_deck_limits(updated.normal_mut()?, &req.limits, today); self.update_deck_inner(&mut updated, deck, usn)?; selected_config.id } else { @@ -207,7 +208,11 @@ impl Collection { // if weights differ, memory state needs to be recomputed let current_weights = current_config.map(|c| &c.inner.fsrs_weights); - if fsrs_toggled || previous_weights != current_weights { + let current_retention = current_config.map(|c| c.inner.desired_retention); + if fsrs_toggled + || previous_weights != current_weights + || previous_retention != current_retention + { decks_needing_memory_recompute .entry(current_config_id) .or_default() @@ -219,13 +224,18 @@ impl Collection { } if !decks_needing_memory_recompute.is_empty() { - let input: Vec<(Option, Vec)> = + let input: Vec<(Option, Vec)> = decks_needing_memory_recompute .into_iter() .map(|(conf_id, search)| { let weights = configs_after_update.get(&conf_id).and_then(|c| { - if input.fsrs { - Some((c.inner.fsrs_weights.clone(), c.inner.desired_retention)) + if req.fsrs { + Some(UpdateMemoryStateRequest { + weights: c.inner.fsrs_weights.clone(), + desired_retention: c.inner.desired_retention, + max_interval: c.inner.maximum_review_interval, + reschedule: c.inner.reschedule_fsrs_cards, + }) } else { None } @@ -236,10 +246,10 @@ impl Collection { self.update_memory_state(input)?; } - self.set_config_string_inner(StringKey::CardStateCustomizer, &input.card_state_customizer)?; + self.set_config_string_inner(StringKey::CardStateCustomizer, &req.card_state_customizer)?; self.set_config_bool_inner( BoolKey::NewCardsIgnoreReviewLimit, - input.new_cards_ignore_review_limit, + req.new_cards_ignore_review_limit, )?; Ok(()) diff --git a/rslib/src/revlog/mod.rs b/rslib/src/revlog/mod.rs index 46c571ee1..f1ca93d12 100644 --- a/rslib/src/revlog/mod.rs +++ b/rslib/src/revlog/mod.rs @@ -89,17 +89,23 @@ impl Collection { pub(crate) fn log_manually_scheduled_review( &mut self, card: &Card, - original: &Card, + original_interval: u32, usn: Usn, ) -> Result<()> { + let ease_factor = u32::try_from( + card.memory_state + .map(|s| ((s.difficulty_shifted() * 1000.) as u16)) + .unwrap_or(card.ease_factor), + ) + .unwrap_or_default(); let entry = RevlogEntry { id: RevlogId::new(), cid: card.id, usn, button_chosen: 0, interval: i32::try_from(card.interval).unwrap_or(i32::MAX), - last_interval: i32::try_from(original.interval).unwrap_or(i32::MAX), - ease_factor: u32::from(card.ease_factor), + last_interval: i32::try_from(original_interval).unwrap_or(i32::MAX), + ease_factor, taken_millis: 0, review_kind: RevlogReviewKind::Manual, }; diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 02e5ad0fc..7f278e1e9 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -367,7 +367,7 @@ impl Collection { let days_elapsed = self .storage .time_of_last_review(card.id)? - .map(|ts| ts.elapsed_days_since(timing.next_day_at)) + .map(|ts| timing.next_day_at.elapsed_days_since(ts)) .unwrap_or_default() as u32; Some(fsrs.next_states( card.memory_state.map(Into::into), @@ -464,6 +464,12 @@ pub mod test_helpers { } } +impl Card { + pub(crate) fn get_fuzz_factor(&self) -> Option { + get_fuzz_factor(get_fuzz_seed(self)) + } +} + /// Return a consistent seed for a given card at a given number of reps. /// If in test environment, disable fuzzing. fn get_fuzz_seed(card: &Card) -> Option { diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 8e5b64054..8553e07f9 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -1,6 +1,8 @@ // 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 anki_proto::scheduler::ComputeMemoryStateResponse; use fsrs::FSRSItem; use fsrs::FSRS; @@ -11,6 +13,7 @@ use crate::prelude::*; use crate::revlog::RevlogEntry; use crate::scheduler::fsrs::weights::single_card_revlog_to_items; use crate::scheduler::fsrs::weights::Weights; +use crate::scheduler::states::fuzz::with_review_fuzz; use crate::search::JoinSearches; use crate::search::Negated; use crate::search::SearchNode; @@ -22,7 +25,13 @@ pub struct ComputeMemoryProgress { pub total_cards: u32, } -pub(crate) type WeightsAndDesiredRetention = (Weights, f32); +#[derive(Debug)] +pub(crate) struct UpdateMemoryStateRequest { + pub weights: Weights, + pub desired_retention: f32, + pub max_interval: u32, + pub reschedule: bool, +} impl Collection { /// For each provided set of weights, locate cards with the provided search, @@ -32,26 +41,71 @@ impl Collection { /// memory state should be removed. pub(crate) fn update_memory_state( &mut self, - entries: Vec<(Option, Vec)>, + entries: Vec<(Option, Vec)>, ) -> Result<()> { let timing = self.timing_today()?; let usn = self.usn()?; - for (weights_and_desired_retention, search) in entries { + for (req, search) in entries { let search = SearchBuilder::any(search.into_iter()) .and(SearchNode::State(StateKind::New).negated()); let revlog = self.revlog_for_srs(search)?; + let reschedule = req.as_ref().map(|e| e.reschedule).unwrap_or_default(); + let last_reviews = if reschedule { + Some(get_last_reviews(&revlog)) + } else { + None + }; let items = fsrs_items_for_memory_state(revlog, timing.next_day_at); - let desired_retention = weights_and_desired_retention.as_ref().map(|w| w.1); - let fsrs = FSRS::new(weights_and_desired_retention.as_ref().map(|w| &w.0[..]))?; + let desired_retention = req.as_ref().map(|w| w.desired_retention); + let fsrs = FSRS::new(req.as_ref().map(|w| &w.weights[..]))?; let mut progress = self.new_progress_handler::(); progress.update(false, |s| s.total_cards = items.len() as u32)?; for (idx, (card_id, item)) in items.into_iter().enumerate() { progress.update(true, |state| state.current_cards = idx as u32 + 1)?; let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?; let original = card.clone(); - if weights_and_desired_retention.is_some() { - card.set_memory_state(&fsrs, item); + if let Some(req) = &req { + card.set_memory_state(&fsrs, item.clone()); card.desired_retention = desired_retention; + // if rescheduling + if let Some(reviews) = &last_reviews { + // and we have a last review time for the card + if let Some(last_review) = reviews.get(&card.id) { + let days_elapsed = + timing.next_day_at.elapsed_days_since(*last_review) as i32; + // and the card's not new + if let Some(state) = &card.memory_state { + // or in (re)learning + if card.ctype == CardType::Review { + // reschedule it + let original_interval = card.interval; + let interval = fsrs.next_interval( + Some(state.stability), + card.desired_retention.unwrap(), + 0, + ) as f32; + card.interval = with_review_fuzz( + card.get_fuzz_factor(), + 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 + + card.interval as i32; + self.log_manually_scheduled_review( + &card, + original_interval, + usn, + )?; + } + } + } + } } else { card.memory_state = None; card.desired_retention = None; @@ -117,6 +171,25 @@ pub(crate) fn fsrs_items_for_memory_state( .collect() } +/// Return a map of cards to the last time they were reviewed. +fn get_last_reviews(revlogs: &[RevlogEntry]) -> HashMap { + let mut out = HashMap::new(); + revlogs + .iter() + .group_by(|r| r.cid) + .into_iter() + .for_each(|(card_id, group)| { + let mut last_ts = TimestampSecs::zero(); + for entry in group.into_iter().filter(|r| r.button_chosen >= 1) { + last_ts = entry.id.as_secs(); + } + if last_ts != TimestampSecs::zero() { + out.insert(card_id, last_ts); + } + }); + out +} + /// When calculating memory state, only the last FSRSItem is required. pub(crate) fn single_card_revlog_to_item( entries: Vec, diff --git a/rslib/src/scheduler/new.rs b/rslib/src/scheduler/new.rs index e3eb55c23..7dc2a346b 100644 --- a/rslib/src/scheduler/new.rs +++ b/rslib/src/scheduler/new.rs @@ -168,7 +168,7 @@ impl Collection { position += 1; } if log { - col.log_manually_scheduled_review(&card, &original, usn)?; + col.log_manually_scheduled_review(&card, original.interval, usn)?; } col.update_card_inner(&mut card, original, usn)?; } diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index c853a2942..2fac6eab5 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -134,7 +134,7 @@ impl Collection { let original = card.clone(); let days_from_today = distribution.sample(&mut rng); card.set_due_date(today, days_from_today, ease_factor, spec.force_reset); - col.log_manually_scheduled_review(&card, &original, usn)?; + col.log_manually_scheduled_review(&card, original.interval, usn)?; col.update_card_inner(&mut card, original, usn)?; } if let Some(key) = context { diff --git a/rslib/src/scheduler/states/fuzz.rs b/rslib/src/scheduler/states/fuzz.rs index 0aaa75429..d541f432d 100644 --- a/rslib/src/scheduler/states/fuzz.rs +++ b/rslib/src/scheduler/states/fuzz.rs @@ -33,12 +33,21 @@ impl<'a> StateContext<'a> { /// Apply fuzz, respecting the passed bounds. /// Caller must ensure reasonable bounds. pub(crate) fn with_review_fuzz(&self, interval: f32, minimum: u32, maximum: u32) -> u32 { - if let Some(fuzz_factor) = self.fuzz_factor { - let (lower, upper) = constrained_fuzz_bounds(interval, minimum, maximum); - (lower as f32 + fuzz_factor * ((1 + upper - lower) as f32)).floor() as u32 - } else { - (interval.round() as u32).clamp(minimum, maximum) - } + with_review_fuzz(self.fuzz_factor, interval, minimum, maximum) + } +} + +pub(crate) fn with_review_fuzz( + fuzz_factor: Option, + interval: f32, + minimum: u32, + maximum: u32, +) -> u32 { + if let Some(fuzz_factor) = fuzz_factor { + let (lower, upper) = constrained_fuzz_bounds(interval, minimum, maximum); + (lower as f32 + fuzz_factor * ((1 + upper - lower) as f32)).floor() as u32 + } else { + (interval.round() as u32).clamp(minimum, maximum) } } @@ -61,7 +70,7 @@ fn constrained_fuzz_bounds(interval: f32, minimum: u32, maximum: u32) -> (u32, u (lower, upper) } -fn fuzz_bounds(interval: f32) -> (u32, u32) { +pub(crate) fn fuzz_bounds(interval: f32) -> (u32, u32) { let delta = fuzz_delta(interval); ( (interval - delta).round() as u32, diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index 22d15cd6e..d90b0208f 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -30,7 +30,7 @@ impl Collection { let days_elapsed = self .storage .time_of_last_review(card.id)? - .map(|ts| ts.elapsed_days_since(timing.next_day_at)) + .map(|ts| timing.next_day_at.elapsed_days_since(ts)) .unwrap_or_default() as u32; let fsrs_retrievability = card .memory_state diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index 589dfad89..a020d706d 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -29,7 +29,7 @@ impl TimestampSecs { } pub fn elapsed_days_since(self, other: TimestampSecs) -> u64 { - (other.0 - self.0).max(0) as u64 / 86_400 + (self.0 - other.0).max(0) as u64 / 86_400 } pub fn as_millis(self) -> TimestampMillis { diff --git a/ts/deck-options/FsrsOptions.svelte b/ts/deck-options/FsrsOptions.svelte index 0afe34e39..e98744408 100644 --- a/ts/deck-options/FsrsOptions.svelte +++ b/ts/deck-options/FsrsOptions.svelte @@ -16,6 +16,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } from "@tslib/backend"; import * as tr from "@tslib/ftl"; import { runWithBackendProgress } from "@tslib/progress"; + import SwitchRow from "components/SwitchRow.svelte"; import SettingTitle from "../components/SettingTitle.svelte"; import type { DeckOptionsState } from "./lib"; @@ -223,6 +224,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +
+ + + {tr.deckConfigRescheduleCardsOnChange()} + + +
+
{tr.deckConfigComputeOptimalWeights()}