Anki/anki/sched.py

895 lines
30 KiB
Python

# -*- coding: utf-8 -*-
# Copyright: Damien Elmes <anki@ichi2.net>
# 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()
card = self._getCard()
if card:
card.startTimer()
return card
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._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 counts(self, card=None):
counts = [self.newCount, self.lrnCount, self.revCount]
if card:
idx = self.countIdx(card)
if idx == 1:
counts[1] += card.left
else:
counts[idx] += 1
return tuple(counts)
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)
def _walkingCount(self, limFn=None, cntFn=None):
tot = 0
pcounts = {}
# for each of the active decks
for did in self.col.decks.active():
# get the individual deck's limit
lim = limFn(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']] = limFn(p)
# take minimum of child and parent
lim = min(pcounts[p['id']], lim)
# see how many cards we actually have
cnt = cntFn(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
tot += cnt
return tot
# 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 _getCard(self):
"Return the next due card id, or None."
# learning card due?
c = self._getLrnCard()
if c:
return c
# new first, or time for one?
if self._timeForNewCard():
return self._getNewCard()
# card due for review?
c = self._getRevCard()
if c:
return c
# new cards left?
c = self._getNewCard()
if c:
return c
# collapse or finish
return self._getLrnCard(collapse=True)
# New cards
##########################################################################
def _resetNewCount(self):
cntFn = lambda did, lim: self.col.db.scalar("""
select count() from (select 1 from cards where
did = ? and queue = 0 limit ?)""", did, lim)
self.newCount = self._walkingCount(self._deckNewLimitSingle, cntFn)
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']['separate']:
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 self.col.getCard(id)
def _updateNewCardRatio(self):
if self.col.conf['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.conf['newSpread'] == NEW_CARDS_LAST:
return False
elif self.col.conf['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, fn=None):
if not fn:
fn = self._deckNewLimitSingle
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 = fn(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.col.db.scalar("""
select sum(left) from (select left from cards where
did in %s and queue = 1 and due < ? limit %d)""" % (
self._deckLimit(), self.reportLimit),
self.dayCutoff) 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.conf['collapseTime']
if self._lrnQueue[0][0] < cutoff:
id = heappop(self._lrnQueue)[1]
card = self.col.getCard(id)
self.lrnCount -= card.left
return card
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
# lrnCount was decremented once when card was fetched
lastLeft = card.left
# immediate graduate?
if ease == 3:
self._rescheduleAsRev(card, conf, True)
leaving = True
# graduation time?
elif ease == 2 and card.left-1 <= 0:
self._rescheduleAsRev(card, conf, False)
leaving = True
else:
# one step towards graduation
if ease == 2:
card.left -= 1
# failed
else:
card.left = self._startingLeft(card)
self.lrnCount += card.left
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._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, adj=True):
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]
if adj:
return self._adjRevIvl(card, ideal)
else:
return 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.conf['collapseTime'])
# Reviews
##########################################################################
def _deckRevLimit(self, did):
return self._deckNewLimit(did, self._deckRevLimitSingle)
def _deckRevLimitSingle(self, d):
c = self.col.decks.conf(d['id'])
return max(0, c['rev']['perDay'] - d['revToday'][1])
def _deckHasRev(self, did):
if not self._deckRevLimit(did):
return False
return self.col.db.scalar(
"select 1 from cards where did = ? and queue = 2 "
"and due <= ? limit 1", did, self.today)
def _resetRevCount(self):
def cntFn(did, lim):
return self.col.db.scalar("""
select count() from (select id from cards where
did = ? and queue = 2 and due <= ? limit %d)""" % lim,
did, self.today)
self.revCount = self._walkingCount(
self._deckRevLimitSingle, cntFn)
def _resetRev(self):
self._resetRevCount()
self._revQueue = []
self._revDids = self.col.decks.active()
def _fillRev(self):
if self._revQueue:
return True
if not self.revCount:
return False
while self._revDids:
did = self._newDids[0]
lim = min(self.queueLimit, self._deckRevLimit(did))
order = self._revOrder(did)
if lim:
# fill the queue with the current did
self._revQueue = self.col.db.list("""
select id from cards where
did = ? and queue = 2 and due <= ? %s limit ?""" % order,
did, self.today, lim)
if self._revQueue:
return True
# nothing left in the deck; move to next
self._newDids.pop(0)
if not order:
r = random.Random()
r.seed(self.today)
r.shuffle(self._revQueue)
else:
self._revQueue.reverse()
return True
def _getRevCard(self):
if self._fillRev():
self.revCount -= 1
return self.col.getCard(self._revQueue.pop())
def _revOrder(self, did):
d = self.col.decks.conf(did)
o = d['rev']['order']
if o:
return "order by %s" % ("ivl desc", "ivl")[o-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['delays']:
card.edue = card.due
card.due = int(self._delayForGrade(conf, 0) + time.time())
card.left = len(conf['delays'])
card.queue = 1
self.lrnCount += card.left
# leech?
if not self._checkLeech(card, conf) and conf['delays']:
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/100.0) / math.log(1-old/100.0)
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 idealIvl - 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 ("<b>"+_(
"Congratulations! You have finished for now.")+
"</b><br><br>" + 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 "<br>".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['delays']:
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):
if card.queue == 0:
card.type = 1
card.left = self._startingLeft(card)
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, adj=False) * 86400
else:
left = card.left - 1
if left <= 0:
# graduate
return self._graduatingIvl(card, conf, False, adj=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)
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)
def randomizeCards(self, did):
cids = self.col.db.list("select id from cards where did = ?", did)
self.sortCards(cids, shuffle=True)
def orderCards(self, did):
cids = self.col.db.list("select id from cards where did = ?", did)
self.sortCards(cids)
def resortConf(self, conf):
for did in self.col.decks.didsForConf(conf):
if conf['new']['order'] == 0:
self.randomizeCards(did)
else:
self.orderCards(did)