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, "editor.formatOnSave": true,
"[python]": { "[python]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": true "source.organizeImports": "explicit"
} }
}, },
"files.watcherExclude": { "files.watcherExclude": {

2
Cargo.lock generated
View file

@ -2099,7 +2099,7 @@ dependencies = [
[[package]] [[package]]
name = "fsrs" name = "fsrs"
version = "3.0.0" 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 = [ dependencies = [
"burn", "burn",
"itertools 0.12.1", "itertools 0.12.1",

View file

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

View file

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

View file

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

View file

@ -142,11 +142,13 @@ impl Collection {
.min(req.new_limit as usize); .min(req.new_limit as usize);
if req.new_limit > 0 { if req.new_limit > 0 {
let new_cards = (0..new_cards).map(|i| fsrs::Card { let new_cards = (0..new_cards).map(|i| fsrs::Card {
id: -(i as i64),
difficulty: f32::NEG_INFINITY, difficulty: f32::NEG_INFINITY,
stability: 1e-8, // Not filtered by fsrs-rs stability: 1e-8, // Not filtered by fsrs-rs
last_date: f32::NEG_INFINITY, // Treated as a new card in simulation last_date: f32::NEG_INFINITY, // Treated as a new card in simulation
due: ((introduced_today_count + i) / req.new_limit as usize) as f32, due: ((introduced_today_count + i) / req.new_limit as usize) as f32,
interval: f32::NEG_INFINITY, interval: f32::NEG_INFINITY,
lapses: 0,
}); });
converted_cards.extend(new_cards); converted_cards.extend(new_cards);
} }
@ -159,9 +161,9 @@ impl Collection {
let post_scheduling_fn: Option<PostSchedulingFn> = let post_scheduling_fn: Option<PostSchedulingFn> =
if self.get_config_bool(BoolKey::LoadBalancerEnabled) { if self.get_config_bool(BoolKey::LoadBalancerEnabled) {
Some(PostSchedulingFn(Arc::new( 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( apply_load_balance_and_easy_days(
interval, card.interval,
max_interval, max_interval,
today, today,
due_cnt_per_day, due_cnt_per_day,
@ -198,6 +200,7 @@ impl Collection {
learn_limit: req.new_limit as usize, learn_limit: req.new_limit as usize,
review_limit: req.review_limit as usize, review_limit: req.review_limit as usize,
new_cards_ignore_review_limit: req.new_cards_ignore_review_limit, new_cards_ignore_review_limit: req.new_cards_ignore_review_limit,
suspend_after_lapses: req.suspend_after_lapse_count,
post_scheduling_fn, post_scheduling_fn,
review_priority_fn, review_priority_fn,
}; };
@ -234,21 +237,25 @@ impl Card {
let relative_due = due - days_elapsed; let relative_due = due - days_elapsed;
let last_date = (relative_due - card.interval as i32).min(0) as f32; let last_date = (relative_due - card.interval as i32).min(0) as f32;
Some(fsrs::Card { Some(fsrs::Card {
id: card.id.0,
difficulty: state.difficulty, difficulty: state.difficulty,
stability: state.stability, stability: state.stability,
last_date, last_date,
due: relative_due as f32, due: relative_due as f32,
interval: card.interval as f32, interval: card.interval as f32,
lapses: card.lapses,
}) })
} }
CardQueue::New => None, CardQueue::New => None,
CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => { CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => {
Some(fsrs::Card { Some(fsrs::Card {
id: card.id.0,
difficulty: state.difficulty, difficulty: state.difficulty,
stability: state.stability, stability: state.stability,
last_date: 0.0, last_date: 0.0,
due: 0.0, due: 0.0,
interval: card.interval as f32, interval: card.interval as f32,
lapses: card.lapses,
}) })
} }
CardQueue::PreviewRepeat => None, 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 TitledContainer from "$lib/components/TitledContainer.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
import EasyDaysInput from "./EasyDaysInput.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>; 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) easyDaysChanged && !($fsrsEnabled && $reschedule)
? tr.deckConfigEasyDaysChange() ? tr.deckConfigEasyDaysChange()
: ""; : "";
const easyDays = [
tr.deckConfigEasyDaysMonday(),
tr.deckConfigEasyDaysTuesday(),
tr.deckConfigEasyDaysWednesday(),
tr.deckConfigEasyDaysThursday(),
tr.deckConfigEasyDaysFriday(),
tr.deckConfigEasyDaysSaturday(),
tr.deckConfigEasyDaysSunday(),
];
</script> </script>
<datalist id="easy_day_steplist"> <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()}> <TitledContainer title={tr.deckConfigEasyDaysTitle()}>
<DynamicallySlottable slotHost={Item} {api}> <DynamicallySlottable slotHost={Item} {api}>
<Item> <EasyDaysInput bind:values={$config.easyDaysPercentages} />
<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>
<Item> <Item>
<Warning warning={noNormalDay} /> <Warning warning={noNormalDay} />
</Item> </Item>
@ -98,36 +53,3 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item> </Item>
</DynamicallySlottable> </DynamicallySlottable>
</TitledContainer> </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 SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import { reviewOrderChoices } from "./choices"; import { reviewOrderChoices } from "./choices";
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte"; 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 shown = false;
export let state: DeckOptionsState; 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[] = []; let points: Point[] = [];
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
let smooth = true; let smooth = true;
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
let leechThreshold = $config.leechThreshold;
$: daysToSimulate = 365; $: daysToSimulate = 365;
$: deckSize = 0; $: deckSize = 0;
@ -75,6 +79,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let resp: SimulateFsrsReviewResponse | undefined; let resp: SimulateFsrsReviewResponse | undefined;
simulateFsrsRequest.daysToSimulate = daysToSimulate; simulateFsrsRequest.daysToSimulate = daysToSimulate;
simulateFsrsRequest.deckSize = deckSize; simulateFsrsRequest.deckSize = deckSize;
simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches
? leechThreshold
: undefined;
simulateFsrsRequest.easyDaysPercentages = easyDayPercentages;
try { try {
await runWithBackendProgress( await runWithBackendProgress(
async () => { async () => {
@ -169,6 +177,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
simulateSubgraph, simulateSubgraph,
); );
} }
let easyDayPercentages = [...$config.easyDaysPercentages];
</script> </script>
<div class="modal" class:show={shown} class:d-block={shown} tabindex="-1"> <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> </SettingTitle>
</SpinBoxRow> </SpinBoxRow>
<SpinBoxRow <details>
bind:value={simulateFsrsRequest.maxInterval} <summary>{tr.deckConfigEasyDaysTitle()}</summary>
defaultValue={$config.maximumReviewInterval} {#key easyDayPercentages}
min={1} <EasyDaysInput bind:values={easyDayPercentages} />
max={36500} {/key}
> </details>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingMaximumInterval()}
</SettingTitle>
</SpinBoxRow>
<EnumSelectorRow <details>
bind:value={simulateFsrsRequest.reviewOrder} <summary>{"Advanced settings"}</summary>
defaultValue={$config.reviewOrder} <SpinBoxRow
choices={reviewOrderChoices($fsrs)} bind:value={simulateFsrsRequest.maxInterval}
> defaultValue={$config.maximumReviewInterval}
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}> min={1}
{tr.deckConfigReviewSortOrder()} max={36500}
</SettingTitle> >
</EnumSelectorRow> <SettingTitle
on:click={() => openHelpModal("simulateFsrsReview")}
>
{tr.schedulingMaximumInterval()}
</SettingTitle>
</SpinBoxRow>
<SwitchRow <EnumSelectorRow
bind:value={simulateFsrsRequest.newCardsIgnoreReviewLimit} bind:value={simulateFsrsRequest.reviewOrder}
defaultValue={$newCardsIgnoreReviewLimit} defaultValue={$config.reviewOrder}
> choices={reviewOrderChoices($fsrs)}
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}> >
<GlobalLabel title={tr.deckConfigNewCardsIgnoreReviewLimit()} /> <SettingTitle
</SettingTitle> on:click={() => openHelpModal("simulateFsrsReview")}
</SwitchRow> >
{tr.deckConfigReviewSortOrder()}
</SettingTitle>
</EnumSelectorRow>
<SwitchRow bind:value={smooth} defaultValue={true}> <SwitchRow
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}> bind:value={simulateFsrsRequest.newCardsIgnoreReviewLimit}
{"Smooth Graph"} defaultValue={$newCardsIgnoreReviewLimit}
</SettingTitle> >
</SwitchRow> <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 <button
class="btn {computing ? 'btn-warning' : 'btn-primary'}" 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 = $newCardsIgnoreReviewLimit =
simulateFsrsRequest.newCardsIgnoreReviewLimit; simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder; $config.reviewOrder = simulateFsrsRequest.reviewOrder;
$config.leechAction = suspendLeeches
? DeckConfig_Config_LeechAction.SUSPEND
: DeckConfig_Config_LeechAction.TAG_ONLY;
$config.leechThreshold = leechThreshold;
$config.easyDaysPercentages = [...easyDayPercentages];
onPresetChange(); onPresetChange();
}} }}
> >
@ -339,15 +401,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</InputBox> </InputBox>
</div> </div>
<svg <div class="svg-container">
bind:this={svg} <svg
viewBox={`0 0 ${bounds.width} ${bounds.height}`} bind:this={svg}
> viewBox={`0 0 ${bounds.width} ${bounds.height}`}
<CumulativeOverlay /> >
<HoverColumns /> <CumulativeOverlay />
<AxisTicks {bounds} /> <HoverColumns />
<NoDataOverlay {bounds} /> <AxisTicks {bounds} />
</svg> <NoDataOverlay {bounds} />
</svg>
</div>
<TableData {tableData} /> <TableData {tableData} />
</Graph> </Graph>
@ -359,6 +423,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style> <style>
.modal { .modal {
background-color: rgba(0, 0, 0, 0.5); 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) { :global(.modal-xl) {
@ -372,4 +457,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.btn { .btn {
margin-bottom: 0.375rem; margin-bottom: 0.375rem;
} }
summary {
margin-bottom: 0.5em;
}
</style> </style>