Feat/support load balance and easy days in FSRS simulator (#3829)

* Feat/support load balance and easy days in FSRS simulator

* format

* consider LoadBalancerEnabled

* use fsrs::PostSchedulingFn

* add load balance and easy days to compute_optimal_retention

* move simulator to a pop-over

* fix incorrect simulationNumber when error 500

* Feat: Save to Preset Options

* update tabs when update newPerDay & reviewsPerDay

* don't reset deckSize & daysToSimulate when save options

* fix missing easy days

* plan to support review priority

* Fix graph line rendering with non-scaling stroke

* simplify review priority function with helper wrapper

* fallback to default ReviewPriority for Added & ReverseAdded

* Update ts/routes/deck-options/SimulatorModal.svelte

Co-authored-by: Luc Mcgrady <lucmcgrady@gmail.com>

* Wrap review priority function in Arc for thread-safe sharing

* more granularity for R sorting

* Add graph smoothing option to FSRS simulator

* Improve graph resize handling in FSRS simulator

* simplify review priority calculation

* Add review order selection to FSRS simulator modal

* Refactor review priority function using macro for conciseness

* Add copyright and license header to SimulatorModal.svelte

* cargo clippy

* ./ninja fix:eslint

* update fsrs-rs

* Update FSRS dependencies and refactor load balancing functions

- Update fsrs-rs dependency to latest commit
- Modify retention and simulator modules to use Arc instead of Box
- Update function signatures and imports in simulator module
- Simplify review card order handling with direct enum usage

* resolve reviewed changes

* replace .unwrap() with ?

* move simulating into SimulatorModal

* add (crate) to interval_to_weekday

* Update FsrsOptions.svelte

* format

---------

Co-authored-by: Luc Mcgrady <lucmcgrady@gmail.com>
This commit is contained in:
Jarrett Ye 2025-02-27 11:53:01 +08:00 committed by GitHub
parent 96df6becfc
commit a6426bebe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 597 additions and 249 deletions

7
Cargo.lock generated
View file

@ -2098,9 +2098,8 @@ dependencies = [
[[package]]
name = "fsrs"
version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9174d1073f50c78ac1bb74ec9add329139f0bbc95747b1c1a490513ef5df50f"
version = "3.0.0"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=96520531415e032781adfe212f8a5eed216006be#96520531415e032781adfe212f8a5eed216006be"
dependencies = [
"burn",
"itertools 0.12.1",
@ -2497,7 +2496,7 @@ dependencies = [
"log",
"presser",
"thiserror 1.0.69",
"windows 0.56.0",
"windows 0.58.0",
]
[[package]]

View file

@ -35,9 +35,9 @@ git = "https://github.com/ankitects/linkcheck.git"
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs]
version = "=2.0.3"
# git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
# rev = "58ca25ed2bc4bb1dc376208bbcaed7f5a501b941"
# version = "=2.0.3"
git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "96520531415e032781adfe212f8a5eed216006be"
# path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies]

View file

@ -1360,7 +1360,7 @@
},
{
"name": "fsrs",
"version": "2.0.3",
"version": "3.0.0",
"authors": "Open Spaced Repetition",
"repository": "https://github.com/open-spaced-repetition/fsrs-rs",
"license": "BSD-3-Clause",

View file

@ -12,6 +12,7 @@ import "anki/cards.proto";
import "anki/decks.proto";
import "anki/collection.proto";
import "anki/config.proto";
import "anki/deck_config.proto";
service SchedulerService {
rpc GetQueuedCards(GetQueuedCardsRequest) returns (QueuedCards);
@ -390,6 +391,8 @@ message SimulateFsrsReviewRequest {
uint32 max_interval = 7;
string search = 8;
bool new_cards_ignore_review_limit = 9;
repeated float easy_days_percentages = 10;
deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11;
}
message SimulateFsrsReviewResponse {
@ -405,6 +408,7 @@ message ComputeOptimalRetentionRequest {
uint32 max_interval = 3;
string search = 4;
double loss_aversion = 5;
repeated float easy_days_percentages = 6;
}
message ComputeOptimalRetentionResponse {

View file

@ -1,13 +1,17 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::sync::Arc;
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
use fsrs::extract_simulator_config;
use fsrs::PostSchedulingFn;
use fsrs::SimulatorConfig;
use fsrs::FSRS;
use super::simulator::apply_load_balance_and_easy_days;
use crate::prelude::*;
use crate::revlog::RevlogEntry;
use crate::scheduler::states::load_balancer::parse_easy_days_percentages;
use crate::search::SortMode;
#[derive(Default, Clone, Copy, Debug)]
@ -35,6 +39,26 @@ impl Collection {
let learn_span = req.days_to_simulate as usize;
let learn_limit = 10;
let deck_size = learn_span * learn_limit;
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?;
let next_day_at = self.timing_today()?.next_day_at;
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| {
apply_load_balance_and_easy_days(
interval,
max_interval,
today,
due_cnt_per_day,
rng,
next_day_at,
&easy_days_percentages,
)
},
)))
} else {
None
};
Ok(fsrs
.optimal_retention(
&SimulatorConfig {
@ -54,6 +78,8 @@ impl Collection {
learn_limit,
review_limit: usize::MAX,
new_cards_ignore_review_limit: true,
post_scheduling_fn,
review_priority_fn: None,
},
&req.params,
|ip| {

View file

@ -1,16 +1,118 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::sync::Arc;
use anki_proto::deck_config::deck_config::config::ReviewCardOrder;
use anki_proto::deck_config::deck_config::config::ReviewCardOrder::*;
use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::SimulateFsrsReviewResponse;
use fsrs::simulate;
use fsrs::PostSchedulingFn;
use fsrs::ReviewPriorityFn;
use fsrs::SimulatorConfig;
use itertools::Itertools;
use rand::rngs::StdRng;
use rand::Rng;
use crate::card::CardQueue;
use crate::prelude::*;
use crate::scheduler::states::fuzz::constrained_fuzz_bounds;
use crate::scheduler::states::load_balancer::calculate_easy_days_modifiers;
use crate::scheduler::states::load_balancer::interval_to_weekday;
use crate::scheduler::states::load_balancer::parse_easy_days_percentages;
use crate::scheduler::states::load_balancer::select_weighted_interval;
use crate::scheduler::states::load_balancer::EasyDay;
use crate::scheduler::states::load_balancer::LoadBalancerInterval;
use crate::search::SortMode;
pub(crate) fn apply_load_balance_and_easy_days(
interval: f32,
max_interval: f32,
day_elapsed: usize,
due_cnt_per_day: &[usize],
rng: &mut StdRng,
next_day_at: TimestampSecs,
easy_days_percentages: &[EasyDay; 7],
) -> f32 {
let (lower, upper) = constrained_fuzz_bounds(interval, 1, max_interval as u32);
let mut review_counts = vec![0; upper as usize - lower as usize + 1];
// Fill review_counts with due counts for each interval
let start = day_elapsed + lower as usize;
let end = (day_elapsed + upper as usize + 1).min(due_cnt_per_day.len());
if start < due_cnt_per_day.len() {
let copy_len = (end - start).min(review_counts.len());
review_counts[..copy_len].copy_from_slice(&due_cnt_per_day[start..start + copy_len]);
}
let possible_intervals: Vec<u32> = (lower..=upper).collect();
let weekdays = possible_intervals
.iter()
.map(|interval| {
interval_to_weekday(
*interval,
next_day_at.adding_secs(day_elapsed as i64 * 86400),
)
})
.collect::<Vec<_>>();
let easy_days_modifier =
calculate_easy_days_modifiers(easy_days_percentages, &weekdays, &review_counts);
let intervals =
possible_intervals
.iter()
.enumerate()
.map(|(interval_index, &target_interval)| LoadBalancerInterval {
target_interval,
review_count: review_counts[interval_index],
sibling_modifier: 1.0,
easy_days_modifier: easy_days_modifier[interval_index],
});
let fuzz_seed = rng.gen();
select_weighted_interval(intervals, Some(fuzz_seed)).unwrap() as f32
}
fn create_review_priority_fn(
review_order: ReviewCardOrder,
deck_size: usize,
) -> Option<ReviewPriorityFn> {
// Helper macro to wrap closure in ReviewPriorityFn
macro_rules! wrap {
($f:expr) => {
Some(ReviewPriorityFn(std::sync::Arc::new($f)))
};
}
match review_order {
// Ease-based ordering
EaseAscending => wrap!(|c| -(c.difficulty * 100.0) as i32),
EaseDescending => wrap!(|c| (c.difficulty * 100.0) as i32),
// Interval-based ordering
IntervalsAscending => wrap!(|c| c.interval as i32),
IntervalsDescending => wrap!(|c| -(c.interval as i32)),
// Retrievability-based ordering
RetrievabilityAscending => wrap!(|c| (c.retrievability() * 1000.0) as i32),
RetrievabilityDescending => {
wrap!(|c| -(c.retrievability() * 1000.0) as i32)
}
// Due date ordering
Day | DayThenDeck | DeckThenDay => {
wrap!(|c| c.scheduled_due() as i32)
}
// Random ordering
Random => {
wrap!(move |_| rand::thread_rng().gen_range(0..deck_size) as i32)
}
// Not implemented yet
Added | ReverseAdded => None,
}
}
impl Collection {
pub fn simulate_review(
&mut self,
@ -44,12 +146,43 @@ impl Collection {
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,
});
converted_cards.extend(new_cards);
}
let deck_size = converted_cards.len();
let p = self.get_optimal_retention_parameters(revlogs)?;
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?;
let next_day_at = self.timing_today()?.next_day_at;
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| {
apply_load_balance_and_easy_days(
interval,
max_interval,
today,
due_cnt_per_day,
rng,
next_day_at,
&easy_days_percentages,
)
},
)))
} else {
None
};
let review_priority_fn = req
.review_order
.try_into()
.ok()
.and_then(|order| create_review_priority_fn(order, deck_size));
let config = SimulatorConfig {
deck_size: converted_cards.len(),
deck_size,
learn_span: req.days_to_simulate as usize,
max_cost_perday: f32::MAX,
max_ivl: req.max_interval as f32,
@ -65,6 +198,8 @@ 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,
post_scheduling_fn,
review_priority_fn,
};
let result = simulate(
&config,
@ -74,7 +209,7 @@ impl Collection {
Some(converted_cards),
)?;
Ok(SimulateFsrsReviewResponse {
accumulated_knowledge_acquisition: result.memorized_cnt_per_day.to_vec(),
accumulated_knowledge_acquisition: result.memorized_cnt_per_day,
daily_review_count: result
.review_cnt_per_day
.iter()
@ -85,7 +220,7 @@ impl Collection {
.iter()
.map(|x| *x as u32)
.collect_vec(),
daily_time_cost: result.cost_per_day.to_vec(),
daily_time_cost: result.cost_per_day,
})
}
}
@ -103,6 +238,7 @@ impl Card {
stability: state.stability,
last_date,
due: relative_due as f32,
interval: card.interval as f32,
})
}
CardQueue::New => None,
@ -112,6 +248,7 @@ impl Card {
stability: state.stability,
last_date: 0.0,
due: 0.0,
interval: card.interval as f32,
})
}
CardQueue::PreviewRepeat => None,

View file

@ -277,29 +277,30 @@ impl LoadBalancer {
}
}
/// Build a mapping of deck config IDs to their easy days settings.
/// For each deck config, maintains an array of 7 EasyDay values representing
/// the load modifier for each day of the week.
pub(crate) fn parse_easy_days_percentages(percentages: Vec<f32>) -> Result<[EasyDay; 7]> {
if percentages.is_empty() {
return Ok([EasyDay::Normal; 7]);
}
Ok(TryInto::<[_; 7]>::try_into(percentages)
.map_err(|_| {
AnkiError::from(InvalidInputError {
message: "expected 7 days".into(),
source: None,
backtrace: None,
})
})?
.map(EasyDay::from))
}
pub(crate) fn build_easy_days_percentages(
configs: HashMap<DeckConfigId, DeckConfig>,
) -> Result<HashMap<DeckConfigId, [EasyDay; 7]>> {
configs
.into_iter()
.map(|(dcid, conf)| {
let easy_days_percentages: [EasyDay; 7] = if conf.inner.easy_days_percentages.is_empty()
{
[EasyDay::Normal; 7]
} else {
TryInto::<[_; 7]>::try_into(conf.inner.easy_days_percentages)
.map_err(|_| {
AnkiError::from(InvalidInputError {
message: "expected 7 days".into(),
source: None,
backtrace: None,
})
})?
.map(EasyDay::from)
};
let easy_days_percentages =
parse_easy_days_percentages(conf.inner.easy_days_percentages)?;
Ok((dcid, easy_days_percentages))
})
.collect()
@ -389,7 +390,7 @@ pub fn select_weighted_interval(
Some(intervals_and_weights[selected_interval_index].0)
}
fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
pub(crate) fn interval_to_weekday(interval: u32, next_day_at: TimestampSecs) -> usize {
let target_datetime = next_day_at
.adding_secs((interval - 1) as i64 * 86400)
.local_datetime()

View file

@ -90,7 +90,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Row>
<Row class="row-columns">
<FsrsOptionsOuter {state} api={{}} />
<FsrsOptionsOuter {state} api={{}} bind:onPresetChange />
</Row>
</div>

View file

@ -10,12 +10,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import {
ComputeOptimalRetentionRequest,
SimulateFsrsReviewRequest,
type SimulateFsrsReviewResponse,
} from "@generated/anki/scheduler_pb";
import {
computeFsrsParams,
computeOptimalRetention,
simulateFsrsReview,
evaluateParams,
setWantsAbort,
} from "@generated/backend";
@ -32,23 +30,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Warning from "./Warning.svelte";
import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte";
import {
renderSimulationChart,
SimulateSubgraph,
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";
import InputBox from "../graphs/InputBox.svelte";
import SimulatorModal from "./SimulatorModal.svelte";
import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb";
export let state: DeckOptionsState;
export let openHelpModal: (String) => void;
export let onPresetChange: () => void;
const presetName = state.currentPresetName;
@ -64,13 +51,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let computingParams = false;
let checkingParams = false;
let computingRetention = false;
let simulating = false;
let optimalRetention = 0;
$: if ($presetName) {
optimalRetention = 0;
}
$: computing =
computingParams || checkingParams || computingRetention || simulating;
$: computing = computingParams || checkingParams || computingRetention;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning(roundedRetention);
@ -81,8 +67,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
| ComputeRetentionProgress
| undefined;
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
const optimalRetentionRequest = new ComputeOptimalRetentionRequest({
daysToSimulate: 365,
lossAversion: 2.5,
@ -93,16 +77,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
const simulateFsrsRequest = new SimulateFsrsReviewRequest({
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({
params: fsrsParams($config),
desiredRetention: $config.desiredRetention,
deckSize: 0,
daysToSimulate: 365,
newLimit: $config.newPerDay,
reviewLimit: $config.reviewsPerDay,
maxInterval: $config.maximumReviewInterval,
search: `preset:"${state.getCurrentNameForSearch()}" -is:suspended`,
newCardsIgnoreReviewLimit: $newCardsIgnoreReviewLimit,
easyDaysPercentages: $config.easyDaysPercentages,
reviewOrder: $config.reviewOrder,
});
function getRetentionWarning(retention: number): string {
@ -263,6 +247,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
optimalRetentionRequest.maxInterval = $config.maximumReviewInterval;
optimalRetentionRequest.params = fsrsParams($config);
optimalRetentionRequest.search = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
optimalRetentionRequest.easyDaysPercentages =
$config.easyDaysPercentages;
const resp = await computeOptimalRetention(optimalRetentionRequest);
optimalRetention = resp.optimalRetention;
computeRetentionProgress = undefined;
@ -317,81 +303,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);
}
let tableData: TableDatum[] = [];
const bounds = defaultGraphBounds();
bounds.marginLeft += 8;
let svg: HTMLElement | SVGElement | null = null;
let simulationNumber = 0;
let points: Point[] = [];
function addArrays(arr1: number[], arr2: number[]): number[] {
return arr1.map((value, index) => value + arr2[index]);
}
async function simulateFsrs(): Promise<void> {
let resp: SimulateFsrsReviewResponse | undefined;
simulationNumber += 1;
try {
await runWithBackendProgress(
async () => {
simulateFsrsRequest.params = fsrsParams($config);
simulateFsrsRequest.desiredRetention = $config.desiredRetention;
simulateFsrsRequest.search = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
simulateFsrsRequest.newCardsIgnoreReviewLimit =
$newCardsIgnoreReviewLimit;
simulating = true;
resp = await simulateFsrsReview(simulateFsrsRequest);
},
() => {},
);
} finally {
simulating = false;
if (resp) {
const dailyTotalCount = addArrays(
resp.dailyReviewCount,
resp.dailyNewCount,
);
const dailyMemorizedCount = resp.accumulatedKnowledgeAcquisition;
points = points.concat(
resp.dailyTimeCost.map((v, i) => ({
x: i,
timeCost: v,
count: dailyTotalCount[i],
memorized: dailyMemorizedCount[i],
label: simulationNumber,
})),
);
tableData = renderSimulationChart(
svg as SVGElement,
bounds,
points,
simulateSubgraph,
);
}
}
}
$: tableData = renderSimulationChart(
svg as SVGElement,
bounds,
points,
simulateSubgraph,
);
function clearSimulation(): void {
points = points.filter((p) => p.label !== simulationNumber);
simulationNumber = Math.max(0, simulationNumber - 1);
tableData = renderSimulationChart(
svg as SVGElement,
bounds,
points,
simulateSubgraph,
);
}
let showSimulator = false;
</script>
<SpinBoxFloatRow
@ -519,130 +431,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div>
<div class="m-2">
<details>
<summary>{tr.deckConfigFsrsSimulatorExperimental()}</summary>
<SpinBoxRow
bind:value={simulateFsrsRequest.daysToSimulate}
defaultValue={365}
min={1}
max={3650}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.deckConfigDaysToSimulate()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.deckSize}
defaultValue={0}
min={0}
max={100000}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.deckConfigAdditionalNewCardsToSimulate()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.newLimit}
defaultValue={$config.newPerDay}
min={0}
max={9999}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingNewCardsday()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.reviewLimit}
defaultValue={$config.reviewsPerDay}
min={0}
max={9999}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingMaximumReviewsday()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.maxInterval}
defaultValue={$config.maximumReviewInterval}
min={1}
max={36500}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingMaximumInterval()}
</SettingTitle>
</SpinBoxRow>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={() => simulateFsrs()}
>
{tr.deckConfigSimulate()}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={() => clearSimulation()}
>
{tr.deckConfigClearLastSimulate()}
</button>
{#if simulating}
{tr.qtMiscProcessing()}
{/if}
<Graph>
<div class="radio-group">
<InputBox>
<label>
<input
type="radio"
value={SimulateSubgraph.count}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioCount()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.time}
bind:group={simulateSubgraph}
/>
{tr.statisticsReviewsTimeCheckbox()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.memorized}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
</InputBox>
</div>
<svg bind:this={svg} viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
<CumulativeOverlay />
<HoverColumns />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} />
</svg>
<TableData {tableData} />
</Graph>
</details>
<button class="btn btn-primary" on:click={() => (showSimulator = true)}>
{tr.deckConfigFsrsSimulatorExperimental()}
</button>
</div>
<style>
div.radio-group {
margin: 0.5em;
}
<SimulatorModal
bind:shown={showSimulator}
{state}
{simulateFsrsRequest}
{computing}
{openHelpModal}
{onPresetChange}
/>
<style>
.btn {
margin-bottom: 0.375rem;
}

View file

@ -22,6 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let state: DeckOptionsState;
export let api: Record<string, never>;
export let onPresetChange: () => void;
const fsrs = state.fsrs;
@ -95,6 +96,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{state}
openHelpModal={(key) =>
openHelpModal(Object.keys(settings).indexOf(key))}
{onPresetChange}
/>
{/if}
</DynamicallySlottable>

View file

@ -0,0 +1,375 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import SpinBoxRow from "./SpinBoxRow.svelte";
import SettingTitle from "$lib/components/SettingTitle.svelte";
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 InputBox from "../graphs/InputBox.svelte";
import { defaultGraphBounds, type TableDatum } from "../graphs/graph-helpers";
import { SimulateSubgraph, type Point } from "../graphs/simulator";
import * as tr from "@generated/ftl";
import { renderSimulationChart } from "../graphs/simulator";
import { simulateFsrsReview } from "@generated/backend";
import { runWithBackendProgress } from "@tslib/progress";
import type {
SimulateFsrsReviewRequest,
SimulateFsrsReviewResponse,
} from "@generated/anki/scheduler_pb";
import type { DeckOptionsState } from "./lib";
import SwitchRow from "$lib/components/SwitchRow.svelte";
import GlobalLabel from "./GlobalLabel.svelte";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import { reviewOrderChoices } from "./choices";
import EnumSelectorRow from "$lib/components/EnumSelectorRow.svelte";
export let shown = false;
export let state: DeckOptionsState;
export let simulateFsrsRequest: SimulateFsrsReviewRequest;
export let computing: boolean;
export let openHelpModal: (key: string) => void;
export let onPresetChange: () => void;
const config = state.currentConfig;
let simulateSubgraph: SimulateSubgraph = SimulateSubgraph.count;
let tableData: TableDatum[] = [];
let simulating: boolean = false;
const fsrs = state.fsrs;
const bounds = defaultGraphBounds();
let svg: HTMLElement | SVGElement | null = null;
let simulationNumber = 0;
let points: Point[] = [];
const newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
let smooth = true;
$: daysToSimulate = 365;
$: deckSize = 0;
$: windowSize = Math.ceil(daysToSimulate / 365);
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;
}
function addArrays(arr1: number[], arr2: number[]): number[] {
return arr1.map((value, index) => value + arr2[index]);
}
async function simulateFsrs(): Promise<void> {
let resp: SimulateFsrsReviewResponse | undefined;
simulateFsrsRequest.daysToSimulate = daysToSimulate;
simulateFsrsRequest.deckSize = deckSize;
try {
await runWithBackendProgress(
async () => {
simulating = true;
resp = await simulateFsrsReview(simulateFsrsRequest);
},
() => {},
);
} finally {
simulating = false;
if (resp) {
simulationNumber += 1;
const dailyTotalCount = addArrays(
resp.dailyReviewCount,
resp.dailyNewCount,
);
const dailyMemorizedCount = resp.accumulatedKnowledgeAcquisition;
points = points.concat(
resp.dailyTimeCost.map((v, i) => ({
x: i,
timeCost: v,
count: dailyTotalCount[i],
memorized: dailyMemorizedCount[i],
label: simulationNumber,
})),
);
tableData = renderSimulationChart(
svg as SVGElement,
bounds,
points,
simulateSubgraph,
);
}
}
}
function clearSimulation() {
points = points.filter((p) => p.label !== simulationNumber);
simulationNumber = Math.max(0, simulationNumber - 1);
tableData = renderSimulationChart(
svg as SVGElement,
bounds,
points,
simulateSubgraph,
);
}
$: if (svg) {
let pointsToRender = points;
if (smooth) {
// Group points by label (simulation number)
const groupedPoints = points.reduce(
(acc, point) => {
acc[point.label] = acc[point.label] || [];
acc[point.label].push(point);
return acc;
},
{} as Record<number, Point[]>,
);
// Apply smoothing to each group separately
pointsToRender = Object.values(groupedPoints).flatMap((group) => {
const smoothedTimeCost = movingAverage(
group.map((p) => p.timeCost),
windowSize,
);
const smoothedCount = movingAverage(
group.map((p) => p.count),
windowSize,
);
const smoothedMemorized = movingAverage(
group.map((p) => p.memorized),
windowSize,
);
return group.map((p, i) => ({
...p,
timeCost: smoothedTimeCost[i],
count: smoothedCount[i],
memorized: smoothedMemorized[i],
}));
});
}
tableData = renderSimulationChart(
svg as SVGElement,
bounds,
pointsToRender,
simulateSubgraph,
);
}
</script>
<div class="modal" class:show={shown} class:d-block={shown} tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{tr.deckConfigFsrsSimulatorExperimental()}</h5>
<button
type="button"
class="btn-close"
aria-label="Close"
on:click={() => (shown = false)}
></button>
</div>
<div class="modal-body">
<SpinBoxRow
bind:value={daysToSimulate}
defaultValue={365}
min={1}
max={3650}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.deckConfigDaysToSimulate()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow bind:value={deckSize} defaultValue={0} min={0} max={100000}>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.deckConfigAdditionalNewCardsToSimulate()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxFloatRow
bind:value={simulateFsrsRequest.desiredRetention}
defaultValue={$config.desiredRetention}
min={0.7}
max={0.99}
percentage={true}
>
<SettingTitle on:click={() => openHelpModal("desiredRetention")}>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.newLimit}
defaultValue={$config.newPerDay}
min={0}
max={9999}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingNewCardsday()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.reviewLimit}
defaultValue={$config.reviewsPerDay}
min={0}
max={9999}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingMaximumReviewsday()}
</SettingTitle>
</SpinBoxRow>
<SpinBoxRow
bind:value={simulateFsrsRequest.maxInterval}
defaultValue={$config.maximumReviewInterval}
min={1}
max={36500}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.schedulingMaximumInterval()}
</SettingTitle>
</SpinBoxRow>
<EnumSelectorRow
bind:value={simulateFsrsRequest.reviewOrder}
defaultValue={$config.reviewOrder}
choices={reviewOrderChoices($fsrs)}
>
<SettingTitle on:click={() => openHelpModal("simulateFsrsReview")}>
{tr.deckConfigReviewSortOrder()}
</SettingTitle>
</EnumSelectorRow>
<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>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={simulateFsrs}
>
{tr.deckConfigSimulate()}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={clearSimulation}
>
{tr.deckConfigClearLastSimulate()}
</button>
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing}
on:click={() => {
$config.newPerDay = simulateFsrsRequest.newLimit;
$config.reviewsPerDay = simulateFsrsRequest.reviewLimit;
$config.maximumReviewInterval = simulateFsrsRequest.maxInterval;
$config.desiredRetention = simulateFsrsRequest.desiredRetention;
$newCardsIgnoreReviewLimit =
simulateFsrsRequest.newCardsIgnoreReviewLimit;
$config.reviewOrder = simulateFsrsRequest.reviewOrder;
onPresetChange();
}}
>
<!-- {tr.deckConfigApplyChanges()} -->
{"Save to Preset Options"}
</button>
{#if simulating}
{tr.actionsProcessing()}
{/if}
<Graph>
<div class="radio-group">
<InputBox>
<label>
<input
type="radio"
value={SimulateSubgraph.count}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioCount()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.time}
bind:group={simulateSubgraph}
/>
{tr.statisticsReviewsTimeCheckbox()}
</label>
<label>
<input
type="radio"
value={SimulateSubgraph.memorized}
bind:group={simulateSubgraph}
/>
{tr.deckConfigFsrsSimulatorRadioMemorized()}
</label>
</InputBox>
</div>
<svg
bind:this={svg}
viewBox={`0 0 ${bounds.width} ${bounds.height}`}
>
<CumulativeOverlay />
<HoverColumns />
<AxisTicks {bounds} />
<NoDataOverlay {bounds} />
</svg>
<TableData {tableData} />
</Graph>
</div>
</div>
</div>
</div>
<style>
.modal {
background-color: rgba(0, 0, 0, 0.5);
}
:global(.modal-xl) {
max-width: 100vw;
}
div.radio-group {
margin: 0.5em;
}
.btn {
margin-bottom: 0.375rem;
}
</style>

View file

@ -124,6 +124,7 @@ export function renderSimulationChart(
.selectAll("path")
.data(Array.from(groups.entries()))
.join("path")
.attr("vector-effect", "non-scaling-stroke")
.attr("stroke", (d, i) => color[i % color.length])
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
.attr("data-group", d => d[0]);