diff --git a/.version b/.version index 5951b08da..9bab2a4b4 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -25.05 +25.06 diff --git a/CONTRIBUTORS b/CONTRIBUTORS index e8814bf93..068760e6b 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -230,7 +230,7 @@ KolbyML Adnane Taghi Spiritual Father Emmanuel Ferdman - +Marvin Kopf ******************** The text of the 3 clause BSD license follows: diff --git a/Cargo.lock b/Cargo.lock index f73263c28..562f4242f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2295,7 +2295,7 @@ dependencies = [ [[package]] name = "fsrs" version = "4.0.0" -source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=33ec3ee4d5d73e704633469cf5bf1a42e620a524#33ec3ee4d5d73e704633469cf5bf1a42e620a524" +source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=a7f7efc10f0a26b14ee348cc7402155685f2a24f#a7f7efc10f0a26b14ee348cc7402155685f2a24f" dependencies = [ "burn", "itertools 0.14.0", @@ -5010,9 +5010,9 @@ dependencies = [ [[package]] name = "priority-queue" -version = "2.3.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef08705fa1589a1a59aa924ad77d14722cb0cd97b67dd5004ed5f4a4873fce8d" +checksum = "5676d703dda103cbb035b653a9f11448c0a7216c7926bd35fcb5865475d0c970" dependencies = [ "autocfg", "equivalent", @@ -6290,18 +6290,18 @@ checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" [[package]] name = "snafu" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "223891c85e2a29c3fe8fb900c1fae5e69c2e42415e3177752e8718475efa5019" +checksum = "320b01e011bf8d5d7a4a4a4be966d9160968935849c83b918827f6a435e7f627" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c3c6b7927ffe7ecaa769ee0e3994da3b8cafc8f444578982c83ecb161af917" +checksum = "1961e2ef424c1424204d3a5d6975f934f56b6d50ff5732382d84ebf460e147f7" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 7d1645fc4..fbca1fe56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" [workspace.dependencies.fsrs] # version = "3.0.0" git = "https://github.com/open-spaced-repetition/fsrs-rs.git" -rev = "33ec3ee4d5d73e704633469cf5bf1a42e620a524" +rev = "a7f7efc10f0a26b14ee348cc7402155685f2a24f" # path = "../open-spaced-repetition/fsrs-rs" [workspace.dependencies] @@ -125,7 +125,7 @@ serde_tuple = "0.5.0" sha1 = "0.10.6" sha2 = { version = "0.10.8" } simple-file-manifest = "0.11.0" -snafu = { version = "0.8.5", features = ["rust_1_61"] } +snafu = { version = "0.8.6", features = ["rust_1_61"] } strum = { version = "0.26.3", features = ["derive"] } syn = { version = "2.0.82", features = ["parsing", "printing"] } tar = "0.4.42" diff --git a/cargo/licenses.json b/cargo/licenses.json index 4854cb085..87a69df25 100644 --- a/cargo/licenses.json +++ b/cargo/licenses.json @@ -3151,7 +3151,7 @@ }, { "name": "priority-queue", - "version": "2.3.1", + "version": "2.5.0", "authors": "Gianmarco Garrisi ", "repository": "https://github.com/garro95/priority-queue", "license": "LGPL-3.0-or-later OR MPL-2.0", @@ -4015,7 +4015,7 @@ }, { "name": "snafu", - "version": "0.8.5", + "version": "0.8.6", "authors": "Jake Goulding ", "repository": "https://github.com/shepmaster/snafu", "license": "Apache-2.0 OR MIT", @@ -4024,7 +4024,7 @@ }, { "name": "snafu-derive", - "version": "0.8.5", + "version": "0.8.6", "authors": "Jake Goulding ", "repository": "https://github.com/shepmaster/snafu", "license": "Apache-2.0 OR MIT", diff --git a/ftl/core-repo b/ftl/core-repo index ca04132a8..78412ce16 160000 --- a/ftl/core-repo +++ b/ftl/core-repo @@ -1 +1 @@ -Subproject commit ca04132a8f82296f3e0ea22b74bb4221e1d11d3f +Subproject commit 78412ce163d4dc50dd82f5b27cde3119086a2eb7 diff --git a/ftl/core/deck-config.ftl b/ftl/core/deck-config.ftl index 12512acb0..15507f468 100644 --- a/ftl/core/deck-config.ftl +++ b/ftl/core/deck-config.ftl @@ -395,9 +395,10 @@ deck-config-weights = FSRS parameters deck-config-compute-optimal-weights = Optimize FSRS parameters deck-config-compute-minimum-recommended-retention = Minimum recommended retention deck-config-optimize-button = Optimize Current Preset +deck-config-health-check = Check health when optimizing (slow) deck-config-compute-button = Compute deck-config-ignore-before = Ignore cards reviewed before -deck-config-time-to-optimize = It's been a while - using the Optimize All button is recommended. +deck-config-time-to-optimize = It's been a while - using the Optimize All Presets button is recommended. deck-config-evaluate-button = Evaluate deck-config-desired-retention = Desired retention deck-config-historical-retention = Historical retention @@ -470,11 +471,12 @@ deck-config-compute-optimal-retention-tooltip4 = willing to invest more study time to achieve it. Setting your desired retention lower than the minimum is not recommended, as it will lead to a higher workload, because of the high forgetting rate. deck-config-please-save-your-changes-first = Please save your changes first. -deck-config-a-100-day-interval = - { $days -> - [one] A 100 day interval will become { $days } day. - *[other] A 100 day interval will become { $days } days. - } +deck-config-workload-factor-change = Approximate workload: {$factor}x + (compared to {$previousDR}% desired retention) +deck-config-workload-factor-unchanged = The higher this value, the more frequently cards will be shown to you. +deck-config-desired-retention-too-low = Your desired retention is very low, which can lead to very long intervals. +deck-config-desired-retention-too-high = Your desired retention is very high, which can lead to very short intervals. + deck-config-percent-of-reviews = { $reviews -> [one] { $pct }% of { $reviews } review @@ -484,6 +486,15 @@ deck-config-percent-input = { $pct }% deck-config-optimizing-preset = Optimizing preset { $current_count }/{ $total_count }... deck-config-fsrs-must-be-enabled = FSRS must be enabled first. deck-config-fsrs-params-optimal = The FSRS parameters currently appear to be optimal. +deck-config-fsrs-bad-fit-warning = Your memory is difficult for FSRS to predict. Recommendations: + + - Suspend or reformulate leeches. + - Use the answer buttons consistently. Keep in mind that "Hard" is a passing grade, not a failing grade. + - Understand before you memorize. + + If you follow these suggestions, performance will usually improve over the next few months. +deck-config-fsrs-good-fit = FSRS is well adjusted to your memory. + deck-config-fsrs-params-no-reviews = No reviews found. Make sure this preset is assigned to all decks (including subdecks) that you want to optimize, and try again. deck-config-wait-for-audio = Wait for audio @@ -512,6 +523,12 @@ deck-config-fsrs-simulator-radio-memorized = Memorized ## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future. +deck-config-a-100-day-interval = + { $days -> + [one] A 100 day interval will become { $days } day. + *[other] A 100 day interval will become { $days } days. + } + deck-config-fsrs-simulator-y-axis-title-time = Review Time/Day deck-config-fsrs-simulator-y-axis-title-count = Review Count/Day deck-config-fsrs-simulator-y-axis-title-memorized = Memorized Total diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index 64c0db0c1..3aacb9746 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -96,6 +96,7 @@ editing-image-occlusion-rectangle-tool = Rectangle editing-image-occlusion-ellipse-tool = Ellipse editing-image-occlusion-polygon-tool = Polygon editing-image-occlusion-text-tool = Text +editing-image-occlusion-fill-tool = Fill with colour editing-image-occlusion-toggle-mask-editor = Toggle Mask Editor editing-image-occlusion-reset = Reset Image Occlusion editing-image-occlusion-confirm-reset = Are you sure you want to reset this image occlusion? diff --git a/ftl/qt-repo b/ftl/qt-repo index f35acabb4..fbe9d1c73 160000 --- a/ftl/qt-repo +++ b/ftl/qt-repo @@ -1 +1 @@ -Subproject commit f35acabb46dc9197a62c47eb7f2ca062628b1d94 +Subproject commit fbe9d1c731f7ad09953e63fdb0c455a6d3a3b6be diff --git a/proto/anki/deck_config.proto b/proto/anki/deck_config.proto index 48cb71479..831283931 100644 --- a/proto/anki/deck_config.proto +++ b/proto/anki/deck_config.proto @@ -25,6 +25,8 @@ service DeckConfigService { returns (collection.OpChanges); rpc GetIgnoredBeforeCount(GetIgnoredBeforeCountRequest) returns (GetIgnoredBeforeCountResponse); + rpc GetRetentionWorkload(GetRetentionWorkloadRequest) + returns (GetRetentionWorkloadResponse); } // Implicitly includes any of the above methods that are not listed in the @@ -35,6 +37,17 @@ message DeckConfigId { int64 dcid = 1; } +message GetRetentionWorkloadRequest { + repeated float w = 1; + string search = 2; + float before = 3; + float after = 4; +} + +message GetRetentionWorkloadResponse { + float factor = 1; +} + message GetIgnoredBeforeCountRequest { string ignore_revlogs_before_date = 1; string search = 2; @@ -222,6 +235,7 @@ message DeckConfigsForUpdate { // only applies to v3 scheduler bool new_cards_ignore_review_limit = 7; bool fsrs = 8; + bool fsrs_health_check = 11; bool apply_all_parent_limits = 9; uint32 days_since_last_fsrs_optimize = 10; } @@ -245,4 +259,5 @@ message UpdateDeckConfigsRequest { bool fsrs = 8; bool apply_all_parent_limits = 9; bool fsrs_reschedule = 10; + bool fsrs_health_check = 11; } diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 364bf50ad..ea483d3db 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -354,11 +354,13 @@ message ComputeFsrsParamsRequest { repeated float current_params = 2; int64 ignore_revlogs_before_ms = 3; uint32 num_of_relearning_steps = 4; + bool health_check = 5; } message ComputeFsrsParamsResponse { repeated float params = 1; uint32 fsrs_items = 2; + optional bool health_check_passed = 3; } message ComputeFsrsParamsFromItemsRequest { @@ -435,9 +437,9 @@ message GetOptimalRetentionParametersResponse { } message EvaluateParamsRequest { - repeated float params = 1; - string search = 2; - int64 ignore_revlogs_before_ms = 3; + string search = 1; + int64 ignore_revlogs_before_ms = 2; + uint32 num_of_relearning_steps = 3; } message EvaluateParamsResponse { diff --git a/pylib/anki/media.py b/pylib/anki/media.py index 5d8653a97..8ba5d432c 100644 --- a/pylib/anki/media.py +++ b/pylib/anki/media.py @@ -76,7 +76,7 @@ class MediaManager(DeprecatedNamesMixin): return self.col._backend.strip_av_tags(text) def _extract_filenames(self, text: str) -> list[str]: - "This only exists do support a legacy function; do not use." + "This only exists to support a legacy function; do not use." out = self.col._backend.extract_av_tags(text=text, question_side=True) return [ x.filename diff --git a/pylib/anki/sound.py b/pylib/anki/sound.py index 3d375f716..3af584dae 100644 --- a/pylib/anki/sound.py +++ b/pylib/anki/sound.py @@ -9,10 +9,14 @@ These can be accessed via eg card.question_av_tags() from __future__ import annotations +import os +import os.path import re from dataclasses import dataclass from typing import Union +from anki import hooks + @dataclass class TTSTag: @@ -34,10 +38,30 @@ class SoundOrVideoTag: """Contains the filename inside a [sound:...] tag. Video files also use [sound:...]. + + SECURITY: We should only ever construct this with basename(filename), + as passing arbitrary paths to mpv from a shared deck is a security issue. + + Anki add-ons can supply an absolute file path to play any file on disk + using the built-in media player. """ filename: str + def path(self, media_folder: str) -> str: + "Prepend the media folder to the filename." + if os.path.basename(self.filename) == self.filename: + # Path in the current collection's media folder. + # Turn it into a fully-qualified path so mpv can find it, and to + # ensure the filename doesn't get treated like a non-file scheme. + head, tail = media_folder, self.filename + else: + # Add-ons can use absolute paths to play arbitrary files on disk. + # Example: sound.av_player.play_tags([SoundOrVideoTag("/path/to/file")]) + head, tail = os.path.split(os.path.abspath(self.filename)) + tail = hooks.media_file_filter(tail) + return os.path.join(head, tail) + # note this does not include image tags, which are handled with HTML. AVTag = Union[SoundOrVideoTag, TTSTag] diff --git a/pylib/anki/template.py b/pylib/anki/template.py index 3cc8afaf8..118a23c6b 100644 --- a/pylib/anki/template.py +++ b/pylib/anki/template.py @@ -28,6 +28,7 @@ template_legacy.py file, using the legacy addHook() system. from __future__ import annotations +import os.path from collections.abc import Sequence from dataclasses import dataclass from typing import Any, Union @@ -95,7 +96,7 @@ class PartiallyRenderedCard: def av_tag_to_native(tag: card_rendering_pb2.AVTag) -> AVTag: val = tag.WhichOneof("value") if val == "sound_or_video": - return SoundOrVideoTag(filename=tag.sound_or_video) + return SoundOrVideoTag(filename=os.path.basename(tag.sound_or_video)) else: return TTSTag( field_text=tag.tts.field_text, diff --git a/qt/aqt/browser/browser.py b/qt/aqt/browser/browser.py index 260746514..5d41f4ac9 100644 --- a/qt/aqt/browser/browser.py +++ b/qt/aqt/browser/browser.py @@ -53,6 +53,7 @@ from aqt.operations.tag import ( from aqt.qt import * from aqt.sound import av_player from aqt.switch import Switch +from aqt.theme import WidgetStyle from aqt.undo import UndoActionsInfo from aqt.utils import ( HelpPage, @@ -170,6 +171,7 @@ class Browser(QMainWindow): if self.height() != 0: self.aspect_ratio = self.width() / self.height() self.set_layout(self.mw.pm.browser_layout(), True) + self.onSidebarVisibilityChange(not self.sidebarDockWidget.isHidden()) # disable undo/redo self.on_undo_state_change(mw.undo_actions_info()) # legacy alias @@ -726,6 +728,7 @@ class Browser(QMainWindow): self.form.actionSidebarFilter.triggered, self.focusSidebarSearchBar, ) + qconnect(dw.visibilityChanged, self.onSidebarVisibilityChange) grid = QGridLayout() grid.addWidget(self.sidebar.searchBar, 0, 0) grid.addWidget(self.sidebar.toolbar, 0, 1) @@ -745,9 +748,17 @@ class Browser(QMainWindow): self.mw.progress.timer(10, self.sidebar.refresh, False, parent=self.sidebar) def showSidebar(self, show: bool = True) -> None: - want_visible = not self.sidebarDockWidget.isVisible() self.sidebarDockWidget.setVisible(show) - if want_visible and show: + + def onSidebarVisibilityChange(self, visible): + margins = self.form.verticalLayout_3.contentsMargins() + skip_left_margin = visible and not ( + is_mac and aqt.mw.pm.get_widget_style() == WidgetStyle.NATIVE + ) + margins.setLeft(0 if skip_left_margin else margins.right()) + self.form.verticalLayout_3.setContentsMargins(margins) + + if visible: self.sidebar.refresh() def focusSidebar(self) -> None: @@ -1128,6 +1139,9 @@ class Browser(QMainWindow): dialog=dialog, ), ) + if key := aqt.mw.pm.get_answer_key(ease): + QShortcut(key, dialog, activated=btn.click) # type: ignore + btn.setToolTip(tr.actions_shortcut_key(key)) layout.addWidget(btn) # Add cancel button diff --git a/qt/aqt/errors.py b/qt/aqt/errors.py index 1896affbe..c2b2f2ae6 100644 --- a/qt/aqt/errors.py +++ b/qt/aqt/errors.py @@ -235,6 +235,8 @@ class ErrorHandler(QObject): if "unable to get local issuer certificate" in error and is_win: showWarning(tr.errors_windows_ssl_updates()) return + if is_chromium_cert_error(error): + return debug_text = supportText() + "\n" + error diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 203f23ef9..a38790728 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -7,9 +7,8 @@ import enum import logging import mimetypes import os -import random import re -import string +import secrets import sys import threading import traceback @@ -659,6 +658,7 @@ exposed_backend_list = [ "simulate_fsrs_review", # DeckConfigService "get_ignored_before_count", + "get_retention_workload", ] @@ -764,7 +764,7 @@ def legacy_page_data() -> Response: return _text_response(HTTPStatus.NOT_FOUND, "page not found") -_APIKEY = "".join(random.choices(string.ascii_letters + string.digits, k=32)) +_APIKEY = secrets.token_urlsafe(32) def _have_api_access() -> bool: diff --git a/qt/aqt/mpv.py b/qt/aqt/mpv.py index 60ea21290..74155814c 100644 --- a/qt/aqt/mpv.py +++ b/qt/aqt/mpv.py @@ -41,6 +41,7 @@ import time from queue import Empty, Full, Queue from shutil import which +import aqt from anki.utils import is_mac, is_win @@ -68,6 +69,7 @@ if is_win: # pylint: disable=import-error import pywintypes import win32file # pytype: disable=import-error + import win32job import win32pipe import winerror @@ -130,6 +132,22 @@ class MPVBase: def _start_process(self): """Start the mpv process.""" self._proc = subprocess.Popen(self.argv, env=self.popenEnv) + if is_win: + # Ensure mpv gets terminated if Anki closes abruptly. + self._job = win32job.CreateJobObject(None, "") + extended_info = win32job.QueryInformationJobObject( + self._job, win32job.JobObjectExtendedLimitInformation + ) + extended_info["BasicLimitInformation"][ + "LimitFlags" + ] = win32job.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + win32job.SetInformationJobObject( + self._job, + win32job.JobObjectExtendedLimitInformation, + extended_info, + ) + handle = self._proc._handle # pylint: disable=no-member + win32job.AssignProcessToJobObject(self._job, handle) def _stop_process(self): """Stop the mpv process.""" @@ -444,7 +462,7 @@ class MPV(MPVBase): super().__init__(*args, **kwargs) - self._register_callbacks() + aqt.mw.taskman.run_in_background(self._register_callbacks, None) def _register_callbacks(self): self._callbacks = {} diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 11f957a84..386767a30 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -4,6 +4,7 @@ from __future__ import annotations import os +import os.path import platform import re import subprocess @@ -23,7 +24,6 @@ from markdown import markdown import aqt import aqt.mpv import aqt.qt -from anki import hooks from anki.cards import Card from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag from anki.utils import is_lin, is_mac, is_win, namedtmp @@ -177,15 +177,27 @@ class AVPlayer: self._stop_if_playing() def play_file(self, filename: str) -> None: + """Play the provided path. + + SECURITY: Filename may be an arbitrary path. For filenames coming from a collection, + you should only ever use the os.path.basename(filename) as the filename.""" self.play_tags([SoundOrVideoTag(filename=filename)]) def play_file_with_caller(self, filename: str, caller: Any) -> None: + """Play the provided path, noting down the caller. + + SECURITY: Filename may be an arbitrary path. For filenames coming from a collection, + you should only ever use the os.path.basename(filename) as the filename.""" if self.current_caller: self.current_caller_interrupted = True self.current_caller = caller self.play_file(filename) def insert_file(self, filename: str) -> None: + """Place the provided path at the top of the playlist. + + SECURITY: Filename may be an arbitrary path. For filenames coming from a collection, + you should only ever use the os.path.basename(filename) as the filename.""" self._enqueued.insert(0, SoundOrVideoTag(filename=filename)) self._play_next_if_idle() @@ -327,7 +339,7 @@ class SimpleProcessPlayer(Player): # pylint: disable=abstract-method def _play(self, tag: AVTag) -> None: assert isinstance(tag, SoundOrVideoTag) self._process = subprocess.Popen( - self.args + ["--", tag.filename], + self.args + ["--", tag.path(self._media_folder)], env=self.env, cwd=self._media_folder, stdout=subprocess.DEVNULL, @@ -453,8 +465,7 @@ class MpvManager(MPV, SoundOrVideoPlayer): def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: assert isinstance(tag, SoundOrVideoTag) self._on_done = on_done - filename = hooks.media_file_filter(tag.filename) - path = os.path.join(self.media_folder, filename) + path = tag.path(self.media_folder) if self.mpv_version is None or self.mpv_version >= (0, 38, 0): self.command("loadfile", path, "replace", -1, "pause=no") @@ -506,10 +517,8 @@ class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer): def _play(self, tag: AVTag) -> None: assert isinstance(tag, SoundOrVideoTag) - filename = hooks.media_file_filter(tag.filename) - self._process = subprocess.Popen( - self.args + ["--", filename], + self.args + ["--", tag.path(self.media_folder)], env=self.env, cwd=self.media_folder, stdin=subprocess.PIPE, diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index b430babe4..39273b931 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -40,6 +40,7 @@ pub enum BoolKey { WithScheduling, WithDeckConfigs, Fsrs, + FsrsHealthCheck, LoadBalancerEnabled, FsrsShortTermWithStepsEnabled, #[strum(to_string = "normalize_note_text")] @@ -76,6 +77,7 @@ impl Collection { | BoolKey::RestorePositionBrowser | BoolKey::RestorePositionReviewer | BoolKey::LoadBalancerEnabled + | BoolKey::FsrsHealthCheck | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true), // other options default to false diff --git a/rslib/src/deckconfig/mod.rs b/rslib/src/deckconfig/mod.rs index c522ea18a..e8fb0c0c0 100644 --- a/rslib/src/deckconfig/mod.rs +++ b/rslib/src/deckconfig/mod.rs @@ -110,9 +110,9 @@ impl DeckConfig { /// Retrieve the FSRS 6.0 params, falling back on 5.0 or 4.x ones. pub fn fsrs_params(&self) -> &Vec { - if self.inner.fsrs_params_6.len() == 21 { + if !self.inner.fsrs_params_6.is_empty() { &self.inner.fsrs_params_6 - } else if self.inner.fsrs_params_5.len() == 19 { + } else if !self.inner.fsrs_params_5.is_empty() { &self.inner.fsrs_params_5 } else { &self.inner.fsrs_params_4 diff --git a/rslib/src/deckconfig/service.rs b/rslib/src/deckconfig/service.rs index 7ac08902e..bc6bce8f4 100644 --- a/rslib/src/deckconfig/service.rs +++ b/rslib/src/deckconfig/service.rs @@ -96,6 +96,74 @@ impl crate::services::DeckConfigService for Collection { total: guard.cards.try_into().unwrap_or(0), }) } + + fn get_retention_workload( + &mut self, + input: anki_proto::deck_config::GetRetentionWorkloadRequest, + ) -> Result { + const LEARN_SPAN: usize = 100_000_000; + const TERMINATION_PROB: f32 = 0.001; + // the default values are from https://github.com/open-spaced-repetition/Anki-button-usage/blob/881009015c2a85ac911021d76d0aacb124849937/analysis.ipynb + const DEFAULT_LEARN_COST: f32 = 19.4698; + const DEFAULT_PASS_COST: f32 = 7.8454; + const DEFAULT_FAIL_COST: f32 = 23.185; + const DEFAULT_INITIAL_PASS_RATE: f32 = 0.7645; + + let guard = + self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?; + let costs = guard.col.storage.get_costs_for_retention()?; + + fn smoothing(obs: f32, default: f32, count: u32) -> f32 { + let alpha = count as f32 / (50.0 + count as f32); + obs * alpha + default * (1.0 - alpha) + } + + let cost_success = smoothing( + costs.average_pass_time_ms / 1000.0, + DEFAULT_PASS_COST, + costs.pass_count, + ); + let cost_failure = smoothing( + costs.average_fail_time_ms / 1000.0, + DEFAULT_FAIL_COST, + costs.fail_count, + ); + let cost_learn = smoothing( + costs.average_learn_time_ms / 1000.0, + DEFAULT_LEARN_COST, + costs.learn_count, + ); + let initial_pass_rate = smoothing( + costs.initial_pass_rate, + DEFAULT_INITIAL_PASS_RATE, + costs.pass_count, + ); + + let before = fsrs::expected_workload( + &input.w, + input.before, + LEARN_SPAN, + cost_success, + cost_failure, + cost_learn, + initial_pass_rate, + TERMINATION_PROB, + )?; + let after = fsrs::expected_workload( + &input.w, + input.after, + LEARN_SPAN, + cost_success, + cost_failure, + cost_learn, + initial_pass_rate, + TERMINATION_PROB, + )?; + + Ok(anki_proto::deck_config::GetRetentionWorkloadResponse { + factor: after / before, + }) + } } impl From for anki_proto::deck_config::DeckConfig { @@ -124,6 +192,7 @@ impl From for UpdateDeckConfi apply_all_parent_limits: c.apply_all_parent_limits, fsrs: c.fsrs, fsrs_reschedule: c.fsrs_reschedule, + fsrs_health_check: c.fsrs_health_check, } } } diff --git a/rslib/src/deckconfig/update.rs b/rslib/src/deckconfig/update.rs index 6d49bc5b1..128e43770 100644 --- a/rslib/src/deckconfig/update.rs +++ b/rslib/src/deckconfig/update.rs @@ -22,6 +22,7 @@ use crate::prelude::*; use crate::scheduler::fsrs::memory_state::UpdateMemoryStateEntry; use crate::scheduler::fsrs::memory_state::UpdateMemoryStateRequest; use crate::scheduler::fsrs::params::ignore_revlogs_before_ms_from_config; +use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::search::JoinSearches; use crate::search::Negated; use crate::search::SearchNode; @@ -41,6 +42,7 @@ pub struct UpdateDeckConfigsRequest { pub apply_all_parent_limits: bool, pub fsrs: bool, pub fsrs_reschedule: bool, + pub fsrs_health_check: bool, } impl Collection { @@ -71,6 +73,7 @@ impl Collection { new_cards_ignore_review_limit: self.get_config_bool(BoolKey::NewCardsIgnoreReviewLimit), apply_all_parent_limits: self.get_config_bool(BoolKey::ApplyAllParentLimits), fsrs: self.get_config_bool(BoolKey::Fsrs), + fsrs_health_check: self.get_config_bool(BoolKey::FsrsHealthCheck), days_since_last_fsrs_optimize, }) } @@ -300,6 +303,7 @@ impl Collection { req.new_cards_ignore_review_limit, )?; self.set_config_bool_inner(BoolKey::ApplyAllParentLimits, req.apply_all_parent_limits)?; + self.set_config_bool_inner(BoolKey::FsrsHealthCheck, req.fsrs_health_check)?; Ok(()) } @@ -365,14 +369,15 @@ impl Collection { }; let ignore_revlogs_before_ms = ignore_revlogs_before_ms_from_config(config)?; let num_of_relearning_steps = config.inner.relearn_steps.len(); - match self.compute_params( - &search, + match self.compute_params(ComputeParamsRequest { + search: &search, ignore_revlogs_before_ms, - idx as u32 + 1, - config_len, - config.fsrs_params(), + current_preset: idx as u32 + 1, + total_presets: config_len, + current_params: config.fsrs_params(), num_of_relearning_steps, - ) { + health_check: false, + }) { Ok(params) => { println!("{}: {:?}", config.name, params.params); config.inner.fsrs_params_6 = params.params; @@ -452,6 +457,7 @@ mod test { col.set_config_string_inner(StringKey::CardStateCustomizer, "")?; col.set_config_bool_inner(BoolKey::NewCardsIgnoreReviewLimit, false)?; col.set_config_bool_inner(BoolKey::ApplyAllParentLimits, false)?; + col.set_config_bool_inner(BoolKey::FsrsHealthCheck, true)?; // pretend we're in sync let stamps = col.storage.get_collection_timestamps()?; @@ -488,6 +494,7 @@ mod test { apply_all_parent_limits: false, fsrs: false, fsrs_reschedule: false, + fsrs_health_check: true, }; assert!(!col.update_deck_configs(input.clone())?.changes.had_change()); diff --git a/rslib/src/image_occlusion/imageocclusion.rs b/rslib/src/image_occlusion/imageocclusion.rs index 0658b4319..2ba83374f 100644 --- a/rslib/src/image_occlusion/imageocclusion.rs +++ b/rslib/src/image_occlusion/imageocclusion.rs @@ -64,19 +64,9 @@ pub fn get_image_cloze_data(text: &str) -> String { } for property in occlusion.properties { match property.name.as_str() { - "left" => { + "left" | "top" | "angle" | "fill" => { if !property.value.is_empty() { - result.push_str(&format!("data-left=\"{}\" ", property.value)); - } - } - "top" => { - if !property.value.is_empty() { - result.push_str(&format!("data-top=\"{}\" ", property.value)); - } - } - "angle" => { - if !property.value.is_empty() { - result.push_str(&format!("data-angle=\"{}\" ", property.value)); + result.push_str(&format!("data-{}=\"{}\" ", property.name, property.value)); } } "width" => { diff --git a/rslib/src/notetype/render.rs b/rslib/src/notetype/render.rs index 8f686b0a5..490886dda 100644 --- a/rslib/src/notetype/render.rs +++ b/rslib/src/notetype/render.rs @@ -183,6 +183,8 @@ impl Collection { .or_insert_with(|| flag_name(card.flags).into()); map.entry("Card") .or_insert_with(|| template.name.clone().into()); + map.entry("CardID") + .or_insert_with(|| card.id.to_string().into()); Ok(()) } diff --git a/rslib/src/scheduler/fsrs/memory_state.rs b/rslib/src/scheduler/fsrs/memory_state.rs index 7d4346d06..6d65877f5 100644 --- a/rslib/src/scheduler/fsrs/memory_state.rs +++ b/rslib/src/scheduler/fsrs/memory_state.rs @@ -16,7 +16,6 @@ use super::rescheduler::Rescheduler; use crate::card::CardType; use crate::prelude::*; use crate::revlog::RevlogEntry; -use crate::revlog::RevlogReviewKind; use crate::scheduler::answering::get_fuzz_seed; use crate::scheduler::fsrs::params::reviews_for_fsrs; use crate::scheduler::fsrs::params::Params; @@ -163,15 +162,8 @@ impl Collection { ); } *due = new_due; - // Add a rescheduled revlog entry if the last entry wasn't - // rescheduled - if !last_info.last_revlog_is_rescheduled { - self.log_rescheduled_review( - &card, - original_interval, - usn, - )?; - } + // Add a rescheduled revlog entry + self.log_rescheduled_review(&card, original_interval, usn)?; } } } @@ -289,9 +281,6 @@ struct LastRevlogInfo { /// reviewed the card and now, so that we can determine an accurate period /// when the card has subsequently been rescheduled to a different day. last_reviewed_at: Option, - /// If true, the last action on this card was a reschedule, so we - /// can avoid writing an extra revlog entry on another reschedule. - last_revlog_is_rescheduled: bool, } /// Return a map of cards to info about last review/reschedule. @@ -303,20 +292,12 @@ fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap= 1 { last_reviewed_at = Some(e.id.as_secs()); } - last_revlog_is_rescheduled = e.review_kind == RevlogReviewKind::Rescheduled; } - out.insert( - card_id, - LastRevlogInfo { - last_reviewed_at, - last_revlog_is_rescheduled, - }, - ); + out.insert(card_id, LastRevlogInfo { last_reviewed_at }); }); out } diff --git a/rslib/src/scheduler/fsrs/params.rs b/rslib/src/scheduler/fsrs/params.rs index 6da02776a..76bc206be 100644 --- a/rslib/src/scheduler/fsrs/params.rs +++ b/rslib/src/scheduler/fsrs/params.rs @@ -51,19 +51,46 @@ pub(crate) fn ignore_revlogs_before_ms_from_config(config: &DeckConfig) -> Resul ignore_revlogs_before_date_to_ms(&config.inner.ignore_revlogs_before_date) } +pub struct ComputeParamsRequest<'t> { + pub search: &'t str, + pub ignore_revlogs_before_ms: TimestampMillis, + pub current_preset: u32, + pub total_presets: u32, + pub current_params: &'t Params, + pub num_of_relearning_steps: usize, + pub health_check: bool, +} + +/// r: retention +fn log_loss_adjustment(r: f32) -> f32 { + 0.623 * (4. * r * (1. - r)).powf(0.738) +} + +/// r: retention +/// +/// c: review count +fn rmse_adjustment(r: f32, c: u32) -> f32 { + 0.0135 / (r.powf(0.504) - 1.14) + 0.176 / ((c as f32 / 1000.).powf(0.825) + 2.22) + 0.101 +} + impl Collection { /// Note this does not return an error if there are less than 400 items - /// the caller should instead check the fsrs_items count in the return /// value. pub fn compute_params( &mut self, - search: &str, - ignore_revlogs_before: TimestampMillis, - current_preset: u32, - total_presets: u32, - current_params: &Params, - num_of_relearning_steps: usize, + request: ComputeParamsRequest, ) -> Result { + let ComputeParamsRequest { + search, + ignore_revlogs_before_ms: ignore_revlogs_before, + current_preset, + total_presets, + current_params, + num_of_relearning_steps, + health_check, + } = request; + self.clear_progress(); let timing = self.timing_today()?; let revlogs = self.revlog_for_srs(search)?; @@ -75,6 +102,7 @@ impl Collection { return Ok(ComputeFsrsParamsResponse { params: current_params.to_vec(), fsrs_items, + health_check_passed: None, }); } // adapt the progress handler to our built-in progress handling @@ -108,22 +136,22 @@ impl Collection { let (progress, progress_thread) = create_progress_thread()?; let fsrs = FSRS::new(None)?; - let mut params = fsrs.compute_parameters(ComputeParametersInput { + let input = ComputeParametersInput { train_set: items.clone(), progress: Some(progress.clone()), enable_short_term: true, num_relearning_steps: Some(num_of_relearning_steps), - })?; + }; + let mut params = fsrs.compute_parameters(input.clone())?; progress_thread.join().ok(); - if let Ok(fsrs) = FSRS::new(Some(current_params)) { - let current_log_loss = fsrs.evaluate(items.clone(), |_| true)?.log_loss; + if let Ok(current_fsrs) = FSRS::new(Some(current_params)) { + let current_log_loss = current_fsrs.evaluate(items.clone(), |_| true)?.log_loss; let optimized_fsrs = FSRS::new(Some(¶ms))?; let optimized_log_loss = optimized_fsrs.evaluate(items.clone(), |_| true)?.log_loss; if current_log_loss <= optimized_log_loss { if num_of_relearning_steps <= 1 { params = current_params.to_vec(); } else { - let current_fsrs = FSRS::new(Some(current_params))?; let memory_state = MemoryState { stability: 1.0, difficulty: 1.0, @@ -146,7 +174,34 @@ impl Collection { } } - Ok(ComputeFsrsParamsResponse { params, fsrs_items }) + let health_check_passed = if health_check { + let fsrs = FSRS::new(None)?; + fsrs.evaluate_with_time_series_splits(input, |_| true) + .ok() + .map(|eval| { + let r = items.iter().fold(0, |p, item| { + p + (item + .reviews + .last() + .map(|reviews| reviews.rating) + .unwrap_or(0) + > 1) as u32 + }) as f32 + / fsrs_items as f32; + let adjusted_log_loss = eval.log_loss / log_loss_adjustment(r); + let adjusted_rmse = eval.rmse_bins / rmse_adjustment(r, fsrs_items); + + adjusted_log_loss <= 1.11 || adjusted_rmse <= 1.53 + }) + } else { + None + }; + + Ok(ComputeFsrsParamsResponse { + params, + fsrs_items, + health_check_passed, + }) } pub(crate) fn revlog_for_srs( @@ -218,22 +273,24 @@ impl Collection { pub fn evaluate_params( &mut self, - params: &Params, search: &str, ignore_revlogs_before: TimestampMillis, + num_of_relearning_steps: usize, ) -> Result { let timing = self.timing_today()?; - let mut anki_progress = self.new_progress_handler::(); - let guard = self.search_cards_into_table(search, SortMode::NoOrder)?; - let revlogs: Vec = guard - .col - .storage - .get_revlog_entries_for_searched_cards_in_card_order()?; + let revlogs = self.revlog_for_srs(search)?; let (items, review_count) = fsrs_items_for_training(revlogs, timing.next_day_at, ignore_revlogs_before); + let mut anki_progress = self.new_progress_handler::(); anki_progress.state.reviews = review_count as u32; - let fsrs = FSRS::new(Some(params))?; - Ok(fsrs.evaluate(items, |ip| { + let fsrs = FSRS::new(None)?; + let input = ComputeParametersInput { + train_set: items.clone(), + progress: None, + enable_short_term: true, + num_relearning_steps: Some(num_of_relearning_steps), + }; + Ok(fsrs.evaluate_with_time_series_splits(input, |ip| { anki_progress .update(false, |p| { p.total_iterations = ip.total as u32; diff --git a/rslib/src/scheduler/service/mod.rs b/rslib/src/scheduler/service/mod.rs index e7d9a04eb..993fd1dbe 100644 --- a/rslib/src/scheduler/service/mod.rs +++ b/rslib/src/scheduler/service/mod.rs @@ -23,6 +23,7 @@ use fsrs::FSRS; use crate::backend::Backend; use crate::prelude::*; +use crate::scheduler::fsrs::params::ComputeParamsRequest; use crate::scheduler::new::NewCardDueOrder; use crate::scheduler::states::CardState; use crate::scheduler::states::SchedulingStates; @@ -264,14 +265,15 @@ impl crate::services::SchedulerService for Collection { &mut self, input: scheduler::ComputeFsrsParamsRequest, ) -> Result { - self.compute_params( - &input.search, - input.ignore_revlogs_before_ms.into(), - 1, - 1, - &input.current_params, - input.num_of_relearning_steps as usize, - ) + self.compute_params(ComputeParamsRequest { + search: &input.search, + ignore_revlogs_before_ms: input.ignore_revlogs_before_ms.into(), + current_preset: 1, + total_presets: 1, + current_params: &input.current_params, + num_of_relearning_steps: input.num_of_relearning_steps as usize, + health_check: input.health_check, + }) } fn simulate_fsrs_review( @@ -295,9 +297,9 @@ impl crate::services::SchedulerService for Collection { input: scheduler::EvaluateParamsRequest, ) -> Result { let ret = self.evaluate_params( - &input.params, &input.search, input.ignore_revlogs_before_ms.into(), + input.num_of_relearning_steps as usize, )?; Ok(scheduler::EvaluateParamsResponse { log_loss: ret.log_loss, @@ -372,7 +374,11 @@ impl crate::services::BackendSchedulerService for Backend { enable_short_term: true, num_relearning_steps: None, })?; - Ok(ComputeFsrsParamsResponse { params, fsrs_items }) + Ok(ComputeFsrsParamsResponse { + params, + fsrs_items, + health_check_passed: None, + }) } fn fsrs_benchmark( diff --git a/rslib/src/storage/card/get_costs_for_retention.sql b/rslib/src/storage/card/get_costs_for_retention.sql new file mode 100644 index 000000000..ba21cc3f6 --- /dev/null +++ b/rslib/src/storage/card/get_costs_for_retention.sql @@ -0,0 +1,85 @@ +WITH searched_revlogs AS ( + SELECT *, + RANK() OVER ( + PARTITION BY cid + ORDER BY id ASC + ) AS rank_num + FROM revlog + WHERE ease > 0 + AND cid IN search_cids + ORDER BY id DESC -- Use the last 10_000 reviews + LIMIT 10000 +), average_pass AS ( + SELECT AVG(time) + FROM searched_revlogs + WHERE ease > 1 + AND type = 1 +), +lapse_count AS ( + SELECT COUNT(time) AS lapse_count + FROM searched_revlogs + WHERE ease = 1 + AND type = 1 +), +fail_sum AS ( + SELECT SUM(time) AS total_fail_time + FROM searched_revlogs + WHERE ( + ease = 1 + AND type = 1 + ) + OR type = 2 +), +-- (sum(Relearning) + sum(Lapses)) / count(Lapses) +average_fail AS ( + SELECT total_fail_time * 1.0 / NULLIF(lapse_count, 0) AS avg_fail_time + FROM fail_sum, + lapse_count +), +-- Can lead to cards with partial learn histories skewing the time +summed_learns AS ( + SELECT cid, + SUM(time) AS total_time + FROM searched_revlogs + WHERE searched_revlogs.type = 0 + GROUP BY cid +), +average_learn AS ( + SELECT AVG(total_time) AS avg_learn_time + FROM summed_learns +), +initial_pass_rate AS ( + SELECT AVG( + CASE + WHEN ease > 1 THEN 1.0 + ELSE 0.0 + END + ) AS initial_pass_rate + FROM searched_revlogs + WHERE rank_num = 1 +), +pass_cnt AS ( + SELECT COUNT(*) AS cnt + FROM searched_revlogs + WHERE ease > 1 + AND type = 1 +), +fail_cnt AS ( + SELECT COUNT(*) AS cnt + FROM searched_revlogs + WHERE ease = 1 + AND type = 1 +), +learn_cnt AS ( + SELECT COUNT(*) AS cnt + FROM searched_revlogs + WHERE type = 0 +) +SELECT * +FROM average_pass, + average_fail, + average_learn, + initial_pass_rate, + pass_cnt, + fail_cnt, + learn_cnt; \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 353537f90..38cf5ef0f 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -42,6 +42,17 @@ use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampSecs; use crate::types::Usn; +#[derive(Debug, Clone, Default)] +pub struct RetentionCosts { + pub average_pass_time_ms: f32, + pub average_fail_time_ms: f32, + pub average_learn_time_ms: f32, + pub initial_pass_rate: f32, + pub pass_count: u32, + pub fail_count: u32, + pub learn_count: u32, +} + impl FromSql for CardType { fn column_result(value: ValueRef<'_>) -> result::Result { if let ValueRef::Integer(i) = value { @@ -747,6 +758,24 @@ impl super::SqliteStorage { .get(0)?) } + pub(crate) fn get_costs_for_retention(&self) -> Result { + let mut statement = self + .db + .prepare(include_str!("get_costs_for_retention.sql"))?; + let mut query = statement.query(params![])?; + let row = query.next()?.unwrap(); + + Ok(RetentionCosts { + average_pass_time_ms: row.get(0).unwrap_or(7000.), + average_fail_time_ms: row.get(1).unwrap_or(23_000.), + average_learn_time_ms: row.get(2).unwrap_or(30_000.), + initial_pass_rate: row.get(3).unwrap_or(0.5), + pass_count: row.get(4).unwrap_or(0), + fail_count: row.get(5).unwrap_or(0), + learn_count: row.get(6).unwrap_or(0), + }) + } + #[cfg(test)] pub(crate) fn get_all_cards(&self) -> Vec { self.db diff --git a/ts/lib/components/EnumSelector.svelte b/ts/lib/components/EnumSelector.svelte index 328946bd2..8a711e9c0 100644 --- a/ts/lib/components/EnumSelector.svelte +++ b/ts/lib/components/EnumSelector.svelte @@ -20,16 +20,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let disabledChoices: T[] = []; $: label = choices.find((c) => c.value === value)?.label; - - - diff --git a/ts/lib/components/SpinBox.svelte b/ts/lib/components/SpinBox.svelte index a6a1a0b20..f52845a4d 100644 --- a/ts/lib/components/SpinBox.svelte +++ b/ts/lib/components/SpinBox.svelte @@ -23,7 +23,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let percentage = false; let input: HTMLInputElement; - let focused = false; + export let focused = false; let multiplier: number; $: multiplier = percentage ? 100 : 1; @@ -129,6 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html value={stringValue} bind:this={input} on:blur={update} + on:change={update} on:input={onInput} on:focusin={() => (focused = true)} on:focusout={() => (focused = false)} diff --git a/ts/lib/components/icons.ts b/ts/lib/components/icons.ts index 33c6e04cb..ab07cbf17 100644 --- a/ts/lib/components/icons.ts +++ b/ts/lib/components/icons.ts @@ -59,6 +59,8 @@ import FormatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?component"; import formatAlignCenter_ from "@mdi/svg/svg/format-align-center.svg?url"; import FormatBold_ from "@mdi/svg/svg/format-bold.svg?component"; import formatBold_ from "@mdi/svg/svg/format-bold.svg?url"; +import FormatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?component"; +import formatColorFill_ from "@mdi/svg/svg/format-color-fill.svg?url"; import HighlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?component"; import highlightColor_ from "@mdi/svg/svg/format-color-highlight.svg?url"; import TextColor_ from "@mdi/svg/svg/format-color-text.svg?component"; @@ -264,6 +266,7 @@ export const mdiEllipseOutline = { url: ellipseOutline_, component: EllipseOutli export const mdiEye = { url: eye_, component: Eye_ }; export const mdiFormatAlignCenter = { url: formatAlignCenter_, component: FormatAlignCenter_ }; export const mdiFormatBold = { url: formatBold_, component: FormatBold_ }; +export const mdiFormatColorFill = { url: formatColorFill_, component: FormatColorFill_ }; export const mdiFormatItalic = { url: formatItalic_, component: FormatItalic_ }; export const mdiFormatUnderline = { url: formatUnderline_, component: FormatUnderline_ }; export const mdiGroup = { url: group_, component: Group_ }; diff --git a/ts/reviewer/reviewer.scss b/ts/reviewer/reviewer.scss index 3154d99a1..1248836ae 100644 --- a/ts/reviewer/reviewer.scss +++ b/ts/reviewer/reviewer.scss @@ -71,6 +71,7 @@ pre { width: 100%; // https://anki.tenderapp.com/discussions/beta-testing/1854-using-margin-auto-causes-horizontal-scrollbar-on-typesomething box-sizing: border-box; + line-height: 1.75; } code#typeans { diff --git a/ts/routes/deck-options/FsrsOptions.svelte b/ts/routes/deck-options/FsrsOptions.svelte index 356437cb9..af0a468ea 100644 --- a/ts/routes/deck-options/FsrsOptions.svelte +++ b/ts/routes/deck-options/FsrsOptions.svelte @@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { computeFsrsParams, evaluateParams, + getRetentionWorkload, setWantsAbort, } from "@generated/backend"; import * as tr from "@generated/ftl"; @@ -26,11 +27,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import ParamsInputRow from "./ParamsInputRow.svelte"; import ParamsSearchRow from "./ParamsSearchRow.svelte"; import SimulatorModal from "./SimulatorModal.svelte"; - import { UpdateDeckConfigsMode } from "@generated/anki/deck_config_pb"; + import { + GetRetentionWorkloadRequest, + UpdateDeckConfigsMode, + } from "@generated/anki/deck_config_pb"; export let state: DeckOptionsState; export let openHelpModal: (String) => void; export let onPresetChange: () => void; + export let newlyEnabled = false; const config = state.currentConfig; const defaults = state.defaults; @@ -39,18 +44,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $: lastOptimizationWarning = $daysSinceLastOptimization > 30 ? tr.deckConfigTimeToOptimize() : ""; + let desiredRetentionFocused = false; + let desiredRetentionEverFocused = false; + let optimized = false; + const startingDesiredRetention = $config.desiredRetention.toFixed(2); + $: if (desiredRetentionFocused) { + desiredRetentionEverFocused = true; + } + $: showDesiredRetentionTooltip = + newlyEnabled || desiredRetentionEverFocused || optimized; let computeParamsProgress: ComputeParamsProgress | undefined; let computingParams = false; let checkingParams = false; + const healthCheck = state.fsrsHealthCheck; + $: computing = computingParams || checkingParams; $: defaultparamSearch = `preset:"${state.getCurrentNameForSearch()}" -is:suspended`; $: roundedRetention = Number($config.desiredRetention.toFixed(2)); - $: desiredRetentionWarning = getRetentionWarning( - roundedRetention, - fsrsParams($config), - ); + $: desiredRetentionWarning = getRetentionLongShortWarning(roundedRetention); + + let timeoutId: ReturnType | undefined = undefined; + const WORKLOAD_UPDATE_DELAY_MS = 100; + + let desiredRetentionChangeInfo = ""; + $: { + clearTimeout(timeoutId); + if (showDesiredRetentionTooltip) { + timeoutId = setTimeout(() => { + getRetentionChangeInfo(roundedRetention, fsrsParams($config)); + }, WORKLOAD_UPDATE_DELAY_MS); + } else { + desiredRetentionChangeInfo = ""; + } + } + $: retentionWarningClass = getRetentionWarningClass(roundedRetention); $: newCardsIgnoreReviewLimit = state.newCardsIgnoreReviewLimit; @@ -67,23 +96,44 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html reviewOrder: $config.reviewOrder, }); - function getRetentionWarning(retention: number, params: number[]): string { - const decay = params.length > 20 ? -params[20] : -0.5; // default decay for FSRS-4.5 and FSRS-5 - const factor = 0.9 ** (1 / decay) - 1; - const stability = 100; - const days = Math.round( - (stability / factor) * (Math.pow(retention, 1 / decay) - 1), - ); - if (days === 100) { + const DESIRED_RETENTION_LOW_THRESHOLD = 0.8; + const DESIRED_RETENTION_HIGH_THRESHOLD = 0.95; + + function getRetentionLongShortWarning(retention: number) { + if (retention < DESIRED_RETENTION_LOW_THRESHOLD) { + return tr.deckConfigDesiredRetentionTooLow(); + } else if (retention > DESIRED_RETENTION_HIGH_THRESHOLD) { + return tr.deckConfigDesiredRetentionTooHigh(); + } else { return ""; } - return tr.deckConfigA100DayInterval({ days }); + } + + async function getRetentionChangeInfo(retention: number, params: number[]) { + if (+startingDesiredRetention == roundedRetention) { + desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorUnchanged(); + return; + } + const request = new GetRetentionWorkloadRequest({ + w: params, + search: defaultparamSearch, + before: +startingDesiredRetention, + after: retention, + }); + const resp = await getRetentionWorkload(request); + desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({ + factor: resp.factor.toFixed(2), + previousDr: (+startingDesiredRetention * 100).toString(), + }); } function getRetentionWarningClass(retention: number): string { if (retention < 0.7 || retention > 0.97) { return "alert-danger"; - } else if (retention < 0.8 || retention > 0.95) { + } else if ( + retention < DESIRED_RETENTION_LOW_THRESHOLD || + retention > DESIRED_RETENTION_HIGH_THRESHOLD + ) { return "alert-warning"; } else { return "alert-info"; @@ -130,6 +180,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(), currentParams: params, numOfRelearningSteps: numOfRelearningStepsInDay, + healthCheck: $healthCheck, }); const already_optimal = @@ -139,13 +190,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html )) || resp.params.length === 0; - if (already_optimal) { + if (resp.healthCheckPassed !== undefined) { + if (resp.healthCheckPassed) { + setTimeout(() => alert(tr.deckConfigFsrsGoodFit()), 200); + } else { + setTimeout( + () => alert(tr.deckConfigFsrsBadFitWarning()), + 200, + ); + } + } else if (already_optimal) { const msg = resp.fsrsItems ? tr.deckConfigFsrsParamsOptimal() : tr.deckConfigFsrsParamsNoReviews(); setTimeout(() => alert(msg), 200); - } else { + } + if (!already_optimal) { $config.fsrsParams6 = resp.params; + optimized = true; } if (computeParamsProgress) { computeParamsProgress.current = computeParamsProgress.total; @@ -180,9 +242,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html ? $config.paramSearch : defaultparamSearch; const resp = await evaluateParams({ - params: fsrsParams($config), search, ignoreRevlogsBeforeMs: getIgnoreRevlogsBeforeMs(), + numOfRelearningSteps: $config.relearnSteps.length, }); if (computeParamsProgress) { computeParamsProgress.current = computeParamsProgress.total; @@ -237,12 +299,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html min={0.7} max={0.99} percentage={true} + bind:focused={desiredRetentionFocused} > openHelpModal("desiredRetention")}> {tr.deckConfigDesiredRetention()} +
@@ -261,6 +325,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html placeholder={defaultparamSearch} /> + + openHelpModal("rescheduleCardsOnChange")}> + + + + + {#if $fsrsReschedule} + + {/if} + + + openHelpModal("deckConfigHealthCheck")}> + + + + + {#if false} + + + {/if}
{#if computingParams || checkingParams} {computeParamsProgressString} @@ -300,18 +383,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-
- - openHelpModal("rescheduleCardsOnChange")}> - - - - - {#if $fsrsReschedule} - - {/if} -
-
- - {#if optimalRetention} - {estimatedRetention(optimalRetention)} - {#if optimalRetention - $config.desiredRetention >= 0.01} - - {/if} - {/if} - +
+ {tr.deckConfigComputeOptimalRetention()} +
-
+ + + {#if optimalRetention} + {estimatedRetention(optimalRetention)} + {#if optimalRetention - $config.desiredRetention >= 0.01} + + {/if} + {/if} + + {#if computingRetention} +
{computeRetentionProgressString}
+ {/if} +