refactor learning

Rather than showing the user how many cards are in the learning queue, we want
to be able to show them the number of reps they have to do to clear the queue,
so they can better estimate the required time. Before we were counting up with
the grade column, but this means we can't quickly sum up the number of reps
left. So we invert it, and count down instead.

I also dropped the 'first time bonus' for now. If there's enough demand for
it, it can be added back by using the flags column, instead of a dedicated
cycles column.
This commit is contained in:
Damien Elmes 2011-09-23 10:29:49 +09:00
parent 2b34d8a948
commit e7f416406d
7 changed files with 60 additions and 72 deletions

View file

@ -37,8 +37,7 @@ class Card(object):
self.factor = 0
self.reps = 0
self.lapses = 0
self.grade = 0
self.cycles = 0
self.left = 0
self.edue = 0
self.flags = 0
self.data = ""
@ -57,8 +56,7 @@ class Card(object):
self.factor,
self.reps,
self.lapses,
self.grade,
self.cycles,
self.left,
self.edue,
self.flags,
self.data) = self.deck.db.first(
@ -72,7 +70,7 @@ class Card(object):
self.deck.db.execute(
"""
insert or replace into cards values
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
self.id,
self.fid,
self.gid,
@ -86,8 +84,7 @@ insert or replace into cards values
self.factor,
self.reps,
self.lapses,
self.grade,
self.cycles,
self.left,
self.edue,
self.flags,
self.data)
@ -98,10 +95,10 @@ insert or replace into cards values
self.deck.db.execute(
"""update cards set
mod=?, usn=?, type=?, queue=?, due=?, ivl=?, factor=?, reps=?,
lapses=?, grade=?, cycles=?, edue=? where id = ?""",
lapses=?, left=?, edue=? where id = ?""",
self.mod, self.usn, self.type, self.queue, self.due, self.ivl,
self.factor, self.reps, self.lapses,
self.grade, self.cycles, self.edue, self.id)
self.left, self.edue, self.id)
def q(self, classes="q", reload=False):
return self._withClass(self._getQA(reload)['q'], classes)

View file

