experiment with simple resched=off case to 'preview mode'

the previous approach meant we weren't able to preserve the card state
exactly when cards were in learning, since we didn't record the step
position prior to cards being moved into the filtered deck.

it also meant the answer buttons needed to change depending on state - 4
for cards in learning/review, but 2 when the card is on the final step
or is a review.

instead, in preview mode cards always have 2 buttons: again will repeat
again after a delay, and good immediately removes the card and restores
it to its previous state.

to accomplish this, we use a separate queue #, as the learn count
always needs to have a 1:1 correspondence to the number of cards
This commit is contained in:
Damien Elmes 2017-12-26 21:46:49 +10:00
parent 575f61c384
commit ba87fc7736
2 changed files with 110 additions and 222 deletions

View file

@ -57,11 +57,10 @@ class Scheduler:
def answerCard(self, card, ease): def answerCard(self, card, ease):
self.col.log() self.col.log()
assert 1 <= ease <= 4 assert 1 <= ease <= 4
assert 0 <= card.queue <= 3 assert 0 <= card.queue <= 4
self.col.markReview(card) self.col.markReview(card)
if self._burySiblingsOnAnswer: if self._burySiblingsOnAnswer:
self._burySiblings(card) self._burySiblings(card)
card.reps += 1
self._answerCard(card, ease) self._answerCard(card, ease)
@ -71,6 +70,11 @@ class Scheduler:
card.flushSched() card.flushSched()
def _answerCard(self, card, ease): def _answerCard(self, card, ease):
if self._previewingCard(card):
self._answerCardPreview(card, ease)
return
card.reps += 1
card.wasNew = card.type == 0 card.wasNew = card.type == 0
if card.queue == 0: if card.queue == 0:
@ -88,6 +92,22 @@ class Scheduler:
# update daily limit # update daily limit
self._updateStats(card, 'rev') self._updateStats(card, 'rev')
# hard-coded for now
_previewDelay = 600
def _answerCardPreview(self, card, ease):
assert 1 <= ease <= 2
if ease == 1:
# repeat after delay
card.queue = 4
card.due = intTime() + self._previewDelay
self.lrnCount += 1
else:
# restore original card state and remove from filtered deck
self._restoreFromFiltered(card)
self._removeFromFiltered(card)
def counts(self, card=None): def counts(self, card=None):
counts = [self.newCount, self.lrnCount, self.revCount] counts = [self.newCount, self.lrnCount, self.revCount]
if card: if card:
@ -117,23 +137,14 @@ order by due""" % self._deckLimit(),
return ret return ret
def countIdx(self, card): def countIdx(self, card):
if card.queue == 3: if card.queue in (3,4):
return 1 return 1
return card.queue return card.queue
def answerButtons(self, card): def answerButtons(self, card):
conf = self._cardConf(card) conf = self._cardConf(card)
if card.odid and not conf['resched']:
# fixme: resched=off case return 2
# if card.odid:
# if not conf['resched']:
# if card.queue == 2:
# return 4
# conf = self._lrnConf(card)
# if card.type in (0,1) or len(conf['delays']) > 1:
# return 3
# return 2
return 4 return 4
def unburyCards(self): def unburyCards(self):
@ -449,6 +460,10 @@ did in %s and queue = 1 and due < ? limit %d)""" % (
select count() from cards where did in %s and queue = 3 select count() from cards where did in %s and queue = 3
and due <= ? limit %d""" % (self._deckLimit(), self.reportLimit), and due <= ? limit %d""" % (self._deckLimit(), self.reportLimit),
self.today) self.today)
# previews
self.lrnCount += self.col.db.scalar("""
select count() from cards where did in %s and queue = 4
limit %d""" % (self._deckLimit(), self.reportLimit))
def _resetLrn(self): def _resetLrn(self):
self._resetLrnCount() self._resetLrnCount()
@ -464,7 +479,7 @@ and due <= ? limit %d""" % (self._deckLimit(), self.reportLimit),
return True return True
self._lrnQueue = self.col.db.all(""" self._lrnQueue = self.col.db.all("""
select due, id from cards where select due, id from cards where
did in %s and queue = 1 and due < :lim did in %s and queue in (1,4) and due < :lim
limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff) limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff)
# as it arrives sorted by did first, we need to sort it # as it arrives sorted by did first, we need to sort it
self._lrnQueue.sort() self._lrnQueue.sort()
@ -478,7 +493,10 @@ limit %d""" % (self._deckLimit(), self.reportLimit), lim=self.dayCutoff)
if self._lrnQueue[0][0] < cutoff: if self._lrnQueue[0][0] < cutoff:
id = heappop(self._lrnQueue)[1] id = heappop(self._lrnQueue)[1]
card = self.col.getCard(id) card = self.col.getCard(id)
self.lrnCount -= card.left // 1000 if self._previewingCard(card):
self.lrnCount -= 1
else:
self.lrnCount -= card.left // 1000
return card return card
# daily learning # daily learning
@ -542,10 +560,9 @@ did = ? and queue = 3 and due <= ? limit ?""",
def _moveToFirstStep(self, card, conf): def _moveToFirstStep(self, card, conf):
card.left = self._startingLeft(card) card.left = self._startingLeft(card)
resched = self._resched(card)
card.lastIvl = card.ivl card.lastIvl = card.ivl
if card.type == 2 and resched: if card.type == 2:
# review card that will move to relearning # review card that will move to relearning
card.ivl = self._lapseIvl(card, self._lrnConf(card)) card.ivl = self._lapseIvl(card, self._lrnConf(card))
@ -624,27 +641,15 @@ did = ? and queue = 3 and due <= ? limit ?""",
def _rescheduleAsRev(self, card, conf, early): def _rescheduleAsRev(self, card, conf, early):
lapse = card.type == 2 lapse = card.type == 2
resched = self._resched(card)
if resched: if lapse:
if lapse: self._rescheduleGraduatingLapse(card)
self._rescheduleGraduatingLapse(card) else:
else: self._rescheduleNew(card, conf, early)
self._rescheduleNew(card, conf, early)
# if we were dynamic, graduating means moving back to the old deck # if we were dynamic, graduating means moving back to the old deck
if card.odid: if card.odid:
# and leaving learning if scheduling off self._removeFromFiltered(card)
if not resched:
card.due = card.odue
if card.type == 2:
card.queue = 2
else:
card.queue = card.type = 0
card.did = card.odid
card.odue = 0
card.odid = 0
def _rescheduleGraduatingLapse(self, card): def _rescheduleGraduatingLapse(self, card):
card.due = self.today+card.ivl card.due = self.today+card.ivl
@ -860,40 +865,28 @@ select id from cards where did in %s and queue = 2 and due <= ? limit ?)"""
# update interval # update interval
card.lastIvl = card.ivl card.lastIvl = card.ivl
if self._resched(card): self._updateRevIvl(card, ease)
self._updateRevIvl(card, ease) # then the rest
# then the rest card.factor = max(1300, card.factor+[-150, 0, 150][ease-2])
card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) card.due = self.today + card.ivl
card.due = self.today + card.ivl
else:
card.due = card.odue
# card leaves filtered deck # card leaves filtered deck
if card.odid: self._removeFromFiltered(card)
card.did = card.odid
card.odid = 0
card.odue = 0
def _rescheduleEarlyRev(self, card, ease): def _rescheduleEarlyRev(self, card, ease):
# update interval # update interval
card.lastIvl = card.ivl card.lastIvl = card.ivl
if self._resched(card): self._updateEarlyRevIvl(card, ease)
self._updateEarlyRevIvl(card, ease) # then the rest
# then the rest card.factor = max(1300, card.factor+[-150, 0, 150][ease-2])
card.factor = max(1300, card.factor+[-150, 0, 150][ease-2]) card.due = self.today + card.ivl
card.due = self.today + card.ivl
else:
card.due = card.odue
# move from 0->2 # move from 0->2
card.queue = 2 card.queue = 2
# card leaves filtered deck # card leaves filtered deck
if card.odid: self._removeFromFiltered(card)
card.did = card.odid
card.odid = 0
card.odue = 0
def _logRev(self, card, ease, delay, type): def _logRev(self, card, ease, delay, type):
def log(): def log():
@ -1111,6 +1104,25 @@ did = ?, due = ?, usn = ? where id = ?
""" """
self.col.db.executemany(query, data) self.col.db.executemany(query, data)
def _removeFromFiltered(self, card):
if card.odid:
card.did = card.odid
card.odue = 0
card.odid = 0
def _restoreFromFiltered(self, card):
assert card.odid
card.due = card.odue
if card.type in (0, 2):
card.queue = card.type
else:
if card.odue > 1000000000:
card.queue = 1
else:
card.queue = 3
# Leeches # Leeches
########################################################################## ##########################################################################
@ -1129,12 +1141,6 @@ did = ?, due = ?, usn = ? where id = ?
# handle # handle
a = conf['leechAction'] a = conf['leechAction']
if a == 0: if a == 0:
# if it has an old due, remove it from cram/relearning
if card.odue:
card.due = card.odue
if card.odid:
card.did = card.odid
card.odue = card.odid = 0
card.queue = -1 card.queue = -1
# notify UI # notify UI
runHook("leech", card) runHook("leech", card)
@ -1194,11 +1200,9 @@ did = ?, due = ?, usn = ? where id = ?
def _deckLimit(self): def _deckLimit(self):
return ids2str(self.col.decks.active()) return ids2str(self.col.decks.active())
def _resched(self, card): def _previewingCard(self, card):
conf = self._cardConf(card) conf = self._cardConf(card)
if not conf['dyn']: return conf['dyn'] and not conf['resched']
return True
return conf['resched']
# Daily cutoff # Daily cutoff
########################################################################## ##########################################################################
@ -1299,6 +1303,12 @@ To study outside of the normal schedule, click the Custom Study button below."""
def nextIvl(self, card, ease): def nextIvl(self, card, ease):
"Return the next interval for CARD, in seconds." "Return the next interval for CARD, in seconds."
# preview mode?
if self._previewingCard(card):
if ease == 1:
return self._previewDelay
return 0
# (re)learning? # (re)learning?
if card.queue in (0,1,3): if card.queue in (0,1,3):
return self._nextLrnIvl(card, ease) return self._nextLrnIvl(card, ease)
@ -1312,10 +1322,7 @@ To study outside of the normal schedule, click the Custom Study button below."""
# review # review
early = card.odid and (card.odue > self.today) early = card.odid and (card.odue > self.today)
if early: if early:
if self._resched(card): return self._earlyReviewIvl(card, ease)*86400
return self._earlyReviewIvl(card, ease)*86400
else:
return 0
else: else:
return self._nextRevIvl(card, ease, fuzz=False)*86400 return self._nextRevIvl(card, ease, fuzz=False)*86400
@ -1330,16 +1337,11 @@ To study outside of the normal schedule, click the Custom Study button below."""
elif ease == 2: elif ease == 2:
return self._delayForRepeatingGrade(conf, card.left) return self._delayForRepeatingGrade(conf, card.left)
elif ease == 4: elif ease == 4:
# early removal
if not self._resched(card):
return 0
return self._graduatingIvl(card, conf, True, fuzz=False) * 86400 return self._graduatingIvl(card, conf, True, fuzz=False) * 86400
else: # ease == 3 else: # ease == 3
left = card.left%1000 - 1 left = card.left%1000 - 1
if left <= 0: if left <= 0:
# graduate # graduate
if not self._resched(card):
return 0
return self._graduatingIvl(card, conf, False, fuzz=False) * 86400 return self._graduatingIvl(card, conf, False, fuzz=False) * 86400
else: else:
return self._delayForGrade(conf, left) return self._delayForGrade(conf, left)

View file

@ -627,112 +627,51 @@ def test_filt_keep_lrn_state():
c.load() c.load()
assert c.type == c.queue == 1 assert c.type == c.queue == 1
def test_filt_reschedoff(): def test_preview():
# add card # add cards
d = getEmptyCol() d = getEmptyCol()
f = d.newNote() f = d.newNote()
f['Front'] = "one" f['Front'] = "one"
d.addNote(f) d.addNote(f)
c = f.cards()[0]
orig = copy.copy(c)
f2 = d.newNote()
f2['Front'] = "two"
d.addNote(f2)
# cram deck # cram deck
did = d.decks.newDyn("Cram") did = d.decks.newDyn("Cram")
cram = d.decks.get(did) cram = d.decks.get(did)
cram['resched'] = False cram['resched'] = False
d.sched.rebuildDyn(did) d.sched.rebuildDyn(did)
d.reset() d.reset()
# graduate should return it to new # grab the first card
c = d.sched.getCard() c = d.sched.getCard()
ni = d.sched.nextIvl assert d.sched.answerButtons(c) == 2
assert ni(c, 1) == 60 assert d.sched.nextIvl(c, 1) == d.sched._previewDelay
assert ni(c, 2) == (60+600)//2 assert d.sched.nextIvl(c, 2) == 0
assert ni(c, 3) == 600 # failing it will push its due time back
assert ni(c, 4) == 0 due = c.due
assert d.sched.nextIvlStr(c, 4) == "(end)" d.sched.answerCard(c, 1)
d.sched.answerCard(c, 4) assert c.due != due
assert c.queue == c.type == 0
# undue reviews should also be unaffected # the other card should come next
c.ivl = 100 c2 = d.sched.getCard()
c.type = 2 assert c2.id != c.id
c.queue = 2
c.due = d.sched.today + 25 # passing it will remove it
c.factor = STARTING_FACTOR d.sched.answerCard(c2, 2)
c.flush()
cardcopy = copy.copy(c) # the other card should appear again
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard() c = d.sched.getCard()
assert ni(c, 1) == 600 assert c.id == orig.id
assert ni(c, 2) == 0
assert ni(c, 3) == 0 # remove it
d.sched.answerCard(c, 2) d.sched.answerCard(c, 2)
assert c.ivl == 100
assert c.due == d.sched.today + 25 # ensure it's in the same state as it started
# check failure too assert c.queue == 0
c = cardcopy assert c.reps == 0
c.flush() assert c.type == 0
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
d.sched.answerCard(c, 1)
d.sched.emptyDyn(did)
c.load()
assert c.ivl == 100
assert c.due == d.sched.today + 25
# fail+grad early
c = cardcopy
c.flush()
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
d.sched.answerCard(c, 1)
d.sched.answerCard(c, 4)
d.sched.emptyDyn(did)
c.load()
assert c.ivl == 100
assert c.due == d.sched.today + 25
# due cards - pass
c = cardcopy
c.due = -25
c.flush()
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
d.sched.answerCard(c, 3)
d.sched.emptyDyn(did)
c.load()
assert c.ivl == 100
assert c.due == -25
# fail
c = cardcopy
c.due = -25
c.flush()
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
d.sched.answerCard(c, 1)
d.sched.emptyDyn(did)
c.load()
assert c.ivl == 100
assert c.due == -25
# fail with normal grad
c = cardcopy
c.due = -25
c.flush()
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
d.sched.answerCard(c, 1)
d.sched.answerCard(c, 4)
c.load()
assert c.ivl == 100
assert c.due == -25
# lapsed card pulled into cram
# d.sched._cardConf(c)['lapse']['mult']=0.5
# d.sched.answerCard(c, 1)
# d.sched.rebuildDyn(did)
# d.reset()
# c = d.sched.getCard()
# d.sched.answerCard(c, 2)
# print c.__dict__
def test_ordcycle(): def test_ordcycle():
d = getEmptyCol() d = getEmptyCol()
@ -1067,56 +1006,3 @@ def test_failmult():
# so the card is reset to new # so the card is reset to new
d.sched.answerCard(c, 1) d.sched.answerCard(c, 1)
assert c.ivl == 1 assert c.ivl == 1
# answering a new card with scheduling off should not change
# the original position
def test_preview_order():
d = getEmptyCol()
f = d.newNote()
f['Front'] = "oneone"
d.addNote(f)
f = d.newNote()
f['Front'] = "twotwo"
d.addNote(f)
assert d.getCard(d.findCards("oneone")[0]).due == 1
assert d.getCard(d.findCards("twotwo")[0]).due == 2
did = d.decks.newDyn("Cram")
cram = d.decks.get(did)
cram['resched'] = False
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
assert "oneone" in c.q()
d.sched.answerCard(c, 3)
d.sched.answerCard(c, 3)
assert c.due == 1
# answering a due review with scheduling off should not change scheduling
def test_reviews_reschedoff():
d = getEmptyCol()
f = d.newNote()
f['Front'] = "one"
d.addNote(f)
c = f.cards()[0]
c.ivl = 100
c.queue = c.type = 2
c.due = d.sched.today
c.factor = 2500
c.flush()
did = d.decks.newDyn("Cram")
cram = d.decks.get(did)
cram['resched'] = False
d.sched.rebuildDyn(did)
d.reset()
c = d.sched.getCard()
d.sched.answerCard(c, 4)
assert c.ivl == 100
assert c.due == d.sched.today
assert c.factor == 2500