# -*- coding: utf-8 -*- # Copyright: Damien Elmes # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import time, datetime, simplejson, random, itertools, math from operator import itemgetter from heapq import * #from anki.cards import Card from anki.utils import ids2str, intTime, fmtTimeSpan from anki.lang import _, ngettext from anki.consts import * from anki.hooks import runHook # revlog: # types: 0=lrn, 1=rev, 2=relrn, 3=cram # positive intervals are in days (rev), negative intervals in seconds (lrn) # the standard Anki scheduler class Scheduler(object): name = "std" def __init__(self, col): self.col = col self.queueLimit = 50 self.reportLimit = 1000 # fixme: replace reps with deck based counts self.reps = 0 self._updateCutoff() def getCard(self): "Pop the next card from the queue. None if finished." self._checkDay() id = self._getCardId() if id: c = self.col.getCard(id) c.startTimer() return c def reset(self): self._updateCutoff() self._resetLrn() self._resetRev() self._resetNew() def answerCard(self, card, ease): assert ease >= 1 and ease <= 4 self.col.markReview(card) self.reps += 1 card.reps += 1 wasNew = (card.queue == 0) and card.type != 2 if wasNew: # put it in the learn queue card.queue = 1 card.type = 1 card.left = self._startingLeft(card) self.lrnRepCount += card.left self._updateStats(card, 'new') if card.queue == 1: self._answerLrnCard(card, ease) if not wasNew: self._updateStats(card, 'lrn') elif card.queue == 2: self._answerRevCard(card, ease) self._updateStats(card, 'rev') else: raise Exception("Invalid queue") self._updateStats(card, 'time', card.timeTaken()) card.mod = intTime() card.usn = self.col.usn() card.flushSched() def repCounts(self): return (self.newCount, self.lrnRepCount, self.revCount) def cardCounts(self): return (self.newCount, self.lrnCount, self.revCount) def dueForecast(self, days=7): "Return counts over next DAYS. Includes today." daysd = dict(self.col.db.all(""" select due, count() from cards where did in %s and queue = 2 and due between ? and ? group by due order by due""" % self._deckLimit(), self.today, self.today+days-1)) for d in range(days): d = self.today+d if d not in daysd: daysd[d] = 0 # return in sorted order ret = [x[1] for x in sorted(daysd.items())] return ret def countIdx(self, card): return card.queue def answerButtons(self, card): if card.queue == 2: return 4 else: return 3 def onClose(self): "Unbury and remove temporary suspends on close." self.col.db.execute( "update cards set queue = type where queue between -3 and -2") # Rev/lrn/time daily stats ########################################################################## def _updateStats(self, card, type, cnt=1): key = type+"Today" for g in ([self.col.decks.get(card.did)] + self.col.decks.parents(card.did)): # add g[key][1] += cnt self.col.decks.save(g) # Deck list ########################################################################## def deckDueList(self): "Returns [deckname, did, hasDue, hasNew]" # find decks with 1 or more due cards dids = {} for g in self.col.decks.all(): hasDue = self._deckHasLrn(g['id']) or self._deckHasRev(g['id']) hasNew = self._deckHasNew(g['id']) dids[g['id']] = [hasDue or 0, hasNew or 0] return [[grp['name'], int(did)]+dids[int(did)] #.get(int(did)) for (did, grp) in self.col.decks.decks.items()] def deckDueTree(self): return self._groupChildren(self.deckDueList()) def _groupChildren(self, grps): # first, split the group names into components for g in grps: g[0] = g[0].split("::") # and sort based on those components grps.sort(key=itemgetter(0)) # then run main function return self._groupChildrenMain(grps) def _groupChildrenMain(self, grps): tree = [] # group and recurse def key(grp): return grp[0][0] for (head, tail) in itertools.groupby(grps, key=key): tail = list(tail) did = None rev = 0 new = 0 children = [] for c in tail: if len(c[0]) == 1: # current node did = c[1] rev += c[2] new += c[3] else: # set new string to tail c[0] = c[0][1:] children.append(c) children = self._groupChildrenMain(children) # tally up children counts for ch in children: rev += ch[2] new += ch[3] tree.append((head, did, rev, new, children)) return tuple(tree) # Getting the next card ########################################################################## def _getCardId(self): "Return the next due card id, or None." # learning card due? id = self._getLrnCard() if id: return id # new first, or time for one? if self._timeForNewCard(): return self._getNewCard() # card due for review? id = self._getRevCard() if id: return id # new cards left? id = self._getNewCard() if id: return id # collapse or finish return self._getLrnCard(collapse=True) # New cards ########################################################################## def _resetNewCount(self): self.newCount = 0 pcounts = {} # for each of the active decks for did in self.col.decks.active(): # get the individual deck's limit lim = self._deckNewLimitSingle(self.col.decks.get(did)) if not lim: continue # check the parents parents = self.col.decks.parents(did) for p in parents: # add if missing if p['id'] not in pcounts: pcounts[p['id']] = self._deckNewLimitSingle(p) # take minimum of child and parent lim = min(pcounts[p['id']], lim) # see how many cards we actually have cnt = self.col.db.scalar(""" select count() from (select 1 from cards where did = ? and queue = 0 limit ?)""", did, lim) # if non-zero, decrement from parent counts for p in parents: pcounts[p['id']] -= cnt # we may also be a parent pcounts[did] = lim - cnt # and add to running total self.newCount += cnt def _resetNew(self): self._resetNewCount() self.newDids = self.col.decks.active() self._newQueue = [] self._updateNewCardRatio() def _fillNew(self): if self._newQueue: return True if not self.newCount: return False while self.newDids: did = self.newDids[0] lim = min(self.queueLimit, self._deckNewLimit(did)) if lim: # fill the queue with the current did self._newQueue = self.col.db.all(""" select id, due from cards where did = ? and queue = 0 limit ?""", did, lim) if self._newQueue: self._newQueue.reverse() return True # nothing left in the deck; move to next self.newDids.pop(0) def _getNewCard(self): if not self._fillNew(): return (id, due) = self._newQueue.pop() # move any siblings to the end? conf = self.col.decks.conf(self.newDids[0]) if conf['new']['order'] == NEW_TODAY_ORD: n = len(self._newQueue) while self._newQueue and self._newQueue[-1][1] == due: self._newQueue.insert(0, self._newQueue.pop()) n -= 1 if not n: # we only have one note in the queue; stop rotating break self.newCount -= 1 return id def _updateNewCardRatio(self): if self.col.decks.top()['newSpread'] == NEW_CARDS_DISTRIBUTE: if self.newCount: self.newCardModulus = ( (self.newCount + self.revCount) / self.newCount) # if there are cards to review, ensure modulo >= 2 if self.revCount: self.newCardModulus = max(2, self.newCardModulus) return self.newCardModulus = 0 def _timeForNewCard(self): "True if it's time to display a new card when distributing." if not self.newCount: return False if self.col.decks.top()['newSpread'] == NEW_CARDS_LAST: return False elif self.col.decks.top()['newSpread'] == NEW_CARDS_FIRST: return True elif self.newCardModulus: return self.reps and self.reps % self.newCardModulus == 0 def _deckHasNew(self, did): if not self._deckNewLimit(did): return False return self.col.db.scalar( "select 1 from cards where did = ? and queue = 0 limit 1", did) def _deckNewLimit(self, did): sel = self.col.decks.get(did) lim = -1 # for the deck and each of its parents for g in [sel] + self.col.decks.parents(did): rem = self._deckNewLimitSingle(g) if lim == -1: lim = rem else: lim = min(rem, lim) return lim def _deckNewLimitSingle(self, g): c = self.col.decks.conf(g['id']) return max(0, c['new']['perDay'] - g['newToday'][1]) # Learning queue ########################################################################## def _resetLrnCount(self): (self.lrnCount, self.lrnRepCount) = self.col.db.first(""" select count(), sum(left) from (select left from cards where did in %s and queue = 1 and due < ? limit %d)""" % ( self._deckLimit(), self.reportLimit), self.dayCutoff) self.lrnRepCount = self.lrnRepCount or 0 def _resetLrn(self): self._resetLrnCount() self._lrnQueue = [] def _fillLrn(self): if not self.lrnCount: return False if self._lrnQueue: return True self._lrnQueue = self.col.db.all(""" select due, id from cards where did in %s and queue = 1 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() return self._lrnQueue def _getLrnCard(self, collapse=False): if self._fillLrn(): cutoff = time.time() if collapse: cutoff += self.col.decks.top()['collapseTime'] if self._lrnQueue[0][0] < cutoff: id = heappop(self._lrnQueue)[1] self.lrnCount -= 1 return id def _answerLrnCard(self, card, ease): # ease 1=no, 2=yes, 3=remove conf = self._lrnConf(card) if card.type == 2: type = 2 else: type = 0 leaving = False lastLeft = card.left if ease == 3: self._rescheduleAsRev(card, conf, True) self.lrnRepCount -= lastLeft leaving = True elif ease == 2 and card.left-1 <= 0: self._rescheduleAsRev(card, conf, False) self.lrnRepCount -= 1 leaving = True else: if ease == 2: card.left -= 1 self.lrnRepCount -= 1 else: card.left = self._startingLeft(card) self.lrnRepCount += card.left - lastLeft delay = self._delayForGrade(conf, card.left) if card.due < time.time(): # not collapsed; add some randomness delay *= random.uniform(1, 1.25) card.due = int(time.time() + delay) heappush(self._lrnQueue, (card.due, card.id)) self.lrnCount += 1 self._logLrn(card, ease, conf, leaving, type, lastLeft) def _delayForGrade(self, conf, left): try: delay = conf['delays'][-left] except IndexError: delay = conf['delays'][0] return delay*60 def _lrnConf(self, card): conf = self._cardConf(card) if card.type == 2: return conf['lapse'] else: return conf['new'] def _rescheduleAsRev(self, card, conf, early): if card.type == 2: # failed; put back entry due card.due = card.edue else: self._rescheduleNew(card, conf, early) card.queue = 2 card.type = 2 def _startingLeft(self, card): return len(self._cardConf(card)['new']['delays']) def _graduatingIvl(self, card, conf, early): if card.type == 2: # lapsed card being relearnt return card.ivl if not early: # graduate ideal = conf['ints'][0] else: # early remove ideal = conf['ints'][1] return self._adjRevIvl(card, ideal) def _rescheduleNew(self, card, conf, early): card.ivl = self._graduatingIvl(card, conf, early) card.due = self.today+card.ivl card.factor = conf['initialFactor'] def _logLrn(self, card, ease, conf, leaving, type, lastLeft): lastIvl = -(self._delayForGrade(conf, lastLeft)) ivl = card.ivl if leaving else -(self._delayForGrade(conf, card.left)) def log(): self.col.db.execute( "insert into revlog values (?,?,?,?,?,?,?,?,?)", int(time.time()*1000), card.id, self.col.usn(), ease, ivl, lastIvl, card.factor, card.timeTaken(), type) try: log() except: # duplicate pk; retry in 10ms time.sleep(0.01) log() def removeFailed(self, ids=None): "Remove failed cards from the learning queue." extra = "" if ids: extra = " and id in "+ids2str(ids) self.col.db.execute(""" update cards set due = edue, queue = 2, mod = %d, usn = %d where queue = 1 and type = 2 %s """ % (intTime(), self.col.usn(), extra)) def _deckHasLrn(self, did): return self.col.db.scalar( "select 1 from cards where did = ? and queue = 1 " "and due < ? limit 1", did, intTime() + self.col.decks.top()['collapseTime']) # Reviews ########################################################################## def _deckHasRev(self, did): return self.col.db.scalar( "select 1 from cards where did = ? and queue = 2 " "and due <= ? limit 1", did, self.today) def _resetRevCount(self): top = self.col.decks.top() lim = min(self.reportLimit, max(0, top['revLim'] - top['revToday'][1])) self.revCount = self.col.db.scalar(""" select count() from (select id from cards where did in %s and queue = 2 and due <= :day limit %d)""" % ( self._deckLimit(), lim), day=self.today) def _resetRev(self): self._resetRevCount() self._revQueue = [] def _fillRev(self): if not self.revCount: return False if self._revQueue: return True self._revQueue = self.col.db.list(""" select id from cards where did in %s and queue = 2 and due <= :lim %s limit %d""" % ( self._deckLimit(), self._revOrder(), self.queueLimit), lim=self.today) if not self.col.conf['revOrder']: r = random.Random() r.seed(self.today) r.shuffle(self._revQueue) return True def _getRevCard(self): if self._fillRev(): self.revCount -= 1 return self._revQueue.pop() def _revOrder(self): if self.col.conf['revOrder']: return "order by %s" % ("ivl desc", "ivl")[self.col.conf['revOrder']-1] return "" # Answering a review card ########################################################################## def _answerRevCard(self, card, ease): if ease == 1: self._rescheduleLapse(card) else: self._rescheduleRev(card, ease) self._logRev(card, ease) def _rescheduleLapse(self, card): conf = self._cardConf(card)['lapse'] card.lapses += 1 card.lastIvl = card.ivl card.ivl = self._nextLapseIvl(card, conf) card.factor = max(1300, card.factor-200) card.due = self.today + card.ivl # put back in the learn queue? if conf['relearn']: card.edue = card.due card.due = int(self._delayForGrade(conf, 0) + time.time()) card.left = len(conf['delays']) card.queue = 1 self.lrnCount += 1 self.lrnRepCount += card.left # leech? if not self._checkLeech(card, conf) and conf['relearn']: heappush(self._lrnQueue, (card.due, card.id)) def _nextLapseIvl(self, card, conf): return int(card.ivl*conf['mult']) + 1 def _rescheduleRev(self, card, ease): # update interval card.lastIvl = card.ivl self._updateRevIvl(card, ease) # then the rest card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) card.due = self.today + card.ivl def _logRev(self, card, ease): def log(): self.col.db.execute( "insert into revlog values (?,?,?,?,?,?,?,?,?)", int(time.time()*1000), card.id, self.col.usn(), ease, card.ivl, card.lastIvl, card.factor, card.timeTaken(), 1) try: log() except: # duplicate pk; retry in 10ms time.sleep(0.01) log() # Interval management ########################################################################## def _nextRevIvl(self, card, ease): "Ideal next interval for CARD, given EASE." delay = self._daysLate(card) conf = self._cardConf(card) fct = card.factor / 1000.0 if ease == 2: interval = (card.ivl + delay/4) * 1.2 elif ease == 3: interval = (card.ivl + delay/2) * fct elif ease == 4: interval = (card.ivl + delay) * fct * conf['rev']['ease4'] # apply forgetting index transform interval = self._ivlForFI(conf, interval) # must be at least one day greater than previous interval; two if easy return max(card.ivl + (2 if ease==4 else 1), int(interval)) def _ivlForFI(self, conf, ivl): new, old = conf['rev']['fi'] return ivl * math.log(1-new) / math.log(1-old) def _daysLate(self, card): "Number of days later than scheduled." return max(0, self.today - card.due) def _updateRevIvl(self, card, ease): "Update CARD's interval, trying to avoid siblings." idealIvl = self._nextRevIvl(card, ease) card.ivl = self._adjRevIvl(card, idealIvl) def _adjRevIvl(self, card, idealIvl): "Given IDEALIVL, return an IVL away from siblings." idealDue = self.today + idealIvl conf = self._cardConf(card)['rev'] # find sibling positions dues = self.col.db.list( "select due from cards where nid = ? and queue = 2" " and id != ?", card.nid, card.id) if not dues or idealDue not in dues: return idealIvl else: leeway = max(conf['minSpace'], int(idealIvl * conf['fuzz'])) fudge = 0 # do we have any room to adjust the interval? if leeway: # loop through possible due dates for an empty one for diff in range(1, leeway+1): # ensure we're due at least tomorrow if idealDue - diff >= 1 and (idealDue - diff) not in dues: fudge = -diff break elif (idealDue + diff) not in dues: fudge = diff break return idealIvl + fudge # Leeches ########################################################################## def _checkLeech(self, card, conf): "Leech handler. True if card was a leech." lf = conf['leechFails'] if not lf: return # if over threshold or every half threshold reps after that if (lf >= card.lapses 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 == 0: self.suspendCards([card.id]) card.queue = -1 # notify UI runHook("leech", card) return True # Tools ########################################################################## def _cardConf(self, card): return self.col.decks.conf(card.did) def _deckLimit(self): return ids2str(self.col.decks.active()) # Daily cutoff ########################################################################## def _updateCutoff(self): # days since col created self.today = int((time.time() - self.col.crt) / 86400) # end of day cutoff self.dayCutoff = self.col.crt + (self.today+1)*86400 # update all selected decks def update(g): save = False for t in "new", "rev", "lrn", "time": key = t+"Today" if g[key][0] != self.today: save = True g[key] = [self.today, 0] if save: self.col.decks.save(g) for did in self.col.decks.active(): update(self.col.decks.get(did)) # update parents too for grp in self.col.decks.parents(self.col.decks.selected()): update(grp) def _checkDay(self): # check if the day has rolled over if time.time() > self.dayCutoff: self.reset() # Deck finished state ########################################################################## def finishedMsg(self): return (""+_( "Congratulations! You have finished the selected deck for now.")+ "

