Feat: Simulator suspend after lapse count (#3837)

* Added: Leech suspend to simulator

* Added: leech threshold spin box

* Update git rev

* Added: Save to preset options

* ./check

* Added: "Advanced settings" dropdown

* Removed: Indent

* Added: Easy days

* Added: Sticky header

* Removed: Easy Day updating without saving

* un-nest disclosure

* bump fsrs

* Update a VSCode setting to match recent releases

* Move Easy Days above the Advanced settings

I think it's a bit more logical to have Advanced come last.

* Ensure graph fits inside screen height

* Bump fsrs version
This commit is contained in:
Luc Mcgrady 2025-03-15 10:28:15 +00:00 committed by GitHub
parent 122980e06b
commit 79b6f658c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 240 additions and 128 deletions

View file

@ -2,7 +2,7 @@
"editor.formatOnSave": true,
"[python]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
"source.organizeImports": "explicit"
}
},
"files.watcherExclude": {

2
Cargo.lock generated
View file

@ -2099,7 +2099,7 @@ dependencies = [
[[package]]
name = "fsrs"
version = "3.0.0"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=96520531415e032781adfe212f8a5eed216006be#96520531415e032781adfe212f8a5eed216006be"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=22f8e453c120f5bc5996f86558a559c6b7abfc49#22f8e453c120f5bc5996f86558a559c6b7abfc49"
dependencies = [
"burn",
"itertools 0.12.1",

View file

@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs]
# version = "=2.0.3"
git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "96520531415e032781adfe212f8a5eed216006be"
rev = "22f8e453c120f5bc5996f86558a559c6b7abfc49"
# path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies]

View file

@ -393,6 +393,7 @@ message SimulateFsrsReviewRequest {
bool new_cards_ignore_review_limit = 9;
repeated float easy_days_percentages = 10;
deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11;
optional uint32 suspend_after_lapse_count = 12;
}
message SimulateFsrsReviewResponse {
@ -409,6 +410,7 @@ message ComputeOptimalRetentionRequest {
string search = 4;
double loss_aversion = 5;
repeated float easy_days_percentages = 6;
optional uint32 suspend_after_lapse_count = 7;
}
message ComputeOptimalRetentionResponse {

View file

@ -44,9 +44,9 @@ impl Collection {
let post_scheduling_fn: Option<PostSchedulingFn> =
if self.get_config_bool(BoolKey::LoadBalancerEnabled) {
Some(PostSchedulingFn(Arc::new(
move |interval, max_interval, today, due_cnt_per_day, rng| {
move |card, max_interval, today, due_cnt_per_day, rng| {
apply_load_balance_and_easy_days(
interval,
card.interval,
max_interval,
today,
due_cnt_per_day,
@ -78,6 +78,7 @@ impl Collection {
learn_limit,
review_limit: usize::MAX,
new_cards_ignore_review_limit: true,
suspend_after_lapses: None,
post_scheduling_fn,
review_priority_fn: None,
},

View file

@ -142,11 +142,13 @@ impl Collection {
.min(req.new_limit as usize);
if req.new_limit > 0 {
let new_cards = (0..new_cards).map(|i| fsrs::Card {
id: -(i as i64),
difficulty: f32::NEG_INFINITY,
stability: 1e-8, // Not filtered by fsrs-rs
last_date: f32::NEG_INFINITY, // Treated as a new card in simulation
due: ((introduced_today_count + i) / req.new_limit as usize) as f32,
interval: f32::NEG_INFINITY,
lapses: 0,
});
converted_cards.extend(new_cards);
}
@ -159,9 +161,9 @@ impl Collection {
let post_scheduling_fn: Option<PostSchedulingFn> =
if self.get_config_bool(BoolKey::LoadBalancerEnabled) {
Some(PostSchedulingFn(Arc::new(
move |interval, max_interval, today, due_cnt_per_day, rng| {
move |card, max_interval, today, due_cnt_per_day, rng| {
apply_load_balance_and_easy_days(
interval,
card.interval,
max_interval,
today,
due_cnt_per_day,
@ -198,6 +200,7 @@ impl Collection {
learn_limit: req.new_limit as usize,
review_limit: req.review_limit as usize,
new_cards_ignore_review_limit: req.new_cards_ignore_review_limit,
suspend_after_lapses: req.suspend_after_lapse_count,
post_scheduling_fn,
review_priority_fn,
};
@ -234,21 +237,25 @@ impl Card {
let relative_due = due - days_elapsed;
let last_date = (relative_due - card.interval as i32).min(0) as f32;
Some(fsrs::Card {
id: card.id.0,
difficulty: state.difficulty,
stability: state.stability,
last_date,
due: relative_due as f32,
interval: card.interval as f32,
lapses: card.lapses,
})
}
CardQueue::New => None,
CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => {
Some(fsrs::Card {
id: card.id.0,
difficulty: state.difficulty,
stability: state.stability,
last_date: 0.0,
due: 0.0,
interval: card.interval as f32,
lapses: card.lapses,
})
}
CardQueue::PreviewRepeat => None,

View file

@ -9,6 +9,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TitledContainer from "$lib/components/TitledContainer.svelte";
import type { DeckOptionsState } from "./lib";
import Warning from "./Warning.svelte";
import EasyDaysInput from "./EasyDaysInput.svelte";
export let state: DeckOptionsState;
export let api: Record<string, never>;
@ -35,16 +36,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
easyDaysChanged && !($fsrsEnabled && $reschedule)
? tr.deckConfigEasyDaysChange()
: "";
const easyDays = [
tr.deckConfigEasyDaysMonday(),
tr.deckConfigEasyDaysTuesday(),
tr.deckConfigEasyDaysWednesday(),
tr.deckConfigEasyDaysThursday(),
tr.deckConfigEasyDaysFriday(),
tr.deckConfigEasyDaysSaturday(),
tr.deckConfigEasyDaysSunday(),
];
</script>
<datalist id="easy_day_steplist">
@ -53,43 +44,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.deckConfigEasyDaysTitle()}>
<DynamicallySlottable slotHost={Item} {api}>
<Item>
<div class="easy-days-settings">
<table>
<thead>
<tr>
<th></th>
<th class="header min-col">
<span>{tr.deckConfigEasyDaysMinimum()}</span>
</th>
<th class="header text-center">
<span>{tr.deckConfigEasyDaysReduced()}</span>
</th>
<th class="header normal-col">
<span>{tr.deckConfigEasyDaysNormal()}</span>
</th>
</tr>
</thead>
<tbody>
{#each easyDays as day, index}
<tr>
<td class="day">{day}</td>
<td colspan="3">
<input
type="range"
bind:value={$config.easyDaysPercentages[index]}
step={0.5}
max={1.0}
min={0.0}
list="easy_day_steplist"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Item>
<EasyDaysInput bind:values={$config.easyDaysPercentages} />
<Item>
<Warning warning={noNormalDay} />
</Item>
@ -98,36 +53,3 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item>
</DynamicallySlottable>
</TitledContainer>
<style>
.easy-days-settings table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.easy-days-settings th,
.easy-days-settings td {
padding: 8px;
border-bottom: var(--border) solid 1px;
}
.header {
word-wrap: break-word;
font-size: smaller;
}
.easy-days-settings input[type="range"] {
width: 100%;
}
.day {
word-wrap: break-word;
font-size: smaller;
}
.min-col {
text-align: start;
}
.normal-col {
text-align: end;
}
</style>

View file

@ -0,0 +1,91 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script>
import * as tr from "@generated/ftl";
import Item from "$lib/components/Item.svelte";
const easyDays = [
tr.deckConfigEasyDaysMonday(),
tr.deckConfigEasyDaysTuesday(),
tr.deckConfigEasyDaysWednesday(),
tr.deckConfigEasyDaysThursday(),
tr.deckConfigEasyDaysFriday(),
tr.deckConfigEasyDaysSaturday(),
tr.deckConfigEasyDaysSunday(),
];
export let values = [0, 0, 0, 0, 0, 0, 0];
</script>
<Item>
<div class="easy-days-settings">
<table>
<thead>
<tr>
<th></th>
<th class="header min-col">
<span>{tr.deckConfigEasyDaysMinimum()}</span>
</th>
<th class="header text-center">
<span>{tr.deckConfigEasyDaysReduced()}</span>
</th>
<th class="header normal-col">
<span>{tr.deckConfigEasyDaysNormal()}</span>
</th>
</tr>
</thead>
<tbody>
{#each easyDays as day, index}
<tr>
<td class="day">{day}</td>
<td colspan="3">
<input
type="range"
bind:value={values[index]}
step={0.5}
max={1.0}
min={0.0}
list="easy_day_steplist"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Item>
<style>
.easy-days-settings table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.easy-days-settings th,
.easy-days-settings td {
padding: 8px;
border-bottom: var(--border) solid 1px;
}
.header {
word-wrap: break-word;
font-size: smaller;
}
.easy-days-settings input[type="range"] {
width: 100%;
}
.day {
word-wrap: break-word;
font-size: smaller;
}
.min-col {
text-align: start;
}
.normal-col {
text-align: end;
}
</style>

View file

@ -28,6 +28,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import { reviewOrderChoices } from "./choices";
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
import { DeckConfig_Config_LeechAction } from "@generated/anki/deck_config_pb";
import EasyDaysInput from "./EasyDaysInput.svelte";
export let shown = false;
export let state: DeckOptionsState;
@ -48,6 +50,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let points: Point[] = [];
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
let smooth = true;
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
let leechThreshold = $config.leechThreshold;
$: daysToSimulate = 365;
$: deckSize = 0;
@ -75,6 +79,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let resp: SimulateFsrsReviewResponse | undefined;
simulateFsrsRequest.daysToSimulate = daysToSimulate;
simulateFsrsRequest.deckSize = deckSize;
simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches
? leechThreshold
: undefined;
simulateFsrsRequest.easyDaysPercentages = easyDayPercentages;
try {
await runWithBackendProgress(
async () => {
@ -169,6 +177,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
simulateSubgraph,
);
}
let easyDayPercentages = [...$config.easyDaysPercentages];
</script>
<div class="modal" class:show={shown} class:d-block={shown} tabindex="-1">
@ -235,41 +245,88 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.maxInterval}
defaultValue={$config.maximumReviewInterval}
min={1}
max={36500}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingMaximumInterval()}
</SettingTitle>
</SpinBoxRow>
<details>
<summary>{tr.deckConfigEasyDaysTitle()}</summary>
{#key easyDayPercentages}
<EasyDaysInput bind:values={easyDayPercentages} />
{/key}
</details>
<EnumSelectorRow
bind:value={simulateFsrsRequest.reviewOrder}
defaultValue={$config.reviewOrder}
choices={reviewOrderChoices($fsrs)}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.deckConfigReviewSortOrder()}
</SettingTitle>
</EnumSelectorRow>
<details>
<summary>{"Advanced settings"}</summary>
<SpinBoxRow
bind:value={simulateFsrsRequest.maxInterval}
defaultValue={$config.maximumReviewInterval}
min={1}
max={36500}
>
<SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
{tr.schedulingMaximumInterval()}
</SettingTitle>
</SpinBoxRow>
<SwitchRow
bind:value={simulateFsrsRequest.newCardsIgnoreReviewLimit}
defaultValue={$newCardsIgnoreReviewLimit}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
<GlobalLabel title={tr.deckConfigNewCardsIgnoreReviewLimit()} />
</SettingTitle>
</SwitchRow>
<EnumSelectorRow
bind:value={simulateFsrsRequest.reviewOrder}
defaultValue={$config.reviewOrder}
choices={reviewOrderChoices($fsrs)}
>
<SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
{tr.deckConfigReviewSortOrder()}
</SettingTitle>
</EnumSelectorRow>
<SwitchRow bind:value={smooth} defaultValue={true}>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{"Smooth Graph"}
</SettingTitle>
</SwitchRow>
<SwitchRow
bind:value={simulateFsrsRequest.newCardsIgnoreReviewLimit}
defaultValue={$newCardsIgnoreReviewLimit}
>
<SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
<GlobalLabel
title={tr.deckConfigNewCardsIgnoreReviewLimit()}
/>
</SettingTitle>
</SwitchRow>
<SwitchRow bind:value={smooth} defaultValue={true}>
<SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
{"Smooth Graph"}
</SettingTitle>
</SwitchRow>
<SwitchRow
bind:value={suspendLeeches}
defaultValue={$config.leechAction ==
DeckConfig_Config_LeechAction.SUSPEND}
>
<SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
{"Suspend Leeches"}
</SettingTitle>
</SwitchRow>
{#if suspendLeeches}
<SpinBoxRow
bind:value={leechThreshold}
defaultValue={$config.leechThreshold}
min={1}
max={9999}
>
<SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
{tr.schedulingLeechThreshold()}
</SettingTitle>
</SpinBoxRow>
{/if}
</details>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
@ -298,6 +355,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$newCardsIgnoreReviewLimit =
simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
$config.leechAction = suspendLeeches
? DeckConfig_Config_LeechAction.SUSPEND
: DeckConfig_Config_LeechAction.TAG_ONLY;
$config.leechThreshold = leechThreshold;
$config.easyDaysPercentages = [...easyDayPercentages];
onPresetChange();
}}
>
@ -339,15 +401,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</InputBox>
</div>
<svg
bind:this={svg}
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
>
<CumulativeOverlay />
<HoverColumns />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} />
</svg>
<div class="svg-container">
<svg
bind:this={svg}
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
>
<CumulativeOverlay />
<HoverColumns />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} />
</svg>
</div>
<TableData {tableData} />
</Graph>
@ -359,6 +423,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style>
.modal {
background-color: rgba(0, 0, 0, 0.5);
--bs-modal-margin: 0;
}
.svg-container {
width: 100%;
max-height: calc(100vh - 400px); /* Account for modal header, controls, etc */
aspect-ratio: 600 / 250;
display: flex;
align-items: center;
}
svg {
width: 100%;
height: 100%;
}
.modal-header {
position: sticky;
top: 0;
background-color: var(--bs-body-bg);
z-index: 100;
}
:global(.modal-xl) {
@ -372,4 +457,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn {
margin-bottom: 0.375rem;
}
summary {
margin-bottom: 0.5em;
}
</style>