Integrate FSRS into Anki (#2654)

* Pack FSRS data into card.data

* Update FSRS card data when preset or weights change

+ Show FSRS stats in card stats

* Show a warning when there's a limited review history

* Add some translations; tweak UI

* Fix default requested retention

* Add browser columns, fix calculation of R

* Property searches

eg prop:d>0.1

* Integrate FSRS into reviewer

* Warn about long learning steps

* Hide minimum interval when FSRS is on

* Don't apply interval multiplier to FSRS intervals

* Expose memory state to Python

* Don't set memory state on new cards

* Port Jarret's new tests; add some helpers to make tests more compact

https://github.com/open-spaced-repetition/fsrs-rs/pull/64

* Fix learning cards not being given memory state

* Require update to v3 scheduler

* Don't exclude single learning step when calculating memory state

* Use relearning step when learning steps unavailable

* Update docstring

* fix single_card_revlog_to_items (#2656)

* not need check the review_kind for unique_dates

* add email address to CONTRIBUTORS

* fix last first learn & keep early review

* cargo fmt

* cargo clippy --fix

* Add Jarrett to about screen

* Fix fsrs_memory_state being initialized to default in get_card()

* Set initial memory state on graduate

* Update to latest FSRS

* Fix experiment.log being empty

* Fix broken colpkg imports

Introduced by "Update FSRS card data when preset or weights change"

* Update memory state during (re)learning; use FSRS for graduating intervals

* Reset memory state when cards are manually rescheduled as new

* Add difficulty graph; hide eases when FSRS enabled

* Add retrievability graph

* Derive memory_state from revlog when it's missing and shouldn't be

---------

Co-authored-by: Jarrett Ye <jarrett.ye@outlook.com>
This commit is contained in:
Damien Elmes 2023-09-16 16:09:26 +10:00 committed by GitHub
parent 6f0bf58d49
commit 5004cd332b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 1805 additions and 575 deletions

View file

@ -138,6 +138,7 @@ Monty Evans <montyevans@gmail.com>
Nil Admirari <https://github.com/nihil-admirari>
Michael Winkworth <github.com/SteelColossus>
Mateusz Wojewoda <kawa1.11@o2.pl>
Jarrett Ye <jarrett.ye@outlook.com>
********************

50
Cargo.lock generated
View file

@ -107,7 +107,7 @@ dependencies = [
"fluent",
"fluent-bundle",
"fnv",
"fsrs-optimizer",
"fsrs",
"futures",
"hex",
"htmlescape",
@ -574,8 +574,8 @@ checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
[[package]]
name = "burn"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"burn-core",
"burn-train",
@ -583,8 +583,8 @@ dependencies = [
[[package]]
name = "burn-autodiff"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"burn-common",
"burn-tensor",
@ -595,8 +595,8 @@ dependencies = [
[[package]]
name = "burn-common"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"const-random",
"rand 0.8.5",
@ -606,8 +606,8 @@ dependencies = [
[[package]]
name = "burn-core"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"bincode",
"burn-autodiff",
@ -631,8 +631,8 @@ dependencies = [
[[package]]
name = "burn-dataset"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"csv 1.2.2",
"derive-new",
@ -651,8 +651,8 @@ dependencies = [
[[package]]
name = "burn-derive"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"derive-new",
"proc-macro2",
@ -662,8 +662,8 @@ dependencies = [
[[package]]
name = "burn-ndarray"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"burn-autodiff",
"burn-common",
@ -680,8 +680,8 @@ dependencies = [
[[package]]
name = "burn-tensor"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"burn-tensor-testgen",
"derive-new",
@ -696,8 +696,8 @@ dependencies = [
[[package]]
name = "burn-tensor-testgen"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"proc-macro2",
"quote",
@ -706,8 +706,8 @@ dependencies = [
[[package]]
name = "burn-train"
version = "0.9.0"
source = "git+https://github.com/burn-rs/burn.git?rev=36446e8d35694a9158f97e85e44b84544b8c4afb#36446e8d35694a9158f97e85e44b84544b8c4afb"
version = "0.10.0"
source = "git+https://github.com/burn-rs/burn.git?rev=e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c#e7a65e24c4e88110f2bf6b3a29ac456a12c2ca0c"
dependencies = [
"burn-core",
"derive-new",
@ -1365,9 +1365,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "faster-hex"
version = "0.8.0"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9042d281a5eec0f2387f8c3ea6c4514e2cf2732c90a85aaf383b761ee3b290d"
checksum = "239f7bfb930f820ab16a9cd95afc26f88264cf6905c960b340a615384aa3338a"
dependencies = [
"serde",
]
@ -1530,9 +1530,9 @@ dependencies = [
]
[[package]]
name = "fsrs-optimizer"
name = "fsrs"
version = "0.1.0"
source = "git+https://github.com/open-spaced-repetition/fsrs-optimizer-rs?rev=e0b15cce555a94de6fdaa4bf1e096d19704a397d#e0b15cce555a94de6fdaa4bf1e096d19704a397d"
source = "git+https://github.com/open-spaced-repetition/fsrs-rs.git?rev=bae680bfde996f614741b32ac63a27a1a882a45b#bae680bfde996f614741b32ac63a27a1a882a45b"
dependencies = [
"burn",
"itertools 0.11.0",

View file

@ -26,6 +26,23 @@ members = [
exclude = ["qt/bundle"]
resolver = "2"
[workspace.dependencies.csv]
git = "https://github.com/ankitects/rust-csv.git"
rev = "1c9d3aab6f79a7d815c69f925a46a4590c115f90"
[workspace.dependencies.percent-encoding-iri]
git = "https://github.com/ankitects/rust-url.git"
rev = "bb930b8d089f4d30d7d19c12e54e66191de47b88"
[workspace.dependencies.linkcheck]
git = "https://github.com/ankitects/linkcheck.git"
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs]
git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
rev = "bae680bfde996f614741b32ac63a27a1a882a45b"
# path = "../../../fsrs-rs"
[workspace.dependencies]
# local
anki = { path = "rslib" }
@ -36,14 +53,6 @@ anki_process = { path = "rslib/process" }
anki_proto_gen = { path = "rslib/proto_gen" }
ninja_gen = { "path" = "build/ninja_gen" }
fsrs-optimizer = { git = "https://github.com/open-spaced-repetition/fsrs-optimizer-rs", rev = "e0b15cce555a94de6fdaa4bf1e096d19704a397d" }
# fsrs-optimizer.path = "../../../fsrs-optimizer-rs"
# forked
csv = { git = "https://github.com/ankitects/rust-csv.git", rev = "1c9d3aab6f79a7d815c69f925a46a4590c115f90" }
percent-encoding-iri = { git = "https://github.com/ankitects/rust-url.git", rev = "bb930b8d089f4d30d7d19c12e54e66191de47b88" }
linkcheck = { git = "https://github.com/ankitects/linkcheck.git", rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" }
# pinned
unicase = "=2.6.0" # any changes could invalidate sqlite indexes

View file

@ -334,7 +334,7 @@
},
{
"name": "burn",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn",
"license": "Apache-2.0 OR MIT",
@ -343,7 +343,7 @@
},
{
"name": "burn-autodiff",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-autodiff",
"license": "Apache-2.0 OR MIT",
@ -352,7 +352,7 @@
},
{
"name": "burn-common",
"version": "0.9.0",
"version": "0.10.0",
"authors": "Dilshod Tadjibaev (@antimora)",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-common",
"license": "Apache-2.0 OR MIT",
@ -361,7 +361,7 @@
},
{
"name": "burn-core",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-core",
"license": "Apache-2.0 OR MIT",
@ -370,7 +370,7 @@
},
{
"name": "burn-dataset",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-dataset",
"license": "Apache-2.0 OR MIT",
@ -379,7 +379,7 @@
},
{
"name": "burn-derive",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-derive",
"license": "Apache-2.0 OR MIT",
@ -388,7 +388,7 @@
},
{
"name": "burn-ndarray",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-ndarray",
"license": "Apache-2.0 OR MIT",
@ -397,7 +397,7 @@
},
{
"name": "burn-tensor",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-tensor",
"license": "Apache-2.0 OR MIT",
@ -406,7 +406,7 @@
},
{
"name": "burn-tensor-testgen",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-tensor-testgen",
"license": "Apache-2.0 OR MIT",
@ -415,7 +415,7 @@
},
{
"name": "burn-train",
"version": "0.9.0",
"version": "0.10.0",
"authors": "nathanielsimard <nathaniel.simard.42@gmail.com>",
"repository": "https://github.com/burn-rs/burn/tree/main/burn-train",
"license": "Apache-2.0 OR MIT",
@ -847,7 +847,7 @@
},
{
"name": "faster-hex",
"version": "0.8.0",
"version": "0.8.1",
"authors": "zhangsoledad <787953403@qq.com>",
"repository": "https://github.com/NervosFoundation/faster-hex",
"license": "MIT",
@ -972,7 +972,7 @@
"description": "Parser for values from the Forwarded header (RFC 7239)"
},
{
"name": "fsrs-optimizer",
"name": "fsrs",
"version": "0.1.0",
"authors": null,
"repository": null,

View file

@ -24,6 +24,9 @@ card-stats-review-log-type-filtered = Filtered
card-stats-review-log-type-manual = Manual
card-stats-no-card = (No card to display.)
card-stats-custom-data = Custom Data
card-stats-fsrs-stability = Stability
card-stats-fsrs-difficulty = Difficulty
card-stats-fsrs-retrievability = Retrievability
## Window Titles

View file

@ -307,6 +307,26 @@ deck-config-maximum-answer-secs-above-recommended = Anki can schedule your revie
deck-config-which-deck = Which deck would you like to display options for?
## Messages related to the FSRS scheduler
deck-config-updating-cards = Updating cards: { $current_cards_count }/{ $total_cards_count }...
deck-config-invalid-weights = Weights must be either left blank to use the defaults, or must be 17 comma-separated numbers.
deck-config-not-enough-history = Insufficient review history to perform this operation.
deck-config-limited-history =
{ $count ->
[one] Only { $count } review was found.
*[other] Only { $count } reviews were found.
} The custom weights are likely to be inaccurate, and using the defaults instead is recommended.
deck-config-compute-weights-search = Search; leave blank for all cards using this preset
# Numbers that control how aggressively the FSRS algorithm schedules cards
deck-config-weights = Model weights
deck-config-compute-optimal-weights = Compute optimal weights
deck-config-compute-optimal-retention = Compute optimal retention
deck-config-compute-button = Compute
deck-config-analyze-button = Analyze
deck-config-desired-retention = Desired retention
deck-config-smaller-is-better = Smaller numbers indicate better memory estimates.
deck-config-steps-too-large-for-fsrs = When FSRS is enabled, interday (re)learning steps are not recommended.
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.

View file

@ -93,13 +93,27 @@ statistics-range-deck = deck
statistics-range-collection = collection
statistics-range-search = Search
statistics-card-ease-title = Card Ease
statistics-card-difficulty-title = Card Difficulty
statistics-card-retrievability-title = Card Retrievability
statistics-card-ease-subtitle = The lower the ease, the more frequently a card will appear.
statistics-card-difficulty-subtitle = The higher the difficulty, the more frequently a card will appear.
statistics-retrievability-subtitle = How likely you are to remember.
# eg "3 cards with 150-170% ease"
statistics-card-ease-tooltip =
{ $cards ->
[one] { $cards } card with { $percent } ease
*[other] { $cards } cards with { $percent } ease
}
statistics-card-difficulty-tooltip =
{ $cards ->
[one] { $cards } card with { $percent } difficulty
*[other] { $cards } cards with { $percent } difficulty
}
statistics-retrievability-tooltip =
{ $cards ->
[one] { $cards } card with { $percent } retrievability
*[other] { $cards } cards with { $percent } retrievability
}
statistics-future-due-title = Future Due
statistics-future-due-subtitle = The number of reviews due in the future.
statistics-added-title = Added
@ -200,6 +214,8 @@ statistics-cards-per-day =
*[other] { $count } cards/day
}
statistics-average-ease = Average ease
statistics-average-difficulty = Average difficulty
statistics-average-retrievability = Average retrievability
statistics-save-pdf = Save PDF
statistics-saved = Saved.
statistics-stats = stats

View file

@ -49,9 +49,15 @@ message Card {
int64 original_deck_id = 16;
uint32 flags = 17;
optional uint32 original_position = 18;
optional FsrsMemoryState fsrs_memory_state = 20;
string custom_data = 19;
}
message FsrsMemoryState {
float stability = 1;
float difficulty = 2;
}
message UpdateCardsRequest {
repeated Card cards = 1;
bool skip_undo_entry = 2;

View file

@ -118,17 +118,6 @@ message Progress {
uint32 stage_current = 3;
}
message ComputeWeights {
uint32 current = 1;
uint32 total = 2;
uint32 revlog_entries = 3;
}
message ComputeRetention {
uint32 current = 1;
uint32 total = 2;
}
oneof value {
generic.Empty none = 1;
sync.MediaSyncProgress media_sync = 2;
@ -138,11 +127,29 @@ message Progress {
DatabaseCheck database_check = 6;
string importing = 7;
string exporting = 8;
ComputeWeights compute_weights = 9;
ComputeRetention compute_retention = 10;
ComputeWeightsProgress compute_weights = 9;
ComputeRetentionProgress compute_retention = 10;
ComputeMemoryProgress compute_memory = 11;
}
}
message ComputeWeightsProgress {
uint32 current = 1;
uint32 total = 2;
uint32 fsrs_items = 3;
}
message ComputeRetentionProgress {
uint32 current = 1;
uint32 total = 2;
}
message ComputeMemoryProgress {
uint32 current_cards = 1;
uint32 total_cards = 2;
string label = 3;
}
message CreateBackupRequest {
string backup_folder = 1;
// Create a backup even if the configured interval hasn't elapsed yet.

View file

@ -63,6 +63,7 @@ message SchedulingState {
message Learning {
uint32 remaining_steps = 1;
uint32 scheduled_secs = 2;
optional cards.FsrsMemoryState fsrs_memory_state = 6;
}
message Review {
uint32 scheduled_days = 1;
@ -70,6 +71,7 @@ message SchedulingState {
float ease_factor = 3;
uint32 lapses = 4;
bool leeched = 5;
optional cards.FsrsMemoryState fsrs_memory_state = 6;
}
message Relearning {
Review review = 1;
@ -330,6 +332,8 @@ message ComputeFsrsWeightsRequest {
message ComputeFsrsWeightsResponse {
repeated float weights = 1;
// if less than 1000, should warn user
uint32 fsrs_items = 2;
}
message ComputeOptimalRetentionRequest {
@ -338,9 +342,18 @@ message ComputeOptimalRetentionRequest {
uint32 days_to_simulate = 3;
uint32 max_seconds_of_study_per_day = 4;
uint32 max_interval = 5;
uint32 recall_secs = 6;
uint32 forget_secs = 7;
uint32 learn_secs = 8;
double recall_secs_hard = 6;
double recall_secs_good = 7;
double recall_secs_easy = 8;
uint32 forget_secs = 9;
uint32 learn_secs = 10;
double first_rating_probability_again = 11;
double first_rating_probability_hard = 12;
double first_rating_probability_good = 13;
double first_rating_probability_easy = 14;
double review_rating_probability_hard = 15;
double review_rating_probability_good = 16;
double review_rating_probability_easy = 17;
}
message ComputeOptimalRetentionResponse {
@ -354,5 +367,5 @@ message EvaluateWeightsRequest {
message EvaluateWeightsResponse {
float log_loss = 1;
float rmse = 2;
float rmse_bins = 2;
}

View file

@ -52,7 +52,10 @@ message CardStatsResponse {
float total_secs = 15;
string card_type = 16;
string notetype = 17;
string custom_data = 18;
optional cards.FsrsMemoryState fsrs_memory_state = 18;
// not set if due date/state not available
optional float fsrs_retrievability = 19;
string custom_data = 20;
}
message GraphsRequest {
@ -70,6 +73,9 @@ message GraphsResponse {
message Eases {
map<uint32, uint32> eases = 1;
}
message Retrievability {
map<uint32, uint32> retrievability = 1;
}
message FutureDue {
map<int32, uint32> future_due = 1;
bool have_backlog = 2;
@ -141,11 +147,13 @@ message GraphsResponse {
Hours hours = 3;
Today today = 4;
Eases eases = 5;
Eases difficulty = 11;
Intervals intervals = 6;
FutureDue future_due = 7;
Added added = 8;
ReviewCountsAndTimes reviews = 9;
uint32 rollover_hour = 10;
Retrievability retrievability = 12;
}
message GraphPreferences {

View file

@ -32,6 +32,7 @@ from anki.sound import AVTag
# types
CardId = NewType("CardId", int)
BackendCard = cards_pb2.Card
FSRSMemoryState = cards_pb2.FsrsMemoryState
class Card(DeprecatedNamesMixin):
@ -44,6 +45,7 @@ class Card(DeprecatedNamesMixin):
odid: anki.decks.DeckId
queue: CardQueue
type: CardType
fsrs_memory_state: FSRSMemoryState | None
def __init__(
self,
@ -93,6 +95,9 @@ class Card(DeprecatedNamesMixin):
card.original_position if card.HasField("original_position") else None
)
self.custom_data = card.custom_data
self.fsrs_memory_state = (
card.fsrs_memory_state if card.HasField("fsrs_memory_state") else None
)
def _to_backend_card(self) -> cards_pb2.Card:
# mtime & usn are set by backend
@ -114,6 +119,7 @@ class Card(DeprecatedNamesMixin):
flags=self.flags,
original_position=self.original_position,
custom_data=self.custom_data,
fsrs_memory_state=self.fsrs_memory_state,
)
def flush(self) -> None:

View file

@ -232,6 +232,7 @@ def show(mw: aqt.AnkiQt) -> QDialog:
"Edgar Benavent Català",
"Kieran Black",
"Mateusz Wojewoda",
"Jarrett Ye",
)
)

View file

@ -61,7 +61,6 @@ class DeckBrowser:
self.web = mw.web
self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0)
self._v1_message_dismissed_at = 0
self._refresh_needed = False
def show(self) -> None:
@ -116,7 +115,10 @@ class DeckBrowser:
elif cmd == "v2upgrade":
self._confirm_upgrade()
elif cmd == "v2upgradeinfo":
openLink("https://faqs.ankiweb.net/the-anki-2.1-scheduler.html")
if self.mw.col.sched_ver() == 1:
openLink("https://faqs.ankiweb.net/the-anki-2.1-scheduler.html")
else:
openLink("https://faqs.ankiweb.net/the-2021-scheduler.html")
elif cmd == "select":
set_current_deck(
parent=self.mw, deck_id=DeckId(int(arg))
@ -365,14 +367,16 @@ class DeckBrowser:
######################################################################
def _v1_upgrade_message(self) -> str:
if self.mw.col.sched_ver() == 2:
if self.mw.col.sched_ver() == 2 and self.mw.col.v3_scheduler():
return ""
update_required = tr.scheduling_update_required().replace("V2", "v3")
return f"""
<center>
<div class=callout>
<div>
{tr.scheduling_update_required()}
{update_required}
</div>
<div>
<button onclick='pycmd("v2upgrade")'>
@ -387,8 +391,10 @@ class DeckBrowser:
"""
def _confirm_upgrade(self) -> None:
self.mw.col.mod_schema(check=True)
self.mw.col.upgrade_to_v2_scheduler()
if self.mw.col.sched_ver() == 1:
self.mw.col.mod_schema(check=True)
self.mw.col.upgrade_to_v2_scheduler()
self.mw.col.set_v3_scheduler(True)
showInfo(tr.scheduling_update_done())
self.refresh()

View file

@ -25,7 +25,7 @@ import aqt
import aqt.main
import aqt.operations
from anki import hooks
from anki.collection import OpChanges, OpChangesOnly, SearchNode
from anki.collection import OpChanges, OpChangesOnly, Progress, SearchNode
from anki.decks import UpdateDeckConfigs
from anki.scheduler.v3 import SchedulingStatesWithContext, SetSchedulingStatesRequest
from anki.utils import dev_mode
@ -33,6 +33,7 @@ from aqt.changenotetype import ChangeNotetypeDialog
from aqt.deckoptions import DeckOptionsDialog
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
@ -394,6 +395,16 @@ def update_deck_configs() -> bytes:
input = UpdateDeckConfigs()
input.ParseFromString(request.data)
def on_progress(progress: Progress, update: ProgressUpdate) -> None:
if not progress.HasField("compute_memory"):
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
def on_success(changes: OpChanges) -> None:
if isinstance(window := aqt.mw.app.activeWindow(), DeckOptionsDialog):
window.reject()
@ -401,7 +412,7 @@ def update_deck_configs() -> bytes:
def handle_on_main() -> None:
update_deck_configs_op(parent=aqt.mw, input=input).success(
on_success
).run_in_background()
).with_backend_progress(on_progress).run_in_background()
aqt.mw.taskman.run_on_main(handle_on_main)
return b""

View file

@ -153,9 +153,9 @@ class Reviewer:
hooks.card_did_leech.append(self.onLeech)
def show(self) -> None:
if self.mw.col.sched_ver() == 1:
if self.mw.col.sched_ver() == 1 or not self.mw.col.v3_scheduler():
self.mw.moveToState("deckBrowser")
show_warning(tr.scheduling_update_required())
show_warning(tr.scheduling_update_required().replace("V2", "v3"))
return
self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self)

View file

@ -120,8 +120,11 @@ class TaskManager(QObject):
def wrapped_done(fut: Future) -> None:
self.mw.progress.finish()
# allow the event loop to close the window before we proceed
if on_done:
on_done(fut)
self.mw.progress.single_shot(
100, lambda: on_done(fut), requires_collection=False
)
self.run_in_background(task, wrapped_done, uses_collection=uses_collection)

View file

@ -61,7 +61,7 @@ flate2.workspace = true
fluent.workspace = true
fluent-bundle.workspace = true
fnv.workspace = true
fsrs-optimizer.workspace = true
fsrs.workspace = true
futures.workspace = true
hex.workspace = true
htmlescape.workspace = true

View file

@ -40,7 +40,10 @@ impl AnkiError {
AnkiError::FileIoError { .. } => Kind::IoError,
AnkiError::MediaCheckRequired => Kind::InvalidInput,
AnkiError::InvalidId => Kind::InvalidInput,
AnkiError::InvalidMethodIndex | AnkiError::InvalidServiceIndex => Kind::InvalidInput,
AnkiError::InvalidMethodIndex
| AnkiError::InvalidServiceIndex
| AnkiError::FsrsWeightsInvalid
| AnkiError::FsrsInsufficientData => Kind::InvalidInput,
#[cfg(windows)]
AnkiError::WindowsError { .. } => Kind::OsError,
};

View file

@ -3,6 +3,7 @@
use std::sync::Arc;
use fsrs::FSRS;
use itertools::Itertools;
use strum::Display;
use strum::EnumIter;
@ -52,6 +53,9 @@ pub enum Column {
SortField,
#[strum(serialize = "noteTags")]
Tags,
Stability,
Difficulty,
Retrievability,
}
struct RowContext {
@ -115,6 +119,15 @@ impl Card {
None
}
}
pub(crate) fn days_since_last_review(&self, timing: &SchedTimingToday) -> Option<u32> {
self.due_time(timing).map(|due| {
due.adding_secs(-86_400 * self.interval as i64)
.elapsed_secs()
.max(0) as u32
/ 86_400
})
}
}
impl Note {
@ -144,17 +157,18 @@ impl Column {
Self::Reps => tr.scheduling_reviews(),
Self::SortField => tr.browsing_sort_field(),
Self::Tags => tr.editing_tags(),
Self::Stability => tr.card_stats_fsrs_stability(),
Self::Difficulty => tr.card_stats_fsrs_difficulty(),
Self::Retrievability => tr.card_stats_fsrs_retrievability(),
}
.into()
}
pub fn notes_mode_label(self, tr: &I18n) -> String {
match self {
Self::CardMod => tr.search_card_modified(),
Self::Cards => tr.editing_cards(),
Self::Ease => tr.browsing_average_ease(),
Self::Interval => tr.browsing_average_interval(),
Self::Reps => tr.scheduling_reviews(),
_ => return self.cards_mode_label(tr),
}
.into()
@ -196,6 +210,9 @@ impl Column {
| Column::Interval
| Column::NoteCreation
| Column::NoteMod
| Column::Stability
| Column::Difficulty
| Column::Retrievability
| Column::Reps => Sorting::Descending,
}
}
@ -396,6 +413,9 @@ impl RowContext {
Column::NoteMod => self.note.mtime.date_and_time_string(),
Column::Tags => self.note.tags.join(" "),
Column::Notetype => self.notetype.name.to_owned(),
Column::Stability => self.fsrs_stability_str(),
Column::Difficulty => self.fsrs_difficulty_str(),
Column::Retrievability => self.fsrs_retrievability_str(),
Column::Custom => "".to_string(),
})
}
@ -450,6 +470,36 @@ impl RowContext {
}
}
fn fsrs_stability_str(&self) -> String {
self.cards[0]
.fsrs_memory_state
.as_ref()
.map(|s| time_span(s.stability * 86400.0, &self.tr, false))
.unwrap_or_default()
}
fn fsrs_difficulty_str(&self) -> String {
self.cards[0]
.fsrs_memory_state
.as_ref()
.map(|s| format!("{:.0}%", (s.difficulty - 1.0) / 9.0 * 100.0))
.unwrap_or_default()
}
fn fsrs_retrievability_str(&self) -> String {
self.cards[0]
.fsrs_memory_state
.as_ref()
.zip(self.cards[0].days_since_last_review(&self.timing))
.map(|(state, days_elapsed)| {
let r = FSRS::new(None)
.unwrap()
.current_retrievability((*state).into(), days_elapsed);
format!("{:.0}%", r * 100.)
})
.unwrap_or_default()
}
/// Returns the due date of the next due card that is not in a filtered
/// deck, new, suspended or buried or the empty string if there is no
/// such card.

View file

@ -8,6 +8,7 @@ use std::collections::hash_map::Entry;
use std::collections::HashMap;
use std::collections::HashSet;
use fsrs::MemoryState;
use num_enum::TryFromPrimitive;
use serde_repr::Deserialize_repr;
use serde_repr::Serialize_repr;
@ -71,7 +72,7 @@ pub enum CardQueueNumber {
Invalid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct Card {
pub(crate) id: CardId,
pub(crate) note_id: NoteId,
@ -92,11 +93,18 @@ pub struct Card {
pub(crate) flags: u8,
/// The position in the new queue before leaving it.
pub(crate) original_position: Option<u32>,
pub(crate) fsrs_memory_state: Option<FsrsMemoryState>,
/// JSON object or empty; exposed through the reviewer for persisting custom
/// state
pub(crate) custom_data: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FsrsMemoryState {
pub stability: f32,
pub difficulty: f32,
}
impl Default for Card {
fn default() -> Self {
Self {
@ -118,6 +126,7 @@ impl Default for Card {
original_deck_id: DeckId(0),
flags: 0,
original_position: None,
fsrs_memory_state: None,
custom_data: String::new(),
}
}
@ -434,6 +443,24 @@ impl<'a> RemainingStepsAdjuster<'a> {
}
}
impl From<FsrsMemoryState> for MemoryState {
fn from(value: FsrsMemoryState) -> Self {
MemoryState {
stability: value.stability,
difficulty: value.difficulty,
}
}
}
impl From<MemoryState> for FsrsMemoryState {
fn from(value: MemoryState) -> Self {
FsrsMemoryState {
stability: value.stability,
difficulty: value.difficulty,
}
}
}
#[cfg(test)]
mod test {
use crate::tests::open_test_collection_with_learning_card;

View file

@ -4,6 +4,7 @@ use crate::card::Card;
use crate::card::CardId;
use crate::card::CardQueue;
use crate::card::CardType;
use crate::card::FsrsMemoryState;
use crate::collection::Collection;
use crate::decks::DeckId;
use crate::error;
@ -99,6 +100,7 @@ impl TryFrom<anki_proto::cards::Card> for Card {
original_deck_id: DeckId(c.original_deck_id),
flags: c.flags as u8,
original_position: c.original_position,
fsrs_memory_state: c.fsrs_memory_state.map(Into::into),
custom_data: c.custom_data,
})
}
@ -125,6 +127,7 @@ impl From<Card> for anki_proto::cards::Card {
original_deck_id: c.original_deck_id.0,
flags: c.flags as u32,
original_position: c.original_position.map(Into::into),
fsrs_memory_state: c.fsrs_memory_state.map(Into::into),
custom_data: c.custom_data,
}
}
@ -139,3 +142,21 @@ impl From<anki_proto::cards::CardId> for CardId {
CardId(cid.cid)
}
}
impl From<anki_proto::cards::FsrsMemoryState> for FsrsMemoryState {
fn from(value: anki_proto::cards::FsrsMemoryState) -> Self {
FsrsMemoryState {
stability: value.stability,
difficulty: value.difficulty,
}
}
}
impl From<FsrsMemoryState> for anki_proto::cards::FsrsMemoryState {
fn from(value: FsrsMemoryState) -> Self {
anki_proto::cards::FsrsMemoryState {
stability: value.stability,
difficulty: value.difficulty,
}
}
}

View file

@ -268,6 +268,12 @@ pub(crate) fn ensure_deck_config_values_valid(config: &mut DeckConfigInner) {
1,
9999,
);
ensure_f32_valid(
&mut config.desired_retention,
default.desired_retention,
0.8,
0.97,
);
}
fn ensure_f32_valid(val: &mut f32, default: f32, min: f32, max: f32) {

View file

@ -15,6 +15,7 @@ use anki_proto::decks::deck::normal::DayLimit;
use crate::config::StringKey;
use crate::decks::NormalDeck;
use crate::prelude::*;
use crate::scheduler::fsrs::weights::Weights;
use crate::search::JoinSearches;
use crate::search::SearchNode;
@ -130,6 +131,10 @@ impl Collection {
// add/update provided configs
for conf in &mut input.configs {
let weight_len = conf.inner.fsrs_weights.len();
if weight_len != 0 && weight_len != 17 {
return Err(AnkiError::FsrsWeightsInvalid);
}
self.add_or_update_deck_config(conf)?;
configs_after_update.insert(conf.id, conf.clone());
}
@ -154,16 +159,22 @@ impl Collection {
let usn = self.usn()?;
let today = self.timing_today()?.days_elapsed;
let selected_config = input.configs.last().unwrap();
let mut decks_needing_memory_recompute: HashMap<DeckConfigId, Vec<SearchNode>> =
Default::default();
for deck in self.storage.get_all_decks()? {
if let Ok(normal) = deck.normal() {
let deck_id = deck.id;
// previous order
// previous order & weights
let previous_config_id = DeckConfigId(normal.config_id);
let previous_config = configs_before_update.get(&previous_config_id);
let previous_order = previous_config
.map(|c| c.inner.new_card_insert_order())
.unwrap_or_default();
let previous_fsrs_on = previous_config
.map(|c| c.inner.fsrs_enabled)
.unwrap_or_default();
let previous_weights = previous_config.map(|c| &c.inner.fsrs_weights);
// if a selected (sub)deck, or its old config was removed, update deck to point
// to new config
@ -188,10 +199,38 @@ impl Collection {
self.sort_deck(deck_id, current_order, usn)?;
}
// if weights differ, memory state needs to be recomputed
let current_fsrs_on = current_config
.map(|c| c.inner.fsrs_enabled)
.unwrap_or_default();
let current_weights = current_config.map(|c| &c.inner.fsrs_weights);
if current_fsrs_on && (!previous_fsrs_on || previous_weights != current_weights) {
decks_needing_memory_recompute
.entry(current_config_id)
.or_default()
.push(SearchNode::DeckIdWithoutChildren(deck_id));
}
self.adjust_remaining_steps_in_deck(deck_id, previous_config, current_config, usn)?;
}
}
if !decks_needing_memory_recompute.is_empty() {
let input: Vec<(Weights, Vec<SearchNode>)> = decks_needing_memory_recompute
.into_iter()
.map(|(conf_id, search)| {
let weights = configs_after_update
.get(&conf_id)
.or_not_found(conf_id)?
.inner
.fsrs_weights
.clone();
Ok((weights, search))
})
.collect::<Result<_>>()?;
self.update_memory_state(input)?;
}
self.set_config_string_inner(StringKey::CardStateCustomizer, &input.card_state_customizer)?;
self.set_config_bool_inner(
BoolKey::NewCardsIgnoreReviewLimit,

View file

@ -113,6 +113,8 @@ pub enum AnkiError {
},
InvalidMethodIndex,
InvalidServiceIndex,
FsrsWeightsInvalid,
FsrsInsufficientData,
}
// error helpers
@ -164,6 +166,8 @@ impl AnkiError {
AnkiError::FileIoError { source } => source.message(),
AnkiError::InvalidInput { source } => source.message(),
AnkiError::NotFound { source } => source.message(tr),
AnkiError::FsrsInsufficientData => tr.deck_config_not_enough_history().into(),
AnkiError::FsrsWeightsInvalid => tr.deck_config_invalid_weights().into(),
#[cfg(windows)]
AnkiError::WindowsError { source } => format!("{source:?}"),
}

View file

@ -20,6 +20,9 @@ const LOG_ROTATE_BYTES: u64 = 50 * 1024 * 1024;
/// Enable logging to the console, and optionally also to a file.
pub fn set_global_logger(path: Option<&str>) -> Result<()> {
if std::env::var("BURN_LOG").is_ok() {
return Ok(());
}
static ONCE: OnceCell<()> = OnceCell::new();
ONCE.get_or_try_init(|| -> Result<()> {
let file_writer = if let Some(path) = path {

View file

@ -6,8 +6,7 @@ use std::sync::Arc;
use std::sync::Mutex;
use anki_i18n::I18n;
use anki_proto::collection::progress::ComputeRetention;
use anki_proto::collection::progress::ComputeWeights;
use anki_proto::collection::progress::Value;
use crate::dbcheck::DatabaseCheckProgress;
use crate::error::AnkiError;
@ -15,6 +14,7 @@ use crate::error::Result;
use crate::import_export::ExportProgress;
use crate::import_export::ImportProgress;
use crate::prelude::Collection;
use crate::scheduler::fsrs::memory_state::ComputeMemoryProgress;
use crate::scheduler::fsrs::retention::ComputeRetentionProgress;
use crate::scheduler::fsrs::weights::ComputeWeightsProgress;
use crate::sync::collection::normal::NormalSyncProgress;
@ -133,6 +133,7 @@ pub enum Progress {
Export(ExportProgress),
ComputeWeights(ComputeWeightsProgress),
ComputeRetention(ComputeRetentionProgress),
ComputeMemory(ComputeMemoryProgress),
}
pub(crate) fn progress_to_proto(
@ -141,18 +142,12 @@ pub(crate) fn progress_to_proto(
) -> anki_proto::collection::Progress {
let progress = if let Some(progress) = progress {
match progress {
Progress::MediaSync(p) => {
anki_proto::collection::progress::Value::MediaSync(media_sync_progress(p, tr))
}
Progress::MediaCheck(n) => anki_proto::collection::progress::Value::MediaCheck(
tr.media_check_checked(n.checked).into(),
),
Progress::FullSync(p) => anki_proto::collection::progress::Value::FullSync(
anki_proto::collection::progress::FullSync {
transferred: p.transferred_bytes as u32,
total: p.total_bytes as u32,
},
),
Progress::MediaSync(p) => Value::MediaSync(media_sync_progress(p, tr)),
Progress::MediaCheck(n) => Value::MediaCheck(tr.media_check_checked(n.checked).into()),
Progress::FullSync(p) => Value::FullSync(anki_proto::collection::progress::FullSync {
transferred: p.transferred_bytes as u32,
total: p.total_bytes as u32,
}),
Progress::NormalSync(p) => {
let stage = match p.stage {
SyncStage::Connecting => tr.sync_syncing(),
@ -166,13 +161,11 @@ pub(crate) fn progress_to_proto(
let removed = tr
.sync_media_removed_count(p.local_remove, p.remote_remove)
.into();
anki_proto::collection::progress::Value::NormalSync(
anki_proto::collection::progress::NormalSync {
stage,
added,
removed,
},
)
Value::NormalSync(anki_proto::collection::progress::NormalSync {
stage,
added,
removed,
})
}
Progress::DatabaseCheck(p) => {
let mut stage_total = 0;
@ -189,15 +182,13 @@ pub(crate) fn progress_to_proto(
DatabaseCheckProgress::History => tr.database_check_checking_history(),
}
.to_string();
anki_proto::collection::progress::Value::DatabaseCheck(
anki_proto::collection::progress::DatabaseCheck {
stage,
stage_total: stage_total as u32,
stage_current: stage_current as u32,
},
)
Value::DatabaseCheck(anki_proto::collection::progress::DatabaseCheck {
stage,
stage_total: stage_total as u32,
stage_current: stage_current as u32,
})
}
Progress::Import(progress) => anki_proto::collection::progress::Value::Importing(
Progress::Import(progress) => Value::Importing(
match progress {
ImportProgress::File => tr.importing_importing_file(),
ImportProgress::Media(n) => tr.importing_processed_media_file(n),
@ -208,7 +199,7 @@ pub(crate) fn progress_to_proto(
}
.into(),
),
Progress::Export(progress) => anki_proto::collection::progress::Value::Exporting(
Progress::Export(progress) => Value::Exporting(
match progress {
ExportProgress::File => tr.exporting_exporting_file(),
ExportProgress::Media(n) => tr.exporting_processed_media_files(n),
@ -219,21 +210,30 @@ pub(crate) fn progress_to_proto(
.into(),
),
Progress::ComputeWeights(progress) => {
anki_proto::collection::progress::Value::ComputeWeights(ComputeWeights {
Value::ComputeWeights(anki_proto::collection::ComputeWeightsProgress {
current: progress.current,
total: progress.total,
revlog_entries: progress.revlog_entries,
fsrs_items: progress.fsrs_items,
})
}
Progress::ComputeRetention(progress) => {
anki_proto::collection::progress::Value::ComputeRetention(ComputeRetention {
Value::ComputeRetention(anki_proto::collection::ComputeRetentionProgress {
current: progress.current,
total: progress.total,
})
}
Progress::ComputeMemory(progress) => {
Value::ComputeMemory(anki_proto::collection::ComputeMemoryProgress {
current_cards: progress.current_cards,
total_cards: progress.total_cards,
label: tr
.deck_config_updating_cards(progress.current_cards, progress.total_cards)
.into(),
})
}
}
} else {
anki_proto::collection::progress::Value::None(anki_proto::generic::Empty {})
Value::None(anki_proto::generic::Empty {})
};
anki_proto::collection::Progress {
value: Some(progress),
@ -306,6 +306,12 @@ impl From<ComputeRetentionProgress> for Progress {
}
}
impl From<ComputeMemoryProgress> for Progress {
fn from(p: ComputeMemoryProgress) -> Self {
Progress::ComputeMemory(p)
}
}
impl Collection {
pub fn new_progress_handler<P: Into<Progress> + Default + Clone>(
&self,

View file

@ -32,7 +32,7 @@ impl From<TimestampMillis> for RevlogId {
}
}
#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq, Eq)]
#[derive(Serialize_tuple, Deserialize, Debug, Default, PartialEq, Eq, Clone)]
pub struct RevlogEntry {
pub id: RevlogId,
pub cid: CardId,

View file

@ -63,6 +63,7 @@ impl CardStateUpdater {
let lapses = self.card.lapses;
let ease_factor = self.card.ease_factor();
let remaining_steps = self.card.remaining_steps();
let fsrs_memory_state = self.card.fsrs_memory_state;
match self.card.ctype {
CardType::New => NormalState::New(NewState {
@ -72,6 +73,7 @@ impl CardStateUpdater {
LearnState {
scheduled_secs: self.learn_steps().current_delay_secs(remaining_steps),
remaining_steps,
fsrs_memory_state,
}
}
.into(),
@ -82,12 +84,14 @@ impl CardStateUpdater {
ease_factor,
lapses,
leeched: false,
fsrs_memory_state,
}
.into(),
CardType::Relearn => RelearnState {
learning: LearnState {
scheduled_secs: self.relearn_steps().current_delay_secs(remaining_steps),
remaining_steps,
fsrs_memory_state,
},
review: ReviewState {
scheduled_days: interval,
@ -95,6 +99,7 @@ impl CardStateUpdater {
ease_factor,
lapses,
leeched: false,
fsrs_memory_state,
},
}
.into(),

View file

@ -24,6 +24,7 @@ impl CardStateUpdater {
self.card.queue = CardQueue::New;
self.card.due = next.position as i32;
self.card.original_position = None;
self.card.fsrs_memory_state = None;
RevlogEntryPartial::new(current, next.into(), 0.0, self.secs_until_rollover())
}
@ -38,6 +39,7 @@ impl CardStateUpdater {
if let Some(position) = current.new_position() {
self.card.original_position = Some(position)
}
self.card.fsrs_memory_state = next.fsrs_memory_state;
let interval = next
.interval_kind()

View file

@ -8,6 +8,9 @@ mod relearning;
mod review;
mod revlog;
use fsrs::MemoryState;
use fsrs::NextStates;
use fsrs::FSRS;
use rand::prelude::*;
use rand::rngs::StdRng;
use revlog::RevlogEntryPartial;
@ -22,10 +25,13 @@ use super::states::StateContext;
use super::timespan::answer_button_time_collapsible;
use super::timing::SchedTimingToday;
use crate::card::CardQueue;
use crate::card::CardType;
use crate::deckconfig::DeckConfig;
use crate::deckconfig::LeechAction;
use crate::decks::Deck;
use crate::prelude::*;
use crate::scheduler::fsrs::weights::fsrs_items_for_memory_state;
use crate::search::SearchNode;
#[derive(Copy, Clone)]
pub enum Rating {
@ -60,6 +66,8 @@ struct CardStateUpdater {
timing: SchedTimingToday,
now: TimestampSecs,
fuzz_seed: Option<u64>,
/// Set if FSRS is enabled.
fsrs_next_states: Option<NextStates>,
}
impl CardStateUpdater {
@ -87,6 +95,7 @@ impl CardStateUpdater {
} else {
0
},
fsrs_next_states: self.fsrs_next_states.clone(),
}
}
@ -342,6 +351,29 @@ 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 fsrs_next_states = if config.inner.fsrs_enabled {
let fsrs = FSRS::new(Some(&config.inner.fsrs_weights))?;
let memory_state = if let Some(state) = card.fsrs_memory_state {
Some(MemoryState::from(state))
} else if card.ctype == CardType::New {
None
} else {
// Card has been moved or imported into an FSRS deck after weights were set,
// and will need its initial memory state to be calculated based on review
// history.
let revlog = self.revlog_for_srs(SearchNode::CardIds(card.id.to_string()))?;
let mut fsrs_items = fsrs_items_for_memory_state(revlog, timing.next_day_at);
fsrs_items.pop().map(|(_cid, item)| fsrs.memory_state(item))
};
Some(fsrs.next_states(
memory_state,
config.inner.desired_retention,
card.days_since_last_review(&timing).unwrap_or_default(),
))
} else {
None
};
Ok(CardStateUpdater {
fuzz_seed: get_fuzz_seed(&card),
card,
@ -349,6 +381,7 @@ impl Collection {
config,
timing,
now: TimestampSecs::now(),
fsrs_next_states,
})
}

View file

@ -23,6 +23,7 @@ impl CardStateUpdater {
if let Some(position) = current.new_position() {
self.card.original_position = Some(position)
}
self.card.fsrs_memory_state = next.learning.fsrs_memory_state;
let interval = next
.interval_kind()

View file

@ -24,6 +24,7 @@ impl CardStateUpdater {
if let Some(position) = current.new_position() {
self.card.original_position = Some(position)
}
self.card.fsrs_memory_state = next.fsrs_memory_state;
RevlogEntryPartial::new(
current,

View file

@ -1,20 +1,16 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use fsrs_optimizer::FSRSError;
use fsrs::FSRSError;
use crate::error::AnkiError;
use crate::error::InvalidInputError;
impl From<FSRSError> for AnkiError {
fn from(err: FSRSError) -> Self {
match err {
FSRSError::NotEnoughData => InvalidInputError {
message: "Not enough data available".to_string(),
source: None,
backtrace: None,
}
.into(),
FSRSError::NotEnoughData => AnkiError::FsrsInsufficientData,
FSRSError::Interrupted => AnkiError::Interrupted,
FSRSError::InvalidWeights => AnkiError::FsrsWeightsInvalid,
}
}
}

View file

@ -0,0 +1,49 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use fsrs::FSRS;
use crate::prelude::*;
use crate::scheduler::fsrs::weights::fsrs_items_for_memory_state;
use crate::scheduler::fsrs::weights::Weights;
use crate::search::JoinSearches;
use crate::search::Negated;
use crate::search::SearchNode;
use crate::search::StateKind;
#[derive(Debug, Clone, Copy, Default)]
pub struct ComputeMemoryProgress {
pub current_cards: u32,
pub total_cards: u32,
}
impl Collection {
/// For each provided set of weights, locate cards with the provided search,
/// and update their memory state.
/// Should be called inside a transaction.
pub(crate) fn update_memory_state(
&mut self,
entries: Vec<(Weights, Vec<SearchNode>)>,
) -> Result<()> {
let timing = self.timing_today()?;
let usn = self.usn()?;
for (weights, search) in entries {
let search = SearchBuilder::any(search.into_iter())
.and(SearchNode::State(StateKind::New).negated());
let revlog = self.revlog_for_srs(search)?;
let items = fsrs_items_for_memory_state(revlog, timing.next_day_at);
let fsrs = FSRS::new(Some(&weights))?;
let mut progress = self.new_progress_handler::<ComputeMemoryProgress>();
progress.update(false, |s| s.total_cards = items.len() as u32)?;
for (idx, (card_id, item)) in items.into_iter().enumerate() {
progress.update(true, |state| state.current_cards = idx as u32 + 1)?;
let state = fsrs.memory_state(item);
let mut card = self.storage.get_card(card_id)?.or_not_found(card_id)?;
let original = card.clone();
card.fsrs_memory_state = Some(state.into());
self.update_card_inner(&mut card, original, usn)?;
}
}
Ok(())
}
}

View file

@ -1,5 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod error;
pub mod memory_state;
pub mod retention;
pub mod try_collect;
pub mod weights;

View file

@ -1,9 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::scheduler::ComputeOptimalRetentionRequest;
use fsrs_optimizer::find_optimal_retention;
use fsrs_optimizer::SimulatorConfig;
use itertools::Itertools;
use fsrs::SimulatorConfig;
use fsrs::FSRS;
use crate::prelude::*;
@ -19,24 +19,33 @@ impl Collection {
req: ComputeOptimalRetentionRequest,
) -> Result<f32> {
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
if req.weights.len() != 17 {
invalid_input!("must have 17 weights");
}
let mut weights = [0f64; 17];
weights
.iter_mut()
.set_from(req.weights.into_iter().map(|v| v as f64));
Ok(find_optimal_retention(
let fsrs = FSRS::new(None)?;
Ok(fsrs.optimal_retention(
&SimulatorConfig {
w: weights,
deck_size: req.deck_size as usize,
learn_span: req.days_to_simulate as usize,
max_cost_perday: req.max_seconds_of_study_per_day as f64,
max_ivl: req.max_interval as f64,
recall_cost: req.recall_secs as f64,
recall_costs: [
req.recall_secs_hard,
req.recall_secs_good,
req.recall_secs_easy,
],
forget_cost: req.forget_secs as f64,
learn_cost: req.learn_secs as f64,
first_rating_prob: [
req.first_rating_probability_again,
req.first_rating_probability_hard,
req.first_rating_probability_good,
req.first_rating_probability_easy,
],
review_rating_prob: [
req.review_rating_probability_hard,
req.review_rating_probability_good,
req.review_rating_probability_easy,
],
},
&req.weights,
|ip| {
anki_progress
.update(false, |p| {

View file

@ -0,0 +1,34 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::error::AnkiError;
use crate::invalid_input;
// Roll our own implementation until this becomes stable
// https://github.com/rust-lang/rust/issues/94047
#[allow(unused)]
pub(crate) trait TryCollect: ExactSizeIterator {
fn try_collect<const N: usize>(self) -> Result<[Self::Item; N], AnkiError>
where
// Self: Sized,
Self::Item: Copy + Default;
}
impl<I, T> TryCollect for I
where
I: ExactSizeIterator<Item = T>,
T: Copy + Default,
{
fn try_collect<const N: usize>(self) -> Result<[T; N], AnkiError> {
if self.len() != N {
invalid_input!("expected {N}; got {}", self.len());
}
let mut result = [T::default(); N];
for (index, value) in self.enumerate() {
result[index] = value;
}
Ok(result)
}
}

View file

@ -4,11 +4,12 @@ use std::iter;
use std::thread;
use std::time::Duration;
use fsrs_optimizer::compute_weights;
use fsrs_optimizer::evaluate;
use fsrs_optimizer::FSRSItem;
use fsrs_optimizer::FSRSReview;
use fsrs_optimizer::ProgressState;
use anki_proto::scheduler::ComputeFsrsWeightsResponse;
use fsrs::FSRSItem;
use fsrs::FSRSReview;
use fsrs::ModelEvaluation;
use fsrs::ProgressState;
use fsrs::FSRS;
use itertools::Itertools;
use crate::prelude::*;
@ -16,17 +17,16 @@ use crate::revlog::RevlogEntry;
use crate::revlog::RevlogReviewKind;
use crate::search::SortMode;
pub(crate) type Weights = Vec<f32>;
impl Collection {
pub fn compute_weights(&mut self, search: &str) -> Result<Vec<f32>> {
pub fn compute_weights(&mut self, search: &str) -> Result<ComputeFsrsWeightsResponse> {
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;
let mut anki_progress = self.new_progress_handler::<ComputeWeightsProgress>();
let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;
let revlogs = guard
.col
.storage
.get_revlog_entries_for_searched_cards_in_order()?;
anki_progress.state.revlog_entries = revlogs.len() as u32;
let items = anki_to_fsrs(revlogs, timing.next_day_at);
anki_progress.update(false, |p| p.fsrs_items = fsrs_items)?;
// adapt the progress handler to our built-in progress handling
let progress = ProgressState::new_shared();
let progress2 = progress.clone();
@ -45,26 +45,36 @@ impl Collection {
}
}
});
compute_weights(items, Some(progress2)).map_err(Into::into)
let fsrs = FSRS::new(None)?;
let weights = fsrs.compute_weights(items, Some(progress2))?;
Ok(ComputeFsrsWeightsResponse {
weights,
fsrs_items,
})
}
pub fn evaluate_weights(&mut self, weights: &[f32], search: &str) -> Result<(f32, f32)> {
pub(crate) fn revlog_for_srs(
&mut self,
search: impl TryIntoSearch,
) -> Result<Vec<RevlogEntry>> {
self.search_cards_into_table(search, SortMode::NoOrder)?
.col
.storage
.get_revlog_entries_for_searched_cards_in_order()
}
pub fn evaluate_weights(&mut self, weights: &Weights, search: &str) -> Result<ModelEvaluation> {
let timing = self.timing_today()?;
if weights.len() != 17 {
invalid_input!("must have 17 weights");
}
let mut weights_arr = [0f32; 17];
weights_arr.iter_mut().set_from(weights.iter().cloned());
let mut anki_progress = self.new_progress_handler::<ComputeWeightsProgress>();
let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;
let revlogs = guard
.col
.storage
.get_revlog_entries_for_searched_cards_in_order()?;
anki_progress.state.revlog_entries = revlogs.len() as u32;
let items = anki_to_fsrs(revlogs, timing.next_day_at);
Ok(evaluate(weights_arr, items, |ip| {
anki_progress.state.fsrs_items = revlogs.len() as u32;
let items = fsrs_items_for_training(revlogs, timing.next_day_at);
let fsrs = FSRS::new(Some(weights))?;
Ok(fsrs.evaluate(items, |ip| {
anki_progress
.update(false, |p| {
p.total = ip.total as u32;
@ -79,56 +89,87 @@ impl Collection {
pub struct ComputeWeightsProgress {
pub current: u32,
pub total: u32,
pub revlog_entries: u32,
pub fsrs_items: u32,
}
/// Convert a series of revlog entries sorted by card id into FSRS items.
fn anki_to_fsrs(revlogs: Vec<RevlogEntry>, next_day_at: TimestampSecs) -> Vec<FSRSItem> {
fn fsrs_items_for_training(revlogs: Vec<RevlogEntry>, next_day_at: TimestampSecs) -> Vec<FSRSItem> {
let mut revlogs = revlogs
.into_iter()
.group_by(|r| r.cid)
.into_iter()
.filter_map(|(_cid, entries)| single_card_revlog_to_items(entries.collect(), next_day_at))
.filter_map(|(_cid, entries)| {
single_card_revlog_to_items(entries.collect(), next_day_at, true)
})
.flatten()
.collect_vec();
revlogs.sort_by_cached_key(|r| r.reviews.len());
revlogs
}
/// When updating memory state, FSRS only requires the last FSRSItem that
/// contains the full history.
pub(crate) fn fsrs_items_for_memory_state(
revlogs: Vec<RevlogEntry>,
next_day_at: TimestampSecs,
) -> Vec<(CardId, FSRSItem)> {
let mut out = vec![];
for (card_id, group) in revlogs.into_iter().group_by(|r| r.cid).into_iter() {
let entries = group.into_iter().collect_vec();
if let Some(mut items) = single_card_revlog_to_items(entries, next_day_at, false) {
if let Some(item) = items.pop() {
out.push((card_id, item));
}
}
}
out
}
/// Transform the revlog history for a card into a list of FSRSItems. FSRS
/// expects multiple items for a given card when training - for revlog
/// `[1,2,3]`, we create FSRSItems corresponding to `[1,2]` and `[1,2,3]`
/// in training, and `[1]`, [1,2]` and `[1,2,3]` when calculating memory
/// state.
fn single_card_revlog_to_items(
mut entries: Vec<RevlogEntry>,
next_day_at: TimestampSecs,
training: bool,
) -> Option<Vec<FSRSItem>> {
// Find the index of the first learn entry in the last continuous group
let mut index_to_keep = 0;
let mut i = entries.len();
while i > 0 {
i -= 1;
if entries[i].review_kind == RevlogReviewKind::Learning {
index_to_keep = i;
} else if index_to_keep != 0 {
// Found a continuous group
let mut last_learn_entry = None;
for (index, entry) in entries.iter().enumerate().rev() {
if entry.review_kind == RevlogReviewKind::Learning {
last_learn_entry = Some(index);
} else if last_learn_entry.is_some() {
break;
}
}
// Remove all entries before this one
entries.drain(..index_to_keep);
// we ignore cards that don't start in the learning state
if let Some(entry) = entries.first() {
if entry.review_kind != RevlogReviewKind::Learning {
return None;
let first_relearn = entries
.iter()
.enumerate()
.find(|(_idx, e)| e.review_kind == RevlogReviewKind::Relearning)
.map(|(idx, _)| idx);
if let Some(idx) = last_learn_entry.or(first_relearn) {
// start from the (re)learning step
if idx > 0 {
entries.drain(..idx);
}
} else {
// no revlog entries
// we ignore cards that don't have any learning steps
return None;
}
// Keep only the first review when multiple reviews done on one day
// Filter out unwanted entries
let mut unique_dates = std::collections::HashSet::new();
entries.retain(|entry| unique_dates.insert(entry.days_elapsed(next_day_at)));
entries.retain(|entry| {
let manually_rescheduled =
entry.review_kind == RevlogReviewKind::Manual || entry.button_chosen == 0;
let cram = entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0;
if manually_rescheduled || cram {
return false;
}
// Keep only the first review when multiple reviews done on one day
unique_dates.insert(entry.days_elapsed(next_day_at))
});
// Old versions of Anki did not record Manual entries in the review log when
// cards were manually rescheduled. So we look for times when the card has
@ -153,27 +194,31 @@ fn single_card_revlog_to_items(
}))
.collect_vec();
// Skip the first learning step, then convert the remaining entries into
// separate FSRSItems, where each item contains all reviews done until then.
Some(
entries
.iter()
.enumerate()
.skip(1)
.map(|(outer_idx, _)| {
let reviews = entries
.iter()
.take(outer_idx + 1)
.enumerate()
.map(|(inner_idx, r)| FSRSReview {
rating: r.button_chosen as i32,
delta_t: delta_ts[inner_idx] as i32,
})
.collect();
FSRSItem { reviews }
})
.collect(),
)
let skip = if training { 1 } else { 0 };
// Convert the remaining entries into separate FSRSItems, where each item
// contains all reviews done until then.
let items = entries
.iter()
.enumerate()
.skip(skip)
.map(|(outer_idx, _)| {
let reviews = entries
.iter()
.take(outer_idx + 1)
.enumerate()
.map(|(inner_idx, r)| FSRSReview {
rating: r.button_chosen as i32,
delta_t: delta_ts[inner_idx] as i32,
})
.collect();
FSRSItem { reviews }
})
.collect_vec();
if items.is_empty() {
None
} else {
Some(items)
}
}
impl RevlogEntry {
@ -192,94 +237,136 @@ mod tests {
RevlogEntry {
review_kind,
id: ((NEXT_DAY_AT.0 - days_ago * 86400) * 1000).into(),
button_chosen: 3,
..Default::default()
}
}
fn review(delta_t: i32) -> FSRSReview {
FSRSReview { rating: 3, delta_t }
}
fn convert(revlog: &[RevlogEntry], training: bool) -> Option<Vec<FSRSItem>> {
single_card_revlog_to_items(revlog.to_vec(), NEXT_DAY_AT, training)
}
macro_rules! fsrs_items {
($($reviews:expr),*) => {
Some(vec![
$(
FSRSItem {
reviews: $reviews.to_vec()
}
),*
])
};
}
#[test]
fn delta_t_is_correct() -> Result<()> {
assert_eq!(
single_card_revlog_to_items(
vec![
convert(
&[
revlog(RevlogReviewKind::Learning, 1),
revlog(RevlogReviewKind::Review, 0)
],
NEXT_DAY_AT
true,
),
Some(vec![FSRSItem {
reviews: vec![
FSRSReview {
rating: 0,
delta_t: 0
},
FSRSReview {
rating: 0,
delta_t: 1
}
]
}])
fsrs_items!([review(0), review(1)])
);
assert_eq!(
single_card_revlog_to_items(
vec![
convert(
&[
revlog(RevlogReviewKind::Learning, 15),
revlog(RevlogReviewKind::Learning, 13),
revlog(RevlogReviewKind::Review, 10),
revlog(RevlogReviewKind::Review, 5)
],
NEXT_DAY_AT,
true,
),
Some(vec![
FSRSItem {
reviews: vec![
FSRSReview {
rating: 0,
delta_t: 0
},
FSRSReview {
rating: 0,
delta_t: 2
}
]
},
FSRSItem {
reviews: vec![
FSRSReview {
rating: 0,
delta_t: 0
},
FSRSReview {
rating: 0,
delta_t: 2
},
FSRSReview {
rating: 0,
delta_t: 3
}
]
},
FSRSItem {
reviews: vec![
FSRSReview {
rating: 0,
delta_t: 0
},
FSRSReview {
rating: 0,
delta_t: 2
},
FSRSReview {
rating: 0,
delta_t: 3
},
FSRSReview {
rating: 0,
delta_t: 5
}
]
}
])
fsrs_items!(
[review(0), review(2)],
[review(0), review(2), review(3)],
[review(0), review(2), review(3), review(5)]
)
);
assert_eq!(
convert(
&[
revlog(RevlogReviewKind::Learning, 15),
revlog(RevlogReviewKind::Learning, 13),
],
true,
),
fsrs_items!([review(0), review(2),])
);
Ok(())
}
#[test]
fn cram_is_filtered() {
assert_eq!(
convert(
&[
revlog(RevlogReviewKind::Learning, 10),
revlog(RevlogReviewKind::Review, 9),
revlog(RevlogReviewKind::Filtered, 7),
revlog(RevlogReviewKind::Review, 4),
],
true,
),
fsrs_items!([review(0), review(1)], [review(0), review(1), review(5)])
);
}
#[test]
fn set_due_date_is_filtered() {
assert_eq!(
convert(
&[
revlog(RevlogReviewKind::Learning, 10),
revlog(RevlogReviewKind::Review, 9),
RevlogEntry {
ease_factor: 100,
..revlog(RevlogReviewKind::Manual, 7)
},
revlog(RevlogReviewKind::Review, 4),
],
true,
),
fsrs_items!([review(0), review(1)], [review(0), review(1), review(5)])
);
}
#[test]
fn card_reset_drops_all_previous_history() {
assert_eq!(
convert(
&[
revlog(RevlogReviewKind::Learning, 10),
revlog(RevlogReviewKind::Review, 9),
RevlogEntry {
ease_factor: 0,
..revlog(RevlogReviewKind::Manual, 7)
},
revlog(RevlogReviewKind::Learning, 4),
revlog(RevlogReviewKind::Review, 0),
],
true,
),
fsrs_items!([review(0), review(4)])
);
}
#[test]
fn single_learning_step_skipped_when_training() {
assert_eq!(
convert(&[revlog(RevlogReviewKind::Learning, 1),], true),
None,
);
assert_eq!(
convert(&[revlog(RevlogReviewKind::Learning, 1),], false),
fsrs_items!([review(0)])
);
}
}

View file

@ -57,6 +57,7 @@ impl Card {
self.reps = 0;
self.lapses = 0;
}
self.fsrs_memory_state = None;
last_position.is_none()
}

View file

@ -244,9 +244,7 @@ impl crate::services::SchedulerService for Collection {
&mut self,
input: scheduler::ComputeFsrsWeightsRequest,
) -> Result<scheduler::ComputeFsrsWeightsResponse> {
Ok(scheduler::ComputeFsrsWeightsResponse {
weights: self.compute_weights(&input.search)?,
})
self.compute_weights(&input.search)
}
fn compute_optimal_retention(
@ -264,8 +262,8 @@ impl crate::services::SchedulerService for Collection {
) -> Result<scheduler::EvaluateWeightsResponse> {
let ret = self.evaluate_weights(&input.weights, &input.search)?;
Ok(scheduler::EvaluateWeightsResponse {
log_loss: ret.0,
rmse: ret.1,
log_loss: ret.log_loss,
rmse_bins: ret.rmse_bins,
})
}
}

View file

@ -8,6 +8,7 @@ impl From<anki_proto::scheduler::scheduling_state::Learning> for LearnState {
LearnState {
remaining_steps: state.remaining_steps,
scheduled_secs: state.scheduled_secs,
fsrs_memory_state: state.fsrs_memory_state.map(Into::into),
}
}
}
@ -17,6 +18,7 @@ impl From<LearnState> for anki_proto::scheduler::scheduling_state::Learning {
anki_proto::scheduler::scheduling_state::Learning {
remaining_steps: state.remaining_steps,
scheduled_secs: state.scheduled_secs,
fsrs_memory_state: state.fsrs_memory_state.map(Into::into),
}
}
}

View file

@ -11,6 +11,7 @@ impl From<anki_proto::scheduler::scheduling_state::Review> for ReviewState {
ease_factor: state.ease_factor,
lapses: state.lapses,
leeched: state.leeched,
fsrs_memory_state: state.fsrs_memory_state.map(Into::into),
}
}
}
@ -23,6 +24,7 @@ impl From<ReviewState> for anki_proto::scheduler::scheduling_state::Review {
ease_factor: state.ease_factor,
lapses: state.lapses,
leeched: state.leeched,
fsrs_memory_state: state.fsrs_memory_state.map(Into::into),
}
}
}

View file

@ -40,16 +40,6 @@ impl<'a> StateContext<'a> {
(interval.round() as u32).clamp(minimum, maximum)
}
}
pub(crate) fn fuzzed_graduating_interval_good(&self) -> u32 {
let (minimum, maximum) = self.min_and_max_review_intervals(1);
self.with_review_fuzz(self.graduating_interval_good as f32, minimum, maximum)
}
pub(crate) fn fuzzed_graduating_interval_easy(&self) -> u32 {
let (minimum, maximum) = self.min_and_max_review_intervals(1);
self.with_review_fuzz(self.graduating_interval_easy as f32, minimum, maximum)
}
}
/// Return the bounds of the fuzz range, respecting `minimum` and `maximum`.

View file

@ -6,12 +6,14 @@ use super::CardState;
use super::ReviewState;
use super::SchedulingStates;
use super::StateContext;
use crate::card::FsrsMemoryState;
use crate::revlog::RevlogReviewKind;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LearnState {
pub remaining_steps: u32,
pub scheduled_secs: u32,
pub fsrs_memory_state: Option<FsrsMemoryState>,
}
impl LearnState {
@ -27,7 +29,7 @@ impl LearnState {
SchedulingStates {
current: self.into(),
again: self.answer_again(ctx).into(),
hard: self.answer_hard(ctx),
hard: self.answer_hard(ctx).into(),
good: self.answer_good(ctx),
easy: self.answer_easy(ctx).into(),
}
@ -37,38 +39,42 @@ impl LearnState {
LearnState {
remaining_steps: ctx.steps.remaining_for_failed(),
scheduled_secs: ctx.steps.again_delay_secs_learn(),
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into()),
}
}
fn answer_hard(self, ctx: &StateContext) -> CardState {
if let Some(hard_delay) = ctx.steps.hard_delay_secs(self.remaining_steps) {
LearnState {
scheduled_secs: hard_delay,
..self
}
.into()
} else {
// steps modified while card in learning
ReviewState {
scheduled_days: ctx.fuzzed_graduating_interval_good(),
ease_factor: ctx.initial_ease_factor,
..Default::default()
}
.into()
fn answer_hard(self, ctx: &StateContext) -> LearnState {
LearnState {
scheduled_secs: ctx
.steps
.hard_delay_secs(self.remaining_steps)
// user has 0 learning steps, which the UI doesn't allow
.unwrap_or(60),
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()),
..self
}
}
fn answer_good(self, ctx: &StateContext) -> CardState {
let fsrs_memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into());
if let Some(good_delay) = ctx.steps.good_delay_secs(self.remaining_steps) {
LearnState {
remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps),
scheduled_secs: good_delay,
fsrs_memory_state,
}
.into()
} else {
let (minimum, maximum) = ctx.min_and_max_review_intervals(1);
let interval = if let Some(states) = &ctx.fsrs_next_states {
states.good.interval
} else {
ctx.graduating_interval_good
};
ReviewState {
scheduled_days: ctx.fuzzed_graduating_interval_good(),
scheduled_days: ctx.with_review_fuzz(interval as f32, minimum, maximum),
ease_factor: ctx.initial_ease_factor,
fsrs_memory_state,
..Default::default()
}
.into()
@ -76,9 +82,17 @@ impl LearnState {
}
fn answer_easy(self, ctx: &StateContext) -> ReviewState {
let (mut minimum, maximum) = ctx.min_and_max_review_intervals(1);
let interval = if let Some(states) = &ctx.fsrs_next_states {
minimum = states.good.interval + 1;
states.easy.interval
} else {
ctx.graduating_interval_easy
};
ReviewState {
scheduled_days: ctx.fuzzed_graduating_interval_easy(),
scheduled_days: ctx.with_review_fuzz(interval as f32, minimum, maximum),
ease_factor: ctx.initial_ease_factor,
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()),
..Default::default()
}
}

View file

@ -14,6 +14,7 @@ pub(crate) mod review;
pub(crate) mod steps;
pub use filtered::FilteredState;
use fsrs::NextStates;
pub(crate) use interval_kind::IntervalKind;
pub use learning::LearnState;
pub use new::NewState;
@ -83,6 +84,7 @@ pub(crate) struct StateContext<'a> {
/// In range `0.0..1.0`. Used to pick the final interval from the fuzz
/// range.
pub fuzz_factor: Option<f32>,
pub fsrs_next_states: Option<NextStates>,
// learning
pub steps: LearningSteps<'a>,
@ -135,6 +137,7 @@ impl<'a> StateContext<'a> {
minimum_lapse_interval: 1,
in_filtered_deck: false,
preview_step: 10,
fsrs_next_states: None,
}
}
}

View file

@ -44,6 +44,7 @@ impl NormalState {
let next_states = LearnState {
remaining_steps: ctx.steps.remaining_for_failed(),
scheduled_secs: 0,
fsrs_memory_state: None,
}
.next_states(ctx);
// .. but with current as New, not Learning

View file

@ -30,20 +30,23 @@ impl RelearnState {
again: self.answer_again(ctx),
hard: self.answer_hard(ctx),
good: self.answer_good(ctx),
easy: self.answer_easy().into(),
easy: self.answer_easy(ctx).into(),
}
}
fn answer_again(self, ctx: &StateContext) -> CardState {
let (scheduled_days, fsrs_memory_state) = self.review.failing_review_interval(ctx);
if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() {
RelearnState {
learning: LearnState {
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
scheduled_secs: again_delay,
fsrs_memory_state,
},
review: ReviewState {
scheduled_days: self.review.failing_review_interval(ctx),
scheduled_days,
elapsed_days: 0,
fsrs_memory_state,
..self.review
},
}
@ -54,6 +57,7 @@ impl RelearnState {
}
fn answer_hard(self, ctx: &StateContext) -> CardState {
let fsrs_memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into());
if let Some(hard_delay) = ctx
.relearn_steps
.hard_delay_secs(self.learning.remaining_steps)
@ -61,10 +65,12 @@ impl RelearnState {
RelearnState {
learning: LearnState {
scheduled_secs: hard_delay,
fsrs_memory_state,
..self.learning
},
review: ReviewState {
elapsed_days: 0,
fsrs_memory_state,
..self.review
},
}
@ -75,6 +81,7 @@ impl RelearnState {
}
fn answer_good(self, ctx: &StateContext) -> CardState {
let fsrs_memory_state = ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into());
if let Some(good_delay) = ctx
.relearn_steps
.good_delay_secs(self.learning.remaining_steps)
@ -85,9 +92,11 @@ impl RelearnState {
remaining_steps: ctx
.relearn_steps
.remaining_for_good(self.learning.remaining_steps),
fsrs_memory_state,
},
review: ReviewState {
elapsed_days: 0,
fsrs_memory_state,
..self.review
},
}
@ -97,10 +106,11 @@ impl RelearnState {
}
}
fn answer_easy(self) -> ReviewState {
fn answer_easy(self, ctx: &StateContext) -> ReviewState {
ReviewState {
scheduled_days: self.review.scheduled_days + 1,
elapsed_days: 0,
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()),
..self.review
}
}

View file

@ -1,12 +1,15 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use fsrs::NextStates;
use super::interval_kind::IntervalKind;
use super::CardState;
use super::LearnState;
use super::RelearnState;
use super::SchedulingStates;
use super::StateContext;
use crate::card::FsrsMemoryState;
use crate::revlog::RevlogReviewKind;
pub const INITIAL_EASE_FACTOR: f32 = 2.5;
@ -22,6 +25,7 @@ pub struct ReviewState {
pub ease_factor: f32,
pub lapses: u32,
pub leeched: bool,
pub fsrs_memory_state: Option<FsrsMemoryState>,
}
impl Default for ReviewState {
@ -32,6 +36,7 @@ impl Default for ReviewState {
ease_factor: INITIAL_EASE_FACTOR,
lapses: 0,
leeched: false,
fsrs_memory_state: None,
}
}
}
@ -61,27 +66,37 @@ impl ReviewState {
SchedulingStates {
current: self.into(),
again: self.answer_again(ctx),
hard: self.answer_hard(hard_interval).into(),
good: self.answer_good(good_interval).into(),
easy: self.answer_easy(easy_interval).into(),
hard: self.answer_hard(hard_interval, ctx).into(),
good: self.answer_good(good_interval, ctx).into(),
easy: self.answer_easy(easy_interval, ctx).into(),
}
}
pub(crate) fn failing_review_interval(self, ctx: &StateContext) -> u32 {
(((self.scheduled_days as f32) * ctx.lapse_multiplier) as u32)
.max(ctx.minimum_lapse_interval)
.max(1)
pub(crate) fn failing_review_interval(
self,
ctx: &StateContext,
) -> (u32, Option<FsrsMemoryState>) {
if let Some(states) = &ctx.fsrs_next_states {
(states.again.interval, Some(states.again.memory.into()))
} else {
let interval = (((self.scheduled_days as f32) * ctx.lapse_multiplier) as u32)
.max(ctx.minimum_lapse_interval)
.max(1);
(interval, None)
}
}
fn answer_again(self, ctx: &StateContext) -> CardState {
let lapses = self.lapses + 1;
let leeched = leech_threshold_met(lapses, ctx.leech_threshold);
let (scheduled_days, fsrs_memory_state) = self.failing_review_interval(ctx);
let again_review = ReviewState {
scheduled_days: self.failing_review_interval(ctx),
scheduled_days,
elapsed_days: 0,
ease_factor: (self.ease_factor + EASE_FACTOR_AGAIN_DELTA).max(MINIMUM_EASE_FACTOR),
lapses,
leeched,
fsrs_memory_state,
};
if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() {
@ -89,6 +104,7 @@ impl ReviewState {
learning: LearnState {
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
scheduled_secs: again_delay,
fsrs_memory_state,
},
review: again_review,
}
@ -98,28 +114,31 @@ impl ReviewState {
}
}
fn answer_hard(self, scheduled_days: u32) -> ReviewState {
fn answer_hard(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState {
ReviewState {
scheduled_days,
elapsed_days: 0,
ease_factor: (self.ease_factor + EASE_FACTOR_HARD_DELTA).max(MINIMUM_EASE_FACTOR),
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()),
..self
}
}
fn answer_good(self, scheduled_days: u32) -> ReviewState {
fn answer_good(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState {
ReviewState {
scheduled_days,
elapsed_days: 0,
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.good.memory.into()),
..self
}
}
fn answer_easy(self, scheduled_days: u32) -> ReviewState {
fn answer_easy(self, scheduled_days: u32, ctx: &StateContext) -> ReviewState {
ReviewState {
scheduled_days,
elapsed_days: 0,
ease_factor: self.ease_factor + EASE_FACTOR_EASY_DELTA,
fsrs_memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.easy.memory.into()),
..self
}
}
@ -127,13 +146,26 @@ impl ReviewState {
/// Return the intervals for hard, good and easy, each of which depends on
/// the previous.
fn passing_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) {
if self.days_late() < 0 {
if let Some(states) = &ctx.fsrs_next_states {
self.passing_fsrs_review_intervals(ctx, states)
} else if self.days_late() < 0 {
self.passing_early_review_intervals(ctx)
} else {
self.passing_nonearly_review_intervals(ctx)
}
}
fn passing_fsrs_review_intervals(
self,
ctx: &StateContext,
states: &NextStates,
) -> (u32, u32, u32) {
let hard = constrain_passing_interval(ctx, states.hard.interval as f32, 1, true);
let good = constrain_passing_interval(ctx, states.good.interval as f32, hard + 1, true);
let easy = constrain_passing_interval(ctx, states.easy.interval as f32, good + 1, true);
(hard, good, easy)
}
fn passing_nonearly_review_intervals(self, ctx: &StateContext) -> (u32, u32, u32) {
let current_interval = self.scheduled_days as f32;
let days_late = self.days_late().max(0) as f32;
@ -219,12 +251,16 @@ fn leech_threshold_met(lapses: u32, threshold: u32) -> bool {
}
/// Transform the provided hard/good/easy interval.
/// - Apply configured interval multiplier.
/// - Apply configured interval multiplier if not FSRS.
/// - Apply fuzz.
/// - Ensure it is at least `minimum`, and at least 1.
/// - Ensure it is at or below the configured maximum interval.
fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32, fuzz: bool) -> u32 {
let interval = interval * ctx.interval_multiplier;
let interval = if ctx.fsrs_next_states.is_some() {
interval
} else {
interval * ctx.interval_multiplier
};
let (minimum, maximum) = ctx.min_and_max_review_intervals(minimum);
if fuzz {
ctx.with_review_fuzz(interval, minimum, maximum)
@ -277,6 +313,7 @@ mod test {
ease_factor: 1.3,
lapses: 0,
leeched: false,
fsrs_memory_state: None,
};
ctx.fuzz_factor = Some(0.0);
assert_eq!(state.passing_review_intervals(&ctx), (2, 3, 4));
@ -305,6 +342,7 @@ mod test {
ease_factor: 1.3,
lapses: 0,
leeched: false,
fsrs_memory_state: None,
};
ctx.fuzz_factor = Some(0.0);
assert_eq!(state.passing_review_intervals(&ctx), (1, 3, 4));

View file

@ -28,6 +28,7 @@ pub use writer::replace_search_node;
use crate::browser_table::Column;
use crate::card::CardType;
use crate::prelude::*;
use crate::scheduler::timing::SchedTimingToday;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ReturnItemType {
@ -207,7 +208,7 @@ impl Collection {
SortMode::Builtin { column, reverse } => {
prepare_sort(self, column, item_type)?;
sql.push_str(" order by ");
write_order(sql, item_type, column, reverse)?;
write_order(sql, item_type, column, reverse, self.timing_today()?)?;
}
SortMode::Custom(order_clause) => {
sql.push_str(" order by ");
@ -332,9 +333,10 @@ fn write_order(
item_type: ReturnItemType,
column: Column,
reverse: bool,
timing: SchedTimingToday,
) -> Result<()> {
let order = match item_type {
ReturnItemType::Cards => card_order_from_sort_column(column),
ReturnItemType::Cards => card_order_from_sort_column(column, timing),
ReturnItemType::Notes => note_order_from_sort_column(column),
};
require!(!order.is_empty(), "Can't sort {item_type:?} by {column:?}.");
@ -351,7 +353,7 @@ fn write_order(
Ok(())
}
fn card_order_from_sort_column(column: Column) -> Cow<'static, str> {
fn card_order_from_sort_column(column: Column, timing: SchedTimingToday) -> Cow<'static, str> {
match column {
Column::CardMod => "c.mod asc".into(),
Column::Cards => concat!(
@ -372,6 +374,13 @@ fn card_order_from_sort_column(column: Column) -> Cow<'static, str> {
Column::SortField => "n.sfld collate nocase asc, c.ord asc".into(),
Column::Tags => "n.tags asc".into(),
Column::Answer | Column::Custom | Column::Question => "".into(),
Column::Stability => "extract_fsrs_variable(c.data, 's') desc".into(),
Column::Difficulty => "extract_fsrs_variable(c.data, 'd') desc".into(),
Column::Retrievability => format!(
"extract_fsrs_retrievability(c.data, c.due, c.ivl, {})",
timing.days_elapsed
)
.into(),
}
}
@ -390,7 +399,12 @@ fn note_order_from_sort_column(column: Column) -> Cow<'static, str> {
Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(),
Column::SortField => "n.sfld collate nocase asc".into(),
Column::Tags => "n.tags asc".into(),
Column::Answer | Column::Custom | Column::Question => "".into(),
Column::Answer
| Column::Custom
| Column::Question
| Column::Stability
| Column::Difficulty
| Column::Retrievability => "".into(),
}
}

View file

@ -105,6 +105,9 @@ pub enum PropertyKind {
Ease(f32),
Position(u32),
Rated(i32, RatingKind),
Stability(f32),
Difficulty(f32),
Retrievability(f32),
CustomDataNumber { key: String, value: f32 },
CustomDataString { key: String, value: String },
}
@ -410,6 +413,9 @@ fn parse_prop(prop_clause: &str) -> ParseResult<SearchNode> {
tag("pos"),
tag("rated"),
tag("resched"),
tag("s"),
tag("d"),
tag("r"),
recognize(preceded(tag("cdn:"), alphanumeric1)),
recognize(preceded(tag("cds:"), alphanumeric1)),
))(prop_clause)
@ -451,6 +457,9 @@ fn parse_prop(prop_clause: &str) -> ParseResult<SearchNode> {
"reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?),
"lapses" => PropertyKind::Lapses(parse_u32(num, prop_clause)?),
"pos" => PropertyKind::Position(parse_u32(num, prop_clause)?),
"s" => PropertyKind::Stability(parse_f32(num, prop_clause)?),
"d" => PropertyKind::Difficulty(parse_f32(num, prop_clause)?),
"r" => PropertyKind::Retrievability(parse_f32(num, prop_clause)?),
prop if prop.starts_with("cdn:") => PropertyKind::CustomDataNumber {
key: prop.strip_prefix("cdn:").unwrap().into(),
value: parse_f32(num, prop_clause)?,

View file

@ -372,6 +372,21 @@ impl SqlWriter<'_> {
)
.unwrap();
}
PropertyKind::Stability(s) => {
write!(self.sql, "extract_fsrs_variable(c.data, 's') {op} {s}").unwrap()
}
PropertyKind::Difficulty(d) => {
let d = d * 9.0 + 1.0;
write!(self.sql, "extract_fsrs_variable(c.data, 'd') {op} {d}").unwrap()
}
PropertyKind::Retrievability(r) => {
let elap = self.col.timing_today()?.days_elapsed;
write!(
self.sql,
"extract_fsrs_retrievability(c.data, c.due, c.ivl, {elap}) {op} {r}"
)
.unwrap()
}
}
Ok(())

View file

@ -168,6 +168,9 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String {
Lapses(u) => format!("prop:lapses{}{}", operator, u),
Ease(f) => format!("prop:ease{}{}", operator, f),
Position(u) => format!("prop:pos{}{}", operator, u),
Stability(u) => format!("prop:s{}{}", operator, u),
Difficulty(u) => format!("prop:d{}{}", operator, u),
Retrievability(u) => format!("prop:r{}{}", operator, u),
Rated(u, ease) => match ease {
RatingKind::AnswerButton(val) => format!("prop:rated{}{}:{}", operator, u, val),
RatingKind::AnyAnswerButton => format!("prop:rated{}{}", operator, u),

View file

@ -1,6 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use fsrs::FSRS;
use crate::card::CardQueue;
use crate::card::CardType;
use crate::prelude::*;
@ -24,7 +26,15 @@ impl Collection {
let (average_secs, total_secs) = average_and_total_secs_strings(&revlog);
let (due_date, due_position) = self.due_date_and_position(&card)?;
let timing = self.timing_today()?;
let fsrs_retrievability = card
.fsrs_memory_state
.zip(card.days_since_last_review(&timing))
.map(|(state, days)| {
FSRS::new(None)
.unwrap()
.current_retrievability(state.into(), days)
});
Ok(anki_proto::stats::CardStatsResponse {
card_id: card.id.into(),
note_id: card.note_id.into(),
@ -43,6 +53,8 @@ impl Collection {
card_type: nt.get_template(card.template_idx)?.name.clone(),
notetype: nt.name.clone(),
revlog: revlog.iter().rev().map(stats_revlog_entry).collect(),
fsrs_memory_state: card.fsrs_memory_state.map(Into::into),
fsrs_retrievability,
custom_data: card.custom_data,
})
}

View file

@ -7,16 +7,31 @@ use crate::card::CardType;
use crate::stats::graphs::GraphsContext;
impl GraphsContext {
pub(super) fn eases(&self) -> Eases {
let mut data = Eases::default();
/// (SM-2, FSRS)
pub(super) fn eases(&self) -> (Eases, Eases) {
let mut eases = Eases::default();
let mut difficulty = Eases::default();
for card in &self.cards {
if matches!(card.ctype, CardType::Review | CardType::Relearn) {
*data
if let Some(state) = card.fsrs_memory_state {
*difficulty
.eases
.entry(round_to_nearest_five(
(state.difficulty - 1.0) / 9.0 * 100.0,
))
.or_insert_with(Default::default) += 1;
} else if matches!(card.ctype, CardType::Review | CardType::Relearn) {
*eases
.eases
.entry((card.ease_factor / 10) as u32)
.or_insert_with(Default::default) += 1;
}
}
data
(eases, difficulty)
}
}
pub(super) fn round_to_nearest_five(x: f32) -> u32 {
let scaled = x * 10.0;
let rounded = (scaled / 5.0).round() * 5.0;
(rounded / 10.0) as u32
}

View file

@ -8,6 +8,7 @@ mod eases;
mod future_due;
mod hours;
mod intervals;
mod retrievability;
mod reviews;
mod today;
@ -60,17 +61,20 @@ impl Collection {
next_day_start: timing.next_day_at,
local_offset_secs,
};
let (eases, difficulty) = ctx.eases();
let resp = anki_proto::stats::GraphsResponse {
added: Some(ctx.added_days()),
reviews: Some(ctx.review_counts_and_times()),
future_due: Some(ctx.future_due()),
intervals: Some(ctx.intervals()),
eases: Some(ctx.eases()),
eases: Some(eases),
difficulty: Some(difficulty),
today: Some(ctx.today()),
hours: Some(ctx.hours()),
buttons: Some(ctx.buttons()),
card_counts: Some(ctx.card_counts()),
rollover_hour: self.rollover_for_current_scheduler()? as u32,
retrievability: Some(ctx.retrievability()),
};
Ok(resp)
}

View file

@ -0,0 +1,35 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anki_proto::stats::graphs_response::Retrievability;
use fsrs::FSRS;
use crate::scheduler::timing::SchedTimingToday;
use crate::stats::graphs::eases::round_to_nearest_five;
use crate::stats::graphs::GraphsContext;
impl GraphsContext {
/// (SM-2, FSRS)
pub(super) fn retrievability(&self) -> Retrievability {
let mut retrievability = Retrievability::default();
let timing = SchedTimingToday {
days_elapsed: self.days_elapsed,
now: Default::default(),
next_day_at: Default::default(),
};
let fsrs = FSRS::new(None).unwrap();
for card in &self.cards {
if let Some(state) = card.fsrs_memory_state {
let r = fsrs.current_retrievability(
state.into(),
card.days_since_last_review(&timing).unwrap_or_default(),
);
*retrievability
.retrievability
.entry(round_to_nearest_five(r * 100.0))
.or_insert_with(Default::default) += 1;
}
}
retrievability
}
}

View file

@ -12,19 +12,33 @@ use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use crate::card::FsrsMemoryState;
use crate::prelude::*;
use crate::serde::default_on_invalid;
/// Helper for serdeing the card data column.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct CardData {
#[serde(
skip_serializing_if = "Option::is_none",
rename = "pos",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) original_position: Option<u32>,
#[serde(
rename = "s",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) fsrs_stability: Option<f32>,
#[serde(
rename = "d",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) fsrs_difficulty: Option<f32>,
/// A string representation of a JSON object storing optional data
/// associated with the card, so v3 custom scheduling code can persist
/// state.
@ -36,6 +50,8 @@ impl CardData {
pub(crate) fn from_card(card: &Card) -> Self {
Self {
original_position: card.original_position,
fsrs_stability: card.fsrs_memory_state.as_ref().map(|m| m.stability),
fsrs_difficulty: card.fsrs_memory_state.as_ref().map(|m| m.difficulty),
custom_data: card.custom_data.clone(),
}
}
@ -43,6 +59,18 @@ impl CardData {
pub(crate) fn from_str(s: &str) -> Self {
serde_json::from_str(s).unwrap_or_default()
}
pub(crate) fn fsrs_memory_state(&self) -> Option<FsrsMemoryState> {
if let Some(stability) = self.fsrs_stability {
if let Some(difficulty) = self.fsrs_difficulty {
return Some(FsrsMemoryState {
stability,
difficulty,
});
}
}
None
}
}
impl FromSql for CardData {

View file

@ -80,6 +80,7 @@ fn row_to_card(row: &Row) -> result::Result<Card, rusqlite::Error> {
original_deck_id: row.get(15)?,
flags: row.get(16)?,
original_position: data.original_position,
fsrs_memory_state: data.fsrs_memory_state(),
custom_data: data.custom_data,
})
}

View file

@ -138,7 +138,7 @@ impl SqliteStorage {
self.db
.prepare_cached(concat!(
include_str!("get.sql"),
" where cid in (select cid from search_cids) order by cid"
" where cid in (select cid from search_cids) order by cid, id"
))?
.query_and_then([], row_to_revlog_entry)?
.collect()

View file

@ -9,6 +9,7 @@ use std::path::Path;
use std::sync::Arc;
use fnv::FnvHasher;
use fsrs::FSRS;
use regex::Regex;
use rusqlite::functions::FunctionFlags;
use rusqlite::params;
@ -70,6 +71,8 @@ fn open_or_create_collection_db(path: &Path) -> Result<Connection> {
add_without_combining_function(&db)?;
add_fnvhash_function(&db)?;
add_extract_custom_data_function(&db)?;
add_extract_fsrs_variable(&db)?;
add_extract_fsrs_retrievability(&db)?;
db.create_collation("unicase", unicase_compare)?;
@ -232,6 +235,76 @@ fn add_extract_custom_data_function(db: &Connection) -> rusqlite::Result<()> {
)
}
/// eg. extract_fsrs_variable(card.data, 's' | 'd') -> float | null
fn add_extract_fsrs_variable(db: &Connection) -> rusqlite::Result<()> {
db.create_scalar_function(
"extract_fsrs_variable",
2,
FunctionFlags::SQLITE_DETERMINISTIC,
move |ctx| {
assert_eq!(ctx.len(), 2, "called with unexpected number of arguments");
let Ok(card_data) = ctx.get_raw(0).as_str() else {
return Ok(None);
};
if card_data.is_empty() {
return Ok(None);
}
let Ok(key) = ctx.get_raw(1).as_str() else {
return Ok(None);
};
let card_data = &CardData::from_str(card_data);
Ok(match key {
"s" => card_data.fsrs_stability,
"d" => card_data.fsrs_difficulty,
_ => panic!("invalid key: {key}"),
})
},
)
}
/// eg. extract_fsrs_retrievability(card.data, card.due, card.ivl,
/// timing.days_elapsed) -> float | null
fn add_extract_fsrs_retrievability(db: &Connection) -> rusqlite::Result<()> {
db.create_scalar_function(
"extract_fsrs_retrievability",
4,
FunctionFlags::SQLITE_DETERMINISTIC,
move |ctx| {
assert_eq!(ctx.len(), 4, "called with unexpected number of arguments");
let Ok(card_data) = ctx.get_raw(0).as_str() else {
return Ok(None);
};
if card_data.is_empty() {
return Ok(None);
}
let Ok(due) = ctx.get_raw(1).as_i64() else {
return Ok(None);
};
if due > 365_000 {
// learning card
return Ok(None);
}
let Ok(ivl) = ctx.get_raw(2).as_i64() else {
return Ok(None);
};
let Ok(days_elapsed) = ctx.get_raw(3).as_i64() else {
return Ok(None);
};
let review_day = due - ivl;
let days_elapsed = days_elapsed.saturating_sub(review_day) as u32;
let card_data = &CardData::from_str(card_data);
Ok(card_data.fsrs_memory_state().map(|state| {
FSRS::new(None)
.unwrap()
.current_retrievability(state.into(), days_elapsed)
}))
},
)
}
/// Fetch schema version from database.
/// Return (must_create, version)
fn schema_version(db: &Connection) -> Result<(bool, u8)> {

View file

@ -310,10 +310,7 @@ impl Collection {
impl From<CardEntry> for Card {
fn from(e: CardEntry) -> Self {
let CardData {
original_position,
custom_data,
} = CardData::from_str(&e.data);
let data = CardData::from_str(&e.data);
Card {
id: e.id,
note_id: e.nid,
@ -332,8 +329,9 @@ impl From<CardEntry> for Card {
original_due: e.odue,
original_deck_id: e.odid,
flags: e.flags,
original_position,
custom_data,
original_position: data.original_position,
fsrs_memory_state: data.fsrs_memory_state(),
custom_data: data.custom_data,
}
}
}

View file

@ -55,11 +55,42 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
value: timeSpan(stats.interval * DAY),
});
}
if (stats.ease) {
if (stats.fsrsMemoryState) {
let stability = timeSpan(
stats.fsrsMemoryState.stability * 86400,
false,
false,
);
if (stats.fsrsMemoryState.stability > 31) {
const nativeStability = stats.fsrsMemoryState.stability.toFixed(0);
stability += ` (${nativeStability})`;
}
statsRows.push({
label: tr2.cardStatsEase(),
value: `${stats.ease / 10}%`,
label: tr2.cardStatsFsrsStability(),
value: stability,
});
const difficulty = (
((stats.fsrsMemoryState.difficulty - 1.0) / 9.0) *
100.0
).toFixed(0);
statsRows.push({
label: tr2.cardStatsFsrsDifficulty(),
value: `${difficulty}%`,
});
if (stats.fsrsRetrievability) {
const retrievability = (stats.fsrsRetrievability * 100).toFixed(0);
statsRows.push({
label: tr2.cardStatsFsrsRetrievability(),
value: `${retrievability}%`,
});
}
} else {
if (stats.ease) {
statsRows.push({
label: tr2.cardStatsEase(),
value: `${stats.ease / 10}%`,
});
}
}
statsRows.push({ label: tr2.cardStatsReviewCount(), value: stats.reviews });

View file

@ -16,6 +16,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TitledContainer from "../components/TitledContainer.svelte";
import type { HelpItem } from "../components/types";
import CardStateCustomizer from "./CardStateCustomizer.svelte";
import FsrsOptions from "./FsrsOptions.svelte";
import type { DeckOptionsState } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte";
@ -87,6 +88,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}}
/>
<DynamicallySlottable slotHost={Item} {api}>
{#if state.v3Scheduler}
<Item>
<SwitchRow bind:value={$config.fsrsEnabled} defaultValue={false}>
<SettingTitle>FSRS</SettingTitle>
</SwitchRow>
</Item>
{/if}
<Item>
<SpinBoxRow
bind:value={$config.maximumReviewInterval}
@ -103,93 +112,93 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</SpinBoxRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.initialEase}
defaultValue={defaults.initialEase}
min={1.31}
max={5}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("startingEase"))}
>
{settings.startingEase.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.easyMultiplier}
defaultValue={defaults.easyMultiplier}
min={1}
max={5}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("easyBonus"))}
>
{settings.easyBonus.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.intervalMultiplier}
defaultValue={defaults.intervalMultiplier}
min={0.5}
max={2}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("intervalModifier"),
)}
>
{settings.intervalModifier.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.hardMultiplier}
defaultValue={defaults.hardMultiplier}
min={0.5}
max={1.3}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("hardInterval"))}
>
{settings.hardInterval.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.lapseMultiplier}
defaultValue={defaults.lapseMultiplier}
max={1}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("newInterval"))}
>
{settings.newInterval.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
{#if state.v3Scheduler}
{#if !$config.fsrsEnabled || !state.v3Scheduler}
<Item>
<SwitchRow bind:value={$config.fsrsEnabled} defaultValue={false}>
<SettingTitle>FSRS optimizer</SettingTitle>
</SwitchRow>
<SpinBoxFloatRow
bind:value={$config.initialEase}
defaultValue={defaults.initialEase}
min={1.31}
max={5}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("startingEase"),
)}
>
{settings.startingEase.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.easyMultiplier}
defaultValue={defaults.easyMultiplier}
min={1}
max={5}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("easyBonus"))}
>
{settings.easyBonus.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.intervalMultiplier}
defaultValue={defaults.intervalMultiplier}
min={0.5}
max={2}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("intervalModifier"),
)}
>
{settings.intervalModifier.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.hardMultiplier}
defaultValue={defaults.hardMultiplier}
min={0.5}
max={1.3}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("hardInterval"),
)}
>
{settings.hardInterval.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
<Item>
<SpinBoxFloatRow
bind:value={$config.lapseMultiplier}
defaultValue={defaults.lapseMultiplier}
max={1}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("newInterval"))}
>
{settings.newInterval.title}
</SettingTitle>
</SpinBoxFloatRow>
</Item>
{:else}
<FsrsOptions {state} />
{/if}
{#if state.v3Scheduler}

View file

@ -3,33 +3,29 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Col from "../components/Col.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import Row from "../components/Row.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
export let value: string;
export let title: string;
</script>
<Row>
<Col>
<div class="text">
<ConfigInput>
<SettingTitle on:click>{title}</SettingTitle>
<RevertButton slot="revert" bind:value defaultValue="" />
</ConfigInput>
</div>
</Col>
</Row>
<textarea
class="card-state-customizer form-control"
bind:value
spellcheck="false"
autocapitalize="none"
/>
<div class="m-2">
<ConfigInput>
<RevertButton slot="revert" bind:value defaultValue="" />
<details>
<summary>{title}</summary>
<div class="text">
<textarea
class="card-state-customizer form-control"
bind:value
spellcheck="false"
autocapitalize="none"
/>
</div>
</details>
</ConfigInput>
</div>
<style lang="scss">
.text {

View file

@ -17,7 +17,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ConfigSelector from "./ConfigSelector.svelte";
import DailyLimits from "./DailyLimits.svelte";
import DisplayOrder from "./DisplayOrder.svelte";
import FsrsOptions from "./FsrsOptions.svelte";
import HtmlAddon from "./HtmlAddon.svelte";
import LapseOptions from "./LapseOptions.svelte";
import type { DeckOptionsState } from "./lib";
@ -26,7 +25,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let state: DeckOptionsState;
const addons = state.addonComponents;
const config = state.currentConfig;
export function auxData(): Writable<Record<string, unknown>> {
return state.currentAuxData;
@ -122,14 +120,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item>
{/if}
{#if state.v3Scheduler && $config.fsrsEnabled}
<Item>
<Row class="row-columns">
<FsrsOptions {state} />
</Row>
</Item>
{/if}
<Item>
<Row class="row-columns">
<AdvancedOptions {state} api={advancedOptions} />

View file

@ -4,8 +4,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import {
Progress_ComputeRetention,
type Progress_ComputeWeights,
ComputeRetentionProgress,
type ComputeWeightsProgress,
} from "@tslib/anki/collection_pb";
import { ComputeOptimalRetentionRequest } from "@tslib/anki/scheduler_pb";
import {
@ -14,26 +14,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
evaluateWeights,
setWantsAbort,
} from "@tslib/backend";
import * as tr from "@tslib/ftl";
import { runWithBackendProgress } from "@tslib/progress";
import TitledContainer from "components/TitledContainer.svelte";
import ConfigInput from "../components/ConfigInput.svelte";
import RevertButton from "../components/RevertButton.svelte";
import SettingTitle from "../components/SettingTitle.svelte";
import type { DeckOptionsState } from "./lib";
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import Warning from "./Warning.svelte";
import WeightsInputRow from "./WeightsInputRow.svelte";
export let state: DeckOptionsState;
const config = state.currentConfig;
const defaults = state.defaults;
let computeWeightsProgress: Progress_ComputeWeights | undefined;
let computeWeightsProgress: ComputeWeightsProgress | undefined;
let computeWeightsWarning = "";
let customSearch = "";
let computing = false;
let computeRetentionProgress:
| Progress_ComputeWeights
| Progress_ComputeRetention
| ComputeWeightsProgress
| ComputeRetentionProgress
| undefined;
const computeOptimalRequest = new ComputeOptimalRetentionRequest({
@ -41,9 +43,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
daysToSimulate: 365,
maxSecondsOfStudyPerDay: 1800,
maxInterval: 36500,
recallSecs: 10,
recallSecsHard: 14.0,
recallSecsGood: 10.0,
recallSecsEasy: 6.0,
forgetSecs: 50,
learnSecs: 20,
firstRatingProbabilityAgain: 0.15,
firstRatingProbabilityHard: 0.2,
firstRatingProbabilityGood: 0.6,
firstRatingProbabilityEasy: 0.05,
reviewRatingProbabilityHard: 0.3,
reviewRatingProbabilityGood: 0.6,
reviewRatingProbabilityEasy: 0.1,
});
async function computeWeights(): Promise<void> {
@ -62,6 +73,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
if (computeWeightsProgress) {
computeWeightsProgress.current = computeWeightsProgress.total;
}
if (resp.fsrsItems < 1000) {
computeWeightsWarning = tr.deckConfigLimitedHistory({
count: resp.fsrsItems,
});
} else {
computeWeightsWarning = "";
}
$config.fsrsWeights = resp.weights;
},
(progress) => {
@ -97,7 +115,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
alert(
`Log loss: ${resp.logLoss.toFixed(
3,
)}, RMSE: ${resp.rmse.toFixed(3)}`,
)}, RMSE(bins): ${resp.rmseBins.toFixed(
3,
)}. ${tr.deckConfigSmallerIsBetter()}`,
),
200,
);
@ -146,21 +166,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
computeRetentionProgress,
);
function renderWeightProgress(val: Progress_ComputeWeights | undefined): String {
function renderWeightProgress(val: ComputeWeightsProgress | undefined): String {
if (!val || !val.total) {
return "";
}
let pct = ((val.current / val.total) * 100).toFixed(2);
pct = `${pct}%`;
if (val instanceof Progress_ComputeRetention) {
if (val instanceof ComputeRetentionProgress) {
return pct;
} else {
return `${pct} of ${val.revlogEntries} reviews`;
return `${pct} of ${val.fsrsItems} reviews`;
}
}
function renderRetentionProgress(
val: Progress_ComputeRetention | undefined,
val: ComputeRetentionProgress | undefined,
): String {
if (!val || !val.total) {
return "";
@ -170,35 +190,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
</script>
<TitledContainer title={"FSRS"}>
<WeightsInputRow
bind:value={$config.fsrsWeights}
defaultValue={[
0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05,
0.34, 1.26, 0.29, 2.61,
]}
>
<SettingTitle>Weights</SettingTitle>
<SpinBoxFloatRow
bind:value={$config.desiredRetention}
defaultValue={defaults.desiredRetention}
min={0.8}
max={0.97}
>
<SettingTitle>
{tr.deckConfigDesiredRetention()}
</SettingTitle>
</SpinBoxFloatRow>
<div class="ms-1 me-1">
<WeightsInputRow bind:value={$config.fsrsWeights} defaultValue={[]}>
<SettingTitle>{tr.deckConfigWeights()}</SettingTitle>
</WeightsInputRow>
<div>Optimal retention</div>
</div>
<ConfigInput>
<input type="number" bind:value={$config.desiredRetention} />
<RevertButton
slot="revert"
bind:value={$config.desiredRetention}
defaultValue={0.9}
/>
</ConfigInput>
<div class="mb-3" />
<div class="bordered">
<b>Optimize weights</b>
<br />
<div class="m-2">
<details>
<summary>{tr.deckConfigComputeOptimalWeights()}</summary>
<input
bind:value={customSearch}
placeholder="Search; leave blank for all cards using this preset"
placeholder={tr.deckConfigComputeWeightsSearch()}
class="w-100 mb-1"
/>
<button
@ -206,9 +220,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click={() => computeWeights()}
>
{#if computing}
Cancel
{tr.actionsCancel()}
{:else}
Compute
{tr.deckConfigComputeButton()}
{/if}
</button>
<button
@ -216,17 +230,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:click={() => checkWeights()}
>
{#if computing}
Cancel
{tr.actionsCancel()}
{:else}
Check
{tr.deckConfigAnalyzeButton()}
{/if}
</button>
<div>{computeWeightsProgressString}</div>
</div>
{#if computing}<div>{computeWeightsProgressString}</div>{/if}
<Warning warning={computeWeightsWarning} />
</details>
</div>
<div class="bordered">
<b>Calculate optimal retention</b>
<br />
<div class="m-2">
<details>
<summary>{tr.deckConfigComputeOptimalRetention()}</summary>
Deck size:
<br />
@ -251,39 +267,100 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<input type="number" bind:value={computeOptimalRequest.maxInterval} />
<br />
Seconds to recall a card:
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecs} />
<br />
Seconds to forget a card:
Seconds to forget a card (again):
<br />
<input type="number" bind:value={computeOptimalRequest.forgetSecs} />
<br />
Seconds to recall a card (hard):
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecsHard} />
<br />
Seconds to recall a card (good):
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecsGood} />
<br />
Seconds to recall a card (easy):
<br />
<input type="number" bind:value={computeOptimalRequest.recallSecsEasy} />
<br />
Seconds to learn a card:
<br />
<input type="number" bind:value={computeOptimalRequest.learnSecs} />
<br />
First rating probability (again):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityAgain}
/>
<br />
First rating probability (hard):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityHard}
/>
<br />
First rating probability (good):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityGood}
/>
<br />
First rating probability (easy):
<br />
<input
type="number"
bind:value={computeOptimalRequest.firstRatingProbabilityEasy}
/>
<br />
Review rating probability (hard):
<br />
<input
type="number"
bind:value={computeOptimalRequest.reviewRatingProbabilityHard}
/>
<br />
Review rating probability (good):
<br />
<input
type="number"
bind:value={computeOptimalRequest.reviewRatingProbabilityGood}
/>
<br />
Review rating probability (easy):
<br />
<input
type="number"
bind:value={computeOptimalRequest.reviewRatingProbabilityEasy}
/>
<br />
<button
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
on:click={() => computeRetention()}
>
{#if computing}
Cancel
{tr.actionsCancel()}
{:else}
Compute
{tr.deckConfigComputeButton()}
{/if}
</button>
<div>{computeRetentionProgressString}</div>
</div>
</TitledContainer>
</details>
</div>
<style>
.bordered {
border: 1px solid #777;
padding: 1em;
margin-bottom: 2px;
}
</style>

View file

@ -28,6 +28,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const defaults = state.defaults;
let stepsExceedMinimumInterval: string;
let stepsTooLargeForFsrs: string;
$: {
const lastRelearnStepInDays = $config.relearnSteps.length
? $config.relearnSteps[$config.relearnSteps.length - 1] / 60 / 24
@ -36,6 +37,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
lastRelearnStepInDays > $config.minimumLapseInterval
? tr.deckConfigRelearningStepsAboveMinimumInterval()
: "";
stepsTooLargeForFsrs =
$config.fsrsEnabled && lastRelearnStepInDays >= 1
? tr.deckConfigStepsTooLargeForFsrs()
: "";
}
const settings = {
@ -98,20 +103,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item>
<Item>
<SpinBoxRow
bind:value={$config.minimumLapseInterval}
defaultValue={defaults.minimumLapseInterval}
min={1}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("minimumInterval"))}
>
{settings.minimumInterval.title}
</SettingTitle>
</SpinBoxRow>
<Warning warning={stepsTooLargeForFsrs} />
</Item>
{#if !$config.fsrsEnabled}
<Item>
<SpinBoxRow
bind:value={$config.minimumLapseInterval}
defaultValue={defaults.minimumLapseInterval}
min={1}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("minimumInterval"),
)}
>
{settings.minimumInterval.title}
</SettingTitle>
</SpinBoxRow>
</Item>
{/if}
<Item>
<Warning warning={stepsExceedMinimumInterval} />
</Item>

View file

@ -29,6 +29,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const defaults = state.defaults;
let stepsExceedGraduatingInterval: string;
let stepsTooLargeForFsrs: string;
$: {
const lastLearnStepInDays = $config.learnSteps.length
? $config.learnSteps[$config.learnSteps.length - 1] / 60 / 24
@ -37,6 +38,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
lastLearnStepInDays > $config.graduatingIntervalGood
? tr.deckConfigLearningStepAboveGraduatingInterval()
: "";
stepsTooLargeForFsrs =
$config.fsrsEnabled && lastLearnStepInDays >= 1
? tr.deckConfigStepsTooLargeForFsrs()
: "";
}
$: goodExceedsEasy =
@ -110,42 +115,50 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item>
<Item>
<SpinBoxRow
bind:value={$config.graduatingIntervalGood}
defaultValue={defaults.graduatingIntervalGood}
>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("graduatingInterval"),
)}
<Warning warning={stepsTooLargeForFsrs} />
</Item>
{#if !$config.fsrsEnabled}
<Item>
<SpinBoxRow
bind:value={$config.graduatingIntervalGood}
defaultValue={defaults.graduatingIntervalGood}
>
{settings.graduatingInterval.title}
</SettingTitle>
</SpinBoxRow>
</Item>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("graduatingInterval"),
)}
>
{settings.graduatingInterval.title}
</SettingTitle>
</SpinBoxRow>
</Item>
<Item>
<Warning warning={stepsExceedGraduatingInterval} />
</Item>
<Item>
<Warning warning={stepsExceedGraduatingInterval} />
</Item>
<Item>
<SpinBoxRow
bind:value={$config.graduatingIntervalEasy}
defaultValue={defaults.graduatingIntervalEasy}
>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("easyInterval"))}
<Item>
<SpinBoxRow
bind:value={$config.graduatingIntervalEasy}
defaultValue={defaults.graduatingIntervalEasy}
>
{settings.easyInterval.title}
</SettingTitle>
</SpinBoxRow>
</Item>
<SettingTitle
on:click={() =>
openHelpModal(
Object.keys(settings).indexOf("easyInterval"),
)}
>
{settings.easyInterval.title}
</SettingTitle>
</SpinBoxRow>
</Item>
<Item>
<Warning warning={goodExceedsEasy} />
</Item>
<Item>
<Warning warning={goodExceedsEasy} />
</Item>
{/if}
<Item>
<EnumSelectorRow

View file

@ -9,7 +9,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: stringValue = value.map((v) => v.toFixed(4)).join(", ");
function update(this: HTMLInputElement): void {
value = this.value.split(", ").map((v) => Number(v));
value = this.value
.replace(/ /g, "")
.split(",")
.filter((e) => e)
.map((v) => Number(v));
}
</script>

View file

@ -0,0 +1,44 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { GraphsResponse } from "@tslib/anki/stats_pb";
import * as tr from "@tslib/ftl";
import { createEventDispatcher } from "svelte";
import { gatherData, prepareData } from "./difficulty";
import Graph from "./Graph.svelte";
import type { GraphPrefs } from "./graph-helpers";
import type { SearchEventMap, TableDatum } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph";
import HistogramGraph from "./HistogramGraph.svelte";
import TableData from "./TableData.svelte";
export let sourceData: GraphsResponse | null = null;
export let prefs: GraphPrefs;
const dispatch = createEventDispatcher<SearchEventMap>();
let histogramData = null as HistogramData | null;
let tableData: TableDatum[] = [];
$: if (sourceData) {
[histogramData, tableData] = prepareData(
gatherData(sourceData),
dispatch,
$prefs.browserLinksSupported,
);
}
const title = tr.statisticsCardDifficultyTitle();
const subtitle = tr.statisticsCardDifficultySubtitle();
</script>
{#if histogramData}
<Graph {title} {subtitle}>
<HistogramGraph data={histogramData} />
<TableData {tableData} />
</Graph>
{/if}

View file

@ -35,8 +35,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const subtitle = tr.statisticsCardEaseSubtitle();
</script>
<Graph {title} {subtitle}>
<HistogramGraph data={histogramData} />
{#if histogramData}
<Graph {title} {subtitle}>
<HistogramGraph data={histogramData} />
<TableData {tableData} />
</Graph>
<TableData {tableData} />
</Graph>
{/if}

View file

@ -0,0 +1,44 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type { GraphsResponse } from "@tslib/anki/stats_pb";
import * as tr from "@tslib/ftl";
import { createEventDispatcher } from "svelte";
import Graph from "./Graph.svelte";
import type { GraphPrefs } from "./graph-helpers";
import type { SearchEventMap, TableDatum } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph";
import HistogramGraph from "./HistogramGraph.svelte";
import { gatherData, prepareData } from "./retrievability";
import TableData from "./TableData.svelte";
export let sourceData: GraphsResponse | null = null;
export let prefs: GraphPrefs;
const dispatch = createEventDispatcher<SearchEventMap>();
let histogramData = null as HistogramData | null;
let tableData: TableDatum[] = [];
$: if (sourceData) {
[histogramData, tableData] = prepareData(
gatherData(sourceData),
dispatch,
$prefs.browserLinksSupported,
);
}
const title = tr.statisticsCardRetrievabilityTitle();
const subtitle = tr.statisticsRetrievabilitySubtitle();
</script>
{#if histogramData}
<Graph {title} {subtitle}>
<HistogramGraph data={histogramData} />
<TableData {tableData} />
</Graph>
{/if}

122
ts/graphs/difficulty.ts Normal file
View file

@ -0,0 +1,122 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import type { GraphsResponse } from "@tslib/anki/stats_pb";
import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n";
import type { Bin, ScaleLinear } from "d3";
import { bin, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3";
import type { SearchDispatch, TableDatum } from "./graph-helpers";
import { getNumericMapBinValue, numericMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph";
export interface GraphData {
eases: Map<number, number>;
}
export function gatherData(data: GraphsResponse): GraphData {
return { eases: numericMap(data.difficulty!.eases) };
}
function makeQuery(start: number, end: number): string {
const fromQuery = `"prop:d>=${start / 100}"`;
let tillQuery = `"prop:d<${(end + 1) / 100}"`;
if (end === 99) {
tillQuery = tillQuery.replace("<", "<=");
}
return `${fromQuery} AND ${tillQuery}`;
}
function getAdjustedScaleAndTicks(
min: number,
max: number,
desiredBars: number,
): [ScaleLinear<number, number, never>, number[]] {
const prescale = scaleLinear().domain([min, max]).nice();
const ticks = prescale.ticks(desiredBars);
const predomain = prescale.domain() as [number, number];
const minOffset = min - predomain[0];
const tickSize = ticks[1] - ticks[0];
if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) {
return [prescale, ticks];
}
const add = (n: number): number => n + minOffset;
return [
scaleLinear().domain(predomain.map(add) as [number, number]),
ticks.map(add),
];
}
export function prepareData(
data: GraphData,
dispatch: SearchDispatch,
browserLinksSupported: boolean,
): [HistogramData | null, TableDatum[]] {
// get min/max
const allEases = data.eases;
if (!allEases.size) {
return [null, []];
}
const xMin = 0;
const xMax = 100;
const desiredBars = 20;
const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);
const bins = bin()
.value((m) => {
return m[0];
})
.domain(scale.domain() as [number, number])
.thresholds(ticks)(allEases.entries() as any);
const total = sum(bins as any, getNumericMapBinValue);
const colourScale = scaleSequential(interpolateRdYlGn).domain([100, 0]);
function hoverText(bin: Bin<number, number>, _percent: number): string {
const percent = `${bin.x0}%-${bin.x1}%`;
return tr.statisticsCardDifficultyTooltip({
cards: getNumericMapBinValue(bin as any),
percent,
});
}
function onClick(bin: Bin<number, number>): void {
const start = bin.x0!;
const end = bin.x1! - 1;
const query = makeQuery(start, end);
dispatch("search", { query });
}
const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%";
const tableData = [
{
label: tr.statisticsAverageDifficulty(),
value: xTickFormat(sum(Array.from(allEases.entries()).map(([k, v]) => k * v)) / total),
},
];
return [
{
scale,
bins,
total,
hoverText,
onClick: browserLinksSupported ? onClick : null,
colourScale,
showArea: false,
binValue: getNumericMapBinValue,
xTickFormat,
},
tableData,
];
}

View file

@ -41,12 +41,14 @@ import AddedGraph from "./AddedGraph.svelte";
import ButtonsGraph from "./ButtonsGraph.svelte";
import CalendarGraph from "./CalendarGraph.svelte";
import CardCounts from "./CardCounts.svelte";
import DifficultyGraph from "./DifficultyGraph.svelte";
import EaseGraph from "./EaseGraph.svelte";
import FutureDue from "./FutureDue.svelte";
import { RevlogRange } from "./graph-helpers";
import HourGraph from "./HourGraph.svelte";
import IntervalsGraph from "./IntervalsGraph.svelte";
import RangeBox from "./RangeBox.svelte";
import RetrievabilityGraph from "./RetrievabilityGraph.svelte";
import ReviewsGraph from "./ReviewsGraph.svelte";
import TodayStats from "./TodayStats.svelte";
@ -59,6 +61,8 @@ setupGraphs(
CardCounts,
IntervalsGraph,
EaseGraph,
DifficultyGraph,
RetrievabilityGraph,
HourGraph,
ButtonsGraph,
AddedGraph,
@ -76,6 +80,8 @@ export const graphComponents = {
CardCounts,
IntervalsGraph,
EaseGraph,
DifficultyGraph,
RetrievabilityGraph,
HourGraph,
ButtonsGraph,
AddedGraph,

122
ts/graphs/retrievability.ts Normal file
View file

@ -0,0 +1,122 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-explicit-any: "off",
*/
import type { GraphsResponse } from "@tslib/anki/stats_pb";
import * as tr from "@tslib/ftl";
import { localizedNumber } from "@tslib/i18n";
import type { Bin, ScaleLinear } from "d3";
import { bin, interpolateRdYlGn, scaleLinear, scaleSequential, sum } from "d3";
import type { SearchDispatch, TableDatum } from "./graph-helpers";
import { getNumericMapBinValue, numericMap } from "./graph-helpers";
import type { HistogramData } from "./histogram-graph";
export interface GraphData {
retrievability: Map<number, number>;
}
export function gatherData(data: GraphsResponse): GraphData {
return { retrievability: numericMap(data.retrievability!.retrievability) };
}
function makeQuery(start: number, end: number): string {
const fromQuery = `"prop:r>=${start / 100}"`;
let tillQuery = `"prop:r<${(end + 1) / 100}"`;
if (end === 99) {
tillQuery = tillQuery.replace("<", "<=");
}
return `${fromQuery} AND ${tillQuery}`;
}
function getAdjustedScaleAndTicks(
min: number,
max: number,
desiredBars: number,
): [ScaleLinear<number, number, never>, number[]] {
const prescale = scaleLinear().domain([min, max]).nice();
const ticks = prescale.ticks(desiredBars);
const predomain = prescale.domain() as [number, number];
const minOffset = min - predomain[0];
const tickSize = ticks[1] - ticks[0];
if (minOffset === 0 || (minOffset % tickSize !== 0 && tickSize % minOffset !== 0)) {
return [prescale, ticks];
}
const add = (n: number): number => n + minOffset;
return [
scaleLinear().domain(predomain.map(add) as [number, number]),
ticks.map(add),
];
}
export function prepareData(
data: GraphData,
dispatch: SearchDispatch,
browserLinksSupported: boolean,
): [HistogramData | null, TableDatum[]] {
// get min/max
const allEases = data.retrievability;
if (!allEases.size) {
return [null, []];
}
const xMin = 0;
const xMax = 100;
const desiredBars = 20;
const [scale, ticks] = getAdjustedScaleAndTicks(xMin, xMax, desiredBars);
const bins = bin()
.value((m) => {
return m[0];
})
.domain(scale.domain() as [number, number])
.thresholds(ticks)(allEases.entries() as any);
const total = sum(bins as any, getNumericMapBinValue);
const colourScale = scaleSequential(interpolateRdYlGn).domain([0, 100]);
function hoverText(bin: Bin<number, number>, _percent: number): string {
const percent = `${bin.x0}%-${bin.x1}%`;
return tr.statisticsRetrievabilityTooltip({
cards: getNumericMapBinValue(bin as any),
percent,
});
}
function onClick(bin: Bin<number, number>): void {
const start = bin.x0!;
const end = bin.x1! - 1;
const query = makeQuery(start, end);
dispatch("search", { query });
}
const xTickFormat = (num: number): string => localizedNumber(num, 0) + "%";
const tableData = [
{
label: tr.statisticsAverageRetrievability(),
value: xTickFormat(sum(Array.from(allEases.entries()).map(([k, v]) => k * v)) / total),
},
];
return [
{
scale,
bins,
total,
hoverText,
onClick: browserLinksSupported ? onClick : null,
colourScale,
showArea: false,
binValue: getNumericMapBinValue,
xTickFormat,
},
tableData,
];
}

View file

@ -146,9 +146,12 @@ function i18nFuncForUnit(
If precise is true, show to two decimal places, eg
eg 70 seconds -> "1.17 minutes"
If false, seconds and days are shown without decimals. */
export function timeSpan(seconds: number, short = false): string {
export function timeSpan(seconds: number, short = false, precise = true): string {
const unit = naturalUnit(seconds);
const amount = unitAmount(unit, seconds);
let amount = unitAmount(unit, seconds);
if (!precise && unit < TimespanUnit.Months) {
amount = Math.round(amount);
}
return i18nFuncForUnit(unit, short)({ amount });
}