Merge remote-tracking branch 'upstream/main' into x-axis

This commit is contained in:
Luc Mcgrady 2025-08-06 20:38:13 +01:00
commit 908778dd17
No known key found for this signature in database
GPG key ID: 4F3D7A0B17CC3D9C
24 changed files with 240 additions and 106 deletions

View file

@ -236,6 +236,7 @@ Marvin Kopf <marvinkopf@outlook.com>
Kevin Nakamura <grinkers@grinkers.net> Kevin Nakamura <grinkers@grinkers.net>
Bradley Szoke <bradleyszoke@gmail.com> Bradley Szoke <bradleyszoke@gmail.com>
jcznk <https://github.com/jcznk> jcznk <https://github.com/jcznk>
Thomas Rixen <thomas.rixen@student.uclouvain.be>
******************** ********************

46
Cargo.lock generated
View file

@ -130,7 +130,7 @@ dependencies = [
"prost", "prost",
"prost-reflect", "prost-reflect",
"pulldown-cmark 0.13.0", "pulldown-cmark 0.13.0",
"rand 0.9.1", "rand 0.9.2",
"rayon", "rayon",
"regex", "regex",
"reqwest 0.12.20", "reqwest 0.12.20",
@ -144,7 +144,7 @@ dependencies = [
"serde_tuple", "serde_tuple",
"sha1", "sha1",
"snafu", "snafu",
"strum 0.27.1", "strum 0.27.2",
"syn 2.0.103", "syn 2.0.103",
"tempfile", "tempfile",
"tokio", "tokio",
@ -220,7 +220,7 @@ dependencies = [
"prost-types", "prost-types",
"serde", "serde",
"snafu", "snafu",
"strum 0.27.1", "strum 0.27.2",
] ]
[[package]] [[package]]
@ -706,7 +706,7 @@ dependencies = [
"log", "log",
"num-traits", "num-traits",
"portable-atomic-util", "portable-atomic-util",
"rand 0.9.1", "rand 0.9.2",
"rmp-serde", "rmp-serde",
"serde", "serde",
"serde_json", "serde_json",
@ -732,7 +732,7 @@ dependencies = [
"hashbrown 0.15.4", "hashbrown 0.15.4",
"log", "log",
"num-traits", "num-traits",
"rand 0.9.1", "rand 0.9.2",
"serde", "serde",
"spin 0.10.0", "spin 0.10.0",
"text_placeholder", "text_placeholder",
@ -762,12 +762,12 @@ dependencies = [
"csv", "csv",
"derive-new 0.7.0", "derive-new 0.7.0",
"dirs 6.0.0", "dirs 6.0.0",
"rand 0.9.1", "rand 0.9.2",
"rmp-serde", "rmp-serde",
"sanitize-filename 0.6.0", "sanitize-filename 0.6.0",
"serde", "serde",
"serde_json", "serde_json",
"strum 0.27.1", "strum 0.27.2",
"tempfile", "tempfile",
"thiserror 2.0.12", "thiserror 2.0.12",
] ]
@ -817,7 +817,7 @@ dependencies = [
"num-traits", "num-traits",
"paste", "paste",
"portable-atomic-util", "portable-atomic-util",
"rand 0.9.1", "rand 0.9.2",
"seq-macro", "seq-macro",
"spin 0.10.0", "spin 0.10.0",
] ]
@ -865,7 +865,7 @@ dependencies = [
"half", "half",
"hashbrown 0.15.4", "hashbrown 0.15.4",
"num-traits", "num-traits",
"rand 0.9.1", "rand 0.9.2",
"rand_distr", "rand_distr",
"serde", "serde",
"serde_bytes", "serde_bytes",
@ -959,7 +959,7 @@ dependencies = [
"memmap2", "memmap2",
"num-traits", "num-traits",
"num_cpus", "num_cpus",
"rand 0.9.1", "rand 0.9.2",
"rand_distr", "rand_distr",
"rayon", "rayon",
"safetensors", "safetensors",
@ -1403,7 +1403,7 @@ dependencies = [
"log", "log",
"num-traits", "num-traits",
"portable-atomic", "portable-atomic",
"rand 0.9.1", "rand 0.9.2",
"sanitize-filename 0.5.0", "sanitize-filename 0.5.0",
"serde", "serde",
"serde_json", "serde_json",
@ -2214,20 +2214,20 @@ dependencies = [
[[package]] [[package]]
name = "fsrs" name = "fsrs"
version = "5.0.0" version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f590cfcbe25079bb54a39900f45e6e308935bd6067249ce00d265b280465cde2" checksum = "04954cc67c3c11ee342a2ee1f5222bf76d73f7772df08d37dc9a6cdd73c467eb"
dependencies = [ dependencies = [
"burn", "burn",
"itertools 0.14.0", "itertools 0.14.0",
"log", "log",
"ndarray", "ndarray",
"priority-queue", "priority-queue",
"rand 0.9.1", "rand 0.9.2",
"rayon", "rayon",
"serde", "serde",
"snafu", "snafu",
"strum 0.27.1", "strum 0.27.2",
] ]
[[package]] [[package]]
@ -2804,7 +2804,7 @@ dependencies = [
"cfg-if", "cfg-if",
"crunchy", "crunchy",
"num-traits", "num-traits",
"rand 0.9.1", "rand 0.9.2",
"rand_distr", "rand_distr",
"serde", "serde",
] ]
@ -3658,7 +3658,7 @@ dependencies = [
"linkcheck", "linkcheck",
"regex", "regex",
"reqwest 0.12.20", "reqwest 0.12.20",
"strum 0.27.1", "strum 0.27.2",
"tokio", "tokio",
] ]
@ -5096,7 +5096,7 @@ dependencies = [
"bytes", "bytes",
"getrandom 0.3.3", "getrandom 0.3.3",
"lru-slab", "lru-slab",
"rand 0.9.1", "rand 0.9.2",
"ring", "ring",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"rustls", "rustls",
@ -5150,9 +5150,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.1" version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
"rand_core 0.9.3", "rand_core 0.9.3",
@ -5203,7 +5203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463"
dependencies = [ dependencies = [
"num-traits", "num-traits",
"rand 0.9.1", "rand 0.9.2",
] ]
[[package]] [[package]]
@ -6113,9 +6113,9 @@ dependencies = [
[[package]] [[package]]
name = "strum" name = "strum"
version = "0.27.1" version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [ dependencies = [
"strum_macros 0.27.1", "strum_macros 0.27.1",
] ]

View file

@ -33,10 +33,8 @@ git = "https://github.com/ankitects/linkcheck.git"
rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca" rev = "184b2ca50ed39ca43da13f0b830a463861adb9ca"
[workspace.dependencies.fsrs] [workspace.dependencies.fsrs]
version = "5.0.0" version = "5.1.0"
# git = "https://github.com/open-spaced-repetition/fsrs-rs.git" # git = "https://github.com/open-spaced-repetition/fsrs-rs.git"
# branch = "Refactor/expected_workload_via_dp"
# rev = "a7f7efc10f0a26b14ee348cc7402155685f2a24f"
# path = "../open-spaced-repetition/fsrs-rs" # path = "../open-spaced-repetition/fsrs-rs"
[workspace.dependencies] [workspace.dependencies]

View file

@ -1450,7 +1450,7 @@
}, },
{ {
"name": "fsrs", "name": "fsrs",
"version": "5.0.0", "version": "5.1.0",
"authors": "Open Spaced Repetition", "authors": "Open Spaced Repetition",
"repository": "https://github.com/open-spaced-repetition/fsrs-rs", "repository": "https://github.com/open-spaced-repetition/fsrs-rs",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
@ -3322,7 +3322,7 @@
}, },
{ {
"name": "rand", "name": "rand",
"version": "0.9.1", "version": "0.9.2",
"authors": "The Rand Project Developers|The Rust Project Developers", "authors": "The Rand Project Developers|The Rust Project Developers",
"repository": "https://github.com/rust-random/rand", "repository": "https://github.com/rust-random/rand",
"license": "Apache-2.0 OR MIT", "license": "Apache-2.0 OR MIT",
@ -4132,7 +4132,7 @@
}, },
{ {
"name": "strum", "name": "strum",
"version": "0.27.1", "version": "0.27.2",
"authors": "Peter Glotfelty <peter.glotfelty@microsoft.com>", "authors": "Peter Glotfelty <peter.glotfelty@microsoft.com>",
"repository": "https://github.com/Peternator7/strum", "repository": "https://github.com/Peternator7/strum",
"license": "MIT", "license": "MIT",

View file

@ -5,6 +5,11 @@ database-check-card-properties =
[one] Fixed { $count } invalid card property. [one] Fixed { $count } invalid card property.
*[other] Fixed { $count } invalid card properties. *[other] Fixed { $count } invalid card properties.
} }
database-check-card-last-review-time-empty =
{ $count ->
[one] Added last review time to { $count } card.
*[other] Added last review time to { $count } cards.
}
database-check-missing-templates = database-check-missing-templates =
{ $count -> { $count ->
[one] Deleted { $count } card with missing template. [one] Deleted { $count } card with missing template.

View file

@ -407,6 +407,8 @@ message SimulateFsrsReviewRequest {
deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11; deck_config.DeckConfig.Config.ReviewCardOrder review_order = 11;
optional uint32 suspend_after_lapse_count = 12; optional uint32 suspend_after_lapse_count = 12;
float historical_retention = 13; float historical_retention = 13;
uint32 learning_step_count = 14;
uint32 relearning_step_count = 15;
} }
message SimulateFsrsReviewResponse { message SimulateFsrsReviewResponse {

View file

@ -246,7 +246,7 @@ def backend_exception_to_pylib(err: backend_pb2.BackendError) -> Exception:
return BackendError(err.message, help_page, context, backtrace) return BackendError(err.message, help_page, context, backtrace)
elif val == kind.SEARCH_ERROR: elif val == kind.SEARCH_ERROR:
return SearchError(markdown(err.message), help_page, context, backtrace) return SearchError(err.message, help_page, context, backtrace)
elif val == kind.UNDO_EMPTY: elif val == kind.UNDO_EMPTY:
return UndoEmpty(err.message, help_page, context, backtrace) return UndoEmpty(err.message, help_page, context, backtrace)

View file

@ -10,6 +10,8 @@ import re
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from typing import Any, cast from typing import Any, cast
from markdown import markdown
import aqt import aqt
import aqt.browser import aqt.browser
import aqt.editor import aqt.editor
@ -20,7 +22,7 @@ from anki.cards import Card, CardId
from anki.collection import Collection, Config, OpChanges, SearchNode from anki.collection import Collection, Config, OpChanges, SearchNode
from anki.consts import * from anki.consts import *
from anki.decks import DeckId from anki.decks import DeckId
from anki.errors import NotFoundError from anki.errors import NotFoundError, SearchError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NotetypeId from anki.models import NotetypeId
from anki.notes import NoteId from anki.notes import NoteId
@ -498,6 +500,8 @@ class Browser(QMainWindow):
text = self.current_search() text = self.current_search()
try: try:
normed = self.col.build_search_string(text) normed = self.col.build_search_string(text)
except SearchError as err:
showWarning(markdown(str(err)))
except Exception as err: except Exception as err:
showWarning(str(err)) showWarning(str(err))
else: else:

View file

@ -126,8 +126,9 @@ impl Card {
} }
} }
/// This uses card.due and card.ivl to infer the elapsed time. If 'set due /// If last_review_date isn't stored in the card, this uses card.due and
/// date' or an add-on has changed the due date, this won't be accurate. /// card.ivl to infer the elapsed time, which won't be accurate if
/// 'set due date' or an add-on has changed the due date.
pub(crate) fn seconds_since_last_review(&self, timing: &SchedTimingToday) -> Option<u32> { pub(crate) fn seconds_since_last_review(&self, timing: &SchedTimingToday) -> Option<u32> {
if let Some(last_review_time) = self.last_review_time { if let Some(last_review_time) = self.last_review_time {
Some(timing.now.elapsed_secs_since(last_review_time) as u32) Some(timing.now.elapsed_secs_since(last_review_time) as u32)

View file

@ -24,6 +24,7 @@ use crate::notetype::NotetypeId;
use crate::notetype::NotetypeKind; use crate::notetype::NotetypeKind;
use crate::prelude::*; use crate::prelude::*;
use crate::progress::ThrottlingProgressHandler; use crate::progress::ThrottlingProgressHandler;
use crate::storage::card::CardFixStats;
use crate::timestamp::TimestampMillis; use crate::timestamp::TimestampMillis;
use crate::timestamp::TimestampSecs; use crate::timestamp::TimestampSecs;
@ -40,6 +41,7 @@ pub struct CheckDatabaseOutput {
notetypes_recovered: usize, notetypes_recovered: usize,
invalid_utf8: usize, invalid_utf8: usize,
invalid_ids: usize, invalid_ids: usize,
card_last_review_time_empty: usize,
} }
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
@ -69,6 +71,11 @@ impl CheckDatabaseOutput {
if self.card_properties_invalid > 0 { if self.card_properties_invalid > 0 {
probs.push(tr.database_check_card_properties(self.card_properties_invalid)); probs.push(tr.database_check_card_properties(self.card_properties_invalid));
} }
if self.card_last_review_time_empty > 0 {
probs.push(
tr.database_check_card_last_review_time_empty(self.card_last_review_time_empty),
);
}
if self.cards_missing_note > 0 { if self.cards_missing_note > 0 {
probs.push(tr.database_check_card_missing_note(self.cards_missing_note)); probs.push(tr.database_check_card_missing_note(self.cards_missing_note));
} }
@ -158,14 +165,25 @@ impl Collection {
fn check_card_properties(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> { fn check_card_properties(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {
let timing = self.timing_today()?; let timing = self.timing_today()?;
let (new_cnt, other_cnt) = self.storage.fix_card_properties( let CardFixStats {
new_cards_fixed,
other_cards_fixed,
last_review_time_fixed,
} = self.storage.fix_card_properties(
timing.days_elapsed, timing.days_elapsed,
TimestampSecs::now(), TimestampSecs::now(),
self.usn()?, self.usn()?,
self.scheduler_version() == SchedulerVersion::V1, self.scheduler_version() == SchedulerVersion::V1,
)?; )?;
out.card_position_too_high = new_cnt; out.card_position_too_high = new_cards_fixed;
out.card_properties_invalid += other_cnt; out.card_properties_invalid += other_cards_fixed;
out.card_last_review_time_empty = last_review_time_fixed;
// Trigger one-way sync if last_review_time was updated to avoid conflicts
if last_review_time_fixed > 0 {
self.set_schema_modified()?;
}
Ok(()) Ok(())
} }

View file

@ -3,6 +3,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use anki_proto::generic; use anki_proto::generic;
use rayon::iter::IntoParallelIterator;
use rayon::iter::ParallelIterator;
use crate::collection::Collection; use crate::collection::Collection;
use crate::deckconfig::DeckConfSchema11; use crate::deckconfig::DeckConfSchema11;
@ -11,6 +13,7 @@ use crate::deckconfig::DeckConfigId;
use crate::deckconfig::UpdateDeckConfigsRequest; use crate::deckconfig::UpdateDeckConfigsRequest;
use crate::error::Result; use crate::error::Result;
use crate::scheduler::fsrs::params::ignore_revlogs_before_date_to_ms; use crate::scheduler::fsrs::params::ignore_revlogs_before_date_to_ms;
use crate::scheduler::fsrs::simulator::is_included_card;
impl crate::services::DeckConfigService for Collection { impl crate::services::DeckConfigService for Collection {
fn add_or_update_deck_config_legacy( fn add_or_update_deck_config_legacy(
@ -103,6 +106,7 @@ impl crate::services::DeckConfigService for Collection {
&mut self, &mut self,
input: anki_proto::deck_config::GetRetentionWorkloadRequest, input: anki_proto::deck_config::GetRetentionWorkloadRequest,
) -> Result<anki_proto::deck_config::GetRetentionWorkloadResponse> { ) -> Result<anki_proto::deck_config::GetRetentionWorkloadResponse> {
let days_elapsed = self.timing_today().unwrap().days_elapsed as i32;
let guard = let guard =
self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?; self.search_cards_into_table(&input.search, crate::search::SortMode::NoOrder)?;
@ -112,12 +116,26 @@ impl crate::services::DeckConfigService for Collection {
.get_revlog_entries_for_searched_cards_in_card_order()?; .get_revlog_entries_for_searched_cards_in_card_order()?;
let config = guard.col.get_optimal_retention_parameters(revlogs)?; let config = guard.col.get_optimal_retention_parameters(revlogs)?;
let cards = guard
.col
.storage
.all_searched_cards()?
.into_iter()
.filter(is_included_card)
.filter_map(|c| crate::card::Card::convert(c.clone(), days_elapsed, c.memory_state?))
.collect::<Vec<fsrs::Card>>();
let costs = (70u32..=99u32) let costs = (70u32..=99u32)
.into_par_iter()
.map(|dr| { .map(|dr| {
Ok(( Ok((
dr, dr,
fsrs::expected_workload(&input.w, dr as f32 / 100., &config)?, fsrs::expected_workload_with_existing_cards(
&input.w,
dr as f32 / 100.,
&config,
&cards,
)?,
)) ))
}) })
.collect::<Result<HashMap<_, _>>>()?; .collect::<Result<HashMap<_, _>>>()?;

View file

@ -84,6 +84,42 @@ impl RevlogEntry {
}) })
.unwrap() .unwrap()
} }
/// Returns true if this entry represents a reset operation.
/// These entries are created when a card is reset using
/// [`Collection::reschedule_cards_as_new`].
/// The 0 value of `ease_factor` differentiates it
/// from entry created by [`Collection::set_due_date`] that has
/// `RevlogReviewKind::Manual` but non-zero `ease_factor`.
pub(crate) fn is_reset(&self) -> bool {
self.review_kind == RevlogReviewKind::Manual && self.ease_factor == 0
}
/// Returns true if this entry represents a cramming operation.
/// These entries are created when a card is reviewed in a
/// filtered deck with "Reschedule cards based on my answers
/// in this deck" disabled.
/// [`crate::scheduler::answering::CardStateUpdater::apply_preview_state`].
/// The 0 value of `ease_factor` distinguishes it from the entry
/// created when a card is reviewed before its due date in a
/// filtered deck with reschedule enabled or using Grade Now.
pub(crate) fn is_cramming(&self) -> bool {
self.review_kind == RevlogReviewKind::Filtered && self.ease_factor == 0
}
pub(crate) fn has_rating(&self) -> bool {
self.button_chosen > 0
}
/// Returns true if the review entry is not manually rescheduled and not
/// cramming. Used to filter out entries that shouldn't be considered
/// for statistics and scheduling.
pub(crate) fn has_rating_and_affects_scheduling(&self) -> bool {
// not rescheduled/set due date/reset
self.has_rating()
// not cramming
&& !self.is_cramming()
}
} }
impl Collection { impl Collection {

View file

@ -306,15 +306,15 @@ pub(crate) fn fsrs_items_for_memory_states(
.collect() .collect()
} }
struct LastRevlogInfo { pub(crate) struct LastRevlogInfo {
/// Used to determine the actual elapsed time between the last time the user /// Used to determine the actual elapsed time between the last time the user
/// reviewed the card and now, so that we can determine an accurate period /// reviewed the card and now, so that we can determine an accurate period
/// when the card has subsequently been rescheduled to a different day. /// when the card has subsequently been rescheduled to a different day.
last_reviewed_at: Option<TimestampSecs>, pub(crate) last_reviewed_at: Option<TimestampSecs>,
} }
/// Return a map of cards to info about last review/reschedule. /// Return a map of cards to info about last review.
fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap<CardId, LastRevlogInfo> { pub(crate) fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap<CardId, LastRevlogInfo> {
let mut out = HashMap::new(); let mut out = HashMap::new();
revlogs revlogs
.iter() .iter()
@ -323,8 +323,10 @@ fn get_last_revlog_info(revlogs: &[RevlogEntry]) -> HashMap<CardId, LastRevlogIn
.for_each(|(card_id, group)| { .for_each(|(card_id, group)| {
let mut last_reviewed_at = None; let mut last_reviewed_at = None;
for e in group.into_iter() { for e in group.into_iter() {
if e.button_chosen >= 1 { if e.has_rating_and_affects_scheduling() {
last_reviewed_at = Some(e.id.as_secs()); last_reviewed_at = Some(e.id.as_secs());
} else if e.is_reset() {
last_reviewed_at = None;
} }
} }
out.insert(card_id, LastRevlogInfo { last_reviewed_at }); out.insert(card_id, LastRevlogInfo { last_reviewed_at });

View file

@ -394,13 +394,13 @@ pub(crate) fn reviews_for_fsrs(
let mut revlogs_complete = false; let mut revlogs_complete = false;
// Working backwards from the latest review... // Working backwards from the latest review...
for (index, entry) in entries.iter().enumerate().rev() { for (index, entry) in entries.iter().enumerate().rev() {
if entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0 { if entry.is_cramming() {
continue; continue;
} }
// For incomplete review histories, initial memory state is based on the first // For incomplete review histories, initial memory state is based on the first
// user-graded review after the cutoff date with interval >= 1d. // user-graded review after the cutoff date with interval >= 1d.
let within_cutoff = entry.id.0 > ignore_revlogs_before.0; let within_cutoff = entry.id.0 > ignore_revlogs_before.0;
let user_graded = matches!(entry.button_chosen, 1..=4); let user_graded = entry.has_rating();
let interday = entry.interval >= 1 || entry.interval <= -86400; let interday = entry.interval >= 1 || entry.interval <= -86400;
if user_graded && within_cutoff && interday { if user_graded && within_cutoff && interday {
first_user_grade_idx = Some(index); first_user_grade_idx = Some(index);
@ -409,10 +409,7 @@ pub(crate) fn reviews_for_fsrs(
if user_graded && entry.review_kind == RevlogReviewKind::Learning { if user_graded && entry.review_kind == RevlogReviewKind::Learning {
first_of_last_learn_entries = Some(index); first_of_last_learn_entries = Some(index);
revlogs_complete = true; revlogs_complete = true;
} else if matches!( } else if entry.is_reset() {
(entry.review_kind, entry.ease_factor),
(RevlogReviewKind::Manual, 0)
) {
// Ignore entries prior to a `Reset` if a learning step has come after, // Ignore entries prior to a `Reset` if a learning step has come after,
// but consider revlogs complete. // but consider revlogs complete.
if first_of_last_learn_entries.is_some() { if first_of_last_learn_entries.is_some() {
@ -472,16 +469,7 @@ pub(crate) fn reviews_for_fsrs(
} }
// Filter out unwanted entries // Filter out unwanted entries
entries.retain(|entry| { entries.retain(|entry| entry.has_rating_and_affects_scheduling());
!(
// set due date, reset or rescheduled
(entry.review_kind == RevlogReviewKind::Manual || entry.button_chosen == 0)
|| // cram
(entry.review_kind == RevlogReviewKind::Filtered && entry.ease_factor == 0)
|| // rescheduled
(entry.review_kind == RevlogReviewKind::Rescheduled)
)
});
// Compute delta_t for each entry // Compute delta_t for each entry
let delta_ts = iter::once(0) let delta_ts = iter::once(0)
@ -560,10 +548,14 @@ pub(crate) mod tests {
} }
pub(crate) fn revlog(review_kind: RevlogReviewKind, days_ago: i64) -> RevlogEntry { pub(crate) fn revlog(review_kind: RevlogReviewKind, days_ago: i64) -> RevlogEntry {
let button_chosen = match review_kind {
RevlogReviewKind::Manual | RevlogReviewKind::Rescheduled => 0,
_ => 3,
};
RevlogEntry { RevlogEntry {
review_kind, review_kind,
id: days_ago_ms(days_ago).into(), id: days_ago_ms(days_ago).into(),
button_chosen: 3, button_chosen,
interval: 1, interval: 1,
..Default::default() ..Default::default()
} }

View file

@ -121,6 +121,12 @@ fn create_review_priority_fn(
} }
} }
pub(crate) fn is_included_card(c: &Card) -> bool {
c.queue != CardQueue::Suspended
&& c.queue != CardQueue::PreviewRepeat
&& c.ctype != CardType::New
}
impl Collection { impl Collection {
pub fn simulate_request_to_config( pub fn simulate_request_to_config(
&mut self, &mut self,
@ -133,11 +139,6 @@ impl Collection {
.get_revlog_entries_for_searched_cards_in_card_order()?; .get_revlog_entries_for_searched_cards_in_card_order()?;
let mut cards = guard.col.storage.all_searched_cards()?; let mut cards = guard.col.storage.all_searched_cards()?;
drop(guard); drop(guard);
fn is_included_card(c: &Card) -> bool {
c.queue != CardQueue::Suspended
&& c.queue != CardQueue::PreviewRepeat
&& c.ctype != CardType::New
}
// calculate any missing memory state // calculate any missing memory state
for c in &mut cards { for c in &mut cards {
if is_included_card(c) && c.memory_state.is_none() { if is_included_card(c) && c.memory_state.is_none() {
@ -237,8 +238,8 @@ impl Collection {
learning_step_transitions: p.learning_step_transitions, learning_step_transitions: p.learning_step_transitions,
relearning_step_transitions: p.relearning_step_transitions, relearning_step_transitions: p.relearning_step_transitions,
state_rating_costs: p.state_rating_costs, state_rating_costs: p.state_rating_costs,
learning_step_count: p.learning_step_count, learning_step_count: req.learning_step_count as usize,
relearning_step_count: p.relearning_step_count, relearning_step_count: req.relearning_step_count as usize,
}; };
Ok((config, converted_cards)) Ok((config, converted_cards))
@ -306,7 +307,11 @@ impl Collection {
} }
impl Card { impl Card {
fn convert(card: Card, days_elapsed: i32, memory_state: FsrsMemoryState) -> Option<fsrs::Card> { pub(crate) fn convert(
card: Card,
days_elapsed: i32,
memory_state: FsrsMemoryState,
) -> Option<fsrs::Card> {
match card.queue { match card.queue {
CardQueue::DayLearn | CardQueue::Review => { CardQueue::DayLearn | CardQueue::Review => {
let due = card.original_or_current_due(); let due = card.original_or_current_due();

View file

@ -57,10 +57,10 @@ const SECOND: f32 = 1.0;
const MINUTE: f32 = 60.0 * SECOND; const MINUTE: f32 = 60.0 * SECOND;
const HOUR: f32 = 60.0 * MINUTE; const HOUR: f32 = 60.0 * MINUTE;
const DAY: f32 = 24.0 * HOUR; const DAY: f32 = 24.0 * HOUR;
const MONTH: f32 = 30.417 * DAY; // 365/12 ≈ 30.417
const YEAR: f32 = 365.0 * DAY; const YEAR: f32 = 365.0 * DAY;
const MONTH: f32 = YEAR / 12.0;
#[derive(Clone, Copy)] #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum TimespanUnit { pub(crate) enum TimespanUnit {
Seconds, Seconds,
Minutes, Minutes,
@ -111,6 +111,13 @@ impl Timespan {
} }
} }
pub fn to_unit(self, unit: TimespanUnit) -> Timespan {
Timespan {
seconds: self.seconds,
unit,
}
}
/// Round seconds and days to integers, otherwise /// Round seconds and days to integers, otherwise
/// truncates to one decimal place. /// truncates to one decimal place.
pub fn as_rounded_unit(self) -> f32 { pub fn as_rounded_unit(self) -> f32 {

View file

@ -30,14 +30,24 @@ impl Collection {
let (average_secs, total_secs) = average_and_total_secs_strings(&revlog); let (average_secs, total_secs) = average_and_total_secs_strings(&revlog);
let timing = self.timing_today()?; let timing = self.timing_today()?;
let seconds_elapsed = if let Some(last_review_time) = card.last_review_time {
timing.now.elapsed_secs_since(last_review_time) as u32 let last_review_time = if let Some(last_review_time) = card.last_review_time {
last_review_time
} else { } else {
self.storage let mut new_card = card.clone();
let last_review_time = self
.storage
.time_of_last_review(card.id)? .time_of_last_review(card.id)?
.map(|ts| timing.now.elapsed_secs_since(ts)) .unwrap_or_default();
.unwrap_or_default() as u32
new_card.last_review_time = Some(last_review_time);
self.storage.update_card(&new_card)?;
last_review_time
}; };
let seconds_elapsed = timing.now.elapsed_secs_since(last_review_time) as u32;
let fsrs_retrievability = card let fsrs_retrievability = card
.memory_state .memory_state
.zip(Some(seconds_elapsed)) .zip(Some(seconds_elapsed))
@ -187,7 +197,7 @@ impl Collection {
} }
fn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) { fn average_and_total_secs_strings(revlog: &[RevlogEntry]) -> (f32, f32) {
let normal_answer_count = revlog.iter().filter(|r| r.button_chosen > 0).count(); let normal_answer_count = revlog.iter().filter(|r| r.has_rating()).count();
let total_secs: f32 = revlog let total_secs: f32 = revlog
.iter() .iter()
.map(|entry| (entry.taken_millis as f32) / 1000.0) .map(|entry| (entry.taken_millis as f32) / 1000.0)

View file

@ -53,10 +53,7 @@ impl GraphsContext {
self.revlog self.revlog
.iter() .iter()
.filter(|review| { .filter(|review| {
// not rescheduled/set due date/reset review.has_rating_and_affects_scheduling()
review.button_chosen > 0
// not cramming
&& (review.review_kind != RevlogReviewKind::Filtered || review.ease_factor != 0)
// cards with an interval ≥ 1 day // cards with an interval ≥ 1 day
&& (review.review_kind == RevlogReviewKind::Review && (review.review_kind == RevlogReviewKind::Review
|| review.last_interval <= -86400 || review.last_interval <= -86400

View file

@ -5,17 +5,18 @@ use anki_i18n::I18n;
use crate::prelude::*; use crate::prelude::*;
use crate::scheduler::timespan::Timespan; use crate::scheduler::timespan::Timespan;
use crate::scheduler::timespan::TimespanUnit;
pub fn studied_today(cards: u32, secs: f32, tr: &I18n) -> String { pub fn studied_today(cards: u32, secs: f32, tr: &I18n) -> String {
let span = Timespan::from_secs(secs).natural_span(); let span = Timespan::from_secs(secs).natural_span();
let amount = span.as_unit(); let unit = std::cmp::min(span.unit(), TimespanUnit::Minutes);
let unit = span.unit().as_str(); let amount = span.to_unit(unit).as_unit();
let secs_per_card = if cards > 0 { let secs_per_card = if cards > 0 {
secs / (cards as f32) secs / (cards as f32)
} else { } else {
0.0 0.0
}; };
tr.statistics_studied_today(unit, secs_per_card, amount, cards) tr.statistics_studied_today(unit.as_str(), secs_per_card, amount, cards)
.into() .into()
} }
@ -41,5 +42,9 @@ mod test {
&studied_today(3, 13.0, &tr).replace('\n', " "), &studied_today(3, 13.0, &tr).replace('\n', " "),
"Studied 3 cards in 13 seconds today (4.33s/card)" "Studied 3 cards in 13 seconds today (4.33s/card)"
); );
assert_eq!(
&studied_today(300, 5400.0, &tr).replace('\n', " "),
"Studied 300 cards in 90 minutes today (18s/card)"
);
} }
} }

View file

@ -33,6 +33,7 @@ use crate::decks::DeckKind;
use crate::error::Result; use crate::error::Result;
use crate::notes::NoteId; use crate::notes::NoteId;
use crate::scheduler::congrats::CongratsInfo; use crate::scheduler::congrats::CongratsInfo;
use crate::scheduler::fsrs::memory_state::get_last_revlog_info;
use crate::scheduler::queue::BuryMode; use crate::scheduler::queue::BuryMode;
use crate::scheduler::queue::DueCard; use crate::scheduler::queue::DueCard;
use crate::scheduler::queue::DueCardKind; use crate::scheduler::queue::DueCardKind;
@ -42,6 +43,13 @@ use crate::timestamp::TimestampMillis;
use crate::timestamp::TimestampSecs; use crate::timestamp::TimestampSecs;
use crate::types::Usn; use crate::types::Usn;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CardFixStats {
pub new_cards_fixed: usize,
pub other_cards_fixed: usize,
pub last_review_time_fixed: usize,
}
impl FromSql for CardType { impl FromSql for CardType {
fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> { fn column_result(value: ValueRef<'_>) -> result::Result<Self, FromSqlError> {
if let ValueRef::Integer(i) = value { if let ValueRef::Integer(i) = value {
@ -365,7 +373,7 @@ impl super::SqliteStorage {
mtime: TimestampSecs, mtime: TimestampSecs,
usn: Usn, usn: Usn,
v1_sched: bool, v1_sched: bool,
) -> Result<(usize, usize)> { ) -> Result<CardFixStats> {
let new_cnt = self let new_cnt = self
.db .db
.prepare(include_str!("fix_due_new.sql"))? .prepare(include_str!("fix_due_new.sql"))?
@ -390,7 +398,24 @@ impl super::SqliteStorage {
.db .db
.prepare(include_str!("fix_ordinal.sql"))? .prepare(include_str!("fix_ordinal.sql"))?
.execute(params![mtime, usn])?; .execute(params![mtime, usn])?;
Ok((new_cnt, other_cnt)) let mut last_review_time_cnt = 0;
let revlog = self.get_all_revlog_entries_in_card_order()?;
let last_revlog_info = get_last_revlog_info(&revlog);
for (card_id, last_revlog_info) in last_revlog_info {
let card = self.get_card(card_id)?;
if let Some(mut card) = card {
if card.ctype != CardType::New && card.last_review_time.is_none() {
card.last_review_time = last_revlog_info.last_reviewed_at;
self.update_card(&card)?;
last_review_time_cnt += 1;
}
}
}
Ok(CardFixStats {
new_cards_fixed: new_cnt,
other_cards_fixed: other_cnt,
last_review_time_fixed: last_review_time_cnt,
})
} }
pub(crate) fn delete_orphaned_cards(&self) -> Result<usize> { pub(crate) fn delete_orphaned_cards(&self) -> Result<usize> {

View file

@ -89,7 +89,7 @@ export function naturalWholeUnit(secs: number): TimespanUnit {
} }
export function studiedToday(cards: number, secs: number): string { export function studiedToday(cards: number, secs: number): string {
const unit = naturalUnit(secs); const unit = Math.min(naturalUnit(secs), TimespanUnit.Minutes);
const amount = unitAmount(unit, secs); const amount = unitAmount(unit, secs);
const name = unitName(unit); const name = unitName(unit);

View file

@ -113,6 +113,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
easyDaysPercentages: $config.easyDaysPercentages, easyDaysPercentages: $config.easyDaysPercentages,
reviewOrder: $config.reviewOrder, reviewOrder: $config.reviewOrder,
historicalRetention: $config.historicalRetention, historicalRetention: $config.historicalRetention,
learningStepCount: $config.learnSteps.length,
relearningStepCount: $config.relearnSteps.length,
}); });
const DESIRED_RETENTION_LOW_THRESHOLD = 0.8; const DESIRED_RETENTION_LOW_THRESHOLD = 0.8;
@ -128,7 +130,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
let retentionWorloadInfo: undefined | Promise<GetRetentionWorkloadResponse> = let retentionWorkloadInfo: undefined | Promise<GetRetentionWorkloadResponse> =
undefined; undefined;
let lastParams = [...fsrsParams($config)]; let lastParams = [...fsrsParams($config)];
@ -139,7 +141,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
if ( if (
// If the cache is empty and a request has not yet been made to fill it // If the cache is empty and a request has not yet been made to fill it
!retentionWorloadInfo || !retentionWorkloadInfo ||
// If the parameters have been changed // If the parameters have been changed
lastParams.toString() !== params.toString() lastParams.toString() !== params.toString()
) { ) {
@ -148,12 +150,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
search: defaultparamSearch, search: defaultparamSearch,
}); });
lastParams = [...params]; lastParams = [...params];
retentionWorloadInfo = getRetentionWorkload(request); retentionWorkloadInfo = getRetentionWorkload(request);
} }
const previous = +startingDesiredRetention * 100; const previous = +startingDesiredRetention * 100;
const after = retention * 100; const after = retention * 100;
const resp = await retentionWorloadInfo; const resp = await retentionWorkloadInfo;
const factor = resp.costs[after] / resp.costs[previous]; const factor = resp.costs[after] / resp.costs[previous];
desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({ desiredRetentionChangeInfo = tr.deckConfigWorkloadFactorChange({
@ -218,29 +220,34 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
healthCheck: $healthCheck, healthCheck: $healthCheck,
}); });
const already_optimal = const alreadyOptimal =
(params.length && (params.length &&
params.every( params.every(
(n, i) => n.toFixed(4) === resp.params[i].toFixed(4), (n, i) => n.toFixed(4) === resp.params[i].toFixed(4),
)) || )) ||
resp.params.length === 0; resp.params.length === 0;
let healthCheckMessage = "";
if (resp.healthCheckPassed !== undefined) { if (resp.healthCheckPassed !== undefined) {
if (resp.healthCheckPassed) { healthCheckMessage = resp.healthCheckPassed
setTimeout(() => alert(tr.deckConfigFsrsGoodFit()), 200); ? tr.deckConfigFsrsGoodFit()
} else { : tr.deckConfigFsrsBadFitWarning();
setTimeout( }
() => alert(tr.deckConfigFsrsBadFitWarning()), let alreadyOptimalMessage = "";
200, if (alreadyOptimal) {
); alreadyOptimalMessage = resp.fsrsItems
}
} else if (already_optimal) {
const msg = resp.fsrsItems
? tr.deckConfigFsrsParamsOptimal() ? tr.deckConfigFsrsParamsOptimal()
: tr.deckConfigFsrsParamsNoReviews(); : tr.deckConfigFsrsParamsNoReviews();
setTimeout(() => alert(msg), 200);
} }
if (!already_optimal) { const message = [alreadyOptimalMessage, healthCheckMessage]
.filter((a) => a)
.join("\n\n");
if (message) {
setTimeout(() => alert(message), 200);
}
if (!alreadyOptimal) {
$config.fsrsParams6 = resp.params; $config.fsrsParams6 = resp.params;
setTimeout(() => { setTimeout(() => {
optimized = true; optimized = true;

View file

@ -627,7 +627,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
.svg-container { .svg-container {
width: 100%; width: 100%;
max-height: calc(100vh - 400px); /* Account for modal header, controls, etc */ /* Account for modal header, controls, etc */
max-height: max(calc(100vh - 400px), 200px);
aspect-ratio: 600 / 250; aspect-ratio: 600 / 250;
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -18,8 +18,8 @@ import {
bin, bin,
cumsum, cumsum,
curveBasis, curveBasis,
interpolateBlues,
interpolateGreens, interpolateGreens,
interpolateOranges,
interpolatePurples, interpolatePurples,
interpolateReds, interpolateReds,
max, max,
@ -181,7 +181,7 @@ export function renderReviews(
const reds = scaleSequential((n) => interpolateReds(cappedRange(n)!)).domain( const reds = scaleSequential((n) => interpolateReds(cappedRange(n)!)).domain(
x.domain() as any, x.domain() as any,
); );
const blues = scaleSequential((n) => interpolateBlues(cappedRange(n)!)).domain( const oranges = scaleSequential((n) => interpolateOranges(cappedRange(n)!)).domain(
x.domain() as any, x.domain() as any,
); );
const purples = scaleSequential((n) => interpolatePurples(cappedRange(n)!)).domain( const purples = scaleSequential((n) => interpolatePurples(cappedRange(n)!)).domain(
@ -195,7 +195,7 @@ export function renderReviews(
case BinIndex.Young: case BinIndex.Young:
return lighterGreens; return lighterGreens;
case BinIndex.Learn: case BinIndex.Learn:
return blues; return oranges;
case BinIndex.Relearn: case BinIndex.Relearn:
return reds; return reds;
case BinIndex.Filtered: case BinIndex.Filtered: