mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Include elapsed_secs in learning card state (#2862)
* Include elapsed_time in learning card state * Suggested updates, elapsed_time -> elapsed_secs * Remove outdated comment
This commit is contained in:
parent
9364dad49a
commit
63260631e4
9 changed files with 205 additions and 20 deletions
|
@ -72,6 +72,7 @@ message SchedulingState {
|
||||||
message Learning {
|
message Learning {
|
||||||
uint32 remaining_steps = 1;
|
uint32 remaining_steps = 1;
|
||||||
uint32 scheduled_secs = 2;
|
uint32 scheduled_secs = 2;
|
||||||
|
uint32 elapsed_secs = 3;
|
||||||
optional cards.FsrsMemoryState memory_state = 6;
|
optional cards.FsrsMemoryState memory_state = 6;
|
||||||
}
|
}
|
||||||
message Review {
|
message Review {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use super::get_fuzz_seed_for_id_and_reps;
|
||||||
use super::CardStateUpdater;
|
use super::CardStateUpdater;
|
||||||
|
use crate::card::CardQueue;
|
||||||
use crate::card::CardType;
|
use crate::card::CardType;
|
||||||
use crate::decks::DeckKind;
|
use crate::decks::DeckKind;
|
||||||
use crate::scheduler::states::CardState;
|
use crate::scheduler::states::CardState;
|
||||||
|
@ -64,15 +66,40 @@ impl CardStateUpdater {
|
||||||
let ease_factor = self.card.ease_factor();
|
let ease_factor = self.card.ease_factor();
|
||||||
let remaining_steps = self.card.remaining_steps();
|
let remaining_steps = self.card.remaining_steps();
|
||||||
let memory_state = self.card.memory_state;
|
let memory_state = self.card.memory_state;
|
||||||
|
let elapsed_secs = |last_ivl: u32| {
|
||||||
|
match self.card.queue {
|
||||||
|
CardQueue::Learn => {
|
||||||
|
// Decrease reps by 1 to get correct seed for fuzz.
|
||||||
|
// If the fuzz calculation changes, this will break.
|
||||||
|
let last_ivl_with_fuzz = self.learning_ivl_with_fuzz(
|
||||||
|
get_fuzz_seed_for_id_and_reps(self.card.id, self.card.reps - 1),
|
||||||
|
last_ivl,
|
||||||
|
);
|
||||||
|
let last_answered_time = due as i64 - last_ivl_with_fuzz as i64;
|
||||||
|
(self.now.0 - last_answered_time) as u32
|
||||||
|
}
|
||||||
|
CardQueue::DayLearn => {
|
||||||
|
let days_since_col_creation = self.timing.days_elapsed as i32;
|
||||||
|
// Need .max(1) for same day learning cards pushed to the next day.
|
||||||
|
// 86_400 is the number of seconds in a day.
|
||||||
|
let last_ivl_as_days = (last_ivl / 86_400).max(1) as i32;
|
||||||
|
let elapsed_days = days_since_col_creation - due + last_ivl_as_days;
|
||||||
|
(elapsed_days * 86_400) as u32
|
||||||
|
}
|
||||||
|
_ => 0, // Not used for other card queues.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
match self.card.ctype {
|
match self.card.ctype {
|
||||||
CardType::New => NormalState::New(NewState {
|
CardType::New => NormalState::New(NewState {
|
||||||
position: due.max(0) as u32,
|
position: due.max(0) as u32,
|
||||||
}),
|
}),
|
||||||
CardType::Learn => {
|
CardType::Learn => {
|
||||||
|
let last_ivl = self.learn_steps().current_delay_secs(remaining_steps);
|
||||||
LearnState {
|
LearnState {
|
||||||
scheduled_secs: self.learn_steps().current_delay_secs(remaining_steps),
|
scheduled_secs: last_ivl,
|
||||||
remaining_steps,
|
remaining_steps,
|
||||||
|
elapsed_secs: elapsed_secs(last_ivl),
|
||||||
memory_state,
|
memory_state,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,9 +114,12 @@ impl CardStateUpdater {
|
||||||
memory_state,
|
memory_state,
|
||||||
}
|
}
|
||||||
.into(),
|
.into(),
|
||||||
CardType::Relearn => RelearnState {
|
CardType::Relearn => {
|
||||||
|
let last_ivl = self.relearn_steps().current_delay_secs(remaining_steps);
|
||||||
|
RelearnState {
|
||||||
learning: LearnState {
|
learning: LearnState {
|
||||||
scheduled_secs: self.relearn_steps().current_delay_secs(remaining_steps),
|
scheduled_secs: last_ivl,
|
||||||
|
elapsed_secs: elapsed_secs(last_ivl),
|
||||||
remaining_steps,
|
remaining_steps,
|
||||||
memory_state,
|
memory_state,
|
||||||
},
|
},
|
||||||
|
@ -102,6 +132,7 @@ impl CardStateUpdater {
|
||||||
memory_state,
|
memory_state,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.into(),
|
.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -76,12 +76,12 @@ impl CardStateUpdater {
|
||||||
|
|
||||||
/// Adds secs + fuzz to current time
|
/// Adds secs + fuzz to current time
|
||||||
pub(super) fn fuzzed_next_learning_timestamp(&self, secs: u32) -> i32 {
|
pub(super) fn fuzzed_next_learning_timestamp(&self, secs: u32) -> i32 {
|
||||||
TimestampSecs::now().0 as i32 + self.with_learning_fuzz(secs) as i32
|
TimestampSecs::now().0 as i32 + self.learning_ivl_with_fuzz(self.fuzz_seed, secs) as i32
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add up to 25% increase to seconds, but no more than 5 minutes.
|
/// Add up to 25% increase to seconds, but no more than 5 minutes.
|
||||||
fn with_learning_fuzz(&self, secs: u32) -> u32 {
|
pub(super) fn learning_ivl_with_fuzz(&self, input_seed: Option<u64>, secs: u32) -> u32 {
|
||||||
if let Some(seed) = self.fuzz_seed {
|
if let Some(seed) = input_seed {
|
||||||
let mut rng = StdRng::seed_from_u64(seed);
|
let mut rng = StdRng::seed_from_u64(seed);
|
||||||
let upper_exclusive = secs + ((secs as f32) * 0.25).min(300.0).floor() as u32;
|
let upper_exclusive = secs + ((secs as f32) * 0.25).min(300.0).floor() as u32;
|
||||||
if secs >= upper_exclusive {
|
if secs >= upper_exclusive {
|
||||||
|
|
|
@ -270,6 +270,9 @@ impl Collection {
|
||||||
let mut updater = self.card_state_updater(card)?;
|
let mut updater = self.card_state_updater(card)?;
|
||||||
answer.cap_answer_secs(updater.config.inner.cap_answer_time_to_secs);
|
answer.cap_answer_secs(updater.config.inner.cap_answer_time_to_secs);
|
||||||
let current_state = updater.current_card_state();
|
let current_state = updater.current_card_state();
|
||||||
|
// If the states aren't equal, it's probably because some time has passed.
|
||||||
|
// Try to fix this by setting elapsed_secs equal.
|
||||||
|
self.set_elapsed_secs_equal(¤t_state, &mut answer.current_state);
|
||||||
require!(
|
require!(
|
||||||
current_state == answer.current_state,
|
current_state == answer.current_state,
|
||||||
"card was modified: {current_state:#?} {:#?}",
|
"card was modified: {current_state:#?} {:#?}",
|
||||||
|
@ -417,6 +420,41 @@ impl Collection {
|
||||||
self.add_tags_to_notes_inner(&[nid], "leech")?;
|
self.add_tags_to_notes_inner(&[nid], "leech")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update the elapsed time of the answer state to match the current state.
|
||||||
|
///
|
||||||
|
/// Since the state calculation takes the current time into account, the
|
||||||
|
/// elapsed_secs will probably be different for the two states. This is fine
|
||||||
|
/// for elapsed_secs, but we set the two values equal to easily compare
|
||||||
|
/// the other values of the two states.
|
||||||
|
fn set_elapsed_secs_equal(&self, current_state: &CardState, answer_state: &mut CardState) {
|
||||||
|
if let (Some(current_state), Some(answer_state)) = (
|
||||||
|
match current_state {
|
||||||
|
CardState::Normal(normal_state) => Some(normal_state),
|
||||||
|
CardState::Filtered(FilteredState::Rescheduling(resched_filter_state)) => {
|
||||||
|
Some(&resched_filter_state.original_state)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
match answer_state {
|
||||||
|
CardState::Normal(normal_state) => Some(normal_state),
|
||||||
|
CardState::Filtered(FilteredState::Rescheduling(resched_filter_state)) => {
|
||||||
|
Some(&mut resched_filter_state.original_state)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
match (current_state, answer_state) {
|
||||||
|
(NormalState::Learning(answer), NormalState::Learning(current)) => {
|
||||||
|
current.elapsed_secs = answer.elapsed_secs;
|
||||||
|
}
|
||||||
|
(NormalState::Relearning(answer), NormalState::Relearning(current)) => {
|
||||||
|
current.learning.elapsed_secs = answer.learning.elapsed_secs;
|
||||||
|
}
|
||||||
|
_ => {} // Other states don't use elapsed_secs.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -476,12 +514,16 @@ impl Card {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a consistent seed for a given card at a given number of reps.
|
/// Return a consistent seed for a given card at a given number of reps.
|
||||||
/// If in test environment, disable fuzzing.
|
|
||||||
fn get_fuzz_seed(card: &Card) -> Option<u64> {
|
fn get_fuzz_seed(card: &Card) -> Option<u64> {
|
||||||
|
get_fuzz_seed_for_id_and_reps(card.id, card.reps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If in test environment, disable fuzzing.
|
||||||
|
fn get_fuzz_seed_for_id_and_reps(card_id: CardId, card_reps: u32) -> Option<u64> {
|
||||||
if *crate::PYTHON_UNIT_TESTS || cfg!(test) {
|
if *crate::PYTHON_UNIT_TESTS || cfg!(test) {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some((card.id.0 as u64).wrapping_add(card.reps as u64))
|
Some((card_id.0 as u64).wrapping_add(card_reps as u64))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -670,4 +712,105 @@ mod test {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn elapsed_secs() -> Result<()> {
|
||||||
|
let mut col = Collection::new();
|
||||||
|
let mut conf = col.get_deck_config(DeckConfigId(1), false)?.unwrap();
|
||||||
|
let nt = col.get_notetype_by_name("Basic")?.unwrap();
|
||||||
|
let mut note = nt.new_note();
|
||||||
|
// Need to set col age for interday learning test, arbitrary
|
||||||
|
col.storage
|
||||||
|
.db
|
||||||
|
.execute_batch("update col set crt=1686045847")?;
|
||||||
|
// Fails when near cutoff since it assumes inter- and intraday learning
|
||||||
|
if col.timing_today()?.near_cutoff() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
col.add_note(&mut note, DeckId(1))?;
|
||||||
|
// 5942.7 minutes for just over four days
|
||||||
|
conf.inner.learn_steps = vec![1.0, 10.5, 15.0, 20.0, 5942.7];
|
||||||
|
col.storage.update_deck_conf(&conf)?;
|
||||||
|
|
||||||
|
// Intraday learning, review same day
|
||||||
|
let expected_elapsed_secs = 662;
|
||||||
|
let post_answer = col.answer_good();
|
||||||
|
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||||
|
let shift_due_time = card.due - expected_elapsed_secs;
|
||||||
|
assert_elapsed_secs_approx_equal(
|
||||||
|
&mut col,
|
||||||
|
shift_due_time,
|
||||||
|
post_answer,
|
||||||
|
expected_elapsed_secs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Intraday learning, learn ahead
|
||||||
|
let expected_elapsed_secs = 212;
|
||||||
|
let post_answer = col.answer_good();
|
||||||
|
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||||
|
let shift_due_time = card.due - expected_elapsed_secs;
|
||||||
|
assert_elapsed_secs_approx_equal(
|
||||||
|
&mut col,
|
||||||
|
shift_due_time,
|
||||||
|
post_answer,
|
||||||
|
expected_elapsed_secs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Intraday learning, review two (and some) days later
|
||||||
|
let expected_elapsed_secs = 184092;
|
||||||
|
let post_answer = col.answer_good();
|
||||||
|
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||||
|
let shift_due_time = card.due - expected_elapsed_secs;
|
||||||
|
assert_elapsed_secs_approx_equal(
|
||||||
|
&mut col,
|
||||||
|
shift_due_time,
|
||||||
|
post_answer,
|
||||||
|
expected_elapsed_secs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Interday learning four (and some) days, review three days late
|
||||||
|
let expected_elapsed_secs = 7 * 86_400;
|
||||||
|
let post_answer = col.answer_good();
|
||||||
|
let now = TimestampSecs::now();
|
||||||
|
let timing = col.timing_for_timestamp(now)?;
|
||||||
|
let col_age = timing.days_elapsed as i32;
|
||||||
|
let shift_due_time = col_age - 3; // Three days late
|
||||||
|
assert_elapsed_secs_approx_equal(
|
||||||
|
&mut col,
|
||||||
|
shift_due_time,
|
||||||
|
post_answer,
|
||||||
|
expected_elapsed_secs,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_elapsed_secs_approx_equal(
|
||||||
|
col: &mut Collection,
|
||||||
|
shift_due_time: i32,
|
||||||
|
post_answer: test_helpers::PostAnswerState,
|
||||||
|
expected_elapsed_secs: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
// Change due time to fake card answer_time,
|
||||||
|
// works since answer_time is calculated as due - last_ivl
|
||||||
|
let update_due_string = format!("update cards set due={}", shift_due_time);
|
||||||
|
col.storage.db.execute_batch(&update_due_string)?;
|
||||||
|
col.clear_study_queues();
|
||||||
|
let current_card_state = current_state(col, post_answer.card_id);
|
||||||
|
let state = match current_card_state {
|
||||||
|
CardState::Normal(NormalState::Learning(state)) => state,
|
||||||
|
_ => panic!("State is not Normal: {:?}", current_card_state),
|
||||||
|
};
|
||||||
|
let elapsed_secs = state.elapsed_secs as i32;
|
||||||
|
// Give a 1 second leeway when the test runs on the off chance
|
||||||
|
// that the test runs as a second rolls over.
|
||||||
|
assert!(
|
||||||
|
(elapsed_secs - expected_elapsed_secs).abs() <= 1,
|
||||||
|
"elapsed_secs: {} != expected_elapsed_secs: {}",
|
||||||
|
elapsed_secs,
|
||||||
|
expected_elapsed_secs
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ impl From<anki_proto::scheduler::scheduling_state::Learning> for LearnState {
|
||||||
LearnState {
|
LearnState {
|
||||||
remaining_steps: state.remaining_steps,
|
remaining_steps: state.remaining_steps,
|
||||||
scheduled_secs: state.scheduled_secs,
|
scheduled_secs: state.scheduled_secs,
|
||||||
|
elapsed_secs: state.elapsed_secs,
|
||||||
memory_state: state.memory_state.map(Into::into),
|
memory_state: state.memory_state.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +19,7 @@ impl From<LearnState> for anki_proto::scheduler::scheduling_state::Learning {
|
||||||
anki_proto::scheduler::scheduling_state::Learning {
|
anki_proto::scheduler::scheduling_state::Learning {
|
||||||
remaining_steps: state.remaining_steps,
|
remaining_steps: state.remaining_steps,
|
||||||
scheduled_secs: state.scheduled_secs,
|
scheduled_secs: state.scheduled_secs,
|
||||||
|
elapsed_secs: state.elapsed_secs,
|
||||||
memory_state: state.memory_state.map(Into::into),
|
memory_state: state.memory_state.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ use crate::revlog::RevlogReviewKind;
|
||||||
pub struct LearnState {
|
pub struct LearnState {
|
||||||
pub remaining_steps: u32,
|
pub remaining_steps: u32,
|
||||||
pub scheduled_secs: u32,
|
pub scheduled_secs: u32,
|
||||||
|
pub elapsed_secs: u32,
|
||||||
pub memory_state: Option<FsrsMemoryState>,
|
pub memory_state: Option<FsrsMemoryState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,6 +40,7 @@ impl LearnState {
|
||||||
LearnState {
|
LearnState {
|
||||||
remaining_steps: ctx.steps.remaining_for_failed(),
|
remaining_steps: ctx.steps.remaining_for_failed(),
|
||||||
scheduled_secs: ctx.steps.again_delay_secs_learn(),
|
scheduled_secs: ctx.steps.again_delay_secs_learn(),
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into()),
|
memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.again.memory.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,6 +52,7 @@ impl LearnState {
|
||||||
.hard_delay_secs(self.remaining_steps)
|
.hard_delay_secs(self.remaining_steps)
|
||||||
// user has 0 learning steps, which the UI doesn't allow
|
// user has 0 learning steps, which the UI doesn't allow
|
||||||
.unwrap_or(60),
|
.unwrap_or(60),
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()),
|
memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()),
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
|
@ -61,6 +64,7 @@ impl LearnState {
|
||||||
LearnState {
|
LearnState {
|
||||||
remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps),
|
remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps),
|
||||||
scheduled_secs: good_delay,
|
scheduled_secs: good_delay,
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state,
|
memory_state,
|
||||||
}
|
}
|
||||||
.into()
|
.into()
|
||||||
|
|
|
@ -44,6 +44,7 @@ impl NormalState {
|
||||||
let next_states = LearnState {
|
let next_states = LearnState {
|
||||||
remaining_steps: ctx.steps.remaining_for_failed(),
|
remaining_steps: ctx.steps.remaining_for_failed(),
|
||||||
scheduled_secs: 0,
|
scheduled_secs: 0,
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state: None,
|
memory_state: None,
|
||||||
}
|
}
|
||||||
.next_states(ctx);
|
.next_states(ctx);
|
||||||
|
|
|
@ -41,6 +41,7 @@ impl RelearnState {
|
||||||
learning: LearnState {
|
learning: LearnState {
|
||||||
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
|
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
|
||||||
scheduled_secs: again_delay,
|
scheduled_secs: again_delay,
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state,
|
memory_state,
|
||||||
},
|
},
|
||||||
review: ReviewState {
|
review: ReviewState {
|
||||||
|
@ -92,6 +93,7 @@ impl RelearnState {
|
||||||
remaining_steps: ctx
|
remaining_steps: ctx
|
||||||
.relearn_steps
|
.relearn_steps
|
||||||
.remaining_for_good(self.learning.remaining_steps),
|
.remaining_for_good(self.learning.remaining_steps),
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state,
|
memory_state,
|
||||||
},
|
},
|
||||||
review: ReviewState {
|
review: ReviewState {
|
||||||
|
|
|
@ -104,6 +104,7 @@ impl ReviewState {
|
||||||
learning: LearnState {
|
learning: LearnState {
|
||||||
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
|
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
|
||||||
scheduled_secs: again_delay,
|
scheduled_secs: again_delay,
|
||||||
|
elapsed_secs: 0,
|
||||||
memory_state,
|
memory_state,
|
||||||
},
|
},
|
||||||
review: again_review,
|
review: again_review,
|
||||||
|
|
Loading…
Reference in a new issue