From 452e012c71b3ca5d65d994aa311a1b99a75bc2e5 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 25 Nov 2023 15:09:21 +1000 Subject: [PATCH] Add option to calculate all weights at once --- ftl/core/deck-config.ftl | 8 +++++ proto/anki/collection.proto | 6 ++++ proto/anki/deck_config.proto | 8 ++++- qt/aqt/mediasrv.py | 26 +++++++++++---- rslib/src/deckconfig/service.rs | 3 +- rslib/src/deckconfig/update.rs | 51 +++++++++++++++++++++++++++-- rslib/src/progress.rs | 6 ++-- rslib/src/scheduler/fsrs/weights.rs | 34 ++++++++++++++----- rslib/src/scheduler/service/mod.rs | 2 +- rslib/src/search/writer.rs | 6 ++++ ts/deck-options/FsrsOptions.svelte | 7 ++-- ts/deck-options/SaveButton.svelte | 22 ++++++++++--- ts/deck-options/lib.test.ts | 20 +++++------ ts/deck-options/lib.ts | 9 ++--- 14 files changed, 162 insertions(+), 46 deletions(-) diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index fbc61bc15..2a699c9b5 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -293,6 +293,7 @@ deck-config-confirm-remove-name = Remove { $name }? deck-config-save-button = Save deck-config-save-to-all-subdecks = Save to All Subdecks +deck-config-save-and-optimize = Optimize All Presets deck-config-revert-button-tooltip = Restore this setting to its default value. ## These strings are shown via the Description button at the bottom of the @@ -409,6 +410,13 @@ deck-config-a-100-day-interval = [one] A 100 day interval will become { $days } day. *[other] A 100 day interval will become { $days } days. } +deck-config-percent-of-reviews = + { $reviews -> + [one] { $pct }% of { $reviews } review + *[other] { $pct }% of { $reviews } reviews + } +deck-config-optimizing-preset = Optimizing preset { $current_count }/{ $total_count }... +deck-config-fsrs-must-be-enabled = FSRS must be enabled first. deck-config-wait-for-audio = Wait for audio deck-config-show-reminder = Show Reminder diff --git a/proto/anki/collection.proto b/proto/anki/collection.proto index 84863cbef..f0db48709 100644 --- a/proto/anki/collection.proto +++ b/proto/anki/collection.proto @@ -134,9 +134,15 @@ message Progress { } message ComputeWeightsProgress { + // Current iteration uint32 current = 1; + // Total iterations uint32 total = 2; uint32 fsrs_items = 3; + // Only used in 'compute all weights' case + uint32 current_preset = 4; + // Only used in 'compute all weights' case + uint32 total_presets = 5; } message ComputeRetentionProgress { diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index eb693e14c..fdbdee581 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -200,13 +200,19 @@ message DeckConfigsForUpdate { bool apply_all_parent_limits = 9; } +enum UpdateDeckConfigsMode { + UPDATE_DECK_CONFIGS_MODE_NORMAL = 0; + UPDATE_DECK_CONFIGS_MODE_APPLY_TO_CHILDREN = 1; + UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_WEIGHTS = 2; +} + message UpdateDeckConfigsRequest { int64 target_deck_id = 1; /// Unchanged, non-selected configs can be omitted. Deck will /// be set to whichever entry comes last. repeated DeckConfig configs = 2; repeated int64 removed_config_ids = 3; - bool apply_to_children = 4; + UpdateDeckConfigsMode mode = 4; string card_state_customizer = 5; DeckConfigsForUpdate.CurrentDeck.Limits limits = 6; bool new_cards_ignore_review_limit = 7; diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 197382bed..9796ee8fe 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -36,7 +36,7 @@ from aqt.operations import on_op_finished from aqt.operations.deck import update_deck_configs as update_deck_configs_op from aqt.progress import ProgressUpdate from aqt.qt import * -from aqt.utils import aqt_data_path, show_warning +from aqt.utils import aqt_data_path, show_warning, tr app = flask.Flask(__name__, root_path="/fake") flask_cors.CORS(app, resources={r"/*": {"origins": "127.0.0.1"}}) @@ -433,12 +433,26 @@ def update_deck_configs() -> bytes: input.ParseFromString(request.data) def on_progress(progress: Progress, update: ProgressUpdate) -> None: - if not progress.HasField("compute_memory"): + if progress.HasField("compute_memory"): + val = progress.compute_memory + update.max = val.total_cards + update.value = val.current_cards + update.label = val.label + elif progress.HasField("compute_weights"): + val2 = progress.compute_weights + update.max = val2.total + update.value = val2.current + pct = str(int(val2.current / val2.total * 100) if val2.total > 0 else 0) + label = tr.deck_config_optimizing_preset( + current_count=val2.current_preset, total_count=val2.total_presets + ) + update.label = ( + label + + "\n" + + tr.deck_config_percent_of_reviews(pct=pct, reviews=val2.fsrs_items) + ) + else: return - val = progress.compute_memory - update.max = val.total_cards - update.value = val.current_cards - update.label = val.label if update.user_wants_abort: update.abort = True diff --git a/rslib/src/deckconfig/service.rs b/rslib/src/deckconfig/service.rs index a920ed04b..58ca25115 100644 --- a/rslib/src/deckconfig/service.rs +++ b/rslib/src/deckconfig/service.rs @@ -94,11 +94,12 @@ impl From for anki_proto::deck_config::DeckConfig { impl From for UpdateDeckConfigsRequest { fn from(c: anki_proto::deck_config::UpdateDeckConfigsRequest) -> Self { + let mode = c.mode(); UpdateDeckConfigsRequest { target_deck_id: c.target_deck_id.into(), configs: c.configs.into_iter().map(Into::into).collect(), removed_config_ids: c.removed_config_ids.into_iter().map(Into::into).collect(), - apply_to_children: c.apply_to_children, + mode, card_state_customizer: c.card_state_customizer, limits: c.limits.unwrap_or_default(), new_cards_ignore_review_limit: c.new_cards_ignore_review_limit, diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index ac2d82c4e..b501d0577 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -10,6 +10,7 @@ use std::iter; use anki_proto::deck_config::deck_configs_for_update::current_deck::Limits; use anki_proto::deck_config::deck_configs_for_update::ConfigWithExtra; use anki_proto::deck_config::deck_configs_for_update::CurrentDeck; +use anki_proto::deck_config::UpdateDeckConfigsMode; use anki_proto::decks::deck::normal::DayLimit; use fsrs::DEFAULT_WEIGHTS; @@ -27,7 +28,7 @@ pub struct UpdateDeckConfigsRequest { /// Deck will be set to last provided deck config. pub configs: Vec, pub removed_config_ids: Vec, - pub apply_to_children: bool, + pub mode: UpdateDeckConfigsMode, pub card_state_customizer: String, pub limits: Limits, pub new_cards_ignore_review_limit: bool, @@ -136,6 +137,10 @@ impl Collection { configs_after_update.remove(dcid); } + if req.mode == UpdateDeckConfigsMode::ComputeAllWeights { + self.compute_all_weights(&mut req)?; + } + // add/update provided configs for conf in &mut req.configs { let weight_len = conf.inner.fsrs_weights.len(); @@ -147,7 +152,7 @@ impl Collection { } // get selected deck and possibly children - let selected_deck_ids: HashSet<_> = if req.apply_to_children { + let selected_deck_ids: HashSet<_> = if req.mode == UpdateDeckConfigsMode::ApplyToChildren { let deck = self .storage .get_deck(req.target_deck_id)? @@ -295,6 +300,46 @@ impl Collection { } Ok(()) } + fn compute_all_weights(&mut self, req: &mut UpdateDeckConfigsRequest) -> Result<()> { + require!(req.fsrs, "FSRS must be enabled"); + + // frontend didn't include any unmodified deck configs, so we need to fill them + // in + let changed_configs: HashSet<_> = req.configs.iter().map(|c| c.id).collect(); + let previous_last = req.configs.pop().or_invalid("no configs provided")?; + for config in self.storage.all_deck_config()? { + if !changed_configs.contains(&config.id) { + req.configs.push(config); + } + } + // other parts of the code expect the currently-selected preset to come last + req.configs.push(previous_last); + + // calculate and apply weights to each preset + let config_len = req.configs.len() as u32; + for (idx, config) in req.configs.iter_mut().enumerate() { + let search = if config.inner.weight_search.trim().is_empty() { + SearchNode::Preset(config.name.clone()) + .try_into_search()? + .to_string() + } else { + config.inner.weight_search.clone() + }; + match self.compute_weights(&search, idx as u32 + 1, config_len) { + Ok(weights) => { + if weights.fsrs_items >= 1000 { + println!("{}: {:?}", config.name, weights.weights); + config.inner.fsrs_weights = weights.weights; + } + } + Err(AnkiError::Interrupted) => return Err(AnkiError::Interrupted), + Err(err) => { + println!("{}: {}", config.name, err) + } + } + } + Ok(()) + } } fn normal_deck_to_limits(deck: &NormalDeck, today: u32) -> Limits { @@ -383,7 +428,7 @@ mod test { .map(|c| c.config.unwrap().into()) .collect(), removed_config_ids: vec![], - apply_to_children: false, + mode: UpdateDeckConfigsMode::Normal, card_state_customizer: "".to_string(), limits: Limits::default(), new_cards_ignore_review_limit: false, diff --git a/rslib/src/progress.rs b/rslib/src/progress.rs index ba2e1d34a..8982dcdb8 100644 --- a/rslib/src/progress.rs +++ b/rslib/src/progress.rs @@ -211,9 +211,11 @@ pub(crate) fn progress_to_proto( ), Progress::ComputeWeights(progress) => { Value::ComputeWeights(anki_proto::collection::ComputeWeightsProgress { - current: progress.current, - total: progress.total, + current: progress.current_iteration, + total: progress.total_iterations, fsrs_items: progress.fsrs_items, + current_preset: progress.current_preset, + total_presets: progress.total_presets, }) } Progress::ComputeRetention(progress) => { diff --git a/rslib/src/scheduler/fsrs/weights.rs b/rslib/src/scheduler/fsrs/weights.rs index 1894ad4ee..bc0213150 100644 --- a/rslib/src/scheduler/fsrs/weights.rs +++ b/rslib/src/scheduler/fsrs/weights.rs @@ -27,13 +27,25 @@ use crate::search::SortMode; pub(crate) type Weights = Vec; impl Collection { - pub fn compute_weights(&mut self, search: &str) -> Result { + /// Note this does not return an error if there are less than 1000 items - + /// the caller should instead check the fsrs_items count in the return + /// value. + pub fn compute_weights( + &mut self, + search: &str, + current_preset: u32, + total_presets: u32, + ) -> Result { let mut anki_progress = self.new_progress_handler::(); let timing = self.timing_today()?; let revlogs = self.revlog_for_srs(search)?; let items = fsrs_items_for_training(revlogs, timing.next_day_at); let fsrs_items = items.len() as u32; - anki_progress.update(false, |p| p.fsrs_items = fsrs_items)?; + anki_progress.update(false, |p| { + p.fsrs_items = fsrs_items; + p.current_preset = current_preset; + p.total_presets = total_presets; + })?; // adapt the progress handler to our built-in progress handling let progress = CombinedProgressState::new_shared(); let progress2 = progress.clone(); @@ -43,9 +55,9 @@ impl Collection { thread::sleep(Duration::from_millis(100)); let mut guard = progress.lock().unwrap(); if let Err(_err) = anki_progress.update(false, |s| { - s.total = guard.total() as u32; - s.current = guard.current() as u32; - finished = s.total > 0 && s.total == s.current; + s.total_iterations = guard.total() as u32; + s.current_iteration = guard.current() as u32; + finished = guard.finished(); }) { guard.want_abort = true; return; @@ -112,8 +124,8 @@ impl Collection { Ok(fsrs.evaluate(items, |ip| { anki_progress .update(false, |p| { - p.total = ip.total as u32; - p.current = ip.current as u32; + p.total_iterations = ip.total as u32; + p.current_iteration = ip.current as u32; }) .is_ok() })?) @@ -122,9 +134,13 @@ impl Collection { #[derive(Default, Clone, Copy, Debug)] pub struct ComputeWeightsProgress { - pub current: u32, - pub total: u32, + pub current_iteration: u32, + pub total_iterations: u32, pub fsrs_items: u32, + /// Only used in 'compute all weights' case + pub current_preset: u32, + /// Only used in 'compute all weights' case + pub total_presets: u32, } /// Convert a series of revlog entries sorted by card id into FSRS items. diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index d98c53e11..77e71f4a8 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -254,7 +254,7 @@ impl crate::services::SchedulerService for Collection { &mut self, input: scheduler::ComputeFsrsWeightsRequest, ) -> Result { - self.compute_weights(&input.search) + self.compute_weights(&input.search, 1, 1) } fn compute_optimal_retention( diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 687dd150a..013bdf9e3 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -47,6 +47,12 @@ pub(super) fn write_nodes(nodes: &[Node]) -> String { nodes.iter().map(write_node).collect() } +impl ToString for Node { + fn to_string(&self) -> String { + write_node(self) + } +} + fn write_node(node: &Node) -> String { use Node::*; match node { diff --git a/ts/deck-options/FsrsOptions.svelte b/ts/deck-options/FsrsOptions.svelte index 9a0650818..7821b0121 100644 --- a/ts/deck-options/FsrsOptions.svelte +++ b/ts/deck-options/FsrsOptions.svelte @@ -202,12 +202,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html if (!val || !val.total) { return ""; } - let pct = ((val.current / val.total) * 100).toFixed(1); - pct = `${pct}%`; + const pct = ((val.current / val.total) * 100).toFixed(1); if (val instanceof ComputeRetentionProgress) { - return pct; + return `${pct}%`; } else { - return `${pct} of ${val.fsrsItems} reviews`; + return tr.deckConfigPercentOfReviews({ pct, reviews: val.fsrsItems }); } } diff --git a/ts/deck-options/SaveButton.svelte b/ts/deck-options/SaveButton.svelte index a50ecf679..ca71ec00a 100644 --- a/ts/deck-options/SaveButton.svelte +++ b/ts/deck-options/SaveButton.svelte @@ -3,10 +3,12 @@ Copyright: Ankitects Pty Ltd and contributors License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -->