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
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(¤t_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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue