Skip to content

Commit

Permalink
Feat/FSRS Simulator (#3257)
Browse files Browse the repository at this point in the history
* test using existed cards

* plot new and review

* convert learning cards & use line chart

* allow draw multiple simulations in the same chart

* support hide simulation

* convert x axis to Date

* convert y from second to minute

* support clear last simulation

* remove unused import

* rename

* add hover/tooltip

* fallback to default parameters

* update default value and maximum of deckSize

* add "processing..."

* fix mistake
  • Loading branch information
L-M-Sherlock authored Aug 22, 2024
1 parent e92aaa4 commit 8ed9f49
Show file tree
Hide file tree
Showing 4 changed files with 457 additions and 22 deletions.
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()
};
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

0 comments on commit 8ed9f49

Please sign in to comment.