diff --git a/proto/anki/scheduler.proto b/proto/anki/scheduler.proto index 3370138bd..27bb73709 100644 --- a/proto/anki/scheduler.proto +++ b/proto/anki/scheduler.proto @@ -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 { diff --git a/rslib/src/scheduler/answering/current.rs b/rslib/src/scheduler/answering/current.rs index bce04fcf0..2fcc92f63 100644 --- a/rslib/src/scheduler/answering/current.rs +++ b/rslib/src/scheduler/answering/current.rs @@ -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(), } diff --git a/rslib/src/scheduler/answering/learning.rs b/rslib/src/scheduler/answering/learning.rs index 3888ae651..80204e13f 100644 --- a/rslib/src/scheduler/answering/learning.rs +++ b/rslib/src/scheduler/answering/learning.rs @@ -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, 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 { diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 76debca62..d27b799b4 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -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 { + 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 { 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(()) + } } diff --git a/rslib/src/scheduler/service/states/learning.rs b/rslib/src/scheduler/service/states/learning.rs index 17598065e..a52c674c8 100644 --- a/rslib/src/scheduler/service/states/learning.rs +++ b/rslib/src/scheduler/service/states/learning.rs @@ -8,6 +8,7 @@ impl From 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 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), } } diff --git a/rslib/src/scheduler/states/learning.rs b/rslib/src/scheduler/states/learning.rs index 32f9d5721..b26774131 100644 --- a/rslib/src/scheduler/states/learning.rs +++ b/rslib/src/scheduler/states/learning.rs @@ -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, } @@ -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() diff --git a/rslib/src/scheduler/states/normal.rs b/rslib/src/scheduler/states/normal.rs index 8fbb68c66..c41f7e9cb 100644 --- a/rslib/src/scheduler/states/normal.rs +++ b/rslib/src/scheduler/states/normal.rs @@ -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); diff --git a/rslib/src/scheduler/states/relearning.rs b/rslib/src/scheduler/states/relearning.rs index 14206d802..dceb49222 100644 --- a/rslib/src/scheduler/states/relearning.rs +++ b/rslib/src/scheduler/states/relearning.rs @@ -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 { diff --git a/rslib/src/scheduler/states/review.rs b/rslib/src/scheduler/states/review.rs index 308f0931a..2f3bdbd30 100644 --- a/rslib/src/scheduler/states/review.rs +++ b/rslib/src/scheduler/states/review.rs @@ -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,