@ -44,7 +44,7 @@ defaultTopConf = {
defaultConf = {
'new': {
'delays': [1, 10],
'ints': [1, 7, 4],
'ints': [1, 4],
'initialFactor': 2500,
'order': NEW_TODAY_ORD,
'perDay': 20,

View file

@ -51,6 +51,7 @@ class Scheduler(object):
# 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)
@ -208,6 +209,7 @@ order by due""" % self._groupLimit(),
def _resetNewCount(self):
self.newCount = 0
self.newRepCount = 0
pcounts = {}
# for each of the active groups
for gid in self.deck.groups.active():
@ -234,6 +236,8 @@ gid = ? and queue = 0 limit ?)""", gid, lim)
pcounts[gid] = lim - cnt
# and add to running total
self.newCount += cnt
conf = self.deck.groups.conf(gid)
self.newRepCount += cnt * len(conf['new']['delays'])
def _resetNew(self):
self._resetNewCount()
@ -327,11 +331,12 @@ select id, due from cards where gid = ? and queue = 0 limit ?""", gid, lim)
##########################################################################
def _resetLrnCount(self):
self.lrnCount = self.deck.db.scalar("""
select count() from (select 1 from cards where
(self.lrnCount, self.lrnRepCount) = self.deck.db.first("""
select count(), sum(left) from (select left from cards where
gid in %s and queue = 1 and due < ? limit %d)""" % (
self._groupLimit(), self.reportLimit),
intTime() + self.deck.groups.top()['collapseTime'])
self.lrnRepCount = self.lrnRepCount or 0
def _resetLrn(self):
self._resetLrnCount()
@ -368,19 +373,19 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
else:
type = 0
leaving = False
lastLeft = card.left
if ease == 3:
self._rescheduleAsRev(card, conf, True)
leaving = True
elif ease == 2 and card.grade+1 >= len(conf['delays']):
elif ease == 2 and card.left-1 <= 0:
self._rescheduleAsRev(card, conf, False)
leaving = True
else:
card.cycles += 1
if ease == 2:
card.grade += 1
card.left -= 1
else:
card.grade = 0
delay = self._delayForGrade(conf, card.grade)
card.left = self._startingLeft(card)
delay = self._delayForGrade(conf, card.left)
if card.due < time.time():
# not collapsed; add some randomness
delay *= random.uniform(1, 1.25)
@ -389,13 +394,13 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
# 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)
self._logLrn(card, ease, conf, leaving, type, lastLeft)
def _delayForGrade(self, conf, grade):
def _delayForGrade(self, conf, left):
try:
delay = conf['delays'][grade]
delay = conf['delays'][-left]
except IndexError:
delay = conf['delays'][-1]
delay = conf['delays'][0]
return delay*60
def _lrnConf(self, card):
@ -414,6 +419,9 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
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
@ -421,11 +429,8 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
if not early:
# graduate
ideal = conf['ints'][0]
elif card.cycles:
# remove
ideal = conf['ints'][2]
else:
# first time bonus
# early remove
ideal = conf['ints'][1]
return self._adjRevIvl(card, ideal)
@ -434,9 +439,9 @@ limit %d""" % (self._groupLimit(), self.reportLimit), lim=self.dayCutoff)
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 _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.deck.db.execute(
"insert into revlog values (?,?,?,?,?,?,?,?,?)",
@ -534,6 +539,7 @@ gid in %s and queue = 2 and due <= :lim %s limit %d""" % (
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
# leech?
@ -742,19 +748,18 @@ gid in %s and queue = 2 and due <= :lim %s limit %d""" % (
def _nextLrnIvl(self, card, ease):
conf = self._lrnConf(card)
if ease == 1:
# grade 0
return self._delayForGrade(conf, 0)
# fail
return self._delayForGrade(conf, len(conf['delays']))
elif ease == 3:
# early removal
return self._graduatingIvl(card, conf, True) * 86400
else:
grade = card.grade + 1
if grade >= len(conf['delays']):
left = card.left - 1
if left <= 0:
# graduate
return self._graduatingIvl(card, conf, False) * 86400
else:
# next level
return self._delayForGrade(conf, grade)
return self._delayForGrade(conf, left)
# Suspending
##########################################################################

View file

@ -106,8 +106,7 @@ create table if not exists cards (
factor integer not null,
reps integer not null,
lapses integer not null,
grade integer not null,
cycles integer not null,
left integer not null,
edue integer not null,
flags integer not null,
data text not null
@ -290,7 +289,7 @@ order by created"""):
db.execute("drop table cards")
_addSchema(db, False)
db.executemany("""
insert into cards values (?,?,1,?,?,?,?,?,?,?,?,?,?,0,0,0,0,"")""",
insert into cards values (?,?,1,?,?,?,?,?,?,?,?,?,?,0,0,0,"")""",
rows)
# reviewHistory -> revlog

View file

@ -247,7 +247,7 @@ class Syncer(object):
# add missing
self.deck.db.executemany(
"insert or replace into cards values "
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
"(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
toAdd)
# remove remotely deleted
self.deck.remCards(toRem)

View file

@ -108,10 +108,10 @@ def test_newBoxes():
d.addFact(f)
d.reset()
c = d.sched.getCard()
d.sched._cardConf(c)['new']['delays'] = [1,2,3,4,5]
d.sched.answerCard(c, 2)
assert c.grade == 1
d.sched._cardConf(c)['new']['delays'] = [0.5]
# should handle gracefully
d.sched._cardConf(c)['new']['delays'] = [1]
d.sched.answerCard(c, 2)
def test_learn():
@ -127,23 +127,18 @@ def test_learn():
c = d.sched.getCard()
assert c
d.sched._cardConf(c)['new']['delays'] = [0.5, 3, 10]
# it should have no cycles and a grade of 0
assert c.grade == c.cycles == 0
# fail it
d.sched.answerCard(c, 1)
# it should have three reps left to graduation
assert c.left == 3
# it should by due in 30 seconds
t = round(c.due - time.time())
assert t >= 25 and t <= 40
# and have 1 cycle, but still a zero grade
assert c.grade == 0
assert c.cycles == 1
# pass it once
d.sched.answerCard(c, 2)
# it should by due in 3 minutes
assert round(c.due - time.time()) in (179, 180)
# and it should be grade 1 now
assert c.grade == 1
assert c.cycles == 2
assert c.left == 2
# check log is accurate
log = d.db.first("select * from revlog order by id desc")
assert log[3] == 2
@ -153,9 +148,7 @@ def test_learn():
d.sched.answerCard(c, 2)
# it should by due in 10 minutes
assert round(c.due - time.time()) in (599, 600)
# and it should be grade 1 now
assert c.grade == 2
assert c.cycles == 3
assert c.left == 1
# the next pass should graduate the card
assert c.queue == 1
assert c.type == 1
@ -165,13 +158,6 @@ def test_learn():
# should be due tomorrow, with an interval of 1
assert c.due == d.sched.today+1
assert c.ivl == 1
# let's try early removal bonus
c.type = 0
c.queue = 1
c.cycles = 0
d.sched.answerCard(c, 3)
assert c.type == 2
assert c.ivl == 7
# or normal removal
c.type = 0
c.queue = 1
@ -318,18 +304,18 @@ def test_nextIvl():
d.sched._cardConf(c)['lapse']['delays'] = [0.5, 3, 10]
# cards in learning
##################################################
c.left = 3
ni = d.sched.nextIvl
assert ni(c, 1) == 30
assert ni(c, 2) == 180
# immediate removal is 7 days
assert ni(c, 3) == 7*86400
c.cycles = 1
c.grade = 1
# removal is 4 days
assert ni(c, 3) == 4*86400
c.left -= 1
assert ni(c, 1) == 30
assert ni(c, 2) == 600
# no first time bonus
assert ni(c, 3) == 4*86400
c.grade = 2
c.left = 1
# normal graduation is tomorrow
assert ni(c, 2) == 1*86400
assert ni(c, 3) == 4*86400
@ -412,6 +398,8 @@ def test_suspend():
assert c.due == 1
def test_cram():
print "disabled for now"
return
d = getEmptyDeck()
f = d.newFact()
f['Front'] = u"one"
@ -554,19 +542,19 @@ def test_adjIvl():
# immediately remove first; it should get ideal ivl
c = d.sched.getCard()
d.sched.answerCard(c, 3)
assert c.ivl == 7
assert c.ivl == 4
# with the default settings, second card should be -1
c = d.sched.getCard()
d.sched.answerCard(c, 3)
assert c.ivl == 6
assert c.ivl == 3
# and third +1
c = d.sched.getCard()
d.sched.answerCard(c, 3)
assert c.ivl == 8
assert c.ivl == 5
# fourth exceeds default settings, so gets ideal again
c = d.sched.getCard()
d.sched.answerCard(c, 3)
assert c.ivl == 7
assert c.ivl == 4
# try again with another fact
f = d.newFact()
f['Front'] = "2"; f['Back'] = "2"
@ -578,11 +566,11 @@ def test_adjIvl():
# first card gets ideal
c = d.sched.getCard()
d.sched.answerCard(c, 3)
assert c.ivl == 7
assert c.ivl == 4
# and second too, because it's below the threshold
c = d.sched.getCard()
d.sched.answerCard(c, 3)
assert c.ivl == 7
assert c.ivl == 4
# if we increase the ivl minSpace isn't needed
conf['new']['ints'][1] = 20
# ideal..

View file

@ -47,11 +47,10 @@ def test_review():
assert d.sched.counts() == (1, 0, 0)
c = d.sched.getCard()
assert c.queue == 0
assert c.grade == 0
d.sched.answerCard(c, 2)
assert c.left == 1
assert d.sched.counts() == (0, 1, 0)
assert c.queue == 1
assert c.grade == 1
# undo
assert d.undoName()
d.undo()
@ -59,7 +58,7 @@ def test_review():
assert d.sched.counts() == (1, 0, 0)
c.load()
assert c.queue == 0
assert c.grade == 0
assert c.left != 1
assert not d.undoName()
# we should be able to undo multiple answers too
f['Front'] = u"two"