Anki/anki/sched.py
Damien Elmes 28d045feef rewrite groupCounts()
Instead of collecting the exact number of cards, we just record whether a
group has any reviews or new cards. By not needing to calculate the exact
numbers, it runs a lot faster than before.

Also, changed the group code to ensure parents are automatically created when
a group is added.
2011-09-07 03:02:07 +09:00

815 lines
28 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
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, deck):
self.deck = deck
self.queueLimit = 200
self.reportLimit = 1000
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.deck.getCard(id)
c.startTimer()
return c
def reset(self):
self._resetCounts()
self._resetLrn()
self._resetRev()
self._resetNew()
def answerCard(self, card, ease):
assert ease >= 1 and ease <= 4
self.deck.markReview(card)
self.reps += 1
card.reps += 1
if card.queue == 0:
# put it in the learn queue
card.queue = 1
card.type = 1
self.deck.groups.top()['newToday'][1] += 1
if card.queue == 1:
self._answerLrnCard(card, ease)
elif card.queue == 2:
self._answerRevCard(card, ease)
else:
raise Exception("Invalid queue")
card.mod = intTime()
card.flushSched()
def counts(self):
"Does not include fetched but unanswered."
return (self.newCount, self.lrnCount, self.revCount)
def dueForecast(self, days=7):
"Return counts over next DAYS. Includes today."
daysd = dict(self.deck.db.all("""
select due, count() from cards
where queue = 2 %s
and due between ? and ?
group by due
order by due""" % self._groupLimit(),
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.deck.db.execute(
"update cards set queue = type where queue between -3 and -2")
# Counts
##########################################################################
def _resetCounts(self):
self._updateCutoff()
self._resetLrnCount()
self._resetRevCount()
self._resetNewCount()
# Group counts
##########################################################################
def groupCounts(self):
"Returns [groupname, hasDue, hasNew]"
# find groups with 1 or more due cards
gids = {}
for g in self.deck.groups.all():
hasDue = self.deck.db.scalar("""
select 1 from cards where gid = ? and
((queue = 2 and due <= ?) or (queue = 1 and due < ?)) limit 1""",
g['id'], self.today, intTime())
top = self.deck.groups.get(
self.deck.groups.topFor(g['name']))
if top['newToday'][0] != self.today:
# it's a new day; reset counts
top['newToday'] = [self.today, 0]
hasNew = max(0, top['newPerDay'] - top['newToday'][1])
if hasNew:
# if the limit hasn't run out, check to see if there are
# actually cards
hasNew = self.deck.db.scalar(
"select 1 from cards where queue = 0 and gid = ? limit 1",
g['id'])
gids[g['id']] = [hasDue or 0, hasNew or 0]
return [[grp['name'], int(gid)]+gids.get(int(gid))
for (gid, grp) in self._orderedGroups()]
def _orderedGroups(self):
grps = self.deck.groups.groups.items()
def key(grp):
return grp[1]['name']
grps.sort(key=key)
return grps
def groupCountTree(self):
return self._groupChildren(self.groupCounts())
def groupTree(self):
"Like the count tree without the counts. Faster."
return self._groupChildren([[grp['name'], int(gid), 0, 0, 0]
for (gid, grp) in self._orderedGroups()])
def _groupChildren(self, grps):
tree = []
# split strings
for g in grps:
g[0] = g[0].split("::", 1)
# group and recurse
def key(grp):
return grp[0][0]
for (head, tail) in itertools.groupby(grps, key=key):
tail = list(tail)
gid = None
rev = 0
new = 0
children = []
for c in tail:
if len(c[0]) == 1:
# current node
gid = c[1]
rev += c[2]
new += c[3]
else:
# set new string to tail
c[0] = c[0][1]
children.append(c)
children = self._groupChildren(children)
# tally up children counts
for ch in children:
rev += ch[2]
new += ch[3]
tree.append((head, gid, 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
##########################################################################
# FIXME: need to keep track of reps for timebox and new card introduction
def _resetNewCount(self):
l = self.deck.groups.top()
if l['newToday'][0] != self.today:
# it's a new day; reset counts
l['newToday'] = [self.today, 0]
lim = min(self.reportLimit, l['newPerDay'] - l['newToday'][1])
if lim <= 0:
self.newCount = 0
else:
self.newCount = self.deck.db.scalar("""
select count() from (select id from cards where
queue = 0 %s limit %d)""" % (self._groupLimit(), lim))
def _resetNew(self):
lim = min(self.queueLimit, self.newCount)
self.newQueue = self.deck.db.all("""
select id, due from cards where
queue = 0 %s order by due limit %d""" % (self._groupLimit(),
lim))
self.newQueue.reverse()
self._updateNewCardRatio()
def _getNewCard(self):
# We rely on sqlite to return the cards in id order. This may not
# correspond to the 'ord' order. The alternative would be to do
# something like due = fid*100+ord, but then we have no efficient way
# of spacing siblings as we'd need to fetch the fid as well.
if self.newQueue:
(id, due) = self.newQueue.pop()
# move any siblings to the end?
if self.deck.groups.top()['newTodayOrder'] == 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 fact in the queue; stop rotating
break
self.newCount -= 1
return id
def _updateNewCardRatio(self):
if self.deck.groups.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.deck.groups.top()['newSpread'] == NEW_CARDS_LAST:
return False
elif self.deck.groups.top()['newSpread'] == NEW_CARDS_FIRST:
return True
elif self.newCardModulus:
return self.reps and self.reps % self.newCardModulus == 0
# Learning queue
##########################################################################
def _resetLrnCount(self):
self.lrnCount = self.deck.db.scalar("""
select count() from (select id from cards where
queue = 1 %s and due < ? limit %d)""" % (
self._groupLimit(), self.reportLimit),
intTime() + self.deck.groups.top()['collapseTime'])
def _resetLrn(self):
self.lrnQueue = self.deck.db.all("""
select due, id from cards where
queue = 1 %s and due < :lim order by due
limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
def _getLrnCard(self, collapse=False):
if self.lrnQueue:
cutoff = time.time()
if collapse:
cutoff += self.deck.groups.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
if ease == 3:
self._rescheduleAsRev(card, conf, True)
leaving = True
elif ease == 2 and card.grade+1 >= len(conf['delays']):
self._rescheduleAsRev(card, conf, False)
leaving = True
else:
card.cycles += 1
if ease == 2:
card.grade += 1
else:
card.grade = 0
delay = self._delayForGrade(conf, card.grade)
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))
# if it's due within the cutoff, increment count
if delay <= self.deck.groups.top()['collapseTime']:
self.lrnCount += 1
self._logLrn(card, ease, conf, leaving, type)
def _delayForGrade(self, conf, grade):
try:
delay = conf['delays'][grade]
except IndexError:
delay = conf['delays'][-1]
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 _graduatingIvl(self, card, conf, early):
if card.type == 2:
# lapsed card being relearnt
return card.ivl
if not early:
# graduate
ideal = conf['ints'][0]
elif card.cycles:
# remove
ideal = conf['ints'][2]
else:
# first time bonus
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):
# limit time taken to global setting
taken = min(card.timeTaken(), self._cardConf(card)['maxTaken']*1000)
lastIvl = -(self._delayForGrade(conf, max(0, card.grade-1)))
ivl = card.ivl if leaving else -(self._delayForGrade(conf, card.grade))
def log():
self.deck.db.execute(
"insert into revlog values (?,?,?,?,?,?,?,?)",
int(time.time()*1000), card.id, ease,
ivl, lastIvl,
card.factor, taken, 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.deck.db.execute("""
update cards set
due = edue, queue = 2, mod = %d
where queue = 1 and type = 2
%s
""" % (intTime(), extra))
# Reviews
##########################################################################
def _resetRevCount(self):
self.revCount = self.deck.db.scalar("""
select count() from (select id from cards where
queue = 2 %s and due <= :lim limit %d)""" % (
self._groupLimit(), self.reportLimit),
lim=self.today)
def _resetRev(self):
self.revQueue = self.deck.db.list("""
select id from cards where
queue = 2 %s and due <= :lim order by %s limit %d""" % (
self._groupLimit(), self._revOrder(), self.queueLimit),
lim=self.today)
if self.deck.conf['revOrder'] == REV_CARDS_RANDOM:
r = random.Random()
r.seed(self.today)
r.shuffle(self.revQueue)
else:
self.revQueue.reverse()
def _getRevCard(self):
if self._haveRevCards():
self.revCount -= 1
return self.revQueue.pop()
def _haveRevCards(self):
if self.revCount:
if not self.revQueue:
self._resetRev()
return self.revQueue
def _revOrder(self):
return ("ivl desc",
"ivl",
"due")[self.deck.conf['revOrder']]
# 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.queue = 1
self.lrnCount += 1
heappush(self.lrnQueue, (card.due, card.id))
# leech?
self._checkLeech(card, conf)
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):
taken = min(card.timeTaken(), self._cardConf(card)['maxTaken']*1000)
def log():
self.deck.db.execute(
"insert into revlog values (?,?,?,?,?,?,?,?)",
int(time.time()*1000), card.id, ease,
card.ivl, card.lastIvl, card.factor, taken,
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']
# 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 _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.deck.db.list(
"select due from cards where fid = ? and queue = 2"
" and id != ?", card.fid, 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.fact()
f.addTag("leech")
f.flush()
# handle
a = conf['leechAction']
if a == 0:
self.suspendCards([card.id])
card.queue = -1
# notify UI
runHook("leech", card)
# Tools
##########################################################################
def _cardConf(self, card):
return self.deck.groups.conf(card.gid)
def _groupLimit(self):
l = self.deck.groups.active()
if not l:
# everything
return ""
return " and gid in %s" % ids2str(l)
# Daily cutoff
##########################################################################
def _updateCutoff(self):
# days since deck created
self.today = int((time.time() - self.deck.crt) / 86400)
# end of day cutoff
self.dayCutoff = self.deck.crt + (self.today+1)*86400
def _checkDay(self):
# check if the day has rolled over
if time.time() > self.dayCutoff:
self.updateCutoff()
self.reset()
# Deck finished state
##########################################################################
def finishedMsg(self):
return (
"<h1>"+_("Congratulations!")+"</h1>"+
_("You have finished the selected groups for now.") +
"<br><br>"+
self._nextDueMsg())
def _nextDueMsg(self):
line = []
rev = self.revTomorrow() + self.lrnTomorrow()
if rev:
line.append(
ngettext("There will be <b>%s review</b>.",
"There will be <b>%s reviews</b>.", rev) % rev)
new = self.newTomorrow()
if new:
line.append(
ngettext("There will be <b>%d new</b> card.",
"There will be <b>%d new</b> cards.", new) % new)
if line:
line.insert(0, _("At this time tomorrow:"))
buf = "<br>".join(line)
else:
buf = _("No cards are due tomorrow.")
buf = '<style>b { color: #00f; }</style>' + buf
return buf
def lrnTomorrow(self):
"Number of cards in the learning queue due tomorrow."
return self.deck.db.scalar(
"select count() from cards where queue = 1 and due < ?",
self.dayCutoff+86400)
def revTomorrow(self):
"Number of reviews due tomorrow."
return self.deck.db.scalar(
"select count() from cards where queue = 2 and due = ?"+
self._groupLimit(),
self.today+1)
def newTomorrow(self):
"Number of new cards tomorrow."
lim = self.deck.groups.top()['newPerDay']
return self.deck.db.scalar(
"select count() from (select id from cards where "
"queue = 0 %s limit %d)" % (self._groupLimit(), lim))
# 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:
# grade 0
return self._delayForGrade(conf, 0)
elif ease == 3:
# early removal
return self._graduatingIvl(card, conf, True) * 86400
else:
grade = card.grade + 1
if grade >= len(conf['delays']):
# graduate
return self._graduatingIvl(card, conf, False) * 86400
else:
# next level
return self._delayForGrade(conf, grade)
# Suspending
##########################################################################
def suspendCards(self, ids):
"Suspend cards."
self.removeFailed(ids)
self.deck.db.execute(
"update cards set queue = -1, mod = ? where id in "+
ids2str(ids), intTime())
def unsuspendCards(self, ids):
"Unsuspend cards."
self.deck.db.execute(
"update cards set queue = type, mod = ? "
"where queue = -1 and id in "+ ids2str(ids),
intTime())
def buryFact(self, fid):
"Bury all cards for fact until next session."
self.deck.setDirty()
self.removeFailed(
self.deck.db.list("select id from cards where fid = ?", fid))
self.deck.db.execute("update cards set queue = -2 where fid = ?", fid)
# Counts
##########################################################################
def timeToday(self):
"Time spent learning today, in seconds."
return self.deck.db.scalar(
"select sum(time/1000.0) from revlog where id > ?*1000",
self.dayCutoff-86400) or 0
def repsToday(self):
"Number of cards answered today."
return self.deck.db.scalar(
"select count() from revlog where id > ?*1000",
self.dayCutoff-86400)
# Dynamic indices
##########################################################################
def updateDynamicIndices(self):
# determine required columns
required = []
if self.deck.conf['revOrder'] in (
REV_CARDS_OLD_FIRST, REV_CARDS_NEW_FIRST):
required.append("interval")
cols = ["queue", "due", "gid"] + required
# update if changed
if self.deck.db.scalar(
"select 1 from sqlite_master where name = 'ix_cards_multi'"):
rows = self.deck.db.all("pragma index_info('ix_cards_multi')")
else:
rows = None
if not (rows and cols == [r[2] for r in rows]):
self.deck.db.execute("drop index if exists ix_cards_multi")
self.deck.db.execute("create index ix_cards_multi on cards (%s)" %
", ".join(cols))
self.deck.db.execute("analyze")
# Resetting
##########################################################################
def forgetCards(self, ids):
"Put cards at the end of the new queue."
self.deck.db.execute(
"update cards set type=0, queue=0, ivl=0 where id in "+ids2str(ids))
pmax = self.deck.db.scalar("select max(due) from cards where type=0")
self.sortCards(ids, start=pmax+1, shuffle=self.deck.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.deck.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()
fids = self.deck.db.list(
("select distinct fid from cards where type = 0 and id in %s "
"order by fid") % scids)
if not fids:
# no new cards
return
# determine fid ordering
due = {}
if shuffle:
random.shuffle(fids)
for c, fid in enumerate(fids):
due[fid] = start+c*step
high = start+c*step
# shift?
if shift:
low = self.deck.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.deck.db.execute("""
update cards set mod=?, due=due+? where id not in %s
and due >= ?""" % scids, now, shiftby, low)
# reorder cards
d = []
for id, fid in self.deck.db.execute(
"select id, fid from cards where type = 0 and id in "+scids):
d.append(dict(now=now, due=due[fid], cid=id))
self.deck.db.executemany(
"update cards set due = :due, mod = :now 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.deck.db.list("select id from cards"), shuffle=True)
def orderCards(self):
self.sortCards(self.deck.db.list("select id from cards"))