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:
Gustaf-C 2023-12-06 14:40:22 +08:00 committed by GitHub
parent 9364dad49a
commit 63260631e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 205 additions and 20 deletions

View File

@ -72,6 +72,7 @@ message SchedulingState {
message Learning {
uint32 remaining_steps = 1;
uint32 scheduled_secs = 2;
uint32 elapsed_secs = 3;
optional cards.FsrsMemoryState memory_state = 6;
}
message Review {

View File

@ -1,7 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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 crate::card::CardQueue;
use crate::card::CardType;
use crate::decks::DeckKind;
use crate::scheduler::states::CardState;
@ -64,15 +66,40 @@ impl CardStateUpdater {
let ease_factor = self.card.ease_factor();
let remaining_steps = self.card.remaining_steps();
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 {
CardType::New => NormalState::New(NewState {
position: due.max(0) as u32,
}),
CardType::Learn => {
let last_ivl = self.learn_steps().current_delay_secs(remaining_steps);
LearnState {
scheduled_secs: self.learn_steps().current_delay_secs(remaining_steps),
scheduled_secs: last_ivl,
remaining_steps,
elapsed_secs: elapsed_secs(last_ivl),
memory_state,
}
}
@ -87,20 +114,24 @@ impl CardStateUpdater {
memory_state,
}
.into(),
CardType::Relearn => RelearnState {
learning: LearnState {
scheduled_secs: self.relearn_steps().current_delay_secs(remaining_steps),
remaining_steps,
memory_state,
},
review: ReviewState {
scheduled_days: interval,
elapsed_days: interval,
ease_factor,
lapses,
leeched: false,
memory_state,
},
CardType::Relearn => {
let last_ivl = self.relearn_steps().current_delay_secs(remaining_steps);
RelearnState {
learning: LearnState {
scheduled_secs: last_ivl,
elapsed_secs: elapsed_secs(last_ivl),
remaining_steps,
memory_state,
},
review: ReviewState {
scheduled_days: interval,
elapsed_days: interval,
ease_factor,
lapses,
leeched: false,
memory_state,
},
}
}
.into(),
}

View File

@ -76,12 +76,12 @@ impl CardStateUpdater {
/// Adds secs + fuzz to current time
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.
fn with_learning_fuzz(&self, secs: u32) -> u32 {
if let Some(seed) = self.fuzz_seed {
pub(super) fn learning_ivl_with_fuzz(&self, input_seed: Option<u64>, secs: u32) -> u32 {
if let Some(seed) = input_seed {
let mut rng = StdRng::seed_from_u64(seed);
let upper_exclusive = secs + ((secs as f32) * 0.25).min(300.0).floor() as u32;
if secs >= upper_exclusive {

View File

@ -270,6 +270,9 @@ impl Collection {
let mut updater = self.card_state_updater(card)?;
answer.cap_answer_secs(updater.config.inner.cap_answer_time_to_secs);
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(&current_state, &mut answer.current_state);
require!(
current_state == answer.current_state,
"card was modified: {current_state:#?} {:#?}",
@ -417,6 +420,41 @@ impl Collection {
self.add_tags_to_notes_inner(&[nid], "leech")?;
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)]
@ -476,12 +514,16 @@ impl Card {
}
/// 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> {
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) {
None
} 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(())
}
#[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(())
}
}

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,
elapsed_secs: state.elapsed_secs,
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 {
remaining_steps: state.remaining_steps,
scheduled_secs: state.scheduled_secs,
elapsed_secs: state.elapsed_secs,
memory_state: state.memory_state.map(Into::into),
}
}

View File

@ -13,6 +13,7 @@ use crate::revlog::RevlogReviewKind;
pub struct LearnState {
pub remaining_steps: u32,
pub scheduled_secs: u32,
pub elapsed_secs: u32,
pub memory_state: Option<FsrsMemoryState>,
}
@ -39,6 +40,7 @@ impl LearnState {
LearnState {
remaining_steps: ctx.steps.remaining_for_failed(),
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()),
}
}
@ -50,6 +52,7 @@ impl LearnState {
.hard_delay_secs(self.remaining_steps)
// user has 0 learning steps, which the UI doesn't allow
.unwrap_or(60),
elapsed_secs: 0,
memory_state: ctx.fsrs_next_states.as_ref().map(|s| s.hard.memory.into()),
..self
}
@ -61,6 +64,7 @@ impl LearnState {
LearnState {
remaining_steps: ctx.steps.remaining_for_good(self.remaining_steps),
scheduled_secs: good_delay,
elapsed_secs: 0,
memory_state,
}
.into()

View File

@ -44,6 +44,7 @@ impl NormalState {
let next_states = LearnState {
remaining_steps: ctx.steps.remaining_for_failed(),
scheduled_secs: 0,
elapsed_secs: 0,
memory_state: None,
}
.next_states(ctx);

View File

@ -41,6 +41,7 @@ impl RelearnState {
learning: LearnState {
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
scheduled_secs: again_delay,
elapsed_secs: 0,
memory_state,
},
review: ReviewState {
@ -92,6 +93,7 @@ impl RelearnState {
remaining_steps: ctx
.relearn_steps
.remaining_for_good(self.learning.remaining_steps),
elapsed_secs: 0,
memory_state,
},
review: ReviewState {

View File

@ -104,6 +104,7 @@ impl ReviewState {
learning: LearnState {
remaining_steps: ctx.relearn_steps.remaining_for_failed(),
scheduled_secs: again_delay,
elapsed_secs: 0,
memory_state,
},
review: again_review,