From 46bcf4efa6d1ed7d1e6cbc0a674ce7dbac7b85e6 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Mon, 28 Jul 2025 16:22:35 +0800 Subject: [PATCH] Feat/per-deck desired retention (#4194) * Feat/per-deck desired retention * Refactor desired retention logic in Collection implementation Updated the logic for retrieving deck-specific desired retention in both `memory_state.rs` and `mod.rs` to handle cases where the deck's normal state may not be available. This change ensures that the default configuration is used when necessary, improving the robustness of the retention handling. * Refactor desired retention handling in FsrsOptions.svelte Updated the logic for effective desired retention to use the configuration default instead of the deck-specific value. This change improves consistency in the retention value used throughout the component, ensuring that the correct value is bound to the UI elements. * refactor the logic for obtaining deck-specific desired retention by using method chaining * support deck-specific desired retention when rescheduling * Refactor desired retention logic to use a dedicated method for improved clarity and maintainability. --- proto/anki/deck_config.proto | 2 + proto/anki/decks.proto | 2 + rslib/src/deckconfig/update.rs | 10 ++- rslib/src/decks/mod.rs | 11 ++++ rslib/src/decks/schema11.rs | 1 + rslib/src/scheduler/answering/mod.rs | 43 +++++++++++- rslib/src/scheduler/fsrs/memory_state.rs | 21 ++++-- ts/routes/deck-options/FsrsOptions.svelte | 65 ++++++++++++++----- ts/routes/deck-options/SpinBoxFloatRow.svelte | 11 ++-- 9 files changed, 138 insertions(+), 28 deletions(-) diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 9dae49c6a..55291ee5f 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -219,6 +219,8 @@ message DeckConfigsForUpdate { bool review_today_active = 5; // Whether new_today applies to today or a past day. bool new_today_active = 6; + // Deck-specific desired retention override + optional float desired_retention = 7; } string name = 1; int64 config_id = 2; diff --git a/proto/anki/decks.proto b/proto/anki/decks.proto index bcd206b06..b244eb4a1 100644 --- a/proto/anki/decks.proto +++ b/proto/anki/decks.proto @@ -83,6 +83,8 @@ message Deck { optional uint32 new_limit = 7; DayLimit review_limit_today = 8; DayLimit new_limit_today = 9; + // Deck-specific desired retention override + optional float desired_retention = 10; reserved 12 to 15; } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 9eb3b595f..0bd549a20 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -212,10 +212,13 @@ impl Collection { if fsrs_toggled { self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?; } + let mut deck_desired_retention: HashMap = Default::default(); for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { let deck_id = deck.id; - + if let Some(desired_retention) = normal.desired_retention { + deck_desired_retention.insert(deck_id, desired_retention); + } // previous order & params let previous_config_id = DeckConfigId(normal.config_id); let previous_config = configs_before_update.get(&previous_config_id); @@ -277,10 +280,11 @@ impl Collection { if req.fsrs { Some(UpdateMemoryStateRequest { params: c.fsrs_params().clone(), - desired_retention: c.inner.desired_retention, + preset_desired_retention: c.inner.desired_retention, max_interval: c.inner.maximum_review_interval, reschedule: req.fsrs_reschedule, historical_retention: c.inner.historical_retention, + deck_desired_retention: deck_desired_retention.clone(), }) } else { None @@ -409,6 +413,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits { .new_limit_today .map(|limit| limit.today == today) .unwrap_or_default(), + desired_retention: deck.desired_retention, } } @@ -417,6 +422,7 @@ fn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) { deck.new_limit = limits.new; update_day_limit(&mut deck.review_limit_today, limits.review_today, today); update_day_limit(&mut deck.new_limit_today, limits.new_today, today); + deck.desired_retention = limits.desired_retention; } fn update_day_limit(day_limit: &mut Option, new_limit: Option, today: u32) { diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index d16ebac49..44b5d9e59 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -31,6 +31,7 @@ pub(crate) use name::immediate_parent_name; pub use name::NativeDeckName; pub use schema11::DeckSchema11; +use crate::deckconfig::DeckConfig; use crate::define_newtype; use crate::error::FilteredDeckError; use crate::markdown::render_markdown; @@ -89,6 +90,16 @@ impl Deck { } } + /// Get the effective desired retention value for a deck. + /// Returns deck-specific desired retention if available, otherwise falls + /// back to config default. + pub fn effective_desired_retention(&self, config: &DeckConfig) -> f32 { + self.normal() + .ok() + .and_then(|d| d.desired_retention) + .unwrap_or(config.inner.desired_retention) + } + // used by tests at the moment #[allow(dead_code)] diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index e10820ca1..5cd4094f0 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -325,6 +325,7 @@ impl From for NormalDeck { new_limit: deck.new_limit, review_limit_today: deck.review_limit_today, new_limit_today: deck.new_limit_today, + desired_retention: None, } } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index bfe0eafaf..6ff8c6e2d 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -444,6 +444,8 @@ impl Collection { .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?; + + let desired_retention = deck.effective_desired_retention(&config); let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs); let fsrs_next_states = if fsrs_enabled { let params = config.fsrs_params(); @@ -473,13 +475,13 @@ impl Collection { }; Some(fsrs.next_states( card.memory_state.map(Into::into), - config.inner.desired_retention, + desired_retention, days_elapsed, )?) } else { None }; - let desired_retention = fsrs_enabled.then_some(config.inner.desired_retention); + let desired_retention = fsrs_enabled.then_some(desired_retention); let fsrs_short_term_with_steps = self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled); let fsrs_allow_short_term = if fsrs_enabled { @@ -662,6 +664,43 @@ pub(crate) mod test { col.get_scheduling_states(card_id).unwrap().current } + // Test that deck-specific desired retention is used when available + #[test] + fn deck_specific_desired_retention() -> Result<()> { + let mut col = Collection::new(); + + // Enable FSRS + col.set_config_bool(BoolKey::Fsrs, true, false)?; + + // Create a deck with specific desired retention + let deck_id = DeckId(1); + let deck = col.get_deck(deck_id)?.unwrap(); + let mut deck_clone = (*deck).clone(); + deck_clone.normal_mut().unwrap().desired_retention = Some(0.85); + col.update_deck(&mut deck_clone)?; + + // Create a card in this deck + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, deck_id)?; + + // Get the card using search_cards + let cards = col.search_cards(note.id, SortMode::NoOrder)?; + let card = col.storage.get_card(cards[0])?.unwrap(); + + // Test that the card state updater uses deck-specific desired retention + let updater = col.card_state_updater(card)?; + + // Print debug information + println!("FSRS enabled: {}", col.get_config_bool(BoolKey::Fsrs)); + println!("Desired retention: {:?}", updater.desired_retention); + + // Verify that the desired retention is from the deck, not the config + assert_eq!(updater.desired_retention, Some(0.85)); + + Ok(()) + } + // make sure the 'current' state for a card matches the // state we applied to it #[test] diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index b2640fa3e..199b19329 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -45,10 +45,11 @@ pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 { #[derive(Debug)] pub(crate) struct UpdateMemoryStateRequest { pub params: Params, - pub desired_retention: f32, + pub preset_desired_retention: f32, pub historical_retention: f32, pub max_interval: u32, pub reschedule: bool, + pub deck_desired_retention: HashMap, } pub(crate) struct UpdateMemoryStateEntry { @@ -98,7 +99,8 @@ impl Collection { historical_retention.unwrap_or(0.9), ignore_before, )?; - let desired_retention = req.as_ref().map(|w| w.desired_retention); + let preset_desired_retention = + req.as_ref().map(|w| w.preset_desired_retention).unwrap(); 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() { @@ -109,7 +111,12 @@ impl Collection { // Store decay and desired retention in the card so that add-ons, card info, // stats and browser search/sorts don't need to access the deck config. // Unlike memory states, scheduler doesn't use decay and dr stored in the card. - card.desired_retention = desired_retention; + let deck_id = card.original_or_current_deck_id(); + let desired_retention = *req + .deck_desired_retention + .get(&deck_id) + .unwrap_or(&preset_desired_retention); + card.desired_retention = Some(desired_retention); card.decay = decay; if let Some(item) = item { card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?; @@ -132,7 +139,7 @@ impl Collection { let original_interval = card.interval; let interval = fsrs.next_interval( Some(state.stability), - desired_retention.unwrap(), + desired_retention, 0, ); card.interval = rescheduler @@ -205,7 +212,11 @@ impl Collection { .storage .get_deck_config(conf_id)? .or_not_found(conf_id)?; - let desired_retention = config.inner.desired_retention; + + // Get deck-specific desired retention if available, otherwise use config + // default + let desired_retention = deck.effective_desired_retention(&config); + let historical_retention = config.inner.historical_retention; let params = config.fsrs_params(); let decay = get_decay_from_params(params); diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index cfdea341c..9b7bb218e 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -21,7 +21,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import SwitchRow from "$lib/components/SwitchRow.svelte"; import GlobalLabel from "./GlobalLabel.svelte"; - import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib"; + import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from "./lib"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import Warning from "./Warning.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte"; @@ -32,6 +32,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html UpdateDeckConfigsMode, } from "@generated/anki/deck_config_pb"; import type Modal from "bootstrap/js/dist/modal"; + import TabbedValue from "./TabbedValue.svelte"; + import Item from "$lib/components/Item.svelte"; + import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -42,13 +45,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const defaults = state.defaults; const fsrsReschedule = state.fsrsReschedule; const daysSinceLastOptimization = state.daysSinceLastOptimization; + const limits = state.deckLimits; $: lastOptimizationWarning = $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; let desiredRetentionFocused = false; let desiredRetentionEverFocused = false; let optimized = false; - const startingDesiredRetention = $config.desiredRetention.toFixed(2); $: if (desiredRetentionFocused) { desiredRetentionEverFocused = true; } @@ -63,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: computing = computingParams || checkingParams; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; - $: roundedRetention = Number($config.desiredRetention.toFixed(2)); + $: roundedRetention = Number(effectiveDesiredRetention.toFixed(2)); $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); let timeoutId: ReturnType | undefined = undefined; @@ -85,6 +88,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; + // Create tabs for desired retention + const desiredRetentionTabs: ValueTab[] = [ + new ValueTab( + tr.deckConfigSharedPreset(), + $config.desiredRetention, + (value) => ($config.desiredRetention = value!), + $config.desiredRetention, + null, + ), + new ValueTab( + tr.deckConfigDeckOnly(), + $limits.desiredRetention ?? null, + (value) => ($limits.desiredRetention = value ?? undefined), + null, + null, + ), + ]; + + // Get the effective desired retention value (deck-specific if set, otherwise config default) + let effectiveDesiredRetention = + $limits.desiredRetention ?? $config.desiredRetention; + const startingDesiredRetention = effectiveDesiredRetention.toFixed(2); + $: simulateFsrsRequest = new SimulateFsrsReviewRequest({ params: fsrsParams($config), desiredRetention: $config.desiredRetention, @@ -301,18 +327,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let simulatorModal: Modal; - - openHelpModal("desiredRetention")}> - {tr.deckConfigDesiredRetention()} - - + + + + + openHelpModal("desiredRetention")}> + {tr.deckConfigDesiredRetention()} + + + + diff --git a/ts/routes/deck-options/SpinBoxFloatRow.svelte b/ts/routes/deck-options/SpinBoxFloatRow.svelte index 5aa93bd30..3b16f32d8 100644 --- a/ts/routes/deck-options/SpinBoxFloatRow.svelte +++ b/ts/routes/deck-options/SpinBoxFloatRow.svelte @@ -23,9 +23,12 @@ - - - - + + + + + + +