Feat/per-deck desired retention

This commit is contained in:
Jarrett Ye 2025-07-10 11:46:51 +08:00
parent 51cf09daf3
commit eef199830e
No known key found for this signature in database
GPG key ID: EBFC55E0C1A352BB
8 changed files with 126 additions and 23 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

@ -409,6 +409,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 +418,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

@ -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,15 @@ 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)?;
// Get deck-specific desired retention if available, otherwise use config
// default
let desired_retention = if let Some(deck_dr) = deck.normal()?.desired_retention {
deck_dr
} else {
config.inner.desired_retention
};
let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs);
let fsrs_next_states = if fsrs_enabled {
let params = config.fsrs_params();
@ -473,13 +482,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 +671,46 @@ 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();
if let DeckKind::Normal(ref mut normal) = deck_clone.kind {
normal.desired_retention = Some(0.85); // Set deck-specific desired
// retention
}
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

@ -205,7 +205,15 @@ 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 = if let Some(deck_dr) = deck.normal()?.desired_retention {
deck_dr
} else {
config.inner.desired_retention
};
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,9 +88,33 @@ 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,
),
];
let desiredRetentionValue = $config.desiredRetention;
// Get the effective desired retention value (deck-specific if set, otherwise config default)
$: effectiveDesiredRetention = $limits.desiredRetention ?? $config.desiredRetention;
const startingDesiredRetention = effectiveDesiredRetention.toFixed(2);
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
params: fsrsParams($config),
desiredRetention: $config.desiredRetention,
desiredRetention: effectiveDesiredRetention,
newLimit: $config.newPerDay,
reviewLimit: $config.reviewsPerDay,
maxInterval: $config.maximumReviewInterval,
@ -301,18 +328,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={desiredRetentionValue}
defaultValue={defaults.desiredRetention}
min={0.7}
max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<TabbedValue
slot="tabs"
tabs={desiredRetentionTabs}
bind:value={desiredRetentionValue}
/>
<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">
<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>