From b6bdd4aa219e0ce3ff83ff84c91583c49b1ca0b1 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 20 May 2012 16:56:14 +0900 Subject: [PATCH] daily learning queue The way we were handling 1 day+ learning intervals was not great - they'd show up at the start of a new day before normal reviews, meaning the hardest cards came first. In previous Anki versions we deliberately sorted the queue in the opposite order to prevent that. When relearning the cards the next day, if you failed a card and expected to see it in 10 minutes that wouldn't happen either, as all the overdue cards took precedence. To fix this, we put cards that are due tomorrow or later into a separate queue (queue 3), and pull cards from that queue only after the reviews are done. In the future it might also be nice to move overdue learning cards into that queue automatically at the start of a session. --- anki/collection.py | 3 +- anki/sched.py | 78 ++++++++++++++++++++++++++++++++++++--------- tests/test_sched.py | 47 +++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/anki/collection.py b/anki/collection.py index 309a8f1b5..700d8b05d 100644 --- a/anki/collection.py +++ b/anki/collection.py @@ -585,7 +585,8 @@ where c.nid == f.id self.db.execute("delete from revlog where id = ?", last) # and finally, update daily counts # fixme: what to do in cramming case? - type = ("new", "lrn", "rev")[c.queue] + n = 1 if c.queue == 3 else c.queue + type = ("new", "lrn", "rev")[n] self.sched._updateStats(c, type, -1) return c.id diff --git a/anki/sched.py b/anki/sched.py index 0803bf112..060d392a4 100644 --- a/anki/sched.py +++ b/anki/sched.py @@ -11,12 +11,9 @@ from anki.lang import _, ngettext from anki.consts import * from anki.hooks import runHook +# queue types: 0=new/cram, 1=lrn, 2=rev, 3=day lrn, -1=suspended, -2=buried # revlog types: 0=lrn, 1=rev, 2=relrn, 3=cram -# queue types: 0=new/cram, 1=lrn, 2=rev, -1=suspended, -2=buried -# positive intervals are in days (rev), negative intervals in seconds (lrn) - -# fixme: -# - should log cram reps as cramming +# positive revlog intervals are in days (rev), negative in seconds (lrn) class Scheduler(object): name = "std" @@ -69,7 +66,7 @@ class Scheduler(object): card.ivl = self._dynIvlBoost(card) card.odue = self.today + card.ivl self._updateStats(card, 'new') - if card.queue == 1: + if card.queue in (1, 3): self._answerLrnCard(card, ease) if not wasNew: self._updateStats(card, 'lrn') @@ -272,6 +269,10 @@ order by due""" % self._deckLimit(), return self._getNewCard() # card due for review? c = self._getRevCard() + if c: + return c + # day learning card due? + c = self._getLrnDayCard() if c: return c # new cards left? @@ -383,20 +384,29 @@ select count() from c = self.col.decks.confForDid(g['id']) return max(0, c['new']['perDay'] - g['newToday'][1]) - # Learning queue + # Learning queues ########################################################################## def _resetLrnCount(self): + # sub-day self.lrnCount = self.col.db.scalar(""" select sum(left/1000) from (select left from cards where did in %s and queue = 1 and due < ? limit %d)""" % ( self._deckLimit(), self.reportLimit), self.dayCutoff) or 0 + # day + self.lrnCount += self.col.db.scalar(""" +select count() from cards where did in %s and queue = 3 +and due <= ? limit %d""" % (self._deckLimit(), self.reportLimit), + self.today) def _resetLrn(self): self._resetLrnCount() self._lrnQueue = [] + self._lrnDayQueue = [] + self._lrnDids = self.col.decks.active()[:] + # sub-day learning def _fillLrn(self): if not self.lrnCount: return False @@ -421,6 +431,36 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) self.lrnCount -= card.left/1000 return card + # daily learning + def _fillLrnDay(self): + if not self.lrnCount: + return False + if self._lrnDayQueue: + return True + while self._lrnDids: + did = self._lrnDids[0] + # fill the queue with the current did + self._lrnDayQueue = self.col.db.list(""" +select id from cards where +did = ? and queue = 3 and due <= ? limit ?""", + did, self.today, self.queueLimit) + if self._lrnDayQueue: + # order + r = random.Random() + r.seed(self.today) + r.shuffle(self._lrnDayQueue) + # is the current did empty? + if len(self._lrnDayQueue) < self.queueLimit: + self._lrnDids.pop(0) + return True + # nothing left in the deck; move to next + self._lrnDids.pop(0) + + def _getLrnDayCard(self): + if self._fillLrnDay(): + self.lrnCount -= 1 + return self.col.getCard(self._lrnDayQueue.pop()) + def _answerLrnCard(self, card, ease): # ease 1=no, 2=yes, 3=remove conf = self._lrnConf(card) @@ -463,15 +503,23 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) # not collapsed; add some randomness delay *= random.uniform(1, 1.25) card.due = int(time.time() + delay) + # due today? if card.due < self.dayCutoff: self.lrnCount += card.left/1000 - # if the queue is not empty and there's nothing else to do, make - # sure we don't put it at the head of the queue and end up showing - # it twice in a row - if self._lrnQueue and not self.revCount and not self.newCount: - smallestDue = self._lrnQueue[0][0] - card.due = max(card.due, smallestDue+1) - heappush(self._lrnQueue, (card.due, card.id)) + # if the queue is not empty and there's nothing else to do, make + # sure we don't put it at the head of the queue and end up showing + # it twice in a row + card.queue = 1 + if self._lrnQueue and not self.revCount and not self.newCount: + smallestDue = self._lrnQueue[0][0] + card.due = max(card.due, smallestDue+1) + heappush(self._lrnQueue, (card.due, card.id)) + else: + # the card is due in one or more days, so we need to use the + # day learn queue + ahead = ((card.due - self.dayCutoff) / 86400) + 1 + card.due = self.today + ahead + card.queue = 3 self._logLrn(card, ease, conf, leaving, type, lastLeft) def _delayForGrade(self, conf, left): @@ -1019,7 +1067,7 @@ your short-term review workload will become.""")) def nextIvl(self, card, ease): "Return the next interval for CARD, in seconds." - if card.queue in (0,1): + if card.queue in (0,1,3): return self._nextLrnIvl(card, ease) elif ease == 1: # lapsed diff --git a/tests/test_sched.py b/tests/test_sched.py index a563ad275..c0e848a03 100644 --- a/tests/test_sched.py +++ b/tests/test_sched.py @@ -197,6 +197,53 @@ def test_learn_collapsed(): c = d.sched.getCard() assert not c.q().endswith("2") +def test_learn_day(): + d = getEmptyDeck() + # add a note + f = d.newNote() + f['Front'] = u"one" + f = d.addNote(f) + d.sched.reset() + c = d.sched.getCard() + d.sched._cardConf(c)['new']['delays'] = [1, 10, 1440, 2880] + # pass it + d.sched.answerCard(c, 2) + # two reps to graduate, 1 more today + assert c.left%1000 == 3 + assert c.left/1000 == 1 + assert d.sched.counts() == (0, 1, 0) + c = d.sched.getCard() + ni = d.sched.nextIvl + assert ni(c, 2) == 86400 + # answering it will place it in queue 3 + d.sched.answerCard(c, 2) + assert c.due == d.sched.today+1 + assert c.queue == 3 + assert not d.sched.getCard() + # for testing, move it back a day + c.due -= 1 + c.flush() + d.reset() + assert d.sched.counts() == (0, 1, 0) + c = d.sched.getCard() + # nextIvl should work + assert ni(c, 2) == 86400*2 + # if we fail it, it should be back in the correct queue + d.sched.answerCard(c, 1) + assert c.queue == 1 + d.undo() + d.reset() + c = d.sched.getCard() + d.sched.answerCard(c, 2) + # simulate the passing of another two days + c.due -= 2 + c.flush() + d.reset() + # the last pass should graduate it into a review card + assert ni(c, 2) == 86400 + d.sched.answerCard(c, 2) + assert c.queue == c.type == 2 + def test_reviews(): d = getEmptyDeck() # add a note