Add option to calculate all weights at once

This commit is contained in:
Damien Elmes 2023-11-25 15:09:21 +10:00
parent c67f510b9a
commit 452e012c71
14 changed files with 162 additions and 46 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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;

View file

@ -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

View file

@ -94,11 +94,12 @@ impl From<DeckConfig> for anki_proto::deck_config::DeckConfig {
impl From<anki_proto::deck_config::UpdateDeckConfigsRequest> 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,

View file

@ -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<DeckConfig>,
pub removed_config_ids: Vec<DeckConfigId>,
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,

View file

@ -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) => {

View file

@ -27,13 +27,25 @@ use crate::search::SortMode;
pub(crate) type Weights = Vec<f32>;
impl Collection {
pub fn compute_weights(&mut self, search: &str) -> Result<ComputeFsrsWeightsResponse> {
/// 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<ComputeFsrsWeightsResponse> {
let mut anki_progress = self.new_progress_handler::<ComputeWeightsProgress>();
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.

View file

@ -254,7 +254,7 @@ impl crate::services::SchedulerService for Collection {
&mut self,
input: scheduler::ComputeFsrsWeightsRequest,
) -> Result<scheduler::ComputeFsrsWeightsResponse> {
self.compute_weights(&input.search)
self.compute_weights(&input.search, 1, 1)
}
fn compute_optimal_retention(

View file

@ -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 {

View file

@ -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 });
}
}

View file

@ -3,10 +3,12 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { UpdateDeckConfigsMode } from "@tslib/anki/deck_config_pb";
import * as tr from "@tslib/ftl";
import { withCollapsedWhitespace } from "@tslib/i18n";
import { getPlatformString } from "@tslib/shortcuts";
import { createEventDispatcher, tick } from "svelte";
import { get } from "svelte/store";
import DropdownDivider from "../components/DropdownDivider.svelte";
import DropdownItem from "../components/DropdownItem.svelte";
@ -57,9 +59,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
}
async function save(applyToChildDecks: boolean): Promise<void> {
async function save(mode: UpdateDeckConfigsMode): Promise<void> {
await commitEditing();
state.save(applyToChildDecks);
if (!get(state.fsrs)) {
alert(tr.deckConfigFsrsMustBeEnabled());
return;
}
state.save(mode);
}
const saveKeyCombination = "Control+Enter";
@ -69,14 +75,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<LabelButton
primary
on:click={() => save(false)}
on:click={() => save(UpdateDeckConfigsMode.NORMAL)}
tooltip={getPlatformString(saveKeyCombination)}
--border-left-radius={!rtl ? "var(--border-radius)" : "0"}
--border-right-radius={rtl ? "var(--border-radius)" : "0"}
>
<div class="save">{tr.deckConfigSaveButton()}</div>
</LabelButton>
<Shortcut keyCombination={saveKeyCombination} on:action={() => save(false)} />
<Shortcut
keyCombination={saveKeyCombination}
on:action={() => save(UpdateDeckConfigsMode.NORMAL)}
/>
<WithFloating
show={showFloating}
@ -108,9 +117,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{tr.deckConfigRemoveGroup()}
</DropdownItem>
<DropdownDivider />
<DropdownItem on:click={() => save(true)}>
<DropdownItem on:click={() => save(UpdateDeckConfigsMode.APPLY_TO_CHILDREN)}>
{tr.deckConfigSaveToAllSubdecks()}
</DropdownItem>
<DropdownItem on:click={() => save(UpdateDeckConfigsMode.COMPUTE_ALL_WEIGHTS)}>
{tr.deckConfigSaveAndOptimize()}
</DropdownItem>
</Popover>
</WithFloating>

View file

@ -6,7 +6,7 @@
*/
import { protoBase64 } from "@bufbuild/protobuf";
import { DeckConfig_Config_LeechAction, DeckConfigsForUpdate } from "@tslib/anki/deck_config_pb";
import { DeckConfig_Config_LeechAction, DeckConfigsForUpdate, UpdateDeckConfigsMode } from "@tslib/anki/deck_config_pb";
import { get } from "svelte/store";
import { DeckOptionsState } from "./lib";
@ -203,7 +203,7 @@ test("deck list", () => {
expect(get(state.currentConfig).newPerDay).toBe(10);
// only the pre-existing deck should be listed for removal
const out = state.dataForSaving(false);
const out = state.dataForSaving(UpdateDeckConfigsMode.NORMAL);
expect(out.removedConfigIds).toStrictEqual([1618570764780n]);
});
@ -221,24 +221,24 @@ test("duplicate name", () => {
test("saving", () => {
let state = startingState();
let out = state.dataForSaving(false);
let out = state.dataForSaving(UpdateDeckConfigsMode.NORMAL);
expect(out.removedConfigIds).toStrictEqual([]);
expect(out.targetDeckId).toBe(123n);
// in no-changes case, currently selected config should
// be returned
expect(out.configs!.length).toBe(1);
expect(out.configs![0].name).toBe("another one");
expect(out.applyToChildren).toBe(false);
expect(out.mode).toBe(UpdateDeckConfigsMode.NORMAL);
// rename, then change current deck
state.setCurrentName("zzz");
out = state.dataForSaving(true);
out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);
state.setCurrentIndex(0);
// renamed deck should be in changes, with current deck as last element
out = state.dataForSaving(true);
out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);
expect(out.configs!.map((c) => c.name)).toStrictEqual(["zzz", "Default"]);
expect(out.applyToChildren).toBe(true);
expect(out.mode).toBe(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);
// start again, adding new deck
state = startingState();
@ -246,7 +246,7 @@ test("saving", () => {
// deleting it should not change removedConfigs
state.removeCurrentConfig();
out = state.dataForSaving(true);
out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);
expect(out.removedConfigIds).toStrictEqual([]);
// select the other non-default deck & remove
@ -255,7 +255,7 @@ test("saving", () => {
// should be listed in removedConfigs, and modified should
// only contain Default, which is the new current deck
out = state.dataForSaving(true);
out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);
expect(out.removedConfigIds).toStrictEqual([1618570764780n]);
expect(out.configs!.map((c) => c.name)).toStrictEqual(["Default"]);
});
@ -283,7 +283,7 @@ test("aux data", () => {
});
// ensure changes serialize
const out = state.dataForSaving(true);
const out = state.dataForSaving(UpdateDeckConfigsMode.APPLY_TO_CHILDREN);
expect(out.configs!.length).toBe(2);
const json = out.configs!.map((c) => JSON.parse(new TextDecoder().decode(c.config!.other)));
expect(json).toStrictEqual([

View file

@ -5,6 +5,7 @@ import type { PlainMessage } from "@bufbuild/protobuf";
import type {
DeckConfigsForUpdate,
DeckConfigsForUpdate_CurrentDeck,
UpdateDeckConfigsMode,
UpdateDeckConfigsRequest,
} from "@tslib/anki/deck_config_pb";
import { DeckConfig, DeckConfig_Config, DeckConfigsForUpdate_CurrentDeck_Limits } from "@tslib/anki/deck_config_pb";
@ -178,7 +179,7 @@ export class DeckOptionsState {
}
dataForSaving(
applyToChildren: boolean,
mode: UpdateDeckConfigsMode,
): PlainMessage<UpdateDeckConfigsRequest> {
const modifiedConfigsExcludingCurrent = this.configs
.map((c) => c.config)
@ -197,7 +198,7 @@ export class DeckOptionsState {
targetDeckId: this.targetDeckId,
removedConfigIds: this.removedConfigs,
configs,
applyToChildren,
mode,
cardStateCustomizer: get(this.cardStateCustomizer),
limits: get(this.deckLimits),
newCardsIgnoreReviewLimit: get(this.newCardsIgnoreReviewLimit),
@ -210,9 +211,9 @@ export class DeckOptionsState {
return this._presetAssignmentsChanged;
}
async save(applyToChildren: boolean): Promise<void> {
async save(mode: UpdateDeckConfigsMode): Promise<void> {
await updateDeckConfigs(
this.dataForSaving(applyToChildren),
this.dataForSaving(mode),
);
}