Anki/ts/deck-options/FsrsOptions.svelte
Damien Elmes 5004cd332b
Integrate FSRS into Anki (#2654)
* Pack FSRS data into card.data

* Update FSRS card data when preset or weights change

+ Show FSRS stats in card stats

* Show a warning when there's a limited review history

* Add some translations; tweak UI

* Fix default requested retention

* Add browser columns, fix calculation of R

* Property searches

eg prop:d>0.1

* Integrate FSRS into reviewer

* Warn about long learning steps

* Hide minimum interval when FSRS is on

* Don't apply interval multiplier to FSRS intervals

* Expose memory state to Python

* Don't set memory state on new cards

* Port Jarret's new tests; add some helpers to make tests more compact

https://github.com/open-spaced-repetition/fsrs-rs/pull/64

* Fix learning cards not being given memory state

* Require update to v3 scheduler

* Don't exclude single learning step when calculating memory state

* Use relearning step when learning steps unavailable

* Update docstring

* fix single_card_revlog_to_items (#2656)

* not need check the review_kind for unique_dates

* add email address to CONTRIBUTORS

* fix last first learn & keep early review

* cargo fmt

* cargo clippy --fix

* Add Jarrett to about screen

* Fix fsrs_memory_state being initialized to default in get_card()

* Set initial memory state on graduate

* Update to latest FSRS

* Fix experiment.log being empty

* Fix broken colpkg imports

Introduced by "Update FSRS card data when preset or weights change"

* Update memory state during (re)learning; use FSRS for graduating intervals

* Reset memory state when cards are manually rescheduled as new

* Add difficulty graph; hide eases when FSRS enabled

* Add retrievability graph

* Derive memory_state from revlog when it's missing and shouldn't be

---------

Co-authored-by: Jarrett Ye <jarrett.ye@outlook.com>
2023-09-16 16:09:26 +10:00

366 lines
11 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 ComputeWeightsProgress,
} from "@tslib/anki/collection_pb";
import { ComputeOptimalRetentionRequest } from "@tslib/anki/scheduler_pb";
import {
computeFsrsWeights,
computeOptimalRetention,
evaluateWeights,
setWantsAbort,
} from "@tslib/backend";
import * as tr from "@tslib/ftl";
import { runWithBackendProgress } from "@tslib/progress";
import SettingTitle from "../components/SettingTitle.svelte";
import type { DeckOptionsState } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import Warning from "./Warning.svelte";
import WeightsInputRow from "./WeightsInputRow.svelte";
export let state: DeckOptionsState;
const config = state.currentConfig;
const defaults = state.defaults;
let computeWeightsProgress: ComputeWeightsProgress | undefined;
let computeWeightsWarning = "";
let customSearch = "";
let computing = false;
let computeRetentionProgress:
| ComputeWeightsProgress
| ComputeRetentionProgress
| undefined;
const computeOptimalRequest = new ComputeOptimalRetentionRequest({
deckSize: 10000,
daysToSimulate: 365,
maxSecondsOfStudyPerDay: 1800,
maxInterval: 36500,
recallSecsHard: 14.0,
recallSecsGood: 10.0,
recallSecsEasy: 6.0,
forgetSecs: 50,
learnSecs: 20,
firstRatingProbabilityAgain: 0.15,
firstRatingProbabilityHard: 0.2,
firstRatingProbabilityGood: 0.6,
firstRatingProbabilityEasy: 0.05,
reviewRatingProbabilityHard: 0.3,
reviewRatingProbabilityGood: 0.6,
reviewRatingProbabilityEasy: 0.1,
});
async function computeWeights(): Promise<void> {
if (computing) {
await setWantsAbort({});
return;
}
computing = true;
try {
await runWithBackendProgress(
async () => {
const search = customSearch ?? `preset:"${state.getCurrentName()}"`;
const resp = await computeFsrsWeights({
search,
});
if (computeWeightsProgress) {
computeWeightsProgress.current = computeWeightsProgress.total;
}
if (resp.fsrsItems < 1000) {
computeWeightsWarning = tr.deckConfigLimitedHistory({
count: resp.fsrsItems,
});
} else {
computeWeightsWarning = "";
}
$config.fsrsWeights = resp.weights;
},
(progress) => {
if (progress.value.case === "computeWeights") {
computeWeightsProgress = progress.value.value;
}
},
);
} finally {
computing = false;
}
}
async function checkWeights(): Promise<void> {
if (computing) {
await setWantsAbort({});
return;
}
computing = true;
try {
await runWithBackendProgress(
async () => {
const search = customSearch ?? `preset:"${state.getCurrentName()}"`;
const resp = await evaluateWeights({
weights: $config.fsrsWeights,
search,
});
if (computeWeightsProgress) {
computeWeightsProgress.current = computeWeightsProgress.total;
}
setTimeout(
() =>
alert(
`Log loss: ${resp.logLoss.toFixed(
3,
)}, RMSE(bins): ${resp.rmseBins.toFixed(
3,
)}. ${tr.deckConfigSmallerIsBetter()}`,
),
200,
);
},
(progress) => {
if (progress.value.case === "computeWeights") {
computeWeightsProgress = progress.value.value;
}
},
);
} finally {
computing = false;
}
}
async function computeRetention(): Promise<void> {
if (computing) {
await setWantsAbort({});
return;
}
computing = true;
try {
await runWithBackendProgress(
async () => {
computeOptimalRequest.weights = $config.fsrsWeights;
const resp = await computeOptimalRetention(computeOptimalRequest);
$config.desiredRetention = resp.optimalRetention;
if (computeRetentionProgress) {
computeRetentionProgress.current =
computeRetentionProgress.total;
}
},
(progress) => {
if (progress.value.case === "computeRetention") {
computeRetentionProgress = progress.value.value;
}
},
);
} finally {
computing = false;
}
}
$: computeWeightsProgressString = renderWeightProgress(computeWeightsProgress);
$: computeRetentionProgressString = renderRetentionProgress(
computeRetentionProgress,
);
function renderWeightProgress(val: ComputeWeightsProgress | undefined): String {
if (!val || !val.total) {
return "";
}
let pct = ((val.current / val.total) * 100).toFixed(2);
pct = `${pct}%`;
if (val instanceof ComputeRetentionProgress) {
return pct;
} else {
return `${pct} of ${val.fsrsItems} reviews`;
}
}
function renderRetentionProgress(
val: ComputeRetentionProgress | undefined,
): String {
if (!val || !val.total) {
return "";
}
const pct = ((val.current / val.total) * 100).toFixed(2);
return `${pct}%`;
}
</script>
<SpinBoxFloatRow
bind:value={$config.desiredRetention}
defaultValue={defaults.desiredRetention}
min={0.8}
max={0.97}
>
<SettingTitle>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
<div class="ms-1 me-1">
<WeightsInputRow bind:value={$config.fsrsWeights} defaultValue={[]}>
<SettingTitle>{tr.deckConfigWeights()}</SettingTitle>
</WeightsInputRow>
</div>
<div class="m-2">
<details>
<summary>{tr.deckConfigComputeOptimalWeights()}</summary>
<input
bind:value={customSearch}
placeholder={tr.deckConfigComputeWeightsSearch()}
class="w-100 mb-1"
/>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
on:click={() => computeWeights()}
>
{#if computing}
{tr.actionsCancel()}
{:else}
{tr.deckConfigComputeButton()}
{/if}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
on:click={() => checkWeights()}
>
{#if computing}
{tr.actionsCancel()}
{:else}
{tr.deckConfigAnalyzeButton()}
{/if}
</button>
{#if computing}<div>{computeWeightsProgressString}</div>{/if}
<Warning warning={computeWeightsWarning} />
</details>
</div>
<div class="m-2">
<details>
<summary>{tr.deckConfigComputeOptimalRetention()}</summary>
Deck size:
<br />
<input type="number" bind:value={computeOptimalRequest.deckSize} />
<br />
Days to simulate
<br />
<input type="number" bind:value={computeOptimalRequest.daysToSimulate} />
<br />
Max seconds of study per day:
<br />
<input
type="number"
bind:value={computeOptimalRequest.maxSecondsOfStudyPerDay}
/>
<br />
Maximum interval:
<br />
<input type="number" bind:value={computeOptimalRequest.maxInterval} />
<br />
Seconds to forget a card (again):
<br />
<input type="number" bind:value={computeOptimalRequest.forgetSecs} />
<br />
Seconds to recall a card (hard):
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecsHard} />
<br />
Seconds to recall a card (good):
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecsGood} />
<br />
Seconds to recall a card (easy):
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecsEasy} />
<br />
Seconds to learn a card:
<br />
<input type="number" bind:value={computeOptimalRequest.learnSecs} />
<br />
First rating probability (again):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityAgain}
/>
<br />
First rating probability (hard):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityHard}
/>
<br />
First rating probability (good):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityGood}
/>
<br />
First rating probability (easy):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityEasy}
/>
<br />
Review rating probability (hard):
<br />
<input
type="number"
bind:value={computeOptimalRequest.reviewRatingProbabilityHard}
/>
<br />
Review rating probability (good):
<br />
<input
type="number"
bind:value={computeOptimalRequest.reviewRatingProbabilityGood}
/>
<br />
Review rating probability (easy):
<br />
<input
type="number"
bind:value={computeOptimalRequest.reviewRatingProbabilityEasy}
/>
<br />
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
on:click={() => computeRetention()}
>
{#if computing}
{tr.actionsCancel()}
{:else}
{tr.deckConfigComputeButton()}
{/if}
</button>
<div>{computeRetentionProgressString}</div>
</details>
</div>
<style>
</style>