mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
1e6d12b830
commit
f29bcb743b
10 changed files with 207 additions and 22 deletions
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -659,6 +659,7 @@ exposed_backend_list = [
|
|||
"simulate_fsrs_review",
|
||||
# DeckConfigService
|
||||
"get_ignored_before_count",
|
||||
"get_retention_workload",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
49
rslib/src/storage/card/get_costs_for_retention.sql
Normal file
49
rslib/src/storage/card/get_costs_for_retention.sql
Normal 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;
|
|
@ -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
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue