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.
This commit is contained in:
Jarrett Ye 2025-07-28 16:22:35 +08:00 committed by GitHub
parent 60750f8e4c
commit 46bcf4efa6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 138 additions and 28 deletions

View file

@ -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;

View file

@ -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;
}

View file

@ -212,10 +212,13 @@ impl Collection {
if fsrs_toggled {
self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?;
}
let mut deck_desired_retention: HashMap<DeckId, f32> = 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<DayLimit>, new_limit: Option<u32>, today: u32) {

View file

@ -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)]

View file

@ -325,6 +325,7 @@ impl From<NormalDeckSchema11> for NormalDeck {
new_limit: deck.new_limit,
review_limit_today: deck.review_limit_today,
new_limit_today: deck.new_limit_today,
desired_retention: None,
}
}
}

View file

@ -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]

View file

@ -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<DeckId, f32>,
}
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::<ComputeMemoryProgress>();
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);

View file

@ -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<typeof setTimeout> | 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;
</script>
<DynamicallySlottable slotHost={Item} api={{}}>
<Item>
<SpinBoxFloatRow
bind:value={$config.desiredRetention}
bind:value={effectiveDesiredRetention}
defaultValue={defaults.desiredRetention}
min={0.7}
max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<TabbedValue
slot="tabs"
tabs={desiredRetentionTabs}
bind:value={effectiveDesiredRetention}
/>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
</DynamicallySlottable>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />

View file

@ -23,9 +23,12 @@
<slot />
</Col>
<Col --col-size={6} breakpoint="xs">
<Row class="flex-grow-1">
<slot name="tabs" />
<ConfigInput>
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
<RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput>
</Row>
</Col>
</Row>