From dfee38898d76a2ddc78262fbeecb813a25920ee5 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Mon, 26 May 2025 21:25:27 +0800 Subject: [PATCH 1/3] calculate accurate retrievability in card info (#4034) --- Cargo.lock | 2 +- Cargo.toml | 2 +- rslib/src/stats/card.rs | 16 +++++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eea0017db..f73263c28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "fsrs" version = "4.0.0" -source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=092c20bac7d9239a991ae5b561556ad34c706c16#092c20bac7d9239a991ae5b561556ad34c706c16" +source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=33ec3ee4d5d73e704633469cf5bf1a42e620a524#33ec3ee4d5d73e704633469cf5bf1a42e620a524" dependencies = [ "burn", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index 52894f8ea..7d1645fc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] # version = "3.0.0" git = "https://github.com/open-spaced-repetition/fsrs-rs.git" -rev = "092c20bac7d9239a991ae5b561556ad34c706c16" +rev = "33ec3ee4d5d73e704633469cf5bf1a42e620a524" # path = "../open-spaced-repetition/fsrs-rs" [workspace.dependencies] diff --git a/rslib/src/stats/card.rs b/rslib/src/stats/card.rs index dce38c56a..b04539717 100644 --- a/rslib/src/stats/card.rs +++ b/rslib/src/stats/card.rs @@ -30,19 +30,21 @@ impl Collection { let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let timing = self.timing_today()?; - let days_elapsed = self + let seconds_elapsed = self .storage .time_of_last_review(card.id)? - .map(|ts| timing.next_day_at.elapsed_days_since(ts)) + .map(|ts| timing.now.elapsed_secs_since(ts)) .unwrap_or_default() as u32; let fsrs_retrievability = card .memory_state - .zip(Some(days_elapsed)) + .zip(Some(seconds_elapsed)) .zip(Some(card.decay.unwrap_or(FSRS5_DEFAULT_DECAY))) - .map(|((state, days), decay)| { - FSRS::new(None) - .unwrap() - .current_retrievability(state.into(), days, decay) + .map(|((state, seconds), decay)| { + FSRS::new(None).unwrap().current_retrievability_seconds( + state.into(), + seconds, + decay, + ) }); let original_deck = if card.original_deck_id == DeckId(0) { From 1e6d12b830b9911710d919abc2f68d0b7992ecbb Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Mon, 26 May 2025 21:33:39 +0800 Subject: [PATCH 2/3] Set Due Date: Set interval to actual elapsed days when FSRS is enabled (#4035) --- rslib/src/scheduler/reviews.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/rslib/src/scheduler/reviews.rs b/rslib/src/scheduler/reviews.rs index 983ecc452..53802fb9a 100644 --- a/rslib/src/scheduler/reviews.rs +++ b/rslib/src/scheduler/reviews.rs @@ -32,12 +32,14 @@ impl Card { force_reset: bool, ) { let new_due = (today + days_from_today) as i32; - let new_interval = - if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { - days_from_today - } else { - self.interval - }; + let fsrs_enabled = self.memory_state.is_some(); + let new_interval = if fsrs_enabled { + self.interval.saturating_add_signed(new_due - self.due) + } else if force_reset || !matches!(self.ctype, CardType::Review | CardType::Relearn) { + days_from_today + } else { + self.interval + }; let ease_factor = (ease_factor * 1000.0).round() as u16; self.schedule_as_review(new_interval, new_due, ease_factor); From f29bcb743b78f81f45b912769b3618a266256634 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Tue, 27 May 2025 04:07:21 +0100 Subject: [PATCH 3/3] 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 * 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> --- ftl/core/deck-config.ftl | 17 ++-- proto/anki/deck_config.proto | 13 +++ qt/aqt/mediasrv.py | 1 + rslib/src/deckconfig/service.rs | 34 +++++++ .../storage/card/get_costs_for_retention.sql | 49 ++++++++++ rslib/src/storage/card/mod.rs | 14 +++ ts/lib/components/SpinBox.svelte | 3 +- ts/routes/deck-options/FsrsOptions.svelte | 90 +++++++++++++++---- .../deck-options/FsrsOptionsOuter.svelte | 5 ++ ts/routes/deck-options/SpinBoxFloatRow.svelte | 3 +- 10 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 rslib/src/storage/card/get_costs_for_retention.sql 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 @@ - +