Anki/ts/routes/deck-options/FsrsOptions.svelte
Luc Mcgrady dda730dfa2
Fix/Invalid memory states in simulator after parameters changed (#4317)
* Fix/Invalid memory states after optimization for simulator

* Update ts/routes/deck-options/FsrsOptions.svelte

* typo

* ./check
2025-09-04 14:35:00 +10:00

509 lines
17 KiB
Svelte

<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import {
ComputeRetentionProgress,
type ComputeParamsProgress,
} from "@generated/anki/collection_pb";
import { SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb";
import {
computeFsrsParams,
evaluateParamsLegacy,
getRetentionWorkload,
setWantsAbort,
} from "@generated/backend";
import * as tr from "@generated/ftl";
import { runWithBackendProgress } from "@tslib/progress";
import SettingTitle from "$lib/components/SettingTitle.svelte";
import SwitchRow from "$lib/components/SwitchRow.svelte";
import GlobalLabel from "./GlobalLabel.svelte";
import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import Warning from "./Warning.svelte";
import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte";
import SimulatorModal from "./SimulatorModal.svelte";
import {
GetRetentionWorkloadRequest,
type GetRetentionWorkloadResponse,
UpdateDeckConfigsMode,
} from "@generated/anki/deck_config_pb";
import type Modal from "bootstrap/js/dist/modal";
import TabbedValue from "./TabbedValue.svelte";
import Item from "$lib/components/Item.svelte";
import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte";
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;
const fsrsReschedule = state.fsrsReschedule;
const daysSinceLastOptimization = state.daysSinceLastOptimization;
const limits = state.deckLimits;
$: lastOptimizationWarning =
$daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : "";
let desiredRetentionFocused = false;
let desiredRetentionEverFocused = false;
let optimized = false;
const initialParams = [...fsrsParams($config)];
$: if (desiredRetentionFocused) {
desiredRetentionEverFocused = true;
}
$: showDesiredRetentionTooltip =
newlyEnabled || desiredRetentionEverFocused || optimized;
let computeParamsProgress: ComputeParamsProgress | undefined;
let computingParams = false;
let checkingParams = false;
const healthCheck = state.fsrsHealthCheck;
$: computing = computingParams || checkingParams;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number(effectiveDesiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention);
let desiredRetentionChangeInfo = "";
$: if (showDesiredRetentionTooltip) {
getRetentionChangeInfo(roundedRetention, fsrsParams($config));
}
$: retentionWarningClass = getRetentionWarningClass(roundedRetention);
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
// Create tabs for desired retention
const desiredRetentionTabs: ValueTab[] = [
new ValueTab(
tr.deckConfigSharedPreset(),
$config.desiredRetention,
(value) => ($config.desiredRetention = value!),
$config.desiredRetention,
null,
),
new ValueTab(
tr.deckConfigDeckOnly(),
$limits.desiredRetention ?? null,
(value) => ($limits.desiredRetention = value ?? undefined),
null,
null,
),
];
// Get the effective desired retention value (deck-specific if set, otherwise config default)
let effectiveDesiredRetention =
$limits.desiredRetention ?? $config.desiredRetention;
const startingDesiredRetention = effectiveDesiredRetention.toFixed(2);
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
params: fsrsParams($config),
desiredRetention: $config.desiredRetention,
newLimit: $config.newPerDay,
reviewLimit: $config.reviewsPerDay,
maxInterval: $config.maximumReviewInterval,
search: `preset:"${state.getCurrentNameForSearch()}" -is:suspended`,
newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit,
easyDaysPercentages: $config.easyDaysPercentages,
reviewOrder: $config.reviewOrder,
historicalRetention: $config.historicalRetention,
learningStepCount: $config.learnSteps.length,
relearningStepCount: $config.relearnSteps.length,
});
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 "";
}
}
let retentionWorkloadInfo: undefined | Promise<GetRetentionWorkloadResponse> =
undefined;
let lastParams = [...fsrsParams($config)];
async function getRetentionChangeInfo(retention: number, params: number[]) {
if (+startingDesiredRetention == roundedRetention) {
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged();
return;
}
if (
// If the cache is empty and a request has not yet been made to fill it
!retentionWorkloadInfo ||
// If the parameters have been changed
lastParams.toString() !== params.toString()
) {
const request = new GetRetentionWorkloadRequest({
w: params,
search: defaultparamSearch,
});
lastParams = [...params];
retentionWorkloadInfo = getRetentionWorkload(request);
}
const previous = +startingDesiredRetention * 100;
const after = retention * 100;
const resp = await retentionWorkloadInfo;
const factor = resp.costs[after] / resp.costs[previous];
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({
factor: factor.toFixed(2),
previousDr: previous.toString(),
});
}
function getRetentionWarningClass(retention: number): string {
if (retention < 0.7 || retention > 0.97) {
return "alert-danger";
} else if (
retention < DESIRED_RETENTION_LOW_THRESHOLD ||
retention > DESIRED_RETENTION_HIGH_THRESHOLD
) {
return "alert-warning";
} else {
return "alert-info";
}
}
function getIgnoreRevlogsBeforeMs() {
return BigInt(
$config.ignoreRevlogsBeforeDate
? new Date($config.ignoreRevlogsBeforeDate).getTime()
: 0,
);
}
async function computeParams(): Promise<void> {
if (computingParams) {
await setWantsAbort({});
return;
}
if (state.presetAssignmentsChanged()) {
alert(tr.deckConfigPleaseSaveYourChangesFirst());
return;
}
computingParams = true;
computeParamsProgress = undefined;
try {
await runWithBackendProgress(
async () => {
const params = fsrsParams($config);
const RelearningSteps = $config.relearnSteps;
let numOfRelearningStepsInDay = 0;
let accumulatedTime = 0;
for (let i = 0; i < RelearningSteps.length; i++) {
accumulatedTime += RelearningSteps[i];
if (accumulatedTime >= 1440) {
break;
}
numOfRelearningStepsInDay++;
}
const resp = await computeFsrsParams({
search: $config.paramSearch
? $config.paramSearch
: defaultparamSearch,
ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),
currentParams: params,
numOfRelearningSteps: numOfRelearningStepsInDay,
healthCheck: $healthCheck,
});
const alreadyOptimal =
(params.length &&
params.every(
(n, i) => n.toFixed(4) === resp.params[i].toFixed(4),
)) ||
resp.params.length === 0;
let healthCheckMessage = "";
if (resp.healthCheckPassed !== undefined) {
healthCheckMessage = resp.healthCheckPassed
? tr.deckConfigFsrsGoodFit()
: tr.deckConfigFsrsBadFitWarning();
}
let alreadyOptimalMessage = "";
if (alreadyOptimal) {
alreadyOptimalMessage = resp.fsrsItems
? tr.deckConfigFsrsParamsOptimal()
: tr.deckConfigFsrsParamsNoReviews();
}
const message = [alreadyOptimalMessage, healthCheckMessage]
.filter((a) => a)
.join("\n\n");
if (message) {
setTimeout(() => alert(message), 200);
}
if (!alreadyOptimal) {
$config.fsrsParams6 = resp.params;
setTimeout(() => {
optimized = true;
}, 201);
}
if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total;
}
},
(progress) => {
if (progress.value.case === "computeParams") {
computeParamsProgress = progress.value.value;
}
},
);
} finally {
computingParams = false;
}
}
async function checkParams(): Promise<void> {
if (checkingParams) {
await setWantsAbort({});
return;
}
if (state.presetAssignmentsChanged()) {
alert(tr.deckConfigPleaseSaveYourChangesFirst());
return;
}
checkingParams = true;
computeParamsProgress = undefined;
try {
await runWithBackendProgress(
async () => {
const search = $config.paramSearch
? $config.paramSearch
: defaultparamSearch;
const resp = await evaluateParamsLegacy({
search,
ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(),
params: fsrsParams($config),
});
if (computeParamsProgress) {
computeParamsProgress.current = computeParamsProgress.total;
}
setTimeout(
() =>
alert(
`Log loss: ${resp.logLoss.toFixed(4)}, RMSE(bins): ${(
resp.rmseBins * 100
).toFixed(2)}%. ${tr.deckConfigSmallerIsBetter()}`,
),
200,
);
},
(progress) => {
if (progress.value.case === "computeParams") {
computeParamsProgress = progress.value.value;
}
},
);
} finally {
checkingParams = false;
}
}
$: computeParamsProgressString = renderWeightProgress(computeParamsProgress);
$: totalReviews = computeParamsProgress?.reviews ?? undefined;
function renderWeightProgress(val: ComputeParamsProgress | undefined): String {
if (!val || !val.total) {
return "";
}
const pct = ((val.current / val.total) * 100).toFixed(1);
if (val instanceof ComputeRetentionProgress) {
return `${pct}%`;
} else {
if (val.current === val.total) {
return tr.deckConfigCheckingForImprovement();
} else {
return tr.deckConfigPercentOfReviews({ pct, reviews: val.reviews });
}
}
}
async function computeAllParams(): Promise<void> {
await commitEditing();
state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);
}
function showSimulatorModal(modal: Modal) {
if (fsrsParams($config).toString() === initialParams.toString()) {
modal?.show();
} else {
alert(tr.deckConfigFsrsSimulateSavePreset());
}
}
let simulatorModal: Modal;
let workloadModal: Modal;
</script>
<DynamicallySlottable slotHost={Item} api={{}}>
<Item>
<SpinBoxFloatRow
bind:value={effectiveDesiredRetention}
defaultValue={defaults.desiredRetention}
min={0.7}
max={0.99}
percentage={true}
bind:focused={desiredRetentionFocused}
>
<TabbedValue
slot="tabs"
tabs={desiredRetentionTabs}
bind:value={effectiveDesiredRetention}
/>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
</DynamicallySlottable>
<button
class="btn btn-primary"
on:click={() => {
simulateFsrsRequest.reviewLimit = 9999;
showSimulatorModal(workloadModal);
}}
>
{tr.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental()}
</button>
<Warning warning={desiredRetentionChangeInfo} className={"alert-info two-line"} />
<Warning warning={desiredRetentionWarning} className={retentionWarningClass} />
<div class="ms-1 me-1">
<ParamsInputRow
bind:value={$config.fsrsParams6}
defaultValue={[]}
defaults={defaults.fsrsParams6}
>
<SettingTitle on:click={() => openHelpModal("modelParams")}>
{tr.deckConfigWeights()}
</SettingTitle>
</ParamsInputRow>
<ParamsSearchRow
bind:value={$config.paramSearch}
placeholder={defaultparamSearch}
/>
<SwitchRow bind:value={$fsrsReschedule} defaultValue={false}>
<SettingTitle on:click={() => openHelpModal("rescheduleCardsOnChange")}>
<GlobalLabel title={tr.deckConfigRescheduleCardsOnChange()} />
</SettingTitle>
</SwitchRow>
{#if $fsrsReschedule}
<Warning warning={tr.deckConfigRescheduleCardsWarning()} />
{/if}
<SwitchRow bind:value={$healthCheck} defaultValue={false}>
<SettingTitle on:click={() => openHelpModal("healthCheck")}>
<GlobalLabel
title={tr.deckConfigSlowSuffix({ text: tr.deckConfigHealthCheck() })}
/>
</SettingTitle>
</SwitchRow>
<button
class="btn {computingParams ? 'btn-warning' : 'btn-primary'}"
disabled={!computingParams && computing}
on:click={() => computeParams()}
>
{#if computingParams}
{tr.actionsCancel()}
{:else}
{tr.deckConfigOptimizeButton()}
{/if}
</button>
{#if state.legacyEvaluate}
<button
class="btn {checkingParams ? 'btn-warning' : 'btn-primary'}"
disabled={!checkingParams && computing}
on:click={() => checkParams()}
>
{#if checkingParams}
{tr.actionsCancel()}
{:else}
{tr.deckConfigEvaluateButton()}
{/if}
</button>
{/if}
<div>
{#if computingParams || checkingParams}
{computeParamsProgressString}
{:else if totalReviews !== undefined}
{tr.statisticsReviews({ reviews: totalReviews })}
{/if}
</div>
</div>
<div class="m-1">
<Warning warning={lastOptimizationWarning} className="alert-warning" />
<button class="btn btn-primary" on:click={() => computeAllParams()}>
{tr.deckConfigSaveAndOptimize()}
</button>
</div>
<hr />
<div class="m-1">
<button class="btn btn-primary" on:click={() => showSimulatorModal(simulatorModal)}>
{tr.deckConfigFsrsSimulatorExperimental()}
</button>
</div>
<SimulatorModal
bind:modal={simulatorModal}
{state}
{simulateFsrsRequest}
{computing}
{openHelpModal}
{onPresetChange}
/>
<SimulatorModal
bind:modal={workloadModal}
workload
{state}
{simulateFsrsRequest}
{computing}
{openHelpModal}
{onPresetChange}
/>
<style>
.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;
}
hr {
border-top: 1px solid var(--border);
opacity: 1;
}
</style>