diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 12512acb0..d16ce45bc 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -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 diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 48cb71479..bb9ead778 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -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; diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 203f23ef9..cc7c4c2dd 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -659,6 +659,7 @@ exposed_backend_list = [ "simulate_fsrs_review", # DeckConfigService "get_ignored_before_count", + "get_retention_workload", ] diff --git a/rslib/src/deckconfig/service.rs b/rslib/src/deckconfig/service.rs index 7ac08902e..516132763 100644 --- a/rslib/src/deckconfig/service.rs +++ b/rslib/src/deckconfig/service.rs @@ -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 { + 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 for anki_proto::deck_config::DeckConfig { diff --git a/rslib/src/storage/card/get_costs_for_retention.sql b/rslib/src/storage/card/get_costs_for_retention.sql new file mode 100644 index 000000000..811ca3050 --- /dev/null +++ b/rslib/src/storage/card/get_costs_for_retention.sql @@ -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; \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 353537f90..bef3251de 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -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 { self.db diff --git a/ts/lib/components/SpinBox.svelte b/ts/lib/components/SpinBox.svelte index a6a1a0b20..f52845a4d 100644 --- a/ts/lib/components/SpinBox.svelte +++ b/ts/lib/components/SpinBox.svelte @@ -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)} diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 356437cb9..7b2318e00 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -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 | 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} > openHelpModal("desiredRetention")}> {tr.deckConfigDesiredRetention()} +
@@ -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; + } diff --git a/ts/routes/deck-options/FsrsOptionsOuter.svelte b/ts/routes/deck-options/FsrsOptionsOuter.svelte index d347cca50..ca22ce5af 100644 --- a/ts/routes/deck-options/FsrsOptionsOuter.svelte +++ b/ts/routes/deck-options/FsrsOptionsOuter.svelte @@ -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} openHelpModal(Object.keys(settings).indexOf(key))} {onPresetChange} diff --git a/ts/routes/deck-options/SpinBoxFloatRow.svelte b/ts/routes/deck-options/SpinBoxFloatRow.svelte index f431c0df1..5aa93bd30 100644 --- a/ts/routes/deck-options/SpinBoxFloatRow.svelte +++ b/ts/routes/deck-options/SpinBoxFloatRow.svelte @@ -15,6 +15,7 @@ export let max = 9999; export let step = 0.01; export let percentage = false; + export let focused = false; @@ -23,7 +24,7 @@ - +