Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/FSRS Simulator #3257

Merged
merged 19 commits into from
Aug 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions qt/aqt/mediasrv.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,7 @@ def handle_on_main() -> None:
"set_wants_abort",
"evaluate_weights",
"get_optimal_retention_parameters",
"simulate_fsrs_review",
]


Expand Down
80 changes: 59 additions & 21 deletions rslib/src/scheduler/fsrs/simulator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse;
use fsrs::simulate;
use fsrs::SimulatorConfig;
use fsrs::DEFAULT_PARAMETERS;
use itertools::Itertools;

use crate::card::CardQueue;
use crate::prelude::*;
use crate::search::SortMode;

Expand All @@ -22,9 +24,15 @@ impl Collection {
.get_revlog_entries_for_searched_cards_in_card_order()?;
let cards = guard.col.storage.all_searched_cards()?;
drop(guard);
let days_elapsed = self.timing_today().unwrap().days_elapsed as i32;
let converted_cards = cards
.into_iter()
.filter(|c| c.queue != CardQueue::Suspended && c.queue != CardQueue::PreviewRepeat)
.filter_map(|c| Card::convert(c, days_elapsed, req.days_to_simulate))
.collect_vec();
let p = self.get_optimal_retention_parameters(revlogs)?;
let config = SimulatorConfig {
deck_size: req.deck_size as usize,
deck_size: req.deck_size as usize + converted_cards.len(),
learn_span: req.days_to_simulate as usize,
max_cost_perday: f32::MAX,
max_ivl: req.max_interval as f32,
Expand All @@ -40,23 +48,30 @@ impl Collection {
learn_limit: req.new_limit as usize,
review_limit: req.review_limit as usize,
};
let days_elapsed = self.timing_today().unwrap().days_elapsed as i32;
let parameters = if req.weights.is_empty() {
DEFAULT_PARAMETERS.to_vec()
} else if req.weights.len() != 19 {
if req.weights.len() == 17 {
let mut parameters = req.weights.to_vec();
parameters.extend_from_slice(&[0.0, 0.0]);
parameters
} else {
return Err(AnkiError::FsrsWeightsInvalid);
}
} else {
req.weights.to_vec()
};
Copy link
Member

@dae dae Aug 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not important/required for this PR, but a match would be a bit more readable:

diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs
index b41d98d76..61ea7074e 100644
--- a/rslib/src/scheduler/fsrs/simulator.rs
+++ b/rslib/src/scheduler/fsrs/simulator.rs
@@ -48,18 +48,15 @@ impl Collection {
             learn_limit: req.new_limit as usize,
             review_limit: req.review_limit as usize,
         };
-        let parameters = if req.weights.is_empty() {
-            DEFAULT_PARAMETERS.to_vec()
-        } else if req.weights.len() != 19 {
-            if req.weights.len() == 17 {
+        let parameters = match req.weights.len() {
+            19 => req.weights.to_vec(),
+            17 => {
                 let mut parameters = req.weights.to_vec();
                 parameters.extend_from_slice(&[0.0, 0.0]);
                 parameters
-            } else {
-                return Err(AnkiError::FsrsWeightsInvalid);
             }
-        } else {
-            req.weights.to_vec()
+            0 => DEFAULT_PARAMETERS.to_vec(),
+            _ => return Err(AnkiError::FsrsWeightsInvalid),
         };
         let (
             accumulated_knowledge_acquisition,

That said, I think this check would be better done in the FSRS crate in the future.

let (
accumulated_knowledge_acquisition,
daily_review_count,
daily_new_count,
daily_time_cost,
) = simulate(
&config,
&req.weights,
&parameters,
req.desired_retention,
None,
Some(
cards
.into_iter()
.filter_map(|c| Card::convert(c, days_elapsed))
.collect_vec(),
),
Some(converted_cards),
);
Ok(SimulateFsrsReviewResponse {
accumulated_knowledge_acquisition: accumulated_knowledge_acquisition.to_vec(),
Expand All @@ -68,19 +83,42 @@ impl Collection {
}

impl Card {
fn convert(card: Card, days_elapsed: i32) -> Option<fsrs::Card> {
fn convert(card: Card, days_elapsed: i32, day_to_simulate: u32) -> Option<fsrs::Card> {
match card.memory_state {
Some(state) => {
let due = card.original_or_current_due();
let relative_due = due - days_elapsed;
Some(fsrs::Card {
difficulty: state.difficulty,
stability: state.stability,
last_date: (relative_due - card.interval as i32) as f32,
due: relative_due as f32,
})
}
None => None,
Some(state) => match card.queue {
CardQueue::DayLearn | CardQueue::Review => {
let due = card.original_or_current_due();
let relative_due = due - days_elapsed;
Some(fsrs::Card {
difficulty: state.difficulty,
stability: state.stability,
last_date: (relative_due - card.interval as i32) as f32,
due: relative_due as f32,
})
}
CardQueue::New => Some(fsrs::Card {
difficulty: 1e-10,
stability: 1e-10,
last_date: 0.0,
due: day_to_simulate as f32,
}),
CardQueue::Learn | CardQueue::SchedBuried | CardQueue::UserBuried => {
Some(fsrs::Card {
difficulty: state.difficulty,
stability: state.stability,
last_date: 0.0,
due: 0.0,
})
}
CardQueue::PreviewRepeat => None,
CardQueue::Suspended => None,
},
None => Some(fsrs::Card {
difficulty: 1e-10,
stability: 1e-10,
last_date: 0.0,
due: day_to_simulate as f32,
}),
}
}
}
178 changes: 177 additions & 1 deletion ts/routes/deck-options/FsrsOptions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
ComputeRetentionProgress,
type ComputeWeightsProgress,
} from "@generated/anki/collection_pb";
import { ComputeOptimalRetentionRequest } from "@generated/anki/scheduler_pb";
import {
ComputeOptimalRetentionRequest,
SimulateFsrsReviewRequest,
type SimulateFsrsReviewResponse,
} from "@generated/anki/scheduler_pb";
import {
computeFsrsWeights,
computeOptimalRetention,
simulateFsrsReview,
evaluateWeights,
setWantsAbort,
} from "@generated/backend";
Expand All @@ -28,6 +33,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Warning from "./Warning.svelte";
import WeightsInputRow from "./WeightsInputRow.svelte";
import WeightsSearchRow from "./WeightsSearchRow.svelte";
import { renderSimulationChart, type Point } from "../graphs/simulator";
import Graph from "../graphs/Graph.svelte";
import HoverColumns from "../graphs/HoverColumns.svelte";
import CumulativeOverlay from "../graphs/CumulativeOverlay.svelte";
import AxisTicks from "../graphs/AxisTicks.svelte";
import NoDataOverlay from "../graphs/NoDataOverlay.svelte";
import TableData from "../graphs/TableData.svelte";
import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers";

export let state: DeckOptionsState;
export let openHelpModal: (String) => void;
Expand Down Expand Up @@ -68,6 +81,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
optimalRetentionRequest.daysToSimulate = 3650;
}

const simulateFsrsRequest = new SimulateFsrsReviewRequest({
weights: $config.fsrsWeights,
desiredRetention: $config.desiredRetention,
deckSize: 0,
daysToSimulate: 365,
newLimit: $config.newPerDay,
reviewLimit: $config.reviewsPerDay,
maxInterval: $config.maximumReviewInterval,
search: `preset:"${state.getCurrentName()}" -is:suspended`,
});

function getRetentionWarning(retention: number): string {
const decay = -0.5;
const factor = 0.9 ** (1 / decay) - 1;
Expand Down Expand Up @@ -256,6 +280,69 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) });
}

let tableData: TableDatum[] = [] as any;
const bounds = defaultGraphBounds();
let svg = null as HTMLElement | SVGElement | null;
const title = tr.statisticsReviewsTitle();
let simulationNumber = 0;

let points: Point[] = [];

function movingAverage(y: number[], windowSize: number): number[] {
const result: number[] = [];
for (let i = 0; i < y.length; i++) {
let sum = 0;
let count = 0;
for (let j = Math.max(0, i - windowSize + 1); j <= i; j++) {
sum += y[j];
count++;
}
result.push(sum / count);
}
return result;
}

$: simulateProgressString = "";

async function simulateFsrs(): Promise<void> {
let resp: SimulateFsrsReviewResponse | undefined;
simulationNumber += 1;
try {
await runWithBackendProgress(
async () => {
simulateFsrsRequest.weights = $config.fsrsWeights;
simulateFsrsRequest.desiredRetention = $config.desiredRetention;
simulateFsrsRequest.search = `preset:"${state.getCurrentName()}" -is:suspended`;
simulateProgressString = "processing...";
resp = await simulateFsrsReview(simulateFsrsRequest);
},
() => {},
);
} finally {
if (resp) {
simulateProgressString = "";
const dailyTimeCost = movingAverage(
resp.dailyTimeCost,
Math.round(simulateFsrsRequest.daysToSimulate / 50),
);
points = points.concat(
dailyTimeCost.map((v, i) => ({
x: i,
y: v,
label: simulationNumber,
})),
);
tableData = renderSimulationChart(svg as SVGElement, bounds, points);
}
}
}

function clearSimulation(): void {
points = points.filter((p) => p.label !== simulationNumber);
simulationNumber = Math.max(0, simulationNumber - 1);
tableData = renderSimulationChart(svg as SVGElement, bounds, points);
}
</script>

<SpinBoxFloatRow
Expand Down Expand Up @@ -377,5 +464,94 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</details>
</div>

<div class="m-2">
<details>
<summary>FSRS simulator (experimental)</summary>

<SpinBoxRow
bind:value={simulateFsrsRequest.daysToSimulate}
defaultValue={365}
min={1}
max={3650}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
Days to simulate
</SettingTitle>
</SpinBoxRow>

<SpinBoxRow
bind:value={simulateFsrsRequest.deckSize}
defaultValue={0}
min={1}
max={100000}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
Additional new cards to simulate
</SettingTitle>
</SpinBoxRow>

<SpinBoxRow
bind:value={simulateFsrsRequest.newLimit}
defaultValue={defaults.newPerDay}
min={0}
max={1000}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
New cards/day
</SettingTitle>
</SpinBoxRow>

<SpinBoxRow
bind:value={simulateFsrsRequest.reviewLimit}
defaultValue={defaults.reviewsPerDay}
min={0}
max={1000}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
Maximum reviews/day
</SettingTitle>
</SpinBoxRow>

<SpinBoxRow
bind:value={simulateFsrsRequest.maxInterval}
defaultValue={defaults.maximumReviewInterval}
min={1}
max={36500}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
Maximum interval
</SettingTitle>
</SpinBoxRow>

<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={() => simulateFsrs()}
>
{"Simulate"}
</button>

<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={() => clearSimulation()}
>
{"Clear last simulation"}
</button>
<div>{simulateProgressString}</div>

<Graph {title}>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<CumulativeOverlay />
<HoverColumns />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} />
</svg>

<TableData {tableData} />
</Graph>
</details>
</div>

<style>
</style>
Loading