implement leech handling
Also change the default for new users to "tag only"
This commit is contained in:
parent
e99deedbd8
commit
f165576992
@ -465,22 +465,20 @@ limit ?"""
|
||||
##########################################################################
|
||||
|
||||
def answerCard(self, card: Card, ease: int) -> None:
|
||||
self.col.log()
|
||||
assert 1 <= ease <= 4
|
||||
assert 0 <= card.queue <= 4
|
||||
|
||||
self.col.markReview(card)
|
||||
|
||||
if self._burySiblingsOnAnswer:
|
||||
self._burySiblings(card)
|
||||
|
||||
self._answerCard(card, ease)
|
||||
new_state = self._answerCard(card, ease)
|
||||
|
||||
self._maybe_requeue_card(card)
|
||||
if not self._handle_leech(card, new_state):
|
||||
self._maybe_requeue_card(card)
|
||||
|
||||
card.mod = intTime()
|
||||
card.usn = self.col.usn()
|
||||
card.flush()
|
||||
|
||||
def _answerCard(self, card: Card, ease: int) -> None:
|
||||
def _answerCard(self, card: Card, ease: int) -> _pb.SchedulingState:
|
||||
states = self.col._backend.get_next_card_states(card.id)
|
||||
if ease == BUTTON_ONE:
|
||||
new_state = states.again
|
||||
@ -509,6 +507,22 @@ limit ?"""
|
||||
# fixme: tests assume card will be mutated, so we need to reload it
|
||||
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:
|
||||
# preview cards
|
||||
if card.queue == QUEUE_TYPE_PREVIEW:
|
||||
@ -604,29 +618,6 @@ limit ?"""
|
||||
|
||||
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
|
||||
##########################################################################
|
||||
|
||||
|
@ -54,6 +54,7 @@ fn want_release_gil(method: u32) -> bool {
|
||||
| BackendMethod::JoinSearchNodes
|
||||
| BackendMethod::ReplaceSearchNode
|
||||
| BackendMethod::BuildSearchString
|
||||
| BackendMethod::StateIsLeech
|
||||
)
|
||||
} else {
|
||||
false
|
||||
|
@ -405,6 +405,7 @@ def test_reviews():
|
||||
assert c.queue == QUEUE_TYPE_SUSPENDED
|
||||
c.load()
|
||||
assert c.queue == QUEUE_TYPE_SUSPENDED
|
||||
assert "leech" in c.note().tags
|
||||
|
||||
|
||||
def test_review_limits():
|
||||
|
@ -117,6 +117,7 @@ service BackendService {
|
||||
rpc SortDeck(SortDeckIn) returns (Empty);
|
||||
rpc GetNextCardStates(CardID) returns (NextCardStates);
|
||||
rpc DescribeNextStates(NextCardStates) returns (StringList);
|
||||
rpc StateIsLeech(SchedulingState) returns (Bool);
|
||||
rpc AnswerCard(AnswerCardIn) returns (Empty);
|
||||
rpc UpgradeScheduler(Empty) returns (Empty);
|
||||
|
||||
@ -1283,6 +1284,7 @@ message SchedulingState {
|
||||
uint32 elapsed_days = 2;
|
||||
float ease_factor = 3;
|
||||
uint32 lapses = 4;
|
||||
bool leeched = 5;
|
||||
}
|
||||
message Relearning {
|
||||
Review review = 1;
|
||||
|
@ -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 {
|
||||
fn from(val: i64) -> Self {
|
||||
pb::Int64 { val }
|
||||
|
@ -33,7 +33,7 @@ use crate::{
|
||||
sched::{
|
||||
new::NewCardSortOrder,
|
||||
parse_due_date_str,
|
||||
states::NextCardStates,
|
||||
states::{CardState, NextCardStates},
|
||||
timespan::{answer_button_time, time_span},
|
||||
},
|
||||
search::{
|
||||
@ -683,6 +683,11 @@ impl BackendService for Backend {
|
||||
.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> {
|
||||
self.with_col(|col| col.answer_card(&input.into()))
|
||||
.map(Into::into)
|
||||
|
@ -10,6 +10,7 @@ impl From<pb::scheduling_state::Review> for ReviewState {
|
||||
elapsed_days: state.elapsed_days,
|
||||
ease_factor: state.ease_factor,
|
||||
lapses: state.lapses,
|
||||
leeched: state.leeched,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,6 +22,7 @@ impl From<ReviewState> for pb::scheduling_state::Review {
|
||||
elapsed_days: state.elapsed_days,
|
||||
ease_factor: state.ease_factor,
|
||||
lapses: state.lapses,
|
||||
leeched: state.leeched,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -130,7 +130,7 @@ pub struct LapseConfSchema11 {
|
||||
|
||||
impl Default for LeechAction {
|
||||
fn default() -> Self {
|
||||
LeechAction::Suspend
|
||||
LeechAction::TagOnly
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,7 +32,7 @@ pub(crate) struct TransformNoteOutput {
|
||||
pub mark_modified: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct Note {
|
||||
pub id: NoteID,
|
||||
pub guid: String,
|
||||
@ -467,6 +467,32 @@ impl Collection {
|
||||
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)]
|
||||
|
@ -4,7 +4,7 @@
|
||||
use crate::{
|
||||
backend_proto,
|
||||
card::{CardQueue, CardType},
|
||||
deckconf::DeckConf,
|
||||
deckconf::{DeckConf, LeechAction},
|
||||
decks::{Deck, DeckKind},
|
||||
prelude::*,
|
||||
revlog::{RevlogEntry, RevlogReviewKind},
|
||||
@ -63,6 +63,7 @@ impl AnswerContext {
|
||||
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,
|
||||
@ -105,6 +106,7 @@ impl AnswerContext {
|
||||
as u32,
|
||||
ease_factor,
|
||||
lapses,
|
||||
leeched: false,
|
||||
}
|
||||
.into(),
|
||||
CardType::Relearn => RelearnState {
|
||||
@ -117,6 +119,7 @@ impl AnswerContext {
|
||||
elapsed_days: interval,
|
||||
ease_factor,
|
||||
lapses,
|
||||
leeched: false,
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
@ -197,7 +200,7 @@ impl Card {
|
||||
self.original_due = 0;
|
||||
}
|
||||
|
||||
match next {
|
||||
let revlog = match next {
|
||||
CardState::Normal(normal) => match normal {
|
||||
NormalState::New(next) => self.apply_new_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)
|
||||
}
|
||||
},
|
||||
}?;
|
||||
|
||||
if next.leeched() && ctx.config.inner.leech_action() == LeechAction::Suspend {
|
||||
self.queue = CardQueue::Suspended;
|
||||
}
|
||||
|
||||
Ok(revlog)
|
||||
}
|
||||
|
||||
fn apply_new_state(
|
||||
@ -386,6 +395,7 @@ pub struct RevlogEntryPartial {
|
||||
}
|
||||
|
||||
impl RevlogEntryPartial {
|
||||
/// Returns None in the Preview case, since preview cards do not currently log.
|
||||
fn maybe_new(
|
||||
current: CardState,
|
||||
next: CardState,
|
||||
@ -508,6 +518,9 @@ impl Collection {
|
||||
self.storage.add_revlog_entry(&revlog)?;
|
||||
}
|
||||
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
|
||||
// - might want to avoid that in the future
|
||||
@ -556,6 +569,10 @@ impl Collection {
|
||||
let state_ctx = ctx.state_context();
|
||||
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.
|
||||
|
@ -3,7 +3,9 @@
|
||||
|
||||
use crate::revlog::RevlogReviewKind;
|
||||
|
||||
use super::{IntervalKind, NextCardStates, PreviewState, ReschedulingFilterState, StateContext};
|
||||
use super::{
|
||||
IntervalKind, NextCardStates, PreviewState, ReschedulingFilterState, ReviewState, StateContext,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum FilteredState {
|
||||
@ -32,4 +34,11 @@ impl FilteredState {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -54,6 +54,18 @@ impl CardState {
|
||||
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.
|
||||
@ -70,6 +82,7 @@ pub(crate) struct StateContext<'a> {
|
||||
pub easy_multiplier: f32,
|
||||
pub interval_multiplier: f32,
|
||||
pub maximum_review_interval: u32,
|
||||
pub leech_threshold: u32,
|
||||
|
||||
// relearning
|
||||
pub relearn_steps: LearningSteps<'a>,
|
||||
|
@ -55,6 +55,15 @@ impl NormalState {
|
||||
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 {
|
||||
|
@ -19,6 +19,7 @@ pub struct ReviewState {
|
||||
pub elapsed_days: u32,
|
||||
pub ease_factor: f32,
|
||||
pub lapses: u32,
|
||||
pub leeched: bool,
|
||||
}
|
||||
|
||||
impl Default for ReviewState {
|
||||
@ -28,6 +29,7 @@ impl Default for ReviewState {
|
||||
elapsed_days: 0,
|
||||
ease_factor: INITIAL_EASE_FACTOR,
|
||||
lapses: 0,
|
||||
leeched: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,11 +73,14 @@ impl ReviewState {
|
||||
}
|
||||
|
||||
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 {
|
||||
scheduled_days: self.failing_review_interval(ctx),
|
||||
elapsed_days: 0,
|
||||
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() {
|
||||
@ -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.
|
||||
/// - Apply configured interval multiplier.
|
||||
/// - Apply fuzz.
|
||||
@ -214,3 +231,37 @@ fn constrain_passing_interval(ctx: &StateContext, interval: f32, minimum: u32, f
|
||||
.min(ctx.maximum_review_interval)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user