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
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-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-workload-factor-change = Approximate workload: {$factor}x
(compared to {$previousDR}% desired retention)
deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you.
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 =
{ $reviews ->
[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.
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-count = Review Count/Day
deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total

View file

@ -25,6 +25,8 @@ service DeckConfigService {
returns (collection.OpChanges);
rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest)
returns (GetIgnoredBeforeCountResponse);
rpc GetRetentionWorkload(GetRetentionWorkloadRequest)
returns (GetRetentionWorkloadResponse);
}
// Implicitly includes any of the above methods that are not listed in the
@ -35,6 +37,17 @@ message DeckConfigId {
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 {
string ignore_revlogs_before_date = 1;
string search = 2;

View file

@ -659,6 +659,7 @@ exposed_backend_list = [
"simulate_fsrs_review",
# DeckConfigService
"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),
})
}
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 {

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)?)
}
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)]
pub(crate) fn get_all_cards(&self) -> Vec<Card> {
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;
let input: HTMLInputElement;
let focused = false;
export let focused = false;
let multiplier: number;
$: multiplier = percentage ? 100 : 1;
@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value={stringValue}
bind:this={input}
on:blur={update}
on:change={update}
on:input={onInput}
on:focusin={() => (focused = true)}
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 {
computeFsrsParams,
evaluateParams,
getRetentionWorkload,
setWantsAbort,
} from "@generated/backend";
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 ParamsSearchRow from "./ParamsSearchRow.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 openHelpModal: (String) => void;
export let onPresetChange: () => void;
export let newlyEnabled = false;
const config = state.currentConfig;
const defaults = state.defaults;
@ -39,6 +44,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: lastOptimizationWarning =
$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 computingParams = false;
@ -47,10 +61,23 @@ 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));
$: desiredRetentionWarning = getRetentionWarning(
roundedRetention,
fsrsParams($config),
);
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
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);
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
@ -67,23 +94,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
reviewOrder: $config.reviewOrder,
});
function getRetentionWarning(retention: number, params: number[]): string {
const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5
const factor = 0.9 ** (1 / decay) - 1;
const stability = 100;
const days = Math.round(
(stability / factor) * (Math.pow(retention, 1 / decay) - 1),
);
if (days === 100) {
const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95;
function getRetentionLongShortWarning(retention: number) {
if (retention < DESIRED_RETENTION_LOW_THRESHOLD) {
return tr.deckConfigDesiredRetentionTooLow();
} else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) {
return tr.deckConfigDesiredRetentionTooHigh();
} else {
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 {
if (retention < 0.7 || retention > 0.97) {
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";
} else {
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);
} else {
$config.fsrsParams6 = resp.params;
optimized = true;
}
if (computeParamsProgress) {
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}
max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
<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 {
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>

View file

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

View file

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