implement leech handling

Also change the default for new users to "tag only"
This commit is contained in:
Damien Elmes 2021-02-23 12:44:26 +10:00
parent e99deedbd8
commit f165576992
14 changed files with 171 additions and 38 deletions

View File

@ -465,22 +465,20 @@ limit ?"""
########################################################################## ##########################################################################
def answerCard(self, card: Card, ease: int) -> None: def answerCard(self, card: Card, ease: int) -> None:
self.col.log()
assert 1 <= ease <= 4 assert 1 <= ease <= 4
assert 0 <= card.queue <= 4 assert 0 <= card.queue <= 4
self.col.markReview(card) self.col.markReview(card)
if self._burySiblingsOnAnswer: if self._burySiblingsOnAnswer:
self._burySiblings(card) self._burySiblings(card)
self._answerCard(card, ease) new_state = self._answerCard(card, ease)
if not self._handle_leech(card, new_state):
self._maybe_requeue_card(card) self._maybe_requeue_card(card)
card.mod = intTime() def _answerCard(self, card: Card, ease: int) -> _pb.SchedulingState:
card.usn = self.col.usn()
card.flush()
def _answerCard(self, card: Card, ease: int) -> None:
states = self.col._backend.get_next_card_states(card.id) states = self.col._backend.get_next_card_states(card.id)
if ease == BUTTON_ONE: if ease == BUTTON_ONE:
new_state = states.again new_state = states.again
@ -509,6 +507,22 @@ limit ?"""
# fixme: tests assume card will be mutated, so we need to reload it # fixme: tests assume card will be mutated, so we need to reload it
card.load() card.load()
return new_state
def _handle_leech(self, card: Card, new_state: _pb.SchedulingState) -> bool:
"True if was leech."
if self.col._backend.state_is_leech(new_state):
if hooks.card_did_leech.count() > 0:
hooks.card_did_leech(card)
# leech hooks assumed that card mutations would be saved for them
card.mod = intTime()
card.usn = self.col.usn()
card.flush()
return True
else:
return False
def _maybe_requeue_card(self, card: Card) -> None: def _maybe_requeue_card(self, card: Card) -> None:
# preview cards # preview cards
if card.queue == QUEUE_TYPE_PREVIEW: if card.queue == QUEUE_TYPE_PREVIEW:
@ -604,29 +618,6 @@ limit ?"""
return self._interval_for_state(new_state) return self._interval_for_state(new_state)
# Leeches
##########################################################################
def _checkLeech(self, card: Card, conf: QueueConfig) -> bool:
"Leech handler. True if card was a leech."
lf = conf["leechFails"]
if not lf:
return False
# if over threshold or every half threshold reps after that
if card.lapses >= lf and (card.lapses - lf) % (max(lf // 2, 1)) == 0:
# add a leech tag
f = card.note()
f.addTag("leech")
f.flush()
# handle
a = conf["leechAction"]
if a == LEECH_SUSPEND:
card.queue = QUEUE_TYPE_SUSPENDED
# notify UI
hooks.card_did_leech(card)
return True
return False
# Sibling spacing # Sibling spacing
########################################################################## ##########################################################################

View File

@ -54,6 +54,7 @@ fn want_release_gil(method: u32) -> bool {
| BackendMethod::JoinSearchNodes | BackendMethod::JoinSearchNodes
| BackendMethod::ReplaceSearchNode | BackendMethod::ReplaceSearchNode
| BackendMethod::BuildSearchString | BackendMethod::BuildSearchString
| BackendMethod::StateIsLeech
) )
} else { } else {
false false

View File

@ -405,6 +405,7 @@ def test_reviews():
assert c.queue == QUEUE_TYPE_SUSPENDED assert c.queue == QUEUE_TYPE_SUSPENDED
c.load() c.load()
assert c.queue == QUEUE_TYPE_SUSPENDED assert c.queue == QUEUE_TYPE_SUSPENDED
assert "leech" in c.note().tags
def test_review_limits(): def test_review_limits():

View File

@ -117,6 +117,7 @@ service BackendService {
rpc SortDeck(SortDeckIn) returns (Empty); rpc SortDeck(SortDeckIn) returns (Empty);
rpc GetNextCardStates(CardID) returns (NextCardStates); rpc GetNextCardStates(CardID) returns (NextCardStates);
rpc DescribeNextStates(NextCardStates) returns (StringList); rpc DescribeNextStates(NextCardStates) returns (StringList);
rpc StateIsLeech(SchedulingState) returns (Bool);
rpc AnswerCard(AnswerCardIn) returns (Empty); rpc AnswerCard(AnswerCardIn) returns (Empty);
rpc UpgradeScheduler(Empty) returns (Empty); rpc UpgradeScheduler(Empty) returns (Empty);
@ -1283,6 +1284,7 @@ message SchedulingState {
uint32 elapsed_days = 2; uint32 elapsed_days = 2;
float ease_factor = 3; float ease_factor = 3;
uint32 lapses = 4; uint32 lapses = 4;
bool leeched = 5;
} }
message Relearning { message Relearning {
Review review = 1; Review review = 1;

View File

@ -15,6 +15,12 @@ impl From<String> for pb::String {
} }
} }
impl From<bool> for pb::Bool {
fn from(val: bool) -> Self {
pb::Bool { val }
}
}
impl From<i64> for pb::Int64 { impl From<i64> for pb::Int64 {
fn from(val: i64) -> Self { fn from(val: i64) -> Self {
pb::Int64 { val } pb::Int64 { val }

View File

@ -33,7 +33,7 @@ use crate::{
sched::{ sched::{
new::NewCardSortOrder, new::NewCardSortOrder,
parse_due_date_str, parse_due_date_str,
states::NextCardStates, states::{CardState, NextCardStates},
timespan::{answer_button_time, time_span}, timespan::{answer_button_time, time_span},
}, },
search::{ search::{
@ -683,6 +683,11 @@ impl BackendService for Backend {
.map(Into::into) .map(Into::into)
} }
fn state_is_leech(&self, input: pb::SchedulingState) -> BackendResult<pb::Bool> {
let state: CardState = input.into();
Ok(state.leeched().into())
}
fn answer_card(&self, input: pb::AnswerCardIn) -> BackendResult<pb::Empty> { fn answer_card(&self, input: pb::AnswerCardIn) -> BackendResult<pb::Empty> {
self.with_col(|col| col.answer_card(&input.into())) self.with_col(|col| col.answer_card(&input.into()))
.map(Into::into) .map(Into::into)

View File

@ -10,6 +10,7 @@ impl From<pb::scheduling_state::Review> for ReviewState {
elapsed_days: state.elapsed_days, elapsed_days: state.elapsed_days,
ease_factor: state.ease_factor, ease_factor: state.ease_factor,
lapses: state.lapses, lapses: state.lapses,
leeched: state.leeched,
} }
} }
} }
@ -21,6 +22,7 @@ impl From<ReviewState> for pb::scheduling_state::Review {
elapsed_days: state.elapsed_days, elapsed_days: state.elapsed_days,
ease_factor: state.ease_factor, ease_factor: state.ease_factor,
lapses: state.lapses, lapses: state.lapses,
leeched: state.leeched,
} }
} }
} }

View File

@ -130,7 +130,7 @@ pub struct LapseConfSchema11 {
impl Default for LeechAction { impl Default for LeechAction {
fn default() -> Self { fn default() -> Self {
LeechAction::Suspend LeechAction::TagOnly
} }
} }

View File

@ -32,7 +32,7 @@ pub(crate) struct TransformNoteOutput {
pub mark_modified: bool, pub mark_modified: bool,
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq, Clone)]
pub struct Note { pub struct Note {
pub id: NoteID, pub id: NoteID,
pub guid: String, pub guid: String,
@ -467,6 +467,32 @@ impl Collection {
Ok(DuplicateState::Empty) Ok(DuplicateState::Empty)
} }
} }
/// Update the tags of the provided note, canonifying before save. Requires a transaction.
/// Fixme: this currently pulls in the note type, and does more work than necessary. We
/// could add a separate method to the storage layer to just update the tags in the future,
/// though it does slightly complicate the undo story.
pub(crate) fn update_note_tags<F>(&mut self, nid: NoteID, mutator: F) -> Result<()>
where
F: Fn(&mut Vec<String>),
{
self.transform_notes(&[nid], |note, _nt| {
let mut tags = note.tags.clone();
mutator(&mut tags);
let changed = if tags != note.tags {
note.tags = tags;
true
} else {
false
};
Ok(TransformNoteOutput {
changed,
generate_cards: false,
mark_modified: true,
})
})
.map(|_| ())
}
} }
#[cfg(test)] #[cfg(test)]

View File

@ -4,7 +4,7 @@
use crate::{ use crate::{
backend_proto, backend_proto,
card::{CardQueue, CardType}, card::{CardQueue, CardType},
deckconf::DeckConf, deckconf::{DeckConf, LeechAction},
decks::{Deck, DeckKind}, decks::{Deck, DeckKind},
prelude::*, prelude::*,
revlog::{RevlogEntry, RevlogReviewKind}, revlog::{RevlogEntry, RevlogReviewKind},
@ -63,6 +63,7 @@ impl AnswerContext {
easy_multiplier: self.config.inner.easy_multiplier, easy_multiplier: self.config.inner.easy_multiplier,
interval_multiplier: self.config.inner.interval_multiplier, interval_multiplier: self.config.inner.interval_multiplier,
maximum_review_interval: self.config.inner.maximum_review_interval, maximum_review_interval: self.config.inner.maximum_review_interval,
leech_threshold: self.config.inner.leech_threshold,
relearn_steps: self.relearn_steps(), relearn_steps: self.relearn_steps(),
lapse_multiplier: self.config.inner.lapse_multiplier, lapse_multiplier: self.config.inner.lapse_multiplier,
minimum_lapse_interval: self.config.inner.minimum_lapse_interval, minimum_lapse_interval: self.config.inner.minimum_lapse_interval,
@ -105,6 +106,7 @@ impl AnswerContext {
as u32, as u32,
ease_factor, ease_factor,
lapses, lapses,
leeched: false,
} }
.into(), .into(),
CardType::Relearn => RelearnState { CardType::Relearn => RelearnState {
@ -117,6 +119,7 @@ impl AnswerContext {
elapsed_days: interval, elapsed_days: interval,
ease_factor, ease_factor,
lapses, lapses,
leeched: false,
}, },
} }
.into(), .into(),
@ -197,7 +200,7 @@ impl Card {
self.original_due = 0; self.original_due = 0;
} }
match next { let revlog = match next {
CardState::Normal(normal) => match normal { CardState::Normal(normal) => match normal {
NormalState::New(next) => self.apply_new_state(current, next, ctx), NormalState::New(next) => self.apply_new_state(current, next, ctx),
NormalState::Learning(next) => self.apply_learning_state(current, next, ctx), NormalState::Learning(next) => self.apply_learning_state(current, next, ctx),
@ -210,7 +213,13 @@ impl Card {
self.apply_rescheduling_state(current, next, ctx) self.apply_rescheduling_state(current, next, ctx)
} }
}, },
}?;
if next.leeched() && ctx.config.inner.leech_action() == LeechAction::Suspend {
self.queue = CardQueue::Suspended;
} }
Ok(revlog)
} }
fn apply_new_state( fn apply_new_state(
@ -386,6 +395,7 @@ pub struct RevlogEntryPartial {
} }
impl RevlogEntryPartial { impl RevlogEntryPartial {
/// Returns None in the Preview case, since preview cards do not currently log.
fn maybe_new( fn maybe_new(
current: CardState, current: CardState,
next: CardState, next: CardState,
@ -508,6 +518,9 @@ impl Collection {
self.storage.add_revlog_entry(&revlog)?; self.storage.add_revlog_entry(&revlog)?;
} }
self.update_card(&mut card, &original, usn)?; self.update_card(&mut card, &original, usn)?;
if answer.new_state.leeched() {
self.add_leech_tag(card.note_id)?;
}
// fixme: we're reusing code used by python, which means re-feteching the target deck // fixme: we're reusing code used by python, which means re-feteching the target deck
// - might want to avoid that in the future // - might want to avoid that in the future
@ -556,6 +569,10 @@ impl Collection {
let state_ctx = ctx.state_context(); let state_ctx = ctx.state_context();
Ok(current.next_states(&state_ctx)) Ok(current.next_states(&state_ctx))
} }
fn add_leech_tag(&mut self, nid: NoteID) -> Result<()> {
self.update_note_tags(nid, |tags| tags.push("leech".into()))
}
} }
/// 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.

View File

@ -3,7 +3,9 @@
use crate::revlog::RevlogReviewKind; use crate::revlog::RevlogReviewKind;
use super::{IntervalKind, NextCardStates, PreviewState, ReschedulingFilterState, StateContext}; use super::{
IntervalKind, NextCardStates, PreviewState, ReschedulingFilterState, ReviewState, StateContext,
};
#[derive(Debug, Clone, Copy, PartialEq)] #[derive(Debug, Clone, Copy, PartialEq)]
pub enum FilteredState { pub enum FilteredState {
@ -32,4 +34,11 @@ impl FilteredState {
FilteredState::Rescheduling(state) => state.next_states(ctx), FilteredState::Rescheduling(state) => state.next_states(ctx),
} }
} }
pub(crate) fn review_state(self) -> Option<ReviewState> {
match self {
FilteredState::Preview(state) => state.original_state.review_state(),
FilteredState::Rescheduling(state) => state.original_state.review_state(),
}
}
} }

View File

@ -54,6 +54,18 @@ impl CardState {
CardState::Filtered(state) => state.next_states(&ctx), CardState::Filtered(state) => state.next_states(&ctx),
} }
} }
/// Returns underlying review state, if it exists.
pub(crate) fn review_state(self) -> Option<ReviewState> {
match self {
CardState::Normal(state) => state.review_state(),
CardState::Filtered(state) => state.review_state(),
}
}
pub(crate) fn leeched(self) -> bool {
self.review_state().map(|r| r.leeched).unwrap_or_default()
}
} }
/// Info required during state transitions. /// Info required during state transitions.
@ -70,6 +82,7 @@ pub(crate) struct StateContext<'a> {
pub easy_multiplier: f32, pub easy_multiplier: f32,
pub interval_multiplier: f32, pub interval_multiplier: f32,
pub maximum_review_interval: u32, pub maximum_review_interval: u32,
pub leech_threshold: u32,
// relearning // relearning
pub relearn_steps: LearningSteps<'a>, pub relearn_steps: LearningSteps<'a>,

View File

@ -55,6 +55,15 @@ impl NormalState {
NormalState::Relearning(state) => state.next_states(ctx), NormalState::Relearning(state) => state.next_states(ctx),
} }
} }
pub(crate) fn review_state(self) -> Option<ReviewState> {
match self {
NormalState::New(_) => None,
NormalState::Learning(_) => None,
NormalState::Review(state) => Some(state),
NormalState::Relearning(RelearnState { review, .. }) => Some(review),
}
}
} }
impl From<NewState> for NormalState { impl From<NewState> for NormalState {

View File

@ -19,6 +19,7 @@ pub struct ReviewState {
pub elapsed_days: u32, pub elapsed_days: u32,
pub ease_factor: f32, pub ease_factor: f32,
pub lapses: u32, pub lapses: u32,
pub leeched: bool,
} }
impl Default for ReviewState { impl Default for ReviewState {
@ -28,6 +29,7 @@ impl Default for ReviewState {
elapsed_days: 0, elapsed_days: 0,
ease_factor: INITIAL_EASE_FACTOR, ease_factor: INITIAL_EASE_FACTOR,
lapses: 0, lapses: 0,
leeched: false,
} }
} }
} }
@ -71,11 +73,14 @@ impl ReviewState {
} }
fn answer_again(self, ctx: &StateContext) -> CardState { fn answer_again(self, ctx: &StateContext) -> CardState {
let lapses = self.lapses + 1;
let leeched = leech_threshold_met(lapses, ctx.leech_threshold);
let again_review = ReviewState { let again_review = ReviewState {
scheduled_days: self.failing_review_interval(ctx), scheduled_days: self.failing_review_interval(ctx),
elapsed_days: 0, elapsed_days: 0,
ease_factor: (self.ease_factor + EASE_FACTOR_AGAIN_DELTA).max(MINIMUM_EASE_FACTOR), ease_factor: (self.ease_factor + EASE_FACTOR_AGAIN_DELTA).max(MINIMUM_EASE_FACTOR),
lapses: self.lapses + 1, lapses,
leeched,
}; };
if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() { if let Some(again_delay) = ctx.relearn_steps.again_delay_secs_relearn() {
@ -196,6 +201,18 @@ impl ReviewState {
} }
} }
/// True when lapses is at threshold, or every half threshold after that.
/// Non-even thresholds round up the half threshold.
fn leech_threshold_met(lapses: u32, threshold: u32) -> bool {
if threshold > 0 {
let half_threshold = (threshold as f32 / 2.0).ceil().max(1.0) as u32;
// at threshold, and every half threshold after that, rounding up
lapses >= threshold && (lapses - threshold) % half_threshold == 0
} else {
false
}
}
/// Transform the provided hard/good/easy interval. /// Transform the provided hard/good/easy interval.
/// - Apply configured interval multiplier. /// - Apply configured interval multiplier.
/// - Apply fuzz. /// - Apply fuzz.
@ -214,3 +231,37 @@ fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32, f
.min(ctx.maximum_review_interval) .min(ctx.maximum_review_interval)
.max(1) .max(1)
} }
#[cfg(test)]
mod test {
use super::*;
#[test]
fn leech_threshold() {
assert_eq!(leech_threshold_met(0, 3), false);
assert_eq!(leech_threshold_met(1, 3), false);
assert_eq!(leech_threshold_met(2, 3), false);
assert_eq!(leech_threshold_met(3, 3), true);
assert_eq!(leech_threshold_met(4, 3), false);
assert_eq!(leech_threshold_met(5, 3), true);
assert_eq!(leech_threshold_met(6, 3), false);
assert_eq!(leech_threshold_met(7, 3), true);
assert_eq!(leech_threshold_met(7, 8), false);
assert_eq!(leech_threshold_met(8, 8), true);
assert_eq!(leech_threshold_met(9, 8), false);
assert_eq!(leech_threshold_met(10, 8), false);
assert_eq!(leech_threshold_met(11, 8), false);
assert_eq!(leech_threshold_met(12, 8), true);
assert_eq!(leech_threshold_met(13, 8), false);
// 0 means off
assert_eq!(leech_threshold_met(0, 0), false);
// no div by zero; half of 1 is 1
assert_eq!(leech_threshold_met(0, 1), false);
assert_eq!(leech_threshold_met(1, 1), true);
assert_eq!(leech_threshold_met(2, 1), true);
assert_eq!(leech_threshold_met(3, 1), true);
}
}