From ba87fc77360039eab986b06f7530ec7419a2bfc9 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 26 Dec 2017 21:46:49 +1000 Subject: [PATCH] experiment with simple resched=off case to 'preview mode' the previous approach meant we weren't able to preserve the card state exactly when cards were in learning, since we didn't record the step position prior to cards being moved into the filtered deck. it also meant the answer buttons needed to change depending on state - 4 for cards in learning/review, but 2 when the card is on the final step or is a review. instead, in preview mode cards always have 2 buttons: again will repeat again after a delay, and good immediately removes the card and restores it to its previous state. to accomplish this, we use a separate queue #, as the learn count always needs to have a 1:1 correspondence to the number of cards --- anki/sched.py | 154 +++++++++++++++++++------------------- tests/test_sched.py | 178 ++++++++------------------------------------ 2 files changed, 110 insertions(+), 222 deletions(-) diff --git a/anki/sched.py b/anki/sched.py index f9b14559e..c284128a7 100644 --- a/anki/sched.py +++ b/anki/sched.py @@ -57,11 +57,10 @@ class Scheduler: def answerCard(self, card, ease): self.col.log() assert 1 <= ease <= 4 - assert 0 <= card.queue <= 3 + assert 0 <= card.queue <= 4 self.col.markReview(card) if self._burySiblingsOnAnswer: self._burySiblings(card) - card.reps += 1 self._answerCard(card, ease) @@ -71,6 +70,11 @@ class Scheduler: card.flushSched() def _answerCard(self, card, ease): + if self._previewingCard(card): + self._answerCardPreview(card, ease) + return + + card.reps += 1 card.wasNew = card.type == 0 if card.queue == 0: @@ -88,6 +92,22 @@ class Scheduler: # update daily limit self._updateStats(card, 'rev') + # hard-coded for now + _previewDelay = 600 + + def _answerCardPreview(self, card, ease): + assert 1 <= ease <= 2 + + if ease == 1: + # repeat after delay + card.queue = 4 + card.due = intTime() + self._previewDelay + self.lrnCount += 1 + else: + # restore original card state and remove from filtered deck + self._restoreFromFiltered(card) + self._removeFromFiltered(card) + def counts(self, card=None): counts = [self.newCount, self.lrnCount, self.revCount] if card: @@ -117,23 +137,14 @@ order by due""" % self._deckLimit(), return ret def countIdx(self, card): - if card.queue == 3: + if card.queue in (3,4): return 1 return card.queue def answerButtons(self, card): conf = self._cardConf(card) - - # fixme: resched=off case - # if card.odid: - # if not conf['resched']: - # if card.queue == 2: - # return 4 - # conf = self._lrnConf(card) - # if card.type in (0,1) or len(conf['delays']) > 1: - # return 3 - # return 2 - + if card.odid and not conf['resched']: + return 2 return 4 def unburyCards(self): @@ -449,6 +460,10 @@ did in %s and queue = 1 and due < ? limit %d)""" % ( select count() from cards where did in %s and queue = 3 and due <= ? limit %d""" % (self._deckLimit(), self.reportLimit), self.today) + # previews + self.lrnCount += self.col.db.scalar(""" +select count() from cards where did in %s and queue = 4 +limit %d""" % (self._deckLimit(), self.reportLimit)) def _resetLrn(self): self._resetLrnCount() @@ -464,7 +479,7 @@ and due <= ? limit %d""" % (self._deckLimit(), self.reportLimit), return True self._lrnQueue = self.col.db.all(""" select due, id from cards where -did in %s and queue = 1 and due < :lim +did in %s and queue in (1,4) and due < :lim limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) # as it arrives sorted by did first, we need to sort it self._lrnQueue.sort() @@ -478,7 +493,10 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) if self._lrnQueue[0][0] < cutoff: id = heappop(self._lrnQueue)[1] card = self.col.getCard(id) - self.lrnCount -= card.left // 1000 + if self._previewingCard(card): + self.lrnCount -= 1 + else: + self.lrnCount -= card.left // 1000 return card # daily learning @@ -542,10 +560,9 @@ did = ? and queue = 3 and due <= ? limit ?""", def _moveToFirstStep(self, card, conf): card.left = self._startingLeft(card) - resched = self._resched(card) card.lastIvl = card.ivl - if card.type == 2 and resched: + if card.type == 2: # review card that will move to relearning card.ivl = self._lapseIvl(card, self._lrnConf(card)) @@ -624,27 +641,15 @@ did = ? and queue = 3 and due <= ? limit ?""", def _rescheduleAsRev(self, card, conf, early): lapse = card.type == 2 - resched = self._resched(card) - if resched: - if lapse: - self._rescheduleGraduatingLapse(card) - else: - self._rescheduleNew(card, conf, early) + if lapse: + self._rescheduleGraduatingLapse(card) + else: + self._rescheduleNew(card, conf, early) # if we were dynamic, graduating means moving back to the old deck if card.odid: - # and leaving learning if scheduling off - if not resched: - card.due = card.odue - if card.type == 2: - card.queue = 2 - else: - card.queue = card.type = 0 - - card.did = card.odid - card.odue = 0 - card.odid = 0 + self._removeFromFiltered(card) def _rescheduleGraduatingLapse(self, card): card.due = self.today+card.ivl @@ -860,40 +865,28 @@ select id from cards where did in %s and queue = 2 and due <= ? limit ?)""" # update interval card.lastIvl = card.ivl - if self._resched(card): - self._updateRevIvl(card, ease) - # then the rest - card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) - card.due = self.today + card.ivl - else: - card.due = card.odue + self._updateRevIvl(card, ease) + # then the rest + card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) + card.due = self.today + card.ivl # card leaves filtered deck - if card.odid: - card.did = card.odid - card.odid = 0 - card.odue = 0 + self._removeFromFiltered(card) def _rescheduleEarlyRev(self, card, ease): # update interval card.lastIvl = card.ivl - if self._resched(card): - self._updateEarlyRevIvl(card, ease) - # then the rest - card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) - card.due = self.today + card.ivl - else: - card.due = card.odue + self._updateEarlyRevIvl(card, ease) + # then the rest + card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) + card.due = self.today + card.ivl # move from 0->2 card.queue = 2 # card leaves filtered deck - if card.odid: - card.did = card.odid - card.odid = 0 - card.odue = 0 + self._removeFromFiltered(card) def _logRev(self, card, ease, delay, type): def log(): @@ -1111,6 +1104,25 @@ did = ?, due = ?, usn = ? where id = ? """ self.col.db.executemany(query, data) + def _removeFromFiltered(self, card): + if card.odid: + card.did = card.odid + card.odue = 0 + card.odid = 0 + + def _restoreFromFiltered(self, card): + assert card.odid + + card.due = card.odue + + if card.type in (0, 2): + card.queue = card.type + else: + if card.odue > 1000000000: + card.queue = 1 + else: + card.queue = 3 + # Leeches ########################################################################## @@ -1129,12 +1141,6 @@ did = ?, due = ?, usn = ? where id = ? # handle a = conf['leechAction'] if a == 0: - # if it has an old due, remove it from cram/relearning - if card.odue: - card.due = card.odue - if card.odid: - card.did = card.odid - card.odue = card.odid = 0 card.queue = -1 # notify UI runHook("leech", card) @@ -1194,11 +1200,9 @@ did = ?, due = ?, usn = ? where id = ? def _deckLimit(self): return ids2str(self.col.decks.active()) - def _resched(self, card): + def _previewingCard(self, card): conf = self._cardConf(card) - if not conf['dyn']: - return True - return conf['resched'] + return conf['dyn'] and not conf['resched'] # Daily cutoff ########################################################################## @@ -1299,6 +1303,12 @@ To study outside of the normal schedule, click the Custom Study button below.""" def nextIvl(self, card, ease): "Return the next interval for CARD, in seconds." + # preview mode? + if self._previewingCard(card): + if ease == 1: + return self._previewDelay + return 0 + # (re)learning? if card.queue in (0,1,3): return self._nextLrnIvl(card, ease) @@ -1312,10 +1322,7 @@ To study outside of the normal schedule, click the Custom Study button below.""" # review early = card.odid and (card.odue > self.today) if early: - if self._resched(card): - return self._earlyReviewIvl(card, ease)*86400 - else: - return 0 + return self._earlyReviewIvl(card, ease)*86400 else: return self._nextRevIvl(card, ease, fuzz=False)*86400 @@ -1330,16 +1337,11 @@ To study outside of the normal schedule, click the Custom Study button below.""" elif ease == 2: return self._delayForRepeatingGrade(conf, card.left) elif ease == 4: - # early removal - if not self._resched(card): - return 0 return self._graduatingIvl(card, conf, True, fuzz=False) * 86400 else: # ease == 3 left = card.left%1000 - 1 if left <= 0: # graduate - if not self._resched(card): - return 0 return self._graduatingIvl(card, conf, False, fuzz=False) * 86400 else: return self._delayForGrade(conf, left) diff --git a/tests/test_sched.py b/tests/test_sched.py index f30972ebd..d555524f5 100644 --- a/tests/test_sched.py +++ b/tests/test_sched.py @@ -627,112 +627,51 @@ def test_filt_keep_lrn_state(): c.load() assert c.type == c.queue == 1 -def test_filt_reschedoff(): - # add card +def test_preview(): + # add cards d = getEmptyCol() f = d.newNote() f['Front'] = "one" d.addNote(f) + c = f.cards()[0] + orig = copy.copy(c) + f2 = d.newNote() + f2['Front'] = "two" + d.addNote(f2) # cram deck did = d.decks.newDyn("Cram") cram = d.decks.get(did) cram['resched'] = False d.sched.rebuildDyn(did) d.reset() - # graduate should return it to new + # grab the first card c = d.sched.getCard() - ni = d.sched.nextIvl - assert ni(c, 1) == 60 - assert ni(c, 2) == (60+600)//2 - assert ni(c, 3) == 600 - assert ni(c, 4) == 0 - assert d.sched.nextIvlStr(c, 4) == "(end)" - d.sched.answerCard(c, 4) - assert c.queue == c.type == 0 - # undue reviews should also be unaffected - c.ivl = 100 - c.type = 2 - c.queue = 2 - c.due = d.sched.today + 25 - c.factor = STARTING_FACTOR - c.flush() - cardcopy = copy.copy(c) - d.sched.rebuildDyn(did) - d.reset() + assert d.sched.answerButtons(c) == 2 + assert d.sched.nextIvl(c, 1) == d.sched._previewDelay + assert d.sched.nextIvl(c, 2) == 0 + # failing it will push its due time back + due = c.due + d.sched.answerCard(c, 1) + assert c.due != due + + # the other card should come next + c2 = d.sched.getCard() + assert c2.id != c.id + + # passing it will remove it + d.sched.answerCard(c2, 2) + + # the other card should appear again c = d.sched.getCard() - assert ni(c, 1) == 600 - assert ni(c, 2) == 0 - assert ni(c, 3) == 0 + assert c.id == orig.id + + # remove it d.sched.answerCard(c, 2) - assert c.ivl == 100 - assert c.due == d.sched.today + 25 - # check failure too - c = cardcopy - c.flush() - d.sched.rebuildDyn(did) - d.reset() - c = d.sched.getCard() - d.sched.answerCard(c, 1) - d.sched.emptyDyn(did) - c.load() - assert c.ivl == 100 - assert c.due == d.sched.today + 25 - # fail+grad early - c = cardcopy - c.flush() - d.sched.rebuildDyn(did) - d.reset() - c = d.sched.getCard() - d.sched.answerCard(c, 1) - d.sched.answerCard(c, 4) - d.sched.emptyDyn(did) - c.load() - assert c.ivl == 100 - assert c.due == d.sched.today + 25 - # due cards - pass - c = cardcopy - c.due = -25 - c.flush() - d.sched.rebuildDyn(did) - d.reset() - c = d.sched.getCard() - d.sched.answerCard(c, 3) - d.sched.emptyDyn(did) - c.load() - assert c.ivl == 100 - assert c.due == -25 - # fail - c = cardcopy - c.due = -25 - c.flush() - d.sched.rebuildDyn(did) - d.reset() - c = d.sched.getCard() - d.sched.answerCard(c, 1) - d.sched.emptyDyn(did) - c.load() - assert c.ivl == 100 - assert c.due == -25 - # fail with normal grad - c = cardcopy - c.due = -25 - c.flush() - d.sched.rebuildDyn(did) - d.reset() - c = d.sched.getCard() - d.sched.answerCard(c, 1) - d.sched.answerCard(c, 4) - c.load() - assert c.ivl == 100 - assert c.due == -25 - # lapsed card pulled into cram - # d.sched._cardConf(c)['lapse']['mult']=0.5 - # d.sched.answerCard(c, 1) - # d.sched.rebuildDyn(did) - # d.reset() - # c = d.sched.getCard() - # d.sched.answerCard(c, 2) - # print c.__dict__ + + # ensure it's in the same state as it started + assert c.queue == 0 + assert c.reps == 0 + assert c.type == 0 def test_ordcycle(): d = getEmptyCol() @@ -1067,56 +1006,3 @@ def test_failmult(): # so the card is reset to new d.sched.answerCard(c, 1) assert c.ivl == 1 - -# answering a new card with scheduling off should not change -# the original position -def test_preview_order(): - d = getEmptyCol() - f = d.newNote() - f['Front'] = "oneone" - d.addNote(f) - f = d.newNote() - f['Front'] = "twotwo" - d.addNote(f) - assert d.getCard(d.findCards("oneone")[0]).due == 1 - assert d.getCard(d.findCards("twotwo")[0]).due == 2 - - did = d.decks.newDyn("Cram") - cram = d.decks.get(did) - cram['resched'] = False - d.sched.rebuildDyn(did) - d.reset() - - c = d.sched.getCard() - assert "oneone" in c.q() - d.sched.answerCard(c, 3) - d.sched.answerCard(c, 3) - - assert c.due == 1 - -# answering a due review with scheduling off should not change scheduling -def test_reviews_reschedoff(): - d = getEmptyCol() - f = d.newNote() - f['Front'] = "one" - d.addNote(f) - - c = f.cards()[0] - c.ivl = 100 - c.queue = c.type = 2 - c.due = d.sched.today - c.factor = 2500 - c.flush() - - did = d.decks.newDyn("Cram") - cram = d.decks.get(did) - cram['resched'] = False - d.sched.rebuildDyn(did) - d.reset() - - c = d.sched.getCard() - d.sched.answerCard(c, 4) - - assert c.ivl == 100 - assert c.due == d.sched.today - assert c.factor == 2500