Feat/Desired retention warning improvements (#3995)

* Feat/90% desired retention warning

* Update ftl/core/deck-config.ftl

* show on newly enabled

* Show warning on focus

* Never hide warning

* Display relative change

* Add: Separate warning for too long and short

* Revert unchanged text changes

* interval -> workload

* Remove dead code

* fsrs-rs/@L-M-Sherlock's workload calculation

* Added: delay

* CONSTANT_CASE

* Fix: optimized state

* Removed "Processing"

* Remove dead code

* 1 digit precision

* bump fsrs-rs

* typo

* Apply suggestions from code review

Co-authored-by: Damien Elmes <dae@users.noreply.github.com>

* Improve rounding

* improve comment

* rounding <1%

* decrease rounding precision

* bump ts-fsrs

* use actual cost values

* ./check

* typo

* include relearning

* change factor wording

* simplify sql

* ./check

* Apply suggestions from code review

Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>

* Fix: missing search_cids

* @dae's style patch

* Fix: Doesn't update on arrow keys change

* force two lines

* center two lines

---------

Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>
This commit is contained in:
Luc Mcgrady 2025-05-27 04:07:21 +01:00 committed by GitHub
parent 1e6d12b830
commit f29bcb743b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 207 additions and 22 deletions

View file

@ -470,11 +470,12 @@ deck-config-compute-optimal-retention-tooltip4 =
willing to invest more study time to achieve it. Setting your desired retention lower than the minimum willing to invest more study time to achieve it. Setting your desired retention lower than the minimum
is not recommended, as it will lead to a higher workload, because of the high forgetting rate. is not recommended, as it will lead to a higher workload, because of the high forgetting rate.
deck-config-please-save-your-changes-first = Please save your changes first. deck-config-please-save-your-changes-first = Please save your changes first.
deck-config-a-100-day-interval = deck-config-workload-factor-change = Approximate workload: {$factor}x
{ $days -> (compared to {$previousDR}% desired retention)
[one] A 100 day interval will become { $days } day. deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.
*[other] A 100 day interval will become { $days } days. deck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals.
} deck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals.
deck-config-percent-of-reviews = deck-config-percent-of-reviews =
{ $reviews -> { $reviews ->
[one] { $pct }% of { $reviews } review [one] { $pct }% of { $reviews } review
@ -512,6 +513,12 @@ deck-config-fsrs-simulator-radio-memorized = Memorized
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
deck-config-a-100-day-interval =
{ $days ->
[one] A 100 day interval will become { $days } day.
*[other] A 100 day interval will become { $days } days.
}
deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day
deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day
deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total

View file

@ -25,6 +25,8 @@ service DeckConfigService {
returns (collection.OpChanges); returns (collection.OpChanges);
rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest) rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)
returns (GetIgnoredBeforeCountResponse); returns (GetIgnoredBeforeCountResponse);
rpc GetRetentionWorkload(GetRetentionWorkloadRequest)
returns (GetRetentionWorkloadResponse);
} }
// Implicitly includes any of the above methods that are not listed in the // Implicitly includes any of the above methods that are not listed in the
@ -35,6 +37,17 @@ message DeckConfigId {
int64 dcid = 1; int64 dcid = 1;
} }
message GetRetentionWorkloadRequest {
repeated float w = 1;
string search = 2;
float before = 3;
float after = 4;
}
message GetRetentionWorkloadResponse {
float factor = 1;
}
message GetIgnoredBeforeCountRequest { message GetIgnoredBeforeCountRequest {
string ignore_revlogs_before_date = 1; string ignore_revlogs_before_date = 1;
string search = 2; string search = 2;

View file

@ -659,6 +659,7 @@ exposed_backend_list = [
"simulate_fsrs_review", "simulate_fsrs_review",
# DeckConfigService # DeckConfigService
"get_ignored_before_count", "get_ignored_before_count",
"get_retention_workload",
] ]

View file

@ -96,6 +96,40 @@ impl crate::services::DeckConfigService for Collection {
total: guard.cards.try_into().unwrap_or(0), total: guard.cards.try_into().unwrap_or(0),
}) })
} }
fn get_retention_workload(
&mut self,
input: anki_proto::deck_config::GetRetentionWorkloadRequest,
) -> Result<anki_proto::deck_config::GetRetentionWorkloadResponse> {
const LEARN_SPAN: usize = 1000;
let guard =
self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?;
let (pass_cost, fail_cost, learn_cost) = guard.col.storage.get_costs_for_retention()?;
let before = fsrs::expected_workload(
&input.w,
input.before,
LEARN_SPAN,
pass_cost,
fail_cost,
0.,
input.before,
)? + learn_cost;
let after = fsrs::expected_workload(
&input.w,
input.after,
LEARN_SPAN,
pass_cost,
fail_cost,
0.,
input.after,
)? + learn_cost;
Ok(anki_proto::deck_config::GetRetentionWorkloadResponse {
factor: after / before,
})
}
} }
impl From<DeckConfig> for anki_proto::deck_config::DeckConfig { impl From<DeckConfig> for anki_proto::deck_config::DeckConfig {

View file

@ -0,0 +1,49 @@
WITH searched_revlogs AS (
SELECT *
FROM revlog
WHERE ease > 0
AND cid IN search_cids
ORDER BY id DESC -- Use the last 10_000 reviews
LIMIT 10000
), average_pass AS (
SELECT AVG(time)
FROM searched_revlogs
WHERE ease > 1
),
lapse_count AS (
SELECT COUNT(time) AS lapse_count
FROM searched_revlogs
WHERE ease = 1
AND type = 1
),
fail_sum AS (
SELECT SUM(time) AS total_fail_time
FROM searched_revlogs
WHERE (
ease = 1
AND type = 1
)
OR type = 2
),
-- (sum(Relearning) + sum(Lapses)) / count(Lapses)
average_fail AS (
SELECT total_fail_time * 1.0 / NULLIF(lapse_count, 0) AS avg_fail_time
FROM fail_sum,
lapse_count
),
-- Can lead to cards with partial learn histories skewing the time
summed_learns AS (
SELECT cid,
SUM(time) AS total_time
FROM searched_revlogs
WHERE searched_revlogs.type = 0
GROUP BY cid
),
average_learn AS (
SELECT AVG(total_time) AS avg_learn_time
FROM summed_learns
)
SELECT *
FROM average_pass,
average_fail,
average_learn;

View file

@ -747,6 +747,20 @@ impl super::SqliteStorage {
.get(0)?) .get(0)?)
} }
pub(crate) fn get_costs_for_retention(&self) -> Result<(f32, f32, f32)> {
let mut statement = self
.db
.prepare(include_str!("get_costs_for_retention.sql"))?;
let mut query = statement.query(params![])?;
let row = query.next()?.unwrap();
Ok((
row.get(0).unwrap_or(7000.),
row.get(1).unwrap_or(23_000.),
row.get(2).unwrap_or(30_000.),
))
}
#[cfg(test)] #[cfg(test)]
pub(crate) fn get_all_cards(&self) -> Vec<Card> { pub(crate) fn get_all_cards(&self) -> Vec<Card> {
self.db self.db

View file

@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let percentage = false; export let percentage = false;
let input: HTMLInputElement; let input: HTMLInputElement;
let focused = false; export let focused = false;
let multiplier: number; let multiplier: number;
$: multiplier = percentage ? 100 : 1; $: multiplier = percentage ? 100 : 1;
@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={stringValue} value={stringValue}
bind:this={input} bind:this={input}
on:blur={update} on:blur={update}
on:change={update}
on:input={onInput} on:input={onInput}
on:focusin={() => (focused = true)} on:focusin={() => (focused = true)}
on:focusout={() => (focused = false)} on:focusout={() => (focused = false)}

View file

@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { import {
computeFsrsParams, computeFsrsParams,
evaluateParams, evaluateParams,
getRetentionWorkload,
setWantsAbort, setWantsAbort,
} from "@generated/backend"; } from "@generated/backend";
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
@ -26,11 +27,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte"; import ParamsSearchRow from "./ParamsSearchRow.svelte";
import SimulatorModal from "./SimulatorModal.svelte"; import SimulatorModal from "./SimulatorModal.svelte";
import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb"; import {
GetRetentionWorkloadRequest,
UpdateDeckConfigsMode,
} from "@generated/anki/deck_config_pb";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let openHelpModal: (String) => void; export let openHelpModal: (String) => void;
export let onPresetChange: () => void; export let onPresetChange: () => void;
export let newlyEnabled = false;
const config = state.currentConfig; const config = state.currentConfig;
const defaults = state.defaults; const defaults = state.defaults;
@ -39,6 +44,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: lastOptimizationWarning = $: lastOptimizationWarning =
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
let desiredRetentionFocused = false;
let desiredRetentionEverFocused = false;
let optimized = false;
const startingDesiredRetention = $config.desiredRetention.toFixed(2);
$: if (desiredRetentionFocused) {
desiredRetentionEverFocused = true;
}
$: showDesiredRetentionTooltip =
newlyEnabled || desiredRetentionEverFocused || optimized;
let computeParamsProgress: ComputeParamsProgress | undefined; let computeParamsProgress: ComputeParamsProgress | undefined;
let computingParams = false; let computingParams = false;
@ -47,10 +61,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: computing = computingParams || checkingParams; $: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2)); $: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning( $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
roundedRetention,
fsrsParams($config), let timeoutId: ReturnType<typeof setTimeout> | undefined = undefined;
); const WORKLOAD_UPDATE_DELAY_MS = 100;
let desiredRetentionChangeInfo = "";
$: {
clearTimeout(timeoutId);
if (showDesiredRetentionTooltip) {
timeoutId = setTimeout(() => {
getRetentionChangeInfo(roundedRetention, fsrsParams($config));
}, WORKLOAD_UPDATE_DELAY_MS);
} else {
desiredRetentionChangeInfo = "";
}
}
$: retentionWarningClass = getRetentionWarningClass(roundedRetention); $: retentionWarningClass = getRetentionWarningClass(roundedRetention);
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
@ -67,23 +94,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
reviewOrder: $config.reviewOrder, reviewOrder: $config.reviewOrder,
}); });
function getRetentionWarning(retention: number, params: number[]): string { const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5 const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;
const factor = 0.9 ** (1 / decay) - 1;
const stability = 100; function getRetentionLongShortWarning(retention: number) {
const days = Math.round( if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {
(stability / factor) * (Math.pow(retention, 1 / decay) - 1), return tr.deckConfigDesiredRetentionTooLow();
); } else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {
if (days === 100) { return tr.deckConfigDesiredRetentionTooHigh();
} else {
return ""; return "";
} }
return tr.deckConfigA100DayInterval({ days }); }
async function getRetentionChangeInfo(retention: number, params: number[]) {
if (+startingDesiredRetention == roundedRetention) {
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged();
return;
}
const request = new GetRetentionWorkloadRequest({
w: params,
search: defaultparamSearch,
before: +startingDesiredRetention,
after: retention,
});
const resp = await getRetentionWorkload(request);
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({
factor: resp.factor.toFixed(2),
previousDr: (+startingDesiredRetention * 100).toString(),
});
} }
function getRetentionWarningClass(retention: number): string { function getRetentionWarningClass(retention: number): string {
if (retention < 0.7 || retention > 0.97) { if (retention < 0.7 || retention > 0.97) {
return "alert-danger"; return "alert-danger";
} else if (retention < 0.8 || retention > 0.95) { } else if (
retention < DESIRED_RETENTION_LOW_THRESHOLD ||
retention > DESIRED_RETENTION_HIGH_THRESHOLD
) {
return "alert-warning"; return "alert-warning";
} else { } else {
return "alert-info"; return "alert-info";
@ -146,6 +194,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setTimeout(() => alert(msg), 200); setTimeout(() => alert(msg), 200);
} else { } else {
$config.fsrsParams6 = resp.params; $config.fsrsParams6 = resp.params;
optimized = true;
} }
if (computeParamsProgress) { if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total; computeParamsProgress.current = computeParamsProgress.total;
@ -237,12 +286,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.7} min={0.7}
max={0.99} max={0.99}
percentage={true} percentage={true}
bind:focused={desiredRetentionFocused}
> >
<SettingTitle on:click={() => openHelpModal("desiredRetention")}> <SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()} {tr.deckConfigDesiredRetention()}
</SettingTitle> </SettingTitle>
</SpinBoxFloatRow> </SpinBoxFloatRow>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} /> <Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
<div class="ms-1 me-1"> <div class="ms-1 me-1">
@ -331,4 +382,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn { .btn {
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
} }
:global(.two-line) {
white-space: pre-wrap;
min-height: calc(2ch + 30px);
box-sizing: content-box;
display: flex;
align-content: center;
flex-wrap: wrap;
}
</style> </style>

