Anki/anki/sched.py
Damien Elmes bc9f6e6a24 add USNs
Decks now have an "update sequence number". All objects also have a USN, which
is set to the deck USN each time they are modified. When syncing, each side
sends any objects with a USN >= clientUSN. When objects are copied via sync,
they have their USNs bumped to the current serverUSN. After a sync, the USN on
both sides is set to serverUSN + 1.

This solves the failing three way test, ensures we receive all changes
regardless of clock drift, and as the revlog also has a USN now, ensures that
old revlog entries are imported properly too.

Objects retain a separate modification time, which is used for conflict
resolution, deck subscriptions/importing, and info for the user.

Note that if the clock is too far off, it will still cause confusion for
users, as the due counts may be different depending on the time. For this
reason it's probably a good idea to keep a limit on how far the clock can
deviate.

We still keep track of the last sync time, but only so we can determine if the
schema has changed since the last sync.

The media code needs to be updated to use USNs too.
2011-09-13 21:10:21 +09:00

836 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
wasNew = card.queue == 0
if wasNew:
# put it in the learn queue
card.queue = 1
card.type = 1
self._updateStats('new')
if card.queue == 1:
self._answerLrnCard(card, ease)
if not wasNew:
self._updateStats('lrn')
elif card.queue == 2:
self._answerRevCard(card, ease)
self._updateStats('rev')
else:
raise Exception("Invalid queue")
self._updateStats('time', card.timeTaken())
card.mod = intTime()
card.usn = self.deck.usn()
card.flushSched()
def counts(self):
"Does not include fetched but unanswered."
if self.deck.conf['counts'] == COUNT_REMAINING:
return self.dueCounts()
else:
return self.answeredCounts()
def dueCounts(self):
return (self.newCount, self.lrnCount, self.revCount)
def answeredCounts(self):
t = self.deck.groups.top()
return (t['newToday'][1], t['lrnToday'][1], t['revToday'][1])
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()
self._updateStatsDay("time")
def _updateStatsDay(self, type):
l = self.deck.groups.top()
if l[type+'Today'][0] != self.today:
# it's a new day; reset counts
l[type+'Today'] = [self.today, 0]
def _updateStats(self, type, cnt=1):
self.deck.groups.top()[type+'Today'][1] += cnt
# 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
##########################################################################
def _resetNewCount(self):
self._updateStatsDay("new")
l = self.deck.groups.top()
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._updateStatsDay("lrn")
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):
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, self.deck.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.deck.db.execute("""
update cards set
due = edue, queue = 2, mod = %d, usn = %d
where queue = 1 and type = 2
%s
""" % (intTime(), self.deck.usn(), extra))
# Reviews
##########################################################################
def _resetRevCount(self):
self._updateStatsDay("rev")
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):
def log():
self.deck.db.execute(
"insert into revlog values (?,?,?,?,?,?,?,?,?)",
int(time.time()*1000), card.id, self.deck.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']
# 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=?,usn=? where id in "+
ids2str(ids), intTime(), self.deck.usn())
def unsuspendCards(self, ids):
"Unsuspend cards."
self.deck.db.execute(
"update cards set queue=type,mod=?,usn=? "
"where queue = -1 and id in "+ ids2str(ids),
intTime(), self.deck.usn())
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."
t = self.deck.groups.top()
return t['timeToday'][1] / 1000
def repsToday(self):
"Number of cards answered today."
return sum(self.answeredCounts())
# Dynamic indices
##########################################################################
# fixme: warn user that the default is faster
def updateDynamicIndices(self):
"Call this after revOrder is changed. Bumps schema."
# 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")
self.deck.modSchema()
# 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")
# takes care of mod + usn
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=?, usn=?, due=due+? where id not in %s
and due >= ?""" % scids, now, self.deck.usn(), 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], usn=self.deck.usn(), cid=id))
self.deck.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.deck.db.list("select id from cards"), shuffle=True)
def orderCards(self):
self.sortCards(self.deck.db.list("select id from cards"))