" + self._nextDueMsg()) def _nextDueMsg(self): line = [] if self.revDue(): line.append(_("""\ Today's review limit has been reached, but there are still cards waiting to be reviewed. For optimum memory, consider increasing the daily limit in the options.""")) if self.newDue(): line.append(_("""\ There are more new cards available, but the daily limit has been reached. You can increase the limit in the options, but please bear in mind that the more new cards you introduce, the higher your short-term review workload will become.""")) return "
".join(line) def revDue(self): "True if there are any rev cards due." return self.col.db.scalar( ("select 1 from cards where did in %s and queue = 2 " "and due <= ? limit 1") % self._deckLimit(), self.today) def newDue(self): "True if there are any new cards due." return self.col.db.scalar( ("select 1 from cards where did in %s and queue = 0 " "limit 1") % self._deckLimit()) # Next time reports ########################################################################## def nextIvlStr(self, card, ease, short=False): "Return the next interval for CARD as a string." return fmtTimeSpan( self.nextIvl(card, ease), short=short) def nextIvl(self, card, ease): "Return the next interval for CARD, in seconds." if card.queue in (0,1): return self._nextLrnIvl(card, ease) elif ease == 1: # lapsed conf = self._cardConf(card)['lapse'] if conf['relearn']: return conf['delays'][0]*60 return self._nextLapseIvl(card, conf)*86400 else: # review return self._nextRevIvl(card, ease)*86400 # this isn't easily extracted from the learn code def _nextLrnIvl(self, card, ease): conf = self._lrnConf(card) if ease == 1: # fail return self._delayForGrade(conf, len(conf['delays'])) elif ease == 3: # early removal return self._graduatingIvl(card, conf, True) * 86400 else: if card.queue == 0: left = self._startingLeft(card) - 1 else: left = card.left - 1 if left <= 0: # graduate return self._graduatingIvl(card, conf, False) * 86400 else: return self._delayForGrade(conf, left) # Suspending ########################################################################## def suspendCards(self, ids): "Suspend cards." self.removeFailed(ids) self.col.db.execute( "update cards set queue=-1,mod=?,usn=? where id in "+ ids2str(ids), intTime(), self.col.usn()) def unsuspendCards(self, ids): "Unsuspend cards." self.col.db.execute( "update cards set queue=type,mod=?,usn=? " "where queue = -1 and id in "+ ids2str(ids), intTime(), self.col.usn()) def buryNote(self, nid): "Bury all cards for note until next session." self.col.setDirty() self.removeFailed( self.col.db.list("select id from cards where nid = ?", nid)) self.col.db.execute("update cards set queue = -2 where nid = ?", nid) # Resetting ########################################################################## def forgetCards(self, ids): "Put cards at the end of the new queue." self.col.db.execute( "update cards set type=0,queue=0,ivl=0 where id in "+ids2str(ids)) pmax = self.col.db.scalar("select max(due) from cards where type=0") # takes care of mod + usn self.sortCards(ids, start=pmax+1, shuffle=self.col.models.randomNew()) def reschedCards(self, ids, imin, imax): "Put cards in review queue with a new interval in days (min, max)." d = [] t = self.today mod = intTime() for id in ids: r = random.randint(imin, imax) d.append(dict(id=id, due=r+t, ivl=max(1, r), mod=mod)) self.col.db.executemany( "update cards set type=2,queue=2,ivl=:ivl,due=:due where id=:id", d) # Repositioning new cards ########################################################################## def sortCards(self, cids, start=1, step=1, shuffle=False, shift=False): scids = ids2str(cids) now = intTime() nids = self.col.db.list( ("select distinct nid from cards where type = 0 and id in %s " "order by nid") % scids) if not nids: # no new cards return # determine nid ordering due = {} if shuffle: random.shuffle(nids) for c, nid in enumerate(nids): due[nid] = start+c*step high = start+c*step # shift? if shift: low = self.col.db.scalar( "select min(due) from cards where due >= ? and type = 0 " "and id not in %s" % scids, start) if low is not None: shiftby = high - low + 1 self.col.db.execute(""" update cards set mod=?, usn=?, due=due+? where id not in %s and due >= ?""" % scids, now, self.col.usn(), shiftby, low) # reorder cards d = [] for id, nid in self.col.db.execute( "select id, nid from cards where type = 0 and id in "+scids): d.append(dict(now=now, due=due[nid], usn=self.col.usn(), cid=id)) self.col.db.executemany( "update cards set due=:due,mod=:now,usn=:usn where id = :cid""", d) # fixme: because it's a model property now, these should be done on a # per-model basis def randomizeCards(self): self.sortCards(self.col.db.list("select id from cards"), shuffle=True) def orderCards(self): self.sortCards(self.col.db.list("select id from cards"))