View file

@ -25,6 +25,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let onPresetChange: () => void; export let onPresetChange: () => void;
const fsrs = state.fsrs; const fsrs = state.fsrs;
let newlyEnabled = false;
$: if (!$fsrs) {
newlyEnabled = true;
}
const settings = { const settings = {
fsrs: { fsrs: {
@ -94,6 +98,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{#if $fsrs} {#if $fsrs}
<FsrsOptions <FsrsOptions
{state} {state}
{newlyEnabled}
openHelpModal={(key) => openHelpModal={(key) =>
openHelpModal(Object.keys(settings).indexOf(key))} openHelpModal(Object.keys(settings).indexOf(key))}
{onPresetChange} {onPresetChange}

View file

@ -15,6 +15,7 @@
export let max = 9999; export let max = 9999;
export let step = 0.01; export let step = 0.01;
export let percentage = false; export let percentage = false;
export let focused = false;
</script> </script>
<Row --cols={13}> <Row --cols={13}>
@ -23,7 +24,7 @@
</Col> </Col>
<Col --col-size={6} breakpoint="xs"> <Col --col-size={6} breakpoint="xs">
<ConfigInput> <ConfigInput>
<SpinBox bind:value {min} {max} {step} {percentage} /> <SpinBox bind:value {min} {max} {step} {percentage} bind:focused />
<RevertButton slot="revert" bind:value {defaultValue} /> <RevertButton slot="revert" bind:value {defaultValue} />
</ConfigInput> </ConfigInput>
</Col> </Col>