Anki/rslib/src/scheduler/answering/mod.rs
Damien Elmes f44817e080 revert some interday learning changes in v3
Interday learning cards are now counted in the learning count again,
and are no longer subject to the daily review limit.

The thinking behind the original change was that interday learning cards
are scheduled more like reviews, and counting them in the review count
would allow the learning count to focus on intraday learning - the red
number reflecting the fact that they are the most fragile memories. And
counting them together made it practical to apply the review limit
to both at once.

Since the release, there have been a number of users expecting to see
interday learning cards included in the learning count (the latest being
https://forums.ankiweb.net/t/feedback-and-a-feature-adjustment-request-for-2-1-45/12308),
and a good argument can be made for that too - they are, after all, listed
in the learning steps, and do tend to be harder than reviews. Short of
introducing another count to keep track of interday and intraday learning
separately, moving back to the old behaviour seems like the best move.

This also means it is not really practical to apply the review limit to
interday learning cards anymore, as the limit would be split between two
different numbers, and how much each number is capped would depend on
the order cards are introduced. The scheduler could figure this out, but
the deck list code does not know card order, and would need significant
changes to be able to produce numbers that matched the scheduler. And
even if we ignore implementation complexities, I think it would be more
difficult for users to reason about - the influence of the review limit
on new cards is confusing enough as it is.
2021-08-19 16:40:12 +10:00

559 lines
18 KiB
Rust

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod current;
mod learning;
mod preview;
mod relearning;
mod review;
mod revlog;
use revlog::RevlogEntryPartial;
use super::{
states::{
steps::LearningSteps, CardState, FilteredState, NextCardStates, NormalState, StateContext,
},
timespan::answer_button_time_collapsible,
timing::SchedTimingToday,
};
use crate::{
backend_proto,
card::CardQueue,
deckconfig::{DeckConfig, LeechAction},
decks::Deck,
prelude::*,
};
#[derive(Copy, Clone)]
pub enum Rating {
Again,
Hard,
Good,
Easy,
}
pub struct CardAnswer {
pub card_id: CardId,
pub current_state: CardState,
pub new_state: CardState,
pub rating: Rating,
pub answered_at: TimestampMillis,
pub milliseconds_taken: u32,
}
// fixme: log preview review
/// Holds the information required to determine a given card's
/// current state, and to apply a state change to it.
struct CardStateUpdater {
card: Card,
deck: Deck,
config: DeckConfig,
timing: SchedTimingToday,
now: TimestampSecs,
fuzz_seed: Option<u64>,
}
impl CardStateUpdater {
/// Returns information required when transitioning from one card state to
/// another with `next_states()`. This separate structure decouples the
/// state handling code from the rest of the Anki codebase.
pub(crate) fn state_context(&self) -> StateContext<'_> {
StateContext {
fuzz_seed: self.fuzz_seed,
steps: self.learn_steps(),
graduating_interval_good: self.config.inner.graduating_interval_good,
graduating_interval_easy: self.config.inner.graduating_interval_easy,
initial_ease_factor: self.config.inner.initial_ease,
hard_multiplier: self.config.inner.hard_multiplier,
easy_multiplier: self.config.inner.easy_multiplier,
interval_multiplier: self.config.inner.interval_multiplier,
maximum_review_interval: self.config.inner.maximum_review_interval,
leech_threshold: self.config.inner.leech_threshold,
relearn_steps: self.relearn_steps(),
lapse_multiplier: self.config.inner.lapse_multiplier,
minimum_lapse_interval: self.config.inner.minimum_lapse_interval,
in_filtered_deck: self.deck.is_filtered(),
preview_step: if let DeckKind::Filtered(deck) = &self.deck.kind {
deck.preview_delay
} else {
0
},
}
}
fn learn_steps(&self) -> LearningSteps<'_> {
LearningSteps::new(&self.config.inner.learn_steps)
}
fn relearn_steps(&self) -> LearningSteps<'_> {
LearningSteps::new(&self.config.inner.relearn_steps)
}
fn secs_until_rollover(&self) -> u32 {
self.timing.next_day_at.elapsed_secs_since(self.now) as u32
}
fn into_card(self) -> Card {
self.card
}
fn apply_study_state(
&mut self,
current: CardState,
next: CardState,
) -> Result<Option<RevlogEntryPartial>> {
let revlog = match next {
CardState::Normal(normal) => {
// transitioning from filtered state?
if let CardState::Filtered(filtered) = &current {
match filtered {
FilteredState::Preview(_) => {
return Err(AnkiError::invalid_input(
"should set finished=true, not return different state",
));
}
FilteredState::Rescheduling(_) => {
// card needs to be removed from normal filtered deck, then scheduled normally
self.card.remove_from_filtered_deck_before_reschedule();
}
}
}
// apply normal scheduling
self.apply_normal_study_state(current, normal)
}
CardState::Filtered(filtered) => {
self.ensure_filtered()?;
match filtered {
FilteredState::Preview(next) => self.apply_preview_state(current, next),
FilteredState::Rescheduling(next) => {
self.apply_normal_study_state(current, next.original_state)
}
}
}
};
Ok(revlog)
}
fn apply_normal_study_state(
&mut self,
current: CardState,
next: NormalState,
) -> Option<RevlogEntryPartial> {
self.card.reps += 1;
self.card.original_due = 0;
let revlog = match next {
NormalState::New(next) => self.apply_new_state(current, next),
NormalState::Learning(next) => self.apply_learning_state(current, next),
NormalState::Review(next) => self.apply_review_state(current, next),
NormalState::Relearning(next) => self.apply_relearning_state(current, next),
};
if next.leeched() && self.config.inner.leech_action() == LeechAction::Suspend {
self.card.queue = CardQueue::Suspended;
}
revlog
}
fn ensure_filtered(&self) -> Result<()> {
if self.card.original_deck_id.0 == 0 {
Err(AnkiError::invalid_input(
"card answering can't transition into filtered state",
))
} else {
Ok(())
}
}
}
impl Rating {
fn as_number(self) -> u8 {
match self {
Rating::Again => 1,
Rating::Hard => 2,
Rating::Good => 3,
Rating::Easy => 4,
}
}
}
impl Collection {
/// Return the next states that will be applied for each answer button.
pub fn get_next_card_states(&mut self, cid: CardId) -> Result<NextCardStates> {
let card = self.storage.get_card(cid)?.ok_or(AnkiError::NotFound)?;
let ctx = self.card_state_updater(card)?;
let current = ctx.current_card_state();
let state_ctx = ctx.state_context();
Ok(current.next_states(&state_ctx))
}
/// Describe the next intervals, to display on the answer buttons.
pub fn describe_next_states(&mut self, choices: NextCardStates) -> Result<Vec<String>> {
let collapse_time = self.learn_ahead_secs();
let now = TimestampSecs::now();
let timing = self.timing_for_timestamp(now)?;
let secs_until_rollover = timing.next_day_at.elapsed_secs_since(now).max(0) as u32;
Ok(vec![
answer_button_time_collapsible(
choices
.again
.interval_kind()
.maybe_as_days(secs_until_rollover)
.as_seconds(),
collapse_time,
&self.tr,
),
answer_button_time_collapsible(
choices
.hard
.interval_kind()
.maybe_as_days(secs_until_rollover)
.as_seconds(),
collapse_time,
&self.tr,
),
answer_button_time_collapsible(
choices
.good
.interval_kind()
.maybe_as_days(secs_until_rollover)
.as_seconds(),
collapse_time,
&self.tr,
),
answer_button_time_collapsible(
choices
.easy
.interval_kind()
.maybe_as_days(secs_until_rollover)
.as_seconds(),
collapse_time,
&self.tr,
),
])
}
/// Answer card, writing its new state to the database.
pub fn answer_card(&mut self, answer: &CardAnswer) -> Result<OpOutput<()>> {
self.transact(Op::AnswerCard, |col| col.answer_card_inner(answer))
}
fn answer_card_inner(&mut self, answer: &CardAnswer) -> Result<()> {
let card = self
.storage
.get_card(answer.card_id)?
.ok_or(AnkiError::NotFound)?;
let original = card.clone();
let usn = self.usn()?;
let mut updater = self.card_state_updater(card)?;
let current_state = updater.current_card_state();
if current_state != answer.current_state {
return Err(AnkiError::invalid_input(format!(
"card was modified: {:#?} {:#?}",
current_state, answer.current_state,
)));
}
if let Some(revlog_partial) = updater.apply_study_state(current_state, answer.new_state)? {
self.add_partial_revlog(revlog_partial, usn, answer)?;
}
self.update_deck_stats_from_answer(usn, answer, &updater, original.queue)?;
self.maybe_bury_siblings(&original, &updater.config)?;
let timing = updater.timing;
let mut card = updater.into_card();
self.update_card_inner(&mut card, original, usn)?;
if answer.new_state.leeched() {
self.add_leech_tag(card.note_id)?;
}
self.update_queues_after_answering_card(&card, timing)
}
fn maybe_bury_siblings(&mut self, card: &Card, config: &DeckConfig) -> Result<()> {
if config.inner.bury_new || config.inner.bury_reviews {
self.bury_siblings(
card.id,
card.note_id,
config.inner.bury_new,
config.inner.bury_reviews,
)?;
}
Ok(())
}
fn add_partial_revlog(
&mut self,
partial: RevlogEntryPartial,
usn: Usn,
answer: &CardAnswer,
) -> Result<()> {
let revlog = partial.into_revlog_entry(
usn,
answer.card_id,
answer.rating.as_number(),
answer.answered_at,
answer.milliseconds_taken,
);
self.add_revlog_entry_undoable(revlog)?;
Ok(())
}
fn update_deck_stats_from_answer(
&mut self,
usn: Usn,
answer: &CardAnswer,
updater: &CardStateUpdater,
from_queue: CardQueue,
) -> Result<()> {
let mut new_delta = 0;
let mut review_delta = 0;
match from_queue {
CardQueue::New => new_delta += 1,
CardQueue::Review => review_delta += 1,
_ => {}
}
self.update_deck_stats(
updater.timing.days_elapsed,
usn,
backend_proto::UpdateStatsRequest {
deck_id: updater.deck.id.0,
new_delta,
review_delta,
millisecond_delta: answer.milliseconds_taken as i32,
},
)
}
fn card_state_updater(&mut self, card: Card) -> Result<CardStateUpdater> {
let timing = self.timing_today()?;
let deck = self
.storage
.get_deck(card.deck_id)?
.ok_or(AnkiError::NotFound)?;
let config = self.home_deck_config(deck.config_id(), card.original_deck_id)?;
Ok(CardStateUpdater {
fuzz_seed: get_fuzz_seed(&card),
card,
deck,
config,
timing,
now: TimestampSecs::now(),
})
}
fn home_deck_config(
&self,
config_id: Option<DeckConfigId>,
home_deck_id: DeckId,
) -> Result<DeckConfig> {
let config_id = if let Some(config_id) = config_id {
config_id
} else {
let home_deck = self
.storage
.get_deck(home_deck_id)?
.ok_or(AnkiError::NotFound)?;
home_deck.config_id().ok_or(AnkiError::NotFound)?
};
Ok(self.storage.get_deck_config(config_id)?.unwrap_or_default())
}
fn add_leech_tag(&mut self, nid: NoteId) -> Result<()> {
self.add_tags_to_notes_inner(&[nid], "leech")?;
Ok(())
}
}
#[cfg(test)]
pub mod test_helpers {
use super::*;
pub struct PostAnswerState {
pub card_id: CardId,
pub new_state: CardState,
}
impl Collection {
pub(crate) fn answer_again(&mut self) -> PostAnswerState {
self.answer(|states| states.again, Rating::Again).unwrap()
}
#[allow(dead_code)]
pub(crate) fn answer_hard(&mut self) -> PostAnswerState {
self.answer(|states| states.hard, Rating::Hard).unwrap()
}
pub(crate) fn answer_good(&mut self) -> PostAnswerState {
self.answer(|states| states.good, Rating::Good).unwrap()
}
pub(crate) fn answer_easy(&mut self) -> PostAnswerState {
self.answer(|states| states.easy, Rating::Easy).unwrap()
}
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState>
where
F: FnOnce(&NextCardStates) -> CardState,
{
let queued = self.get_next_card()?.unwrap();
let new_state = get_state(&queued.next_states);
self.answer_card(&CardAnswer {
card_id: queued.card.id,
current_state: queued.next_states.current,
new_state,
rating,
answered_at: TimestampMillis::now(),
milliseconds_taken: 0,
})?;
Ok(PostAnswerState {
card_id: queued.card.id,
new_state,
})
}
}
}
/// 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> {
if *crate::PYTHON_UNIT_TESTS || cfg!(test) {
None
} else {
Some((card.id.0 as u64).wrapping_add(card.reps as u64))
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::{card::CardType, collection::open_test_collection};
fn current_state(col: &mut Collection, card_id: CardId) -> CardState {
col.get_next_card_states(card_id).unwrap().current
}
// make sure the 'current' state for a card matches the
// state we applied to it
#[test]
fn state_application() -> Result<()> {
let mut col = open_test_collection();
if col.timing_today()?.near_cutoff() {
return Ok(());
}
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
col.add_note(&mut note, DeckId(1))?;
// new->learning
let post_answer = col.answer_again();
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.remaining_steps, 2);
// learning step
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let post_answer = col.answer_good();
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.remaining_steps, 1);
// graduation
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_good();
// compensate for shifting the due date
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
state.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Review);
assert_eq!(card.interval, 1);
assert_eq!(card.remaining_steps, 0);
// answering a review card again; easy boost
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_easy();
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
state.elapsed_days = 4;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Review);
assert_eq!(card.interval, 4);
assert_eq!(card.ease_factor, 2650);
// lapsing it
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_again();
if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {
state.review.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.ctype, CardType::Relearn);
assert_eq!(card.interval, 1);
assert_eq!(card.ease_factor, 2450);
assert_eq!(card.lapses, 1);
// failed in relearning
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_again();
if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {
state.review.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Learn);
assert_eq!(card.lapses, 1);
// re-graduating
col.storage.db.execute_batch("update cards set due=0")?;
col.clear_study_queues();
let mut post_answer = col.answer_good();
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
state.elapsed_days = 1;
};
assert_eq!(
post_answer.new_state,
current_state(&mut col, post_answer.card_id)
);
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
assert_eq!(card.queue, CardQueue::Review);
assert_eq!(card.interval, 1);
Ok(())
}
}