From 661f78557f13c923873f5d3416cbfc80539e603c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 28 Jul 2025 17:51:27 +1000 Subject: [PATCH 01/31] Fix sync server message failing to persist It was disappearing immediately on macOS --- qt/aqt/sync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/aqt/sync.py b/qt/aqt/sync.py index bedc05f8e..94ce0c8c1 100644 --- a/qt/aqt/sync.py +++ b/qt/aqt/sync.py @@ -118,7 +118,7 @@ def sync_collection(mw: aqt.main.AnkiQt, on_done: Callable[[], None]) -> None: if out.new_endpoint: mw.pm.set_current_sync_url(out.new_endpoint) if out.server_message: - showText(out.server_message) + showText(out.server_message, parent=mw) if out.required == out.NO_CHANGES: tooltip(parent=mw, msg=tr.sync_collection_complete()) # all done; track media progress From 60750f8e4c2475370f1dfa454445b6ab59322252 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 28 Jul 2025 17:59:41 +1000 Subject: [PATCH 02/31] Update Uzbek language name https://forums.ankiweb.net/t/uzbek-language-name/64725 --- pylib/anki/lang.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylib/anki/lang.py b/pylib/anki/lang.py index 9ff8dcd9e..b639b0416 100644 --- a/pylib/anki/lang.py +++ b/pylib/anki/lang.py @@ -73,7 +73,7 @@ langs = sorted( ("ଓଡ଼ିଆ", "or_OR"), ("Filipino", "tl"), ("ئۇيغۇر", "ug"), - ("Oʻzbek", "uz_UZ"), + ("Oʻzbekcha", "uz_UZ"), ] ) From 46bcf4efa6d1ed7d1e6cbc0a674ce7dbac7b85e6 Mon Sep 17 00:00:00 2001 From: Jarrett Ye Date: Mon, 28 Jul 2025 16:22:35 +0800 Subject: [PATCH 03/31] Feat/per-deck desired retention (#4194) * Feat/per-deck desired retention * Refactor desired retention logic in Collection implementation Updated the logic for retrieving deck-specific desired retention in both `memory_state.rs` and `mod.rs` to handle cases where the deck's normal state may not be available. This change ensures that the default configuration is used when necessary, improving the robustness of the retention handling. * Refactor desired retention handling in FsrsOptions.svelte Updated the logic for effective desired retention to use the configuration default instead of the deck-specific value. This change improves consistency in the retention value used throughout the component, ensuring that the correct value is bound to the UI elements. * refactor the logic for obtaining deck-specific desired retention by using method chaining * support deck-specific desired retention when rescheduling * Refactor desired retention logic to use a dedicated method for improved clarity and maintainability. --- proto/anki/deck_config.proto | 2 + proto/anki/decks.proto | 2 + rslib/src/deckconfig/update.rs | 10 ++- rslib/src/decks/mod.rs | 11 ++++ rslib/src/decks/schema11.rs | 1 + rslib/src/scheduler/answering/mod.rs | 43 +++++++++++- rslib/src/scheduler/fsrs/memory_state.rs | 21 ++++-- ts/routes/deck-options/FsrsOptions.svelte | 65 ++++++++++++++----- ts/routes/deck-options/SpinBoxFloatRow.svelte | 11 ++-- 9 files changed, 138 insertions(+), 28 deletions(-) diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 9dae49c6a..55291ee5f 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -219,6 +219,8 @@ message DeckConfigsForUpdate { bool review_today_active = 5; // Whether new_today applies to today or a past day. bool new_today_active = 6; + // Deck-specific desired retention override + optional float desired_retention = 7; } string name = 1; int64 config_id = 2; diff --git a/proto/anki/decks.proto b/proto/anki/decks.proto index bcd206b06..b244eb4a1 100644 --- a/proto/anki/decks.proto +++ b/proto/anki/decks.proto @@ -83,6 +83,8 @@ message Deck { optional uint32 new_limit = 7; DayLimit review_limit_today = 8; DayLimit new_limit_today = 9; + // Deck-specific desired retention override + optional float desired_retention = 10; reserved 12 to 15; } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 9eb3b595f..0bd549a20 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -212,10 +212,13 @@ impl Collection { if fsrs_toggled { self.set_config_bool_inner(BoolKey::Fsrs, req.fsrs)?; } + let mut deck_desired_retention: HashMap = Default::default(); for deck in self.storage.get_all_decks()? { if let Ok(normal) = deck.normal() { let deck_id = deck.id; - + if let Some(desired_retention) = normal.desired_retention { + deck_desired_retention.insert(deck_id, desired_retention); + } // previous order & params let previous_config_id = DeckConfigId(normal.config_id); let previous_config = configs_before_update.get(&previous_config_id); @@ -277,10 +280,11 @@ impl Collection { if req.fsrs { Some(UpdateMemoryStateRequest { params: c.fsrs_params().clone(), - desired_retention: c.inner.desired_retention, + preset_desired_retention: c.inner.desired_retention, max_interval: c.inner.maximum_review_interval, reschedule: req.fsrs_reschedule, historical_retention: c.inner.historical_retention, + deck_desired_retention: deck_desired_retention.clone(), }) } else { None @@ -409,6 +413,7 @@ fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits { .new_limit_today .map(|limit| limit.today == today) .unwrap_or_default(), + desired_retention: deck.desired_retention, } } @@ -417,6 +422,7 @@ fn update_deck_limits(deck: &mut NormalDeck, limits: &Limits, today: u32) { deck.new_limit = limits.new; update_day_limit(&mut deck.review_limit_today, limits.review_today, today); update_day_limit(&mut deck.new_limit_today, limits.new_today, today); + deck.desired_retention = limits.desired_retention; } fn update_day_limit(day_limit: &mut Option, new_limit: Option, today: u32) { diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index d16ebac49..44b5d9e59 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -31,6 +31,7 @@ pub(crate) use name::immediate_parent_name; pub use name::NativeDeckName; pub use schema11::DeckSchema11; +use crate::deckconfig::DeckConfig; use crate::define_newtype; use crate::error::FilteredDeckError; use crate::markdown::render_markdown; @@ -89,6 +90,16 @@ impl Deck { } } + /// Get the effective desired retention value for a deck. + /// Returns deck-specific desired retention if available, otherwise falls + /// back to config default. + pub fn effective_desired_retention(&self, config: &DeckConfig) -> f32 { + self.normal() + .ok() + .and_then(|d| d.desired_retention) + .unwrap_or(config.inner.desired_retention) + } + // used by tests at the moment #[allow(dead_code)] diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index e10820ca1..5cd4094f0 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -325,6 +325,7 @@ impl From for NormalDeck { new_limit: deck.new_limit, review_limit_today: deck.review_limit_today, new_limit_today: deck.new_limit_today, + desired_retention: None, } } } diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index bfe0eafaf..6ff8c6e2d 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -444,6 +444,8 @@ impl Collection { .get_deck(card.deck_id)? .or_not_found(card.deck_id)?; let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?; + + let desired_retention = deck.effective_desired_retention(&config); let fsrs_enabled = self.get_config_bool(BoolKey::Fsrs); let fsrs_next_states = if fsrs_enabled { let params = config.fsrs_params(); @@ -473,13 +475,13 @@ impl Collection { }; Some(fsrs.next_states( card.memory_state.map(Into::into), - config.inner.desired_retention, + desired_retention, days_elapsed, )?) } else { None }; - let desired_retention = fsrs_enabled.then_some(config.inner.desired_retention); + let desired_retention = fsrs_enabled.then_some(desired_retention); let fsrs_short_term_with_steps = self.get_config_bool(BoolKey::FsrsShortTermWithStepsEnabled); let fsrs_allow_short_term = if fsrs_enabled { @@ -662,6 +664,43 @@ pub(crate) mod test { col.get_scheduling_states(card_id).unwrap().current } + // Test that deck-specific desired retention is used when available + #[test] + fn deck_specific_desired_retention() -> Result<()> { + let mut col = Collection::new(); + + // Enable FSRS + col.set_config_bool(BoolKey::Fsrs, true, false)?; + + // Create a deck with specific desired retention + let deck_id = DeckId(1); + let deck = col.get_deck(deck_id)?.unwrap(); + let mut deck_clone = (*deck).clone(); + deck_clone.normal_mut().unwrap().desired_retention = Some(0.85); + col.update_deck(&mut deck_clone)?; + + // Create a card in this deck + let nt = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = nt.new_note(); + col.add_note(&mut note, deck_id)?; + + // Get the card using search_cards + let cards = col.search_cards(note.id, SortMode::NoOrder)?; + let card = col.storage.get_card(cards[0])?.unwrap(); + + // Test that the card state updater uses deck-specific desired retention + let updater = col.card_state_updater(card)?; + + // Print debug information + println!("FSRS enabled: {}", col.get_config_bool(BoolKey::Fsrs)); + println!("Desired retention: {:?}", updater.desired_retention); + + // Verify that the desired retention is from the deck, not the config + assert_eq!(updater.desired_retention, Some(0.85)); + + Ok(()) + } + // make sure the 'current' state for a card matches the // state we applied to it #[test] diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index b2640fa3e..199b19329 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -45,10 +45,11 @@ pub(crate) fn get_decay_from_params(params: &[f32]) -> f32 { #[derive(Debug)] pub(crate) struct UpdateMemoryStateRequest { pub params: Params, - pub desired_retention: f32, + pub preset_desired_retention: f32, pub historical_retention: f32, pub max_interval: u32, pub reschedule: bool, + pub deck_desired_retention: HashMap, } pub(crate) struct UpdateMemoryStateEntry { @@ -98,7 +99,8 @@ impl Collection { historical_retention.unwrap_or(0.9), ignore_before, )?; - let desired_retention = req.as_ref().map(|w| w.desired_retention); + let preset_desired_retention = + req.as_ref().map(|w| w.preset_desired_retention).unwrap(); let mut progress = self.new_progress_handler::(); progress.update(false, |s| s.total_cards = items.len() as u32)?; for (idx, (card_id, item)) in items.into_iter().enumerate() { @@ -109,7 +111,12 @@ impl Collection { // Store decay and desired retention in the card so that add-ons, card info, // stats and browser search/sorts don't need to access the deck config. // Unlike memory states, scheduler doesn't use decay and dr stored in the card. - card.desired_retention = desired_retention; + let deck_id = card.original_or_current_deck_id(); + let desired_retention = *req + .deck_desired_retention + .get(&deck_id) + .unwrap_or(&preset_desired_retention); + card.desired_retention = Some(desired_retention); card.decay = decay; if let Some(item) = item { card.set_memory_state(&fsrs, Some(item), historical_retention.unwrap())?; @@ -132,7 +139,7 @@ impl Collection { let original_interval = card.interval; let interval = fsrs.next_interval( Some(state.stability), - desired_retention.unwrap(), + desired_retention, 0, ); card.interval = rescheduler @@ -205,7 +212,11 @@ impl Collection { .storage .get_deck_config(conf_id)? .or_not_found(conf_id)?; - let desired_retention = config.inner.desired_retention; + + // Get deck-specific desired retention if available, otherwise use config + // default + let desired_retention = deck.effective_desired_retention(&config); + let historical_retention = config.inner.historical_retention; let params = config.fsrs_params(); let decay = get_decay_from_params(params); diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index cfdea341c..9b7bb218e 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -21,7 +21,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import SwitchRow from "$lib/components/SwitchRow.svelte"; import GlobalLabel from "./GlobalLabel.svelte"; - import { commitEditing, fsrsParams, type DeckOptionsState } from "./lib"; + import { commitEditing, fsrsParams, type DeckOptionsState, ValueTab } from "./lib"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import Warning from "./Warning.svelte"; import ParamsInputRow from "./ParamsInputRow.svelte"; @@ -32,6 +32,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html UpdateDeckConfigsMode, } from "@generated/anki/deck_config_pb"; import type Modal from "bootstrap/js/dist/modal"; + import TabbedValue from "./TabbedValue.svelte"; + import Item from "$lib/components/Item.svelte"; + import DynamicallySlottable from "$lib/components/DynamicallySlottable.svelte"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; @@ -42,13 +45,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const defaults = state.defaults; const fsrsReschedule = state.fsrsReschedule; const daysSinceLastOptimization = state.daysSinceLastOptimization; + const limits = state.deckLimits; $: lastOptimizationWarning = $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; let desiredRetentionFocused = false; let desiredRetentionEverFocused = false; let optimized = false; - const startingDesiredRetention = $config.desiredRetention.toFixed(2); $: if (desiredRetentionFocused) { desiredRetentionEverFocused = true; } @@ -63,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: computing = computingParams || checkingParams; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; - $: roundedRetention = Number($config.desiredRetention.toFixed(2)); + $: roundedRetention = Number(effectiveDesiredRetention.toFixed(2)); $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); let timeoutId: ReturnType | undefined = undefined; @@ -85,6 +88,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; + // Create tabs for desired retention + const desiredRetentionTabs: ValueTab[] = [ + new ValueTab( + tr.deckConfigSharedPreset(), + $config.desiredRetention, + (value) => ($config.desiredRetention = value!), + $config.desiredRetention, + null, + ), + new ValueTab( + tr.deckConfigDeckOnly(), + $limits.desiredRetention ?? null, + (value) => ($limits.desiredRetention = value ?? undefined), + null, + null, + ), + ]; + + // Get the effective desired retention value (deck-specific if set, otherwise config default) + let effectiveDesiredRetention = + $limits.desiredRetention ?? $config.desiredRetention; + const startingDesiredRetention = effectiveDesiredRetention.toFixed(2); + $: simulateFsrsRequest = new SimulateFsrsReviewRequest({ params: fsrsParams($config), desiredRetention: $config.desiredRetention, @@ -301,18 +327,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let simulatorModal: Modal; - - openHelpModal("desiredRetention")}> - {tr.deckConfigDesiredRetention()} - - + + + + + openHelpModal("desiredRetention")}> + {tr.deckConfigDesiredRetention()} + + + + diff --git a/ts/routes/deck-options/SpinBoxFloatRow.svelte b/ts/routes/deck-options/SpinBoxFloatRow.svelte index 5aa93bd30..3b16f32d8 100644 --- a/ts/routes/deck-options/SpinBoxFloatRow.svelte +++ b/ts/routes/deck-options/SpinBoxFloatRow.svelte @@ -23,9 +23,12 @@ - - - - + + + + + + + From 1af3c58d405ded0ec281c48fcc3a703810f70f48 Mon Sep 17 00:00:00 2001 From: Luc Mcgrady Date: Mon, 28 Jul 2025 09:55:08 +0100 Subject: [PATCH 04/31] Feat/Desired retention info graphs (#4199) * backend part * split memorised and cost * slapdash frontend * extract some simulator logic * Add zoomed version of graph * ./check * Fix: Tooltip * Fix: Simulator/workload transition * remove "time" * Update ts/routes/graphs/simulator.ts Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * Added: Mode toggle * Disable Dr in workload mode * keep button order consistant between modes * dont clear points on mode swap * add review count graph * Revert "dont clear points on mode swap" This reverts commit fc89efb1d954ce331002b4dd0b464109938b8a35. * "Help me pick" button * unrelated title case change * Add translation strings * fix: missing translation string * Fix: Layout shift * Add: Experimental * Fix Time / Memorized * per day values * set review limit to 9999 on open * keep default at currently set value * Do DR calculation in parallel (dae) Approx 5x faster on my machine --------- Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> Co-authored-by: Damien Elmes --- Cargo.lock | 1 + Cargo.toml | 1 + ftl/core/deck-config.ftl | 8 +- proto/anki/scheduler.proto | 8 + qt/aqt/mediasrv.py | 1 + rslib/Cargo.toml | 1 + rslib/src/scheduler/fsrs/simulator.rs | 36 +++ rslib/src/scheduler/service/mod.rs | 8 + rslib/src/timestamp.rs | 4 + ts/routes/deck-options/FsrsOptions.svelte | 21 ++ ts/routes/deck-options/SimulatorModal.svelte | 292 +++++++++++++------ ts/routes/graphs/simulator.ts | 211 ++++++++++---- 12 files changed, 445 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 86787124a..fce546a9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -131,6 +131,7 @@ dependencies = [ "prost-reflect", "pulldown-cmark 0.13.0", "rand 0.9.1", + "rayon", "regex", "reqwest 0.12.20", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index 2ff29cd1a..3ef2df9bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ prost-types = "0.13" pulldown-cmark = "0.13.0" pyo3 = { version = "0.25.1", features = ["extension-module", "abi3", "abi3-py39"] } rand = "0.9.1" +rayon = "1.10.0" regex = "1.11.1" reqwest = { version = "0.12.20", default-features = false, features = ["json", "socks", "stream", "multipart"] } rusqlite = { version = "0.36.0", features = ["trace", "functions", "collation", "bundled"] } diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 01eac3369..2ca45fcd8 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -505,7 +505,9 @@ deck-config-desired-retention-below-optimal = Your desired retention is below op # Description of the y axis in the FSRS simulation # diagram (Deck options -> FSRS) showing the total number of # cards that can be recalled or retrieved on a specific date. -deck-config-fsrs-simulator-experimental = FSRS simulator (experimental) +deck-config-fsrs-simulator-experimental = FSRS Simulator (Experimental) +deck-config-fsrs-simulate-desired-retention-experimental = FSRS Desired Retention Simulator (Experimental) +deck-config-fsrs-desired-retention-help-me-decide-experimental = Help Me Decide (Experimental) deck-config-additional-new-cards-to-simulate = Additional new cards to simulate deck-config-simulate = Simulate deck-config-clear-last-simulate = Clear Last Simulation @@ -515,10 +517,14 @@ deck-config-smooth-graph = Smooth graph deck-config-suspend-leeches = Suspend leeches deck-config-save-options-to-preset = Save Changes to Preset deck-config-save-options-to-preset-confirm = Overwrite the options in your current preset with the options that are currently set in the simulator? +deck-config-plotted-on-x-axis = (Plotted on the X-axis) # Radio button in the FSRS simulation diagram (Deck options -> FSRS) selecting # to show the total number of cards that can be recalled or retrieved on a # specific date. deck-config-fsrs-simulator-radio-memorized = Memorized +deck-config-fsrs-simulator-radio-ratio = Time / Memorized Ratio +# $time here is pre-formatted e.g. "10 Seconds" +deck-config-fsrs-simulator-ratio-tooltip = { $time } per memorized card ## Messages related to the FSRS scheduler’s health check. The health check determines whether the correlation between FSRS predictions and your memory is good or bad. It can be optionally triggered as part of the "Optimize" function. diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 1294b4543..a88cd468c 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -55,6 +55,8 @@ service SchedulerService { returns (ComputeOptimalRetentionResponse); rpc SimulateFsrsReview(SimulateFsrsReviewRequest) returns (SimulateFsrsReviewResponse); + rpc SimulateFsrsWorkload(SimulateFsrsReviewRequest) + returns (SimulateFsrsWorkloadResponse); rpc EvaluateParams(EvaluateParamsRequest) returns (EvaluateParamsResponse); rpc EvaluateParamsLegacy(EvaluateParamsLegacyRequest) returns (EvaluateParamsResponse); @@ -414,6 +416,12 @@ message SimulateFsrsReviewResponse { repeated float daily_time_cost = 4; } +message SimulateFsrsWorkloadResponse { + map cost = 1; + map memorized = 2; + map review_count = 3; +} + message ComputeOptimalRetentionResponse { float optimal_retention = 1; } diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index f08be4cef..d1d55e232 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -654,6 +654,7 @@ exposed_backend_list = [ "evaluate_params_legacy", "get_optimal_retention_parameters", "simulate_fsrs_review", + "simulate_fsrs_workload", # DeckConfigService "get_ignored_before_count", "get_retention_workload", diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index a1d24cc87..9be9e8d87 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -81,6 +81,7 @@ pin-project.workspace = true prost.workspace = true pulldown-cmark.workspace = true rand.workspace = true +rayon.workspace = true regex.workspace = true reqwest.workspace = true rusqlite.workspace = true diff --git a/rslib/src/scheduler/fsrs/simulator.rs b/rslib/src/scheduler/fsrs/simulator.rs index e032ecaf3..3b173939b 100644 --- a/rslib/src/scheduler/fsrs/simulator.rs +++ b/rslib/src/scheduler/fsrs/simulator.rs @@ -1,11 +1,13 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::collections::HashMap; 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 anki_proto::scheduler::SimulateFsrsWorkloadResponse; use fsrs::simulate; use fsrs::PostSchedulingFn; use fsrs::ReviewPriorityFn; @@ -14,6 +16,8 @@ use fsrs::FSRS; use itertools::Itertools; use rand::rngs::StdRng; use rand::Rng; +use rayon::iter::IntoParallelIterator; +use rayon::iter::ParallelIterator; use crate::card::CardQueue; use crate::card::CardType; @@ -267,6 +271,38 @@ impl Collection { daily_time_cost: result.cost_per_day, }) } + + pub fn simulate_workload( + &mut self, + req: SimulateFsrsReviewRequest, + ) -> Result { + let (config, cards) = self.simulate_request_to_config(&req)?; + let dr_workload = (70u32..=99u32) + .into_par_iter() + .map(|dr| { + let result = simulate( + &config, + &req.params, + dr as f32 / 100., + None, + Some(cards.clone()), + )?; + Ok(( + dr, + ( + *result.memorized_cnt_per_day.last().unwrap_or(&0.), + result.cost_per_day.iter().sum::(), + result.review_cnt_per_day.iter().sum::() as u32, + ), + )) + }) + .collect::>>()?; + Ok(SimulateFsrsWorkloadResponse { + memorized: dr_workload.iter().map(|(k, v)| (*k, v.0)).collect(), + cost: dr_workload.iter().map(|(k, v)| (*k, v.1)).collect(), + review_count: dr_workload.iter().map(|(k, v)| (*k, v.2)).collect(), + }) + } } impl Card { diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index 43d694e4f..9f42a79f7 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -16,6 +16,7 @@ use anki_proto::scheduler::FuzzDeltaResponse; use anki_proto::scheduler::GetOptimalRetentionParametersResponse; use anki_proto::scheduler::SimulateFsrsReviewRequest; use anki_proto::scheduler::SimulateFsrsReviewResponse; +use anki_proto::scheduler::SimulateFsrsWorkloadResponse; use fsrs::ComputeParametersInput; use fsrs::FSRSItem; use fsrs::FSRSReview; @@ -283,6 +284,13 @@ impl crate::services::SchedulerService for Collection { self.simulate_review(input) } + fn simulate_fsrs_workload( + &mut self, + input: SimulateFsrsReviewRequest, + ) -> Result { + self.simulate_workload(input) + } + fn compute_optimal_retention( &mut self, input: SimulateFsrsReviewRequest, diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index a020d706d..8a6ac4eb7 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -93,6 +93,10 @@ impl TimestampMillis { pub fn adding_secs(self, secs: i64) -> Self { Self(self.0 + secs * 1000) } + + pub fn elapsed_millis(self) -> u64 { + (Self::now().0 - self.0).max(0) as u64 + } } fn elapsed() -> time::Duration { diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 9b7bb218e..526c3aa99 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -325,6 +325,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } let simulatorModal: Modal; + let workloadModal: Modal; @@ -349,6 +350,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + + @@ -444,6 +455,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {onPresetChange} /> + +