start work on learn mode, change models, more

- model config is now stored as a json-serialized dict, which allows us to
  quickly gather the info and allows for adding extra options more easily in
  the future
- denormalize modelId into the cards table, so we can get the model scheduling
  information without having to hit the facts table
- remove position - since we will handle spacing differently we don't need a
  separate variable to due to define sort order
- remove lastInterval from cards; the new cram mode and review early shouldn't
  need it
- successive->streak
- add new columns for learn mode
- move cram mode into new file; learn more and review early need more thought
- initial work on learn mode
- initial unit tests
This commit is contained in:
Damien Elmes 2011-02-28 14:48:52 +09:00
parent 4e7e8b03bc
commit b0b4074cbd
9 changed files with 479 additions and 381 deletions

View file

@ -14,46 +14,39 @@ MAX_TIMER = 60
# Cards # Cards
########################################################################## ##########################################################################
# tasks: # Type: 0=learning, 1=due, 2=new
# - remove all failed cards from learning queue - set queue=1; type=1 and # Queue: 0=learning, 1=due, 2=new
# leave scheduling parameters alone (need separate due for learn queue and # -1=suspended, -2=user buried, -3=sched buried
# reviews)
#
# - cram cards. gather and introduce to queue=0.
# - remove all cram cards from learning queue. if type h
# Type: 0=new+learning, 1=due, 2=new, 3=failed+learning, 4=cram+learning
# Queue: 0=learning, 1=due, 2=new, 3=new today,
# -1=suspended, -2=user buried, -3=sched buried (rev early, etc)
# Ordinal: card template # for fact # Ordinal: card template # for fact
# Position: sorting position, only for new cards
# Flags: unused; reserved for future use # Flags: unused; reserved for future use
cardsTable = Table( cardsTable = Table(
'cards', metadata, 'cards', metadata,
Column('id', Integer, primary_key=True), Column('id', Integer, primary_key=True),
Column('factId', Integer, ForeignKey("facts.id"), nullable=False), Column('factId', Integer, ForeignKey("facts.id"), nullable=False),
Column('modelId', Integer, ForeignKey("models.id"), nullable=False),
Column('cardModelId', Integer, ForeignKey("cardModels.id"), nullable=False), Column('cardModelId', Integer, ForeignKey("cardModels.id"), nullable=False),
# general # general
Column('created', Float, nullable=False, default=time.time), Column('created', Float, nullable=False, default=time.time),
Column('modified', Float, nullable=False, default=time.time), Column('modified', Float, nullable=False, default=time.time),
Column('question', UnicodeText, nullable=False, default=u""), Column('question', UnicodeText, nullable=False, default=u""),
Column('answer', UnicodeText, nullable=False, default=u""), Column('answer', UnicodeText, nullable=False, default=u""),
Column('flags', Integer, nullable=False, default=0),
# ordering
Column('ordinal', Integer, nullable=False), Column('ordinal', Integer, nullable=False),
Column('position', Integer, nullable=False), Column('flags', Integer, nullable=False, default=0),
# scheduling data # shared scheduling
Column('type', Integer, nullable=False, default=2), Column('type', Integer, nullable=False, default=2),
Column('queue', Integer, nullable=False, default=2), Column('queue', Integer, nullable=False, default=2),
Column('lastInterval', Float, nullable=False, default=0),
Column('interval', Float, nullable=False, default=0),
Column('due', Float, nullable=False), Column('due', Float, nullable=False),
# sm2
Column('interval', Float, nullable=False, default=0),
Column('factor', Float, nullable=False, default=2.5), Column('factor', Float, nullable=False, default=2.5),
# counters
Column('reps', Integer, nullable=False, default=0), Column('reps', Integer, nullable=False, default=0),
Column('successive', Integer, nullable=False, default=0), Column('streak', Integer, nullable=False, default=0),
Column('lapses', Integer, nullable=False, default=0)) Column('lapses', Integer, nullable=False, default=0),
# learn
Column('grade', Integer, nullable=False, default=0),
Column('cycles', Integer, nullable=False, default=0)
)
class Card(object): class Card(object):
@ -68,6 +61,7 @@ class Card(object):
self.position = self.due self.position = self.due
if fact: if fact:
self.fact = fact self.fact = fact
self.modelId = fact.modelId
if cardModel: if cardModel:
self.cardModel = cardModel self.cardModel = cardModel
# for non-orm use # for non-orm use
@ -151,45 +145,47 @@ class Card(object):
return return
(self.id, (self.id,
self.factId, self.factId,
self.modelId,
self.cardModelId, self.cardModelId,
self.created, self.created,
self.modified, self.modified,
self.question, self.question,
self.answer, self.answer,
self.flags,
self.ordinal, self.ordinal,
self.position, self.flags,
self.type, self.type,
self.queue, self.queue,
self.lastInterval,
self.interval,
self.due, self.due,
self.interval,
self.factor, self.factor,
self.reps, self.reps,
self.successive, self.streak,
self.lapses) = r self.lapses,
self.grade,
self.cycles) = r
return True return True
def toDB(self, s): def toDB(self, s):
s.execute("""update cards set s.execute("""update cards set
factId=:factId, factId=:factId,
modelId=:modelId,
cardModelId=:cardModelId, cardModelId=:cardModelId,
created=:created, created=:created,
modified=:modified, modified=:modified,
question=:question, question=:question,
answer=:answer, answer=:answer,
flags=:flags,
ordinal=:ordinal, ordinal=:ordinal,
position=:position, flags=:flags,
type=:type, type=:type,
queue=:queue, queue=:queue,
lastInterval=:lastInterval,
interval=:interval,
due=:due, due=:due,
interval=:interval,
factor=:factor, factor=:factor,
reps=:reps, reps=:reps,
successive=:successive, streak=:streak,
lapses=:lapses lapses=:lapses,
grade=:grade,
cycles=:cycles
where id=:id""", self.__dict__) where id=:id""", self.__dict__)
mapper(Card, cardsTable, properties={ mapper(Card, cardsTable, properties={

118
anki/cram.py Normal file
View file

@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
# Copyright: Damien Elmes <anki@ichi2.net>
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
from anki.sched import Scheduler
class CramScheduler(Scheduler):
# Cramming
##########################################################################
def setupCramScheduler(self, active, order):
self.getCardId = self._getCramCardId
self.activeCramTags = active
self.cramOrder = order
self.rebuildNewCount = self._rebuildCramNewCount
self.rebuildRevCount = self._rebuildCramCount
self.rebuildLrnCount = self._rebuildLrnCramCount
self.fillRevQueue = self._fillCramQueue
self.fillLrnQueue = self._fillLrnCramQueue
self.finishScheduler = self.setupStandardScheduler
self.lrnCramQueue = []
print "requeue cram"
self.requeueCard = self._requeueCramCard
self.cardQueue = self._cramCardQueue
self.answerCard = self._answerCramCard
self.spaceCards = self._spaceCramCards
# reuse review early's code
self.answerPreSave = self._cramPreSave
self.cardLimit = self._cramCardLimit
self.scheduler = "cram"
def _cramPreSave(self, card, ease):
# prevent it from appearing in next queue fill
card.lastInterval = self.cramLastInterval
card.type = -3
def _spaceCramCards(self, card):
self.spacedFacts[card.factId] = time.time() + self.newSpacing
def _answerCramCard(self, card, ease):
self.cramLastInterval = card.lastInterval
self._answerCard(card, ease)
if ease == 1:
self.lrnCramQueue.insert(0, [card.id, card.factId])
def _getCramCardId(self, check=True):
self.checkDay()
self.fillQueues()
if self.lrnCardMax and self.learnCount >= self.lrnCardMax:
return self.lrnQueue[-1][0]
# card due for review?
if self.revNoSpaced():
return self.revQueue[-1][0]
if self.lrnQueue:
return self.lrnQueue[-1][0]
if check:
# collapse spaced cards before reverting back to old scheduler
self.reset()
return self.getCardId(False)
# if we're in a custom scheduler, we may need to switch back
if self.finishScheduler:
self.finishScheduler()
self.reset()
return self.getCardId()
def _cramCardQueue(self, card):
if self.revQueue and self.revQueue[-1][0] == card.id:
return 1
else:
return 0
def _requeueCramCard(self, card, oldSuc):
if self.cardQueue(card) == 1:
self.revQueue.pop()
else:
self.lrnCramQueue.pop()
def _rebuildCramNewCount(self):
self.newAvail = 0
self.newCount = 0
def _cramCardLimit(self, active, inactive, sql):
# inactive is (currently) ignored
if isinstance(active, list):
return sql.replace(
"where", "where +c.id in " + ids2str(active) + " and")
else:
yes = parseTags(active)
if yes:
yids = tagIds(self.db, yes).values()
return sql.replace(
"where ",
"where +c.id in (select cardId from cardTags where "
"tagId in %s) and " % ids2str(yids))
else:
return sql
def _fillCramQueue(self):
if self.revCount and not self.revQueue:
self.revQueue = self.db.all(self.cardLimit(
self.activeCramTags, "", """
select id, factId from cards c
where queue between 0 and 2
order by %s
limit %s""" % (self.cramOrder, self.queueLimit)))
self.revQueue.reverse()
def _rebuildCramCount(self):
self.revCount = self.db.scalar(self.cardLimit(
self.activeCramTags, "",
"select count(*) from cards c where queue between 0 and 2"))
def _rebuildLrnCramCount(self):
self.learnCount = len(self.lrnCramQueue)
def _fillLrnCramQueue(self):
self.lrnQueue = self.lrnCramQueue

View file

@ -133,6 +133,9 @@ class Deck(object):
def getCard(self): def getCard(self):
return self.sched.getCard() return self.sched.getCard()
def answerCard(self, card, ease):
self.sched.answerCard(card, ease)
# if card: # if card:
# return card # return card
# if sched.name == "main": # if sched.name == "main":

View file

@ -2,7 +2,7 @@
# Copyright: Damien Elmes <anki@ichi2.net> # Copyright: Damien Elmes <anki@ichi2.net>
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
import time, re import time, re, simplejson
from sqlalchemy.ext.orderinglist import ordering_list from sqlalchemy.ext.orderinglist import ordering_list
from anki.db import * from anki.db import *
from anki.utils import genID, canonifyTags from anki.utils import genID, canonifyTags
@ -153,9 +153,6 @@ def formatQA(cid, mid, fact, tags, cm, deck):
# Model table # Model table
########################################################################## ##########################################################################
# ...Schedule: fail, pass1, pass2, etc in minutes
# ...Intervals: graduation, first remove, later remove
# maybe define a random cutoff at say +/-30% which controls exit interval # maybe define a random cutoff at say +/-30% which controls exit interval
# variation - 30% of 1 day is 0.7 or 1.3 so always 1 day; 30% of 4 days is # variation - 30% of 1 day is 0.7 or 1.3 so always 1 day; 30% of 4 days is
# 2.8-5.2, so any time from 3-5 days is acceptable # 2.8-5.2, so any time from 3-5 days is acceptable
@ -166,27 +163,29 @@ def formatQA(cid, mid, fact, tags, cm, deck):
# optional, what intervals should the default be? 3 days or more if cards are # optional, what intervals should the default be? 3 days or more if cards are
# over that interval range? and what about failed mature bonus? # over that interval range? and what about failed mature bonus?
defaultConf = {
'new': {
'delays': [0.5, 3, 10],
'ints': [1, 7, 4],
},
'lapse': {
'delays': [0.5, 3, 10],
'ints': [1, 7, 4],
'mult': 0
},
'initialFactor': 2.5,
}
modelsTable = Table( modelsTable = Table(
'models', metadata, 'models', metadata,
Column('id', Integer, primary_key=True), Column('id', Integer, primary_key=True),
Column('created', Float, nullable=False, default=time.time), Column('created', Float, nullable=False, default=time.time),
Column('modified', Float, nullable=False, default=time.time), Column('modified', Float, nullable=False, default=time.time),
Column('name', UnicodeText, nullable=False), Column('name', UnicodeText, nullable=False),
# new cards Column('config', UnicodeText, nullable=False,
Column('newSched', UnicodeText, nullable=False, default=u"[0.5, 3, 10]"), default=unicode(simplejson.dumps(defaultConf))),
Column('newInts', UnicodeText, nullable=False, default=u"[1, 7, 4]"),
# failed cards
Column('failSched', UnicodeText, nullable=False, default=u"[0.5, 3, 10]"),
Column('failInts', UnicodeText, nullable=False, default=u"[1, 7, 4]"),
Column('failMult', Float, nullable=False, default=0),
# other scheduling
Column('initialFactor', Float, nullable=False, default=2.5),
) )
class Model(object): class Model(object):
"Defines the way a fact behaves, what fields it can contain, etc." "Defines the way a fact behaves, what fields it can contain, etc."
def __init__(self, name=u""): def __init__(self, name=u""):

View file

@ -2,11 +2,12 @@
# Copyright: Damien Elmes <anki@ichi2.net> # Copyright: Damien Elmes <anki@ichi2.net>
# License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html # License: GNU GPL, version 3 or later; http://www.gnu.org/copyleft/gpl.html
import time, datetime import time, datetime, simplejson
from heapq import * from heapq import *
from anki.db import * from anki.db import *
from anki.cards import Card from anki.cards import Card
from anki.utils import parseTags from anki.utils import parseTags
from anki.lang import _
# the standard Anki scheduler # the standard Anki scheduler
class Scheduler(object): class Scheduler(object):
@ -26,106 +27,57 @@ class Scheduler(object):
def getCard(self, orm=True): def getCard(self, orm=True):
"Pop the next card from the queue. None if finished." "Pop the next card from the queue. None if finished."
id = self._getCard() self.checkDay()
id = self.getCardId()
if id: if id:
card = Card() card = Card()
assert card.fromDB(self.db, id) assert card.fromDB(self.db, id)
return card return card
def reset(self): def reset(self):
self.modelConfigs = {}
self.resetLearn() self.resetLearn()
self.resetReview() self.resetReview()
self.resetNew() self.resetNew()
print "reset(); need to handle new cards"
# # day counts
# (self.repsToday, self.newSeenToday) = self.db.first("""
# select count(), sum(case when rep = 1 then 1 else 0 end) from revlog
# where time > :t""", t=self.dayCutoff-86400)
# self.newSeenToday = self.newSeenToday or 0
# print "newSeenToday in answer(), reset called twice"
# print "newSeenToday needs to account for drill mode too."
# FIXME: can we do this now with the learn queue? def answerCard(self, card, ease):
def rebuildTypes(self): if card.queue == 0:
"Rebuild the type cache. Only necessary on upgrade." self.answerLearnCard(card, ease)
# set type first elif card.queue == 1:
self.db.statement(""" self.answerRevCard(card, ease)
update cards set type = (case else:
when successive then 1 when reps then 0 else 2 end) raise Exception("Invalid queue")
""") card.toDB(self.db)
# then queue
self.db.statement(""" def counts(self):
update cards set queue = type # FIXME: should learn count include new cards due today, or be separate?
when queue != -1""") return (self.learnCount, self.revCount)
# FIXME: merge these into the fetching code? rely on the type/queue
# properties? have to think about implications for cramming
def cardQueue(self, card): def cardQueue(self, card):
return self.cardType(card) return card.queue
def cardType(self, card): # Getting the next card
"Return the type of the current card (what queue it's in)"
if card.successive:
return 1
elif card.reps:
return 0
else:
return 2
# Tools
########################################################################## ##########################################################################
def resetSchedBuried(self): def getCardId(self):
"Put temporarily suspended cards back into play." "Return the next due card id, or None."
self.db.statement( # learning card due?
"update cards set queue = type where queue = -3") id = self.getLearnCard()
if id:
def cardLimit(self, active, inactive, sql): return id
yes = parseTags(getattr(self.deck, active)) # new first, or time for one?
no = parseTags(getattr(self.deck, inactive)) if self.timeForNewCard():
if yes: return self.getNewCard()
yids = tagIds(self.db, yes).values() # card due for review?
nids = tagIds(self.db, no).values() id = self.getReviewCard()
return sql.replace( if id:
"where", return id
"where +c.id in (select cardId from cardTags where " # new cards left?
"tagId in %s) and +c.id not in (select cardId from " id = self.getNewCard()
"cardTags where tagId in %s) and" % ( if id:
ids2str(yids), return id
ids2str(nids))) # collapse or finish
elif no: return self.getLearnCard(collapse=True)
nids = tagIds(self.db, no).values()
return sql.replace(
"where",
"where +c.id not in (select cardId from cardTags where "
"tagId in %s) and" % ids2str(nids))
else:
return sql
# Daily cutoff
##########################################################################
def updateCutoff(self):
d = datetime.datetime.utcfromtimestamp(
time.time() - self.deck.utcOffset) + datetime.timedelta(days=1)
d = datetime.datetime(d.year, d.month, d.day)
newday = self.deck.utcOffset - time.timezone
d += datetime.timedelta(seconds=newday)
cutoff = time.mktime(d.timetuple())
# cutoff must not be in the past
while cutoff < time.time():
cutoff += 86400
# cutoff must not be more than 24 hours in the future
cutoff = min(time.time() + 86400, cutoff)
self.dayCutoff = cutoff
self.dayCount = int(cutoff/86400 - self.deck.created/86400)
print "dayCount", self.dayCount
def checkDay(self):
# check if the day has rolled over
if time.time() > self.dayCutoff:
self.updateCutoff()
self.reset()
# Learning queue # Learning queue
########################################################################## ##########################################################################
@ -135,12 +87,64 @@ when queue != -1""")
select due, id from cards where select due, id from cards where
queue = 0 and due < :lim order by due queue = 0 and due < :lim order by due
limit %d""" % self.learnLimit, lim=self.dayCutoff) limit %d""" % self.learnLimit, lim=self.dayCutoff)
self.learnQueue.reverse()
self.learnCount = len(self.learnQueue) self.learnCount = len(self.learnQueue)
def getLearnCard(self): def getLearnCard(self, collapse=False):
if self.learnQueue and self.learnQueue[0] < time.time(): if self.learnQueue:
return heappop(self.learnQueue) cutoff = time.time()
if collapse:
cutoff -= self.deck.collapseTime
if self.learnQueue[0][0] < cutoff:
return heappop(self.learnQueue)[1]
def answerLearnCard(self, card, ease):
# ease 1=no, 2=yes, 3=remove
conf = self.learnConf(card)
if ease == 3:
self.removeLearnCard(card, conf)
return
card.cycles += 1
if ease == 2:
card.grade += 1
else:
card.grade = 0
if card.grade >= len(conf['delays']):
self.graduateLearnCard(card, conf)
else:
card.due = time.time() + conf['delays'][card.grade]*60
def learnConf(self, card):
conf = self.configForCard(card)
if card.type == 2:
return conf['new']
else:
return conf['lapse']
def removeLearnCard(self, card, conf):
if card.type == 1:
int_ = None
elif not card.cycles:
# first time bonus
int_ = conf['ints'][1]
else:
# normal remove
int_ = conf['ints'][2]
self.rescheduleAsReview(card, int_)
def graduateLearnCard(self, card, conf):
if card.type == 1:
int_ = None
else:
int_ = conf['ints'][0]
self.rescheduleAsReview(card, int_)
def rescheduleAsReview(self, card, int_):
card.queue = 1
if int_:
# new card
card.type = 1
card.interval = int_
print "handle log, etc"
# Reviews # Reviews
########################################################################## ##########################################################################
@ -183,128 +187,10 @@ limit %d""" % (self.revOrder(), self.queueLimit)), lim=self.dayCutoff)
def showFailedLast(self): def showFailedLast(self):
return self.collapseTime or not self.delay0 return self.collapseTime or not self.delay0
# New cards
##########################################################################
# when do we do this?
#self.updateNewCountToday()
def resetNew(self):
# self.updateNewCardRatio()
pass
def rebuildNewCount(self):
self.newAvail = self.db.scalar(
self.cardLimit(
"newActive", "newInactive",
"select count(*) from cards c where queue = 2 "
"and due < :lim"), lim=self.dayCutoff)
self.updateNewCountToday()
self.spacedCards = []
def updateNewCountToday(self):
self.newCount = max(min(
self.newAvail, self.newCardsPerDay -
self.newSeenToday), 0)
def fillNewQueue(self):
if self.newCount and not self.newQueue and not self.spacedCards:
self.newQueue = self.db.all(
self.cardLimit(
"newActive", "newInactive", """
select c.id, factId from cards c where
queue = 2 and due < :lim order by %s
limit %d""" % (self.newOrder(), self.queueLimit)), lim=self.dayCutoff)
self.newQueue.reverse()
def updateNewCardRatio(self):
if self.newCardSpacing == 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)
else:
self.newCardModulus = 0
else:
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.newCardSpacing == NEW_CARDS_LAST:
return False
if self.newCardSpacing == NEW_CARDS_FIRST:
return True
if self.newCardModulus:
return self.repsToday % self.newCardModulus == 0
else:
return False
def getNewCard(self):
src = None
if (self.spacedCards and
self.spacedCards[0][0] < time.time()):
# spaced card has expired
src = 0
elif self.newQueue:
# card left in new queue
src = 1
elif self.spacedCards:
# card left in spaced queue
src = 0
else:
# only cards spaced to another day left
return
if src == 0:
cards = self.spacedCards[0][1]
self.newFromCache = True
return cards[0]
else:
self.newFromCache = False
return self.newQueue[-1][0]
def newOrder(self):
return ("due",
"due",
"due desc")[self.newCardOrder]
# Getting the next card
##########################################################################
def getCard(self):
"Return the next due card id, or None."
self.checkDay()
# learning card due?
id = self.getLearnCard()
if id:
return id
# distribute new cards?
if self.newNoSpaced() and self.timeForNewCard():
return self.getNewCard()
# card due for review?
if self.revNoSpaced():
return self.revQueue[-1][0]
# new cards left?
if self.newCount:
id = self.getNewCard()
if id:
return id
# display failed cards early/last
if not check and self.showFailedLast() and self.learnQueue:
return self.learnQueue[-1][0]
# if we're in a custom scheduler, we may need to switch back
if self.finishScheduler:
self.finishScheduler()
self.reset()
return self.getCardId()
# Answering a card # Answering a card
########################################################################## ##########################################################################
def answerCard(self, card, ease): def _answerCard(self, card, ease):
undoName = _("Answer Card") undoName = _("Answer Card")
self.setUndoStart(undoName) self.setUndoStart(undoName)
now = time.time() now = time.time()
@ -520,6 +406,148 @@ and queue between 1 and 2""",
self.reset() self.reset()
self.refreshSession() self.refreshSession()
# New cards
##########################################################################
# # day counts
# (self.repsToday, self.newSeenToday) = self.db.first("""
# select count(), sum(case when rep = 1 then 1 else 0 end) from revlog
# where time > :t""", t=self.dayCutoff-86400)
# self.newSeenToday = self.newSeenToday or 0
# print "newSeenToday in answer(), reset called twice"
# print "newSeenToday needs to account for drill mode too."
# when do we do this?
#self.updateNewCountToday()
def resetNew(self):
# self.updateNewCardRatio()
pass
def rebuildNewCount(self):
self.newAvail = self.db.scalar(
self.cardLimit(
"newActive", "newInactive",
"select count(*) from cards c where queue = 2 "
"and due < :lim"), lim=self.dayCutoff)
self.updateNewCountToday()
def updateNewCountToday(self):
self.newCount = max(min(
self.newAvail, self.newCardsPerDay -
self.newSeenToday), 0)
def fillNewQueue(self):
if self.newCount and not self.newQueue:
self.newQueue = self.db.all(
self.cardLimit(
"newActive", "newInactive", """
select c.id, factId from cards c where
queue = 2 and due < :lim order by %s
limit %d""" % (self.newOrder(), self.queueLimit)), lim=self.dayCutoff)
self.newQueue.reverse()
def updateNewCardRatio(self):
if self.newCardSpacing == 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)
else:
self.newCardModulus = 0
else:
self.newCardModulus = 0
def timeForNewCard(self):
"True if it's time to display a new card when distributing."
# FIXME
return False
if not self.newCount:
return False
if self.newCardSpacing == NEW_CARDS_LAST:
return False
if self.newCardSpacing == NEW_CARDS_FIRST:
return True
if self.newCardModulus:
return self.repsToday % self.newCardModulus == 0
else:
return False
def getNewCard(self):
# FIXME
return None
#return self.newQueue[-1][0]
def newOrder(self):
return ("due",
"due",
"due desc")[self.newCardOrder]
# Tools
##########################################################################
def configForCard(self, card):
mid = card.modelId
if not mid in self.modelConfigs:
self.modelConfigs[mid] = simplejson.loads(
self.db.scalar("select config from models where id = :id",
id=mid))
return self.modelConfigs[mid]
def resetSchedBuried(self):
"Put temporarily suspended cards back into play."
self.db.statement(
"update cards set queue = type where queue = -3")
def cardLimit(self, active, inactive, sql):
yes = parseTags(getattr(self.deck, active))
no = parseTags(getattr(self.deck, inactive))
if yes:
yids = tagIds(self.db, yes).values()
nids = tagIds(self.db, no).values()
return sql.replace(
"where",
"where +c.id in (select cardId from cardTags where "
"tagId in %s) and +c.id not in (select cardId from "
"cardTags where tagId in %s) and" % (
ids2str(yids),
ids2str(nids)))
elif no:
nids = tagIds(self.db, no).values()
return sql.replace(
"where",
"where +c.id not in (select cardId from cardTags where "
"tagId in %s) and" % ids2str(nids))
else:
return sql
# Daily cutoff
##########################################################################
def updateCutoff(self):
d = datetime.datetime.utcfromtimestamp(
time.time() - self.deck.utcOffset) + datetime.timedelta(days=1)
d = datetime.datetime(d.year, d.month, d.day)
newday = self.deck.utcOffset - time.timezone
d += datetime.timedelta(seconds=newday)
cutoff = time.mktime(d.timetuple())
# cutoff must not be in the past
while cutoff < time.time():
cutoff += 86400
# cutoff must not be more than 24 hours in the future
cutoff = min(time.time() + 86400, cutoff)
self.dayCutoff = cutoff
self.dayCount = int(cutoff/86400 - self.deck.created/86400)
def checkDay(self):
# check if the day has rolled over
if time.time() > self.dayCutoff:
self.updateCutoff()
self.reset()
# Review early # Review early
########################################################################## ##########################################################################
@ -571,115 +599,3 @@ order by due limit %d""" % self.queueLimit), lim=self.dayCutoff)
def _updateLearnMoreCountToday(self): def _updateLearnMoreCountToday(self):
self.newCount = self.newAvail self.newCount = self.newAvail
# Cramming
##########################################################################
def setupCramScheduler(self, active, order):
self.getCardId = self._getCramCardId
self.activeCramTags = active
self.cramOrder = order
self.rebuildNewCount = self._rebuildCramNewCount
self.rebuildRevCount = self._rebuildCramCount
self.rebuildLrnCount = self._rebuildLrnCramCount
self.fillRevQueue = self._fillCramQueue
self.fillLrnQueue = self._fillLrnCramQueue
self.finishScheduler = self.setupStandardScheduler
self.lrnCramQueue = []
print "requeue cram"
self.requeueCard = self._requeueCramCard
self.cardQueue = self._cramCardQueue
self.answerCard = self._answerCramCard
self.spaceCards = self._spaceCramCards
# reuse review early's code
self.answerPreSave = self._cramPreSave
self.cardLimit = self._cramCardLimit
self.scheduler = "cram"
def _cramPreSave(self, card, ease):
# prevent it from appearing in next queue fill
card.lastInterval = self.cramLastInterval
card.type = -3
def _spaceCramCards(self, card):
self.spacedFacts[card.factId] = time.time() + self.newSpacing
def _answerCramCard(self, card, ease):
self.cramLastInterval = card.lastInterval
self._answerCard(card, ease)
if ease == 1:
self.lrnCramQueue.insert(0, [card.id, card.factId])
def _getCramCardId(self, check=True):
self.checkDay()
self.fillQueues()
if self.lrnCardMax and self.lrnCount >= self.lrnCardMax:
return self.lrnQueue[-1][0]
# card due for review?
if self.revNoSpaced():
return self.revQueue[-1][0]
if self.lrnQueue:
return self.lrnQueue[-1][0]
if check:
# collapse spaced cards before reverting back to old scheduler
self.reset()
return self.getCardId(False)
# if we're in a custom scheduler, we may need to switch back
if self.finishScheduler:
self.finishScheduler()
self.reset()
return self.getCardId()
def _cramCardQueue(self, card):
if self.revQueue and self.revQueue[-1][0] == card.id:
return 1
else:
return 0
def _requeueCramCard(self, card, oldSuc):
if self.cardQueue(card) == 1:
self.revQueue.pop()
else:
self.lrnCramQueue.pop()
def _rebuildCramNewCount(self):
self.newAvail = 0
self.newCount = 0
def _cramCardLimit(self, active, inactive, sql):
# inactive is (currently) ignored
if isinstance(active, list):
return sql.replace(
"where", "where +c.id in " + ids2str(active) + " and")
else:
yes = parseTags(active)
if yes:
yids = tagIds(self.db, yes).values()
return sql.replace(
"where ",
"where +c.id in (select cardId from cardTags where "
"tagId in %s) and " % ids2str(yids))
else:
return sql
def _fillCramQueue(self):
if self.revCount and not self.revQueue:
self.revQueue = self.db.all(self.cardLimit(
self.activeCramTags, "", """
select id, factId from cards c
where queue between 0 and 2
order by %s
limit %s""" % (self.cramOrder, self.queueLimit)))
self.revQueue.reverse()
def _rebuildCramCount(self):
self.revCount = self.db.scalar(self.cardLimit(
self.activeCramTags, "",
"select count(*) from cards c where queue between 0 and 2"))
def _rebuildLrnCramCount(self):
self.lrnCount = len(self.lrnCramQueue)
def _fillLrnCramQueue(self):
self.lrnQueue = self.lrnCramQueue

View file

@ -37,9 +37,11 @@ def upgradeSchema(engine, s):
import cards import cards
metadata.create_all(engine, tables=[cards.cardsTable]) metadata.create_all(engine, tables=[cards.cardsTable])
s.execute(""" s.execute("""
insert into cards select id, factId, cardModelId, created, modified, insert into cards select id, factId,
question, answer, 0, ordinal, 0, relativeDelay, type, lastInterval, interval, (select modelId from facts where facts.id = cards.factId),
due, factor, reps, successive, noCount from cards2""") cardModelId, created, modified,
question, answer, ordinal, 0, relativeDelay, type, due, interval,
factor, reps, successive, noCount, 0, 0 from cards2""")
s.execute("drop table cards2") s.execute("drop table cards2")
# tags # tags
########### ###########

View file

@ -1,3 +1,6 @@
import tempfile, os
from anki import Deck
def assertException(exception, func): def assertException(exception, func):
found = False found = False
try: try:
@ -5,3 +8,8 @@ def assertException(exception, func):
except exception: except exception:
found = True found = True
assert found assert found
def getDeck():
(fd, nam) = tempfile.mkstemp(suffix=".anki")
os.unlink(nam)
return Deck(nam)

View file

@ -1,7 +1,7 @@
# coding: utf-8 # coding: utf-8
import nose, os, re import nose, os, re
from tests.shared import assertException from tests.shared import assertException, getDeck
from anki.errors import * from anki.errors import *
from anki import Deck from anki import Deck
@ -15,12 +15,6 @@ newModified = None
testDir = os.path.dirname(__file__) testDir = os.path.dirname(__file__)
def getDeck():
import tempfile
(fd, nam) = tempfile.mkstemp(suffix=".anki")
os.unlink(nam)
return Deck(nam)
## opening/closing ## opening/closing
def test_attachNew(): def test_attachNew():

62
tests/test_sched.py Normal file
View file

@ -0,0 +1,62 @@
# coding: utf-8
import time
from tests.shared import assertException, getDeck
from anki.stdmodels import BasicModel
#from anki.db import *
def getEmptyDeck():
d = getDeck()
d.addModel(BasicModel())
d.db.commit()
return d
def test_basics():
d = getEmptyDeck()
assert not d.getCard()
def test_learn():
d = getEmptyDeck()
# add a fact
f = d.newFact()
f['Front'] = u"one"; f['Back'] = u"two"
f = d.addFact(f)
d.db.flush()
# set as a learn card and rebuild queues
d.db.statement("update cards set queue=0, type=2")
d.reset()
# getCard should return it, since it's due in the past
c = d.getCard()
assert c
# it should have no cycles and a grade of 0
assert c.grade == c.cycles == 0
# fail it
d.answerCard(c, 1)
# it should by due in 30 seconds
assert round(c.due - time.time()) == 30
# and have 1 cycle, but still a zero grade
assert c.grade == 0
assert c.cycles == 1
# pass it once
d.answerCard(c, 2)
# it should by due in 3 minutes
assert round(c.due - time.time()) == 180
# and it should be grade 1 now
assert c.grade == 1
assert c.cycles == 2
# pass again
d.answerCard(c, 2)
# it should by due in 10 minutes
assert round(c.due - time.time()) == 600
# and it should be grade 1 now
assert c.grade == 2
assert c.cycles == 3
# the next pass should graduate the card
assert c.queue == 0
assert c.type == 2
d.answerCard(c, 2)
assert c.queue == 1
assert c.type == 1
print "test intervals, check early removal, etc"