mirror of
https://github.com/ankitects/anki.git
synced 2025-11-10 14:47:12 -05:00
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:
parent
60750f8e4c
commit
46bcf4efa6
9 changed files with 138 additions and 28 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<SpinBoxFloatRow
|
||||
bind:value={$config.desiredRetention}
|
||||
defaultValue={defaults.desiredRetention}
|
||||
min={0.7}
|
||||
max={0.99}
|
||||
percentage={true}
|
||||
bind:focused={desiredRetentionFocused}
|
||||
>
|
||||
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
|
||||
{tr.deckConfigDesiredRetention()}
|
||||
</SettingTitle>
|
||||
</SpinBoxFloatRow>
|
||||
<DynamicallySlottable slotHost={Item} api={{}}>
|
||||
<Item>
|
||||
<SpinBoxFloatRow
|
||||
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} />
|
||||
|
|
|
|||
|
|
@ -23,9 +23,12 @@
|
|||
<slot />
|
||||
</Col>
|
||||
<Col --col-size={6} breakpoint="xs">
|
||||
<ConfigInput>
|
||||
<SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
|
||||
<RevertButton slot="revert" bind:value {defaultValue} />
|
||||
</ConfigInput>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue