Merge branch 'main' into break_answer_buttons_table_x_axis_in_two_rows

This commit is contained in:
GithubAnon0000 2025-04-27 17:06:39 +00:00 committed by GitHub
commit 020f0d1bce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2727 additions and 1433 deletions

View file

@ -223,6 +223,7 @@ derivativeoflog7 <https://github.com/derivativeoflog7>
rreemmii-dev <https://github.com/rreemmii-dev> rreemmii-dev <https://github.com/rreemmii-dev>
babofitos <https://github.com/babofitos> babofitos <https://github.com/babofitos>
Jonathan Schoreels <https://github.com/JSchoreels> Jonathan Schoreels <https://github.com/JSchoreels>
JL710
******************** ********************

2642
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -35,9 +35,9 @@ git = "https://github.com/ankitects/linkcheck.git"
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs] [workspace.dependencies.fsrs]
version = "3.0.0" # version = "3.0.0"
# git = "https://github.com/open-spaced-repetition/fsrs-rs.git" git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
# rev = "c7717682997a8a6d53d97c7196281e745c5b3c8e" rev = "092c20bac7d9239a991ae5b561556ad34c706c16"
# path = "../open-spaced-repetition/fsrs-rs" # path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies] [workspace.dependencies]

File diff suppressed because it is too large Load diff

@ -1 +1 @@
Subproject commit 647e3cb0e697c51248201c4fb0df36514e765aa5 Subproject commit 376ae99eb47eae3c5e6298150162715423bc2e4e

@ -1 +1 @@
Subproject commit b3562ed3594d2afa0973edb877cc2f701ff162c3 Subproject commit 393bacec35703f91520e4d2feec37ed6953114f4

View file

@ -40,6 +40,7 @@ qt-accel-layout-horizontal = &Horizontal
qt-accel-zoom-in = Zoom &In qt-accel-zoom-in = Zoom &In
qt-accel-zoom-out = Zoom &Out qt-accel-zoom-out = Zoom &Out
qt-accel-reset-zoom = &Reset Zoom qt-accel-reset-zoom = &Reset Zoom
qt-accel-toggle-sidebar = Toggle Sidebar
qt-accel-zoom-editor-in = Zoom Editor &In qt-accel-zoom-editor-in = Zoom Editor &In
qt-accel-zoom-editor-out = Zoom Editor &Out qt-accel-zoom-editor-out = Zoom Editor &Out
qt-accel-create-backup = Create &Backup qt-accel-create-backup = Create &Backup

View file

@ -51,7 +51,7 @@ service SchedulerService {
returns (ComputeFsrsParamsResponse); returns (ComputeFsrsParamsResponse);
rpc GetOptimalRetentionParameters(GetOptimalRetentionParametersRequest) rpc GetOptimalRetentionParameters(GetOptimalRetentionParametersRequest)
returns (GetOptimalRetentionParametersResponse); returns (GetOptimalRetentionParametersResponse);
rpc ComputeOptimalRetention(ComputeOptimalRetentionRequest) rpc ComputeOptimalRetention(SimulateFsrsReviewRequest)
returns (ComputeOptimalRetentionResponse); returns (ComputeOptimalRetentionResponse);
rpc SimulateFsrsReview(SimulateFsrsReviewRequest) rpc SimulateFsrsReview(SimulateFsrsReviewRequest)
returns (SimulateFsrsReviewResponse); returns (SimulateFsrsReviewResponse);
@ -409,16 +409,6 @@ message SimulateFsrsReviewResponse {
repeated float daily_time_cost = 4; repeated float daily_time_cost = 4;
} }
message ComputeOptimalRetentionRequest {
repeated float params = 1;
uint32 days_to_simulate = 2;
uint32 max_interval = 3;
string search = 4;
double loss_aversion = 5;
repeated float easy_days_percentages = 6;
optional uint32 suspend_after_lapse_count = 7;
}
message ComputeOptimalRetentionResponse { message ComputeOptimalRetentionResponse {
float optimal_retention = 1; float optimal_retention = 1;
} }

View file

@ -280,6 +280,19 @@ class Browser(QMainWindow):
if note_type_id := self.get_active_note_type_id(): if note_type_id := self.get_active_note_type_id():
add_cards.set_note_type(note_type_id) add_cards.set_note_type(note_type_id)
# If in the Browser we open Preview and press Ctrl+W there,
# both Preview and Browser windows get closed by Qt out of the box.
# We circumvent that behavior by only closing the currently active window
def _handle_close(self):
active_window = QApplication.activeWindow()
if active_window and active_window != self:
if isinstance(active_window, QDialog):
active_window.reject()
else:
active_window.close()
else:
self.close()
def setupMenus(self) -> None: def setupMenus(self) -> None:
# actions # actions
f = self.form f = self.form
@ -366,6 +379,7 @@ class Browser(QMainWindow):
qconnect(f.actionFind.triggered, self.onFind) qconnect(f.actionFind.triggered, self.onFind)
qconnect(f.actionNote.triggered, self.onNote) qconnect(f.actionNote.triggered, self.onNote)
qconnect(f.actionSidebar.triggered, self.focusSidebar) qconnect(f.actionSidebar.triggered, self.focusSidebar)
qconnect(f.actionToggleSidebar.triggered, self.toggle_sidebar)
qconnect(f.actionCardList.triggered, self.onCardList) qconnect(f.actionCardList.triggered, self.onCardList)
# help # help
@ -695,7 +709,7 @@ class Browser(QMainWindow):
def setupSidebar(self) -> None: def setupSidebar(self) -> None:
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self) dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
dw.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) dw.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
dw.setObjectName("Sidebar") dw.setObjectName("Sidebar")
dock_area = ( dock_area = (
Qt.DockWidgetArea.RightDockWidgetArea Qt.DockWidgetArea.RightDockWidgetArea
@ -729,8 +743,11 @@ class Browser(QMainWindow):
# UI is more responsive # UI is more responsive
self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar) self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar)
def showSidebar(self) -> None: def showSidebar(self, show: bool = True) -> None:
self.sidebarDockWidget.setVisible(True) want_visible = not self.sidebarDockWidget.isVisible()
self.sidebarDockWidget.setVisible(show)
if want_visible and show:
self.sidebar.refresh()
def focusSidebar(self) -> None: def focusSidebar(self) -> None:
self.showSidebar() self.showSidebar()
@ -741,10 +758,7 @@ class Browser(QMainWindow):
self.sidebar.searchBar.setFocus() self.sidebar.searchBar.setFocus()
def toggle_sidebar(self) -> None: def toggle_sidebar(self) -> None:
want_visible = not self.sidebarDockWidget.isVisible() self.showSidebar(not self.sidebarDockWidget.isVisible())
self.sidebarDockWidget.setVisible(want_visible)
if want_visible:
self.sidebar.refresh()
# legacy # legacy

View file

@ -317,6 +317,8 @@
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionFullScreen"/> <addaction name="actionFullScreen"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionToggleSidebar"/>
<addaction name="separator"/>
<addaction name="actionZoomIn"/> <addaction name="actionZoomIn"/>
<addaction name="actionZoomOut"/> <addaction name="actionZoomOut"/>
<addaction name="actionResetZoom"/> <addaction name="actionResetZoom"/>
@ -702,6 +704,11 @@
<string>qt_accel_full_screen</string> <string>qt_accel_full_screen</string>
</property> </property>
</action> </action>
<action name="actionToggleSidebar">
<property name="text">
<string>qt_accel_toggle_sidebar</string>
</property>
</action>
<action name="actionZoomIn"> <action name="actionZoomIn">
<property name="text"> <property name="text">
<string>qt_accel_zoom_editor_in</string> <string>qt_accel_zoom_editor_in</string>
@ -785,7 +792,7 @@
<sender>actionClose</sender> <sender>actionClose</sender>
<signal>triggered()</signal> <signal>triggered()</signal>
<receiver>Dialog</receiver> <receiver>Dialog</receiver>
<slot>close()</slot> <slot>_handle_close()</slot>
<hints> <hints>
<hint type="sourcelabel"> <hint type="sourcelabel">
<x>-1</x> <x>-1</x>

View file

@ -1,18 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::sync::Arc; use anki_proto::scheduler::SimulateFsrsReviewRequest;
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
use fsrs::extract_simulator_config; use fsrs::extract_simulator_config;
use fsrs::PostSchedulingFn;
use fsrs::SimulatorConfig; use fsrs::SimulatorConfig;
use fsrs::FSRS; use fsrs::FSRS;
use super::simulator::apply_load_balance_and_easy_days;
use crate::prelude::*; use crate::prelude::*;
use crate::revlog::RevlogEntry; use crate::revlog::RevlogEntry;
use crate::scheduler::states::load_balancer::parse_easy_days_percentages;
use crate::search::SortMode;
#[derive(Default, Clone, Copy, Debug)] #[derive(Default, Clone, Copy, Debug)]
pub struct ComputeRetentionProgress { pub struct ComputeRetentionProgress {
@ -21,65 +15,16 @@ pub struct ComputeRetentionProgress {
} }
impl Collection { impl Collection {
pub fn compute_optimal_retention( pub fn compute_optimal_retention(&mut self, req: SimulateFsrsReviewRequest) -> Result<f32> {
&mut self,
req: ComputeOptimalRetentionRequest,
) -> Result<f32> {
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>(); let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
let fsrs = FSRS::new(None)?; let fsrs = FSRS::new(None)?;
if req.days_to_simulate == 0 { if req.days_to_simulate == 0 {
invalid_input!("no days to simulate") invalid_input!("no days to simulate")
} }
let revlogs = self let (config, cards) = self.simulate_request_to_config(&req)?;
.search_cards_into_table(&req.search, SortMode::NoOrder)?
.col
.storage
.get_revlog_entries_for_searched_cards_in_card_order()?;
let p = self.get_optimal_retention_parameters(revlogs)?;
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 |card, max_interval, today, due_cnt_per_day, rng| {
apply_load_balance_and_easy_days(
card.interval,
max_interval,
today,
due_cnt_per_day,
rng,
next_day_at,
&easy_days_percentages,
)
},
)))
} else {
None
};
Ok(fsrs Ok(fsrs
.optimal_retention( .optimal_retention(
&SimulatorConfig { &config,
deck_size,
learn_span: req.days_to_simulate as usize,
max_cost_perday: f32::MAX,
max_ivl: req.max_interval as f32,
first_rating_prob: p.first_rating_prob,
review_rating_prob: p.review_rating_prob,
learn_limit,
review_limit: usize::MAX,
new_cards_ignore_review_limit: true,
suspend_after_lapses: None,
post_scheduling_fn,
review_priority_fn: None,
learning_step_transitions: p.learning_step_transitions,
relearning_step_transitions: p.relearning_step_transitions,
state_rating_costs: p.state_rating_costs,
learning_step_count: p.learning_step_count,
relearning_step_count: p.relearning_step_count,
},
&req.params, &req.params,
|ip| { |ip| {
anki_progress anki_progress
@ -88,6 +33,7 @@ impl Collection {
}) })
.is_ok() .is_ok()
}, },
Some(cards),
)? )?
.clamp(0.7, 0.95)) .clamp(0.7, 0.95))
} }

View file

@ -75,7 +75,6 @@ pub(crate) fn apply_load_balance_and_easy_days(
fn create_review_priority_fn( fn create_review_priority_fn(
review_order: ReviewCardOrder, review_order: ReviewCardOrder,
deck_size: usize, deck_size: usize,
params: Vec<f32>,
) -> Option<ReviewPriorityFn> { ) -> Option<ReviewPriorityFn> {
// Helper macro to wrap closure in ReviewPriorityFn // Helper macro to wrap closure in ReviewPriorityFn
macro_rules! wrap { macro_rules! wrap {
@ -86,28 +85,28 @@ fn create_review_priority_fn(
match review_order { match review_order {
// Ease-based ordering // Ease-based ordering
EaseAscending => wrap!(|c| -(c.difficulty * 100.0) as i32), EaseAscending => wrap!(|c, _w| -(c.difficulty * 100.0) as i32),
EaseDescending => wrap!(|c| (c.difficulty * 100.0) as i32), EaseDescending => wrap!(|c, _w| (c.difficulty * 100.0) as i32),
// Interval-based ordering // Interval-based ordering
IntervalsAscending => wrap!(|c| c.interval as i32), IntervalsAscending => wrap!(|c, _w| c.interval as i32),
IntervalsDescending => wrap!(|c| -(c.interval as i32)), IntervalsDescending => wrap!(|c, _w| -(c.interval as i32)),
// Retrievability-based ordering // Retrievability-based ordering
RetrievabilityAscending => { RetrievabilityAscending => {
wrap!(move |c| (c.retrievability(&params) * 1000.0) as i32) wrap!(move |c, w| (c.retrievability(w) * 1000.0) as i32)
} }
RetrievabilityDescending => { RetrievabilityDescending => {
wrap!(move |c| -(c.retrievability(&params) * 1000.0) as i32) wrap!(move |c, w| -(c.retrievability(w) * 1000.0) as i32)
} }
// Due date ordering // Due date ordering
Day | DayThenDeck | DeckThenDay => { Day | DayThenDeck | DeckThenDay => {
wrap!(|c| c.scheduled_due() as i32) wrap!(|c, _w| c.scheduled_due() as i32)
} }
// Random ordering // Random ordering
Random => { Random => {
wrap!(move |_| rand::thread_rng().gen_range(0..deck_size) as i32) wrap!(move |_c, _w| rand::thread_rng().gen_range(0..deck_size) as i32)
} }
// Not implemented yet // Not implemented yet
@ -116,10 +115,10 @@ fn create_review_priority_fn(
} }
impl Collection { impl Collection {
pub fn simulate_review( pub fn simulate_request_to_config(
&mut self, &mut self,
req: SimulateFsrsReviewRequest, req: &SimulateFsrsReviewRequest,
) -> Result<SimulateFsrsReviewResponse> { ) -> Result<(SimulatorConfig, Vec<fsrs::Card>)> {
let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?; let guard = self.search_cards_into_table(&req.search, SortMode::NoOrder)?;
let revlogs = guard let revlogs = guard
.col .col
@ -171,7 +170,7 @@ impl Collection {
let deck_size = converted_cards.len(); let deck_size = converted_cards.len();
let p = self.get_optimal_retention_parameters(revlogs)?; let p = self.get_optimal_retention_parameters(revlogs)?;
let easy_days_percentages = parse_easy_days_percentages(req.easy_days_percentages)?; let easy_days_percentages = parse_easy_days_percentages(&req.easy_days_percentages)?;
let next_day_at = self.timing_today()?.next_day_at; let next_day_at = self.timing_today()?.next_day_at;
let post_scheduling_fn: Option<PostSchedulingFn> = let post_scheduling_fn: Option<PostSchedulingFn> =
@ -197,7 +196,7 @@ impl Collection {
.review_order .review_order
.try_into() .try_into()
.ok() .ok()
.and_then(|order| create_review_priority_fn(order, deck_size, req.params.clone())); .and_then(|order| create_review_priority_fn(order, deck_size));
let config = SimulatorConfig { let config = SimulatorConfig {
deck_size, deck_size,
@ -218,12 +217,21 @@ impl Collection {
learning_step_count: p.learning_step_count, learning_step_count: p.learning_step_count,
relearning_step_count: p.relearning_step_count, relearning_step_count: p.relearning_step_count,
}; };
Ok((config, converted_cards))
}
pub fn simulate_review(
&mut self,
req: SimulateFsrsReviewRequest,
) -> Result<SimulateFsrsReviewResponse> {
let (config, cards) = self.simulate_request_to_config(&req)?;
let result = simulate( let result = simulate(
&config, &config,
&req.params, &req.params,
req.desired_retention, req.desired_retention,
None, None,
Some(converted_cards), Some(cards),
)?; )?;
Ok(SimulateFsrsReviewResponse { Ok(SimulateFsrsReviewResponse {
accumulated_knowledge_acquisition: result.memorized_cnt_per_day, accumulated_knowledge_acquisition: result.memorized_cnt_per_day,

View file

@ -9,7 +9,6 @@ use anki_proto::generic;
use anki_proto::scheduler; use anki_proto::scheduler;
use anki_proto::scheduler::ComputeFsrsParamsResponse; use anki_proto::scheduler::ComputeFsrsParamsResponse;
use anki_proto::scheduler::ComputeMemoryStateResponse; use anki_proto::scheduler::ComputeMemoryStateResponse;
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
use anki_proto::scheduler::ComputeOptimalRetentionResponse; use anki_proto::scheduler::ComputeOptimalRetentionResponse;
use anki_proto::scheduler::FsrsBenchmarkResponse; use anki_proto::scheduler::FsrsBenchmarkResponse;
use anki_proto::scheduler::FuzzDeltaRequest; use anki_proto::scheduler::FuzzDeltaRequest;
@ -284,7 +283,7 @@ impl crate::services::SchedulerService for Collection {
fn compute_optimal_retention( fn compute_optimal_retention(
&mut self, &mut self,
input: ComputeOptimalRetentionRequest, input: SimulateFsrsReviewRequest,
) -> Result<ComputeOptimalRetentionResponse> { ) -> Result<ComputeOptimalRetentionResponse> {
Ok(ComputeOptimalRetentionResponse { Ok(ComputeOptimalRetentionResponse {
optimal_retention: self.compute_optimal_retention(input)?, optimal_retention: self.compute_optimal_retention(input)?,

View file

@ -277,7 +277,7 @@ impl LoadBalancer {
} }
} }
pub(crate) fn parse_easy_days_percentages(percentages: Vec<f32>) -> Result<[EasyDay; 7]> { pub(crate) fn parse_easy_days_percentages(percentages: &[f32]) -> Result<[EasyDay; 7]> {
if percentages.is_empty() { if percentages.is_empty() {
return Ok([EasyDay::Normal; 7]); return Ok([EasyDay::Normal; 7]);
} }
@ -300,7 +300,7 @@ pub(crate) fn build_easy_days_percentages(
.into_iter() .into_iter()
.map(|(dcid, conf)| { .map(|(dcid, conf)| {
let easy_days_percentages = let easy_days_percentages =
parse_easy_days_percentages(conf.inner.easy_days_percentages)?; parse_easy_days_percentages(&conf.inner.easy_days_percentages)?;
Ok((dcid, easy_days_percentages)) Ok((dcid, easy_days_percentages))
}) })
.collect() .collect()

View file

@ -326,7 +326,7 @@ fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> {
let review_day = due.saturating_sub(ivl); let review_day = due.saturating_sub(ivl);
days_elapsed.saturating_sub(review_day) as u32 days_elapsed.saturating_sub(review_day) as u32
}; };
let decay = card_data.decay.unwrap_or_default(); let decay = card_data.decay.unwrap_or(FSRS5_DEFAULT_DECAY);
Ok(card_data.memory_state().map(|state| { Ok(card_data.memory_state().map(|state| {
FSRS::new(None) FSRS::new(None)
.unwrap() .unwrap()

View file

@ -7,13 +7,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
ComputeRetentionProgress, ComputeRetentionProgress,
type ComputeParamsProgress, type ComputeParamsProgress,
} from "@generated/anki/collection_pb"; } from "@generated/anki/collection_pb";
import { import { SimulateFsrsReviewRequest } from "@generated/anki/scheduler_pb";
ComputeOptimalRetentionRequest,
SimulateFsrsReviewRequest,
} from "@generated/anki/scheduler_pb";
import { import {
computeFsrsParams, computeFsrsParams,
computeOptimalRetention,
evaluateParams, evaluateParams,
setWantsAbort, setWantsAbort,
} from "@generated/backend"; } from "@generated/backend";
@ -26,7 +22,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import GlobalLabel from "./GlobalLabel.svelte"; import GlobalLabel from "./GlobalLabel.svelte";
import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib"; import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte";
import ParamsSearchRow from "./ParamsSearchRow.svelte"; import ParamsSearchRow from "./ParamsSearchRow.svelte";
@ -37,8 +32,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let openHelpModal: (String) => void; export let openHelpModal: (String) => void;
export let onPresetChange: () => void; export let onPresetChange: () => void;
const presetName = state.currentPresetName;
const config = state.currentConfig; const config = state.currentConfig;
const defaults = state.defaults; const defaults = state.defaults;
const fsrsReschedule = state.fsrsReschedule; const fsrsReschedule = state.fsrsReschedule;
@ -50,13 +43,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let computeParamsProgress: ComputeParamsProgress | undefined; let computeParamsProgress: ComputeParamsProgress | undefined;
let computingParams = false; let computingParams = false;
let checkingParams = false; let checkingParams = false;
let computingRetention = false;
let optimalRetention = 0; $: computing = computingParams || checkingParams;
$: if ($presetName) {
optimalRetention = 0;
}
$: computing = computingParams || checkingParams || computingRetention;
$: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`;
$: roundedRetention = Number($config.desiredRetention.toFixed(2)); $: roundedRetention = Number($config.desiredRetention.toFixed(2));
$: desiredRetentionWarning = getRetentionWarning( $: desiredRetentionWarning = getRetentionWarning(
@ -65,19 +53,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
); );
$: retentionWarningClass = getRetentionWarningClass(roundedRetention); $: retentionWarningClass = getRetentionWarningClass(roundedRetention);
let computeRetentionProgress:
| ComputeParamsProgress
| ComputeRetentionProgress
| undefined;
const optimalRetentionRequest = new ComputeOptimalRetentionRequest({
daysToSimulate: 365,
lossAversion: 2.5,
});
$: if (optimalRetentionRequest.daysToSimulate > 3650) {
optimalRetentionRequest.daysToSimulate = 3650;
}
$: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit;
$: simulateFsrsRequest = new SimulateFsrsReviewRequest({ $: simulateFsrsRequest = new SimulateFsrsReviewRequest({
@ -233,44 +208,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
async function computeRetention(): Promise<void> {
if (computingRetention) {
await setWantsAbort({});
return;
}
if (state.presetAssignmentsChanged()) {
alert(tr.deckConfigPleaseSaveYourChangesFirst());
return;
}
computingRetention = true;
computeRetentionProgress = undefined;
try {
await runWithBackendProgress(
async () => {
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;
},
(progress) => {
if (progress.value.case === "computeRetention") {
computeRetentionProgress = progress.value.value;
}
},
);
} finally {
computingRetention = false;
}
}
$: computeParamsProgressString = renderWeightProgress(computeParamsProgress); $: computeParamsProgressString = renderWeightProgress(computeParamsProgress);
$: computeRetentionProgressString = renderRetentionProgress(
computeRetentionProgress,
);
$: totalReviews = computeParamsProgress?.reviews ?? undefined; $: totalReviews = computeParamsProgress?.reviews ?? undefined;
function renderWeightProgress(val: ComputeParamsProgress | undefined): String { function renderWeightProgress(val: ComputeParamsProgress | undefined): String {
@ -285,22 +223,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
function renderRetentionProgress(
val: ComputeRetentionProgress | undefined,
): String {
if (!val) {
return "";
}
return tr.deckConfigIterations({ count: val.current });
}
function estimatedRetention(retention: number): String {
if (!retention) {
return "";
}
return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) });
}
async function computeAllParams(): Promise<void> { async function computeAllParams(): Promise<void> {
await commitEditing(); await commitEditing();
state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS); state.save(UpdateDeckConfigsMode.COMPUTE_ALL_PARAMS);
@ -390,49 +312,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/if} {/if}
</div> </div>
<div class="m-2">
<details>
<summary>{tr.deckConfigComputeOptimalRetention()}</summary>
<SpinBoxRow
bind:value={optimalRetentionRequest.daysToSimulate}
defaultValue={365}
min={1}
max={3650}
>
<SettingTitle on:click={() => openHelpModal("computeOptimalRetention")}>
{tr.deckConfigDaysToSimulate()}
</SettingTitle>
</SpinBoxRow>
<button
class="btn {computingRetention ? 'btn-warning' : 'btn-primary'}"
disabled={!computingRetention && computing}
on:click={() => computeRetention()}
>
{#if computingRetention}
{tr.actionsCancel()}
{:else}
{tr.deckConfigComputeButton()}
{/if}
</button>
{#if optimalRetention}
{estimatedRetention(optimalRetention)}
{#if optimalRetention - $config.desiredRetention >= 0.01}
<Warning
warning={tr.deckConfigDesiredRetentionBelowOptimal()}
className="alert-warning"
/>
{/if}
{/if}
{#if computingRetention}
<div>{computeRetentionProgressString}</div>
{/if}
</details>
</div>
<div class="m-2"> <div class="m-2">
<button class="btn btn-primary" on:click={() => (showSimulator = true)}> <button class="btn btn-primary" on:click={() => (showSimulator = true)}>
{tr.deckConfigFsrsSimulatorExperimental()} {tr.deckConfigFsrsSimulatorExperimental()}

View file

@ -16,9 +16,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { SimulateSubgraph, type Point } from "../graphs/simulator"; import { SimulateSubgraph, type Point } from "../graphs/simulator";
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
import { renderSimulationChart } from "../graphs/simulator"; import { renderSimulationChart } from "../graphs/simulator";
import { simulateFsrsReview } from "@generated/backend"; import { computeOptimalRetention, simulateFsrsReview } from "@generated/backend";
import { runWithBackendProgress } from "@tslib/progress"; import { runWithBackendProgress } from "@tslib/progress";
import type { import type {
ComputeOptimalRetentionResponse,
SimulateFsrsReviewRequest, SimulateFsrsReviewRequest,
SimulateFsrsReviewResponse, SimulateFsrsReviewResponse,
} from "@generated/anki/scheduler_pb"; } from "@generated/anki/scheduler_pb";
@ -30,6 +31,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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 { DeckConfig_Config_LeechAction } from "@generated/anki/deck_config_pb";
import EasyDaysInput from "./EasyDaysInput.svelte"; import EasyDaysInput from "./EasyDaysInput.svelte";
import Warning from "./Warning.svelte";
import type { ComputeRetentionProgress } from "@generated/anki/collection_pb";
export let shown = false; export let shown = false;
export let state: DeckOptionsState; export let state: DeckOptionsState;
@ -53,9 +56,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND; let suspendLeeches = $config.leechAction == DeckConfig_Config_LeechAction.SUSPEND;
let leechThreshold = $config.leechThreshold; let leechThreshold = $config.leechThreshold;
let optimalRetention: null | number = null;
let computingRetention = false;
let computeRetentionProgress: ComputeRetentionProgress | undefined = undefined;
$: daysToSimulate = 365; $: daysToSimulate = 365;
$: deckSize = 0; $: deckSize = 0;
$: windowSize = Math.ceil(daysToSimulate / 365); $: windowSize = Math.ceil(daysToSimulate / 365);
$: processing = simulating || computingRetention;
function movingAverage(y: number[], windowSize: number): number[] { function movingAverage(y: number[], windowSize: number): number[] {
const result: number[] = []; const result: number[] = [];
@ -75,14 +83,61 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return arr1.map((value, index) => value + arr2[index]); return arr1.map((value, index) => value + arr2[index]);
} }
async function simulateFsrs(): Promise<void> { function estimatedRetention(retention: number): String {
let resp: SimulateFsrsReviewResponse | undefined; if (!retention) {
return "";
}
return tr.deckConfigPredictedOptimalRetention({ num: retention.toFixed(2) });
}
function updateRequest() {
simulateFsrsRequest.daysToSimulate = daysToSimulate; simulateFsrsRequest.daysToSimulate = daysToSimulate;
simulateFsrsRequest.deckSize = deckSize; simulateFsrsRequest.deckSize = deckSize;
simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches simulateFsrsRequest.suspendAfterLapseCount = suspendLeeches
? leechThreshold ? leechThreshold
: undefined; : undefined;
simulateFsrsRequest.easyDaysPercentages = easyDayPercentages; simulateFsrsRequest.easyDaysPercentages = easyDayPercentages;
}
function renderRetentionProgress(
val: ComputeRetentionProgress | undefined,
): String {
if (!val) {
return "";
}
return tr.deckConfigIterations({ count: val.current });
}
$: computeRetentionProgressString = renderRetentionProgress(
computeRetentionProgress,
);
async function computeRetention() {
let resp: ComputeOptimalRetentionResponse | undefined;
updateRequest();
try {
await runWithBackendProgress(
async () => {
computingRetention = true;
resp = await computeOptimalRetention(simulateFsrsRequest);
},
(progress) => {
if (progress.value.case === "computeRetention") {
computeRetentionProgress = progress.value.value;
}
},
);
} finally {
computingRetention = false;
if (resp) {
optimalRetention = resp.optimalRetention;
}
}
}
async function simulateFsrs(): Promise<void> {
let resp: SimulateFsrsReviewResponse | undefined;
updateRequest();
try { try {
await runWithBackendProgress( await runWithBackendProgress(
async () => { async () => {
@ -328,6 +383,39 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{/if} {/if}
</details> </details>
<div class="m-2">
<details>
<summary>{tr.deckConfigComputeOptimalRetention()}</summary>
<button
class="btn {computingRetention
? 'btn-warning'
: 'btn-primary'}"
disabled={!computingRetention && computing}
on:click={() => computeRetention()}
>
{#if computingRetention}
{tr.actionsCancel()}
{:else}
{tr.deckConfigComputeButton()}
{/if}
</button>
{#if optimalRetention}
{estimatedRetention(optimalRetention)}
{#if optimalRetention - $config.desiredRetention >= 0.01}
<Warning
warning={tr.deckConfigDesiredRetentionBelowOptimal()}
className="alert-warning"
/>
{/if}
{/if}
{#if computingRetention}
<div>{computeRetentionProgressString}</div>
{/if}
</details>
</div>
<button <button
class="btn {computing ? 'btn-warning' : 'btn-primary'}" class="btn {computing ? 'btn-warning' : 'btn-primary'}"
disabled={computing} disabled={computing}
@ -366,7 +454,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{tr.deckConfigSaveOptionsToPreset()} {tr.deckConfigSaveOptionsToPreset()}
</button> </button>
{#if simulating} {#if processing}
{tr.actionsProcessing()} {tr.actionsProcessing()}
{/if} {/if}