diff --git a/pylib/anki/scheduler.py b/pylib/anki/scheduler.py index 19a46382a..a73bd83a0 100644 --- a/pylib/anki/scheduler.py +++ b/pylib/anki/scheduler.py @@ -540,505 +540,6 @@ limit ?""" def _deckLimit(self) -> str: return ids2str(self.col.decks.active()) - # Answering (re)learning cards - ########################################################################## - - def _newConf(self, card: Card) -> Any: - conf = self._cardConf(card) - # normal deck - if not card.odid: - return conf["new"] - # dynamic deck; override some attributes, use original deck for others - oconf = self.col.decks.confForDid(card.odid) - return dict( - # original deck - ints=oconf["new"]["ints"], - initialFactor=oconf["new"]["initialFactor"], - bury=oconf["new"].get("bury", True), - delays=oconf["new"]["delays"], - # overrides - order=NEW_CARDS_DUE, - perDay=self.reportLimit, - ) - - def _lapseConf(self, card: Card) -> Any: - conf = self._cardConf(card) - # normal deck - if not card.odid: - return conf["lapse"] - # dynamic deck; override some attributes, use original deck for others - oconf = self.col.decks.confForDid(card.odid) - return dict( - # original deck - minInt=oconf["lapse"]["minInt"], - leechFails=oconf["lapse"]["leechFails"], - leechAction=oconf["lapse"]["leechAction"], - mult=oconf["lapse"]["mult"], - delays=oconf["lapse"]["delays"], - # overrides - resched=conf["resched"], - ) - - def _answerLrnCard(self, card: Card, ease: int) -> None: - conf = self._lrnConf(card) - if card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING): - type = REVLOG_RELRN - else: - type = REVLOG_LRN - # lrnCount was decremented once when card was fetched - lastLeft = card.left - - leaving = False - - # immediate graduate? - if ease == BUTTON_FOUR: - self._rescheduleAsRev(card, conf, True) - leaving = True - # next step? - elif ease == BUTTON_THREE: - # graduation time? - if (card.left % 1000) - 1 <= 0: - self._rescheduleAsRev(card, conf, False) - leaving = True - else: - self._moveToNextStep(card, conf) - elif ease == BUTTON_TWO: - self._repeatStep(card, conf) - else: - # back to first step - self._moveToFirstStep(card, conf) - - self._logLrn(card, ease, conf, leaving, type, lastLeft) - - def _updateRevIvlOnFail(self, card: Card, conf: QueueConfig) -> None: - card.lastIvl = card.ivl - card.ivl = self._lapseIvl(card, conf) - - def _moveToFirstStep(self, card: Card, conf: QueueConfig) -> Any: - card.left = self._startingLeft(card) - - # relearning card? - if card.type == CARD_TYPE_RELEARNING: - self._updateRevIvlOnFail(card, conf) - - return self._rescheduleLrnCard(card, conf) - - def _moveToNextStep(self, card: Card, conf: QueueConfig) -> None: - # decrement real left count and recalculate left today - left = (card.left % 1000) - 1 - card.left = self._leftToday(conf["delays"], left) * 1000 + left - - self._rescheduleLrnCard(card, conf) - - def _repeatStep(self, card: Card, conf: QueueConfig) -> None: - delay = self._delayForRepeatingGrade(conf, card.left) - self._rescheduleLrnCard(card, conf, delay=delay) - - def _rescheduleLrnCard( - self, card: Card, conf: QueueConfig, delay: Optional[int] = None - ) -> Any: - # normal delay for the current step? - if delay is None: - delay = self._delayForGrade(conf, card.left) - - card.due = int(time.time() + delay) - # due today? - if card.due < self.dayCutoff: - # add some randomness, up to 5 minutes or 25% - maxExtra = min(300, int(delay * 0.25)) - fuzz = random.randrange(0, max(1, maxExtra)) - card.due = min(self.dayCutoff - 1, card.due + fuzz) - card.queue = QUEUE_TYPE_LRN - else: - # the card is due in one or more days, so we need to use the - # day learn queue - ahead = ((card.due - self.dayCutoff) // 86400) + 1 - card.due = self.today + ahead - card.queue = QUEUE_TYPE_DAY_LEARN_RELEARN - return delay - - def _delayForGrade(self, conf: QueueConfig, left: int) -> int: - left = left % 1000 - try: - delay = conf["delays"][-left] - except IndexError: - if conf["delays"]: - delay = conf["delays"][0] - else: - # user deleted final step; use dummy value - delay = 1 - return int(delay * 60) - - def _delayForRepeatingGrade(self, conf: QueueConfig, left: int) -> Any: - # halfway between last and next - delay1 = self._delayForGrade(conf, left) - if len(conf["delays"]) > 1: - delay2 = self._delayForGrade(conf, left - 1) - else: - delay2 = delay1 * 2 - avg = (delay1 + max(delay1, delay2)) // 2 - return avg - - def _lrnConf(self, card: Card) -> Any: - if card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING): - return self._lapseConf(card) - else: - return self._newConf(card) - - def _rescheduleAsRev(self, card: Card, conf: QueueConfig, early: bool) -> None: - lapse = card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING) - - if lapse: - self._rescheduleGraduatingLapse(card, early) - else: - self._rescheduleNew(card, conf, early) - - # if we were dynamic, graduating means moving back to the old deck - if card.odid: - self._removeFromFiltered(card) - - def _rescheduleGraduatingLapse(self, card: Card, early: bool = False) -> None: - if early: - card.ivl += 1 - card.due = self.today + card.ivl - card.queue = QUEUE_TYPE_REV - card.type = CARD_TYPE_REV - - def _startingLeft(self, card: Card) -> int: - if card.type == CARD_TYPE_RELEARNING: - conf = self._lapseConf(card) - else: - conf = self._lrnConf(card) - tot = len(conf["delays"]) - tod = self._leftToday(conf["delays"], tot) - return tot + tod * 1000 - - def _leftToday( - self, - delays: List[int], - left: int, - now: Optional[int] = None, - ) -> int: - "The number of steps that can be completed by the day cutoff." - if not now: - now = intTime() - delays = delays[-left:] - ok = 0 - for i in range(len(delays)): - now += int(delays[i] * 60) - if now > self.dayCutoff: - break - ok = i - return ok + 1 - - def _graduatingIvl( - self, card: Card, conf: QueueConfig, early: bool, fuzz: bool = True - ) -> Any: - if card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING): - bonus = early and 1 or 0 - return card.ivl + bonus - if not early: - # graduate - ideal = conf["ints"][0] - else: - # early remove - ideal = conf["ints"][1] - if fuzz: - ideal = self._fuzzedIvl(ideal) - return ideal - - def _rescheduleNew(self, card: Card, conf: QueueConfig, early: bool) -> None: - "Reschedule a new card that's graduated for the first time." - card.ivl = self._graduatingIvl(card, conf, early) - card.due = self.today + card.ivl - card.factor = conf["initialFactor"] - card.type = card.queue = QUEUE_TYPE_REV - - def _logLrn( - self, - card: Card, - ease: int, - conf: QueueConfig, - leaving: bool, - type: int, - lastLeft: int, - ) -> None: - lastIvl = -(self._delayForGrade(conf, lastLeft)) - if leaving: - ivl = card.ivl - else: - if ease == BUTTON_TWO: - ivl = -self._delayForRepeatingGrade(conf, card.left) - else: - ivl = -self._delayForGrade(conf, card.left) - - def log() -> None: - self.col.db.execute( - "insert into revlog values (?,?,?,?,?,?,?,?,?)", - int(time.time() * 1000), - card.id, - self.col.usn(), - ease, - ivl, - lastIvl, - card.factor, - card.timeTaken(), - type, - ) - - try: - log() - except: - # duplicate pk; retry in 10ms - time.sleep(0.01) - log() - - # note: when adding revlog entries in the future, make sure undo - # code deletes the entries - def _answerCardPreview(self, card: Card, ease: int) -> None: - assert 1 <= ease <= 2 - - if ease == BUTTON_ONE: - # repeat after delay - card.queue = QUEUE_TYPE_PREVIEW - card.due = intTime() + self._previewDelay(card) - else: - # BUTTON_TWO - # restore original card state and remove from filtered deck - self._restorePreviewCard(card) - self._removeFromFiltered(card) - - def _previewingCard(self, card: Card) -> Any: - conf = self._cardConf(card) - return conf["dyn"] and not conf["resched"] - - def _previewDelay(self, card: Card) -> Any: - return self._cardConf(card).get("previewDelay", 10) * 60 - - def _removeFromFiltered(self, card: Card) -> None: - if card.odid: - card.did = card.odid - card.odue = 0 - card.odid = 0 - - def _restorePreviewCard(self, card: Card) -> None: - assert card.odid - - card.due = card.odue - - # learning and relearning cards may be seconds-based or day-based; - # other types map directly to queues - if card.type in (CARD_TYPE_LRN, CARD_TYPE_RELEARNING): - if card.odue > 1000000000: - card.queue = QUEUE_TYPE_LRN - else: - card.queue = QUEUE_TYPE_DAY_LEARN_RELEARN - else: - card.queue = card.type - - # Answering a review card - ########################################################################## - - def _revConf(self, card: Card) -> QueueConfig: - conf = self._cardConf(card) - # normal deck - if not card.odid: - return conf["rev"] - # dynamic deck - return self.col.decks.confForDid(card.odid)["rev"] - - def _answerRevCard(self, card: Card, ease: int) -> None: - delay = 0 - early = bool(card.odid and (card.odue > self.today)) - type = early and REVLOG_CRAM or REVLOG_REV - - if ease == BUTTON_ONE: - delay = self._rescheduleLapse(card) - else: - self._rescheduleRev(card, ease, early) - - hooks.schedv2_did_answer_review_card(card, ease, early) - self._logRev(card, ease, delay, type) - - def _rescheduleLapse(self, card: Card) -> Any: - conf = self._lapseConf(card) - - card.lapses += 1 - card.factor = max(1300, card.factor - 200) - - suspended = self._checkLeech(card, conf) and card.queue == QUEUE_TYPE_SUSPENDED - - if conf["delays"] and not suspended: - card.type = CARD_TYPE_RELEARNING - delay = self._moveToFirstStep(card, conf) - else: - # no relearning steps - self._updateRevIvlOnFail(card, conf) - self._rescheduleAsRev(card, conf, early=False) - # need to reset the queue after rescheduling - if suspended: - card.queue = QUEUE_TYPE_SUSPENDED - delay = 0 - - return delay - - def _lapseIvl(self, card: Card, conf: QueueConfig) -> Any: - ivl = max(1, conf["minInt"], int(card.ivl * conf["mult"])) - return ivl - - def _rescheduleRev(self, card: Card, ease: int, early: bool) -> None: - # update interval - card.lastIvl = card.ivl - if early: - self._updateEarlyRevIvl(card, ease) - else: - self._updateRevIvl(card, ease) - - # then the rest - card.factor = max(1300, card.factor + [-150, 0, 150][ease - 2]) - card.due = self.today + card.ivl - - # card leaves filtered deck - self._removeFromFiltered(card) - - def _logRev(self, card: Card, ease: int, delay: int, type: int) -> None: - def log() -> None: - self.col.db.execute( - "insert into revlog values (?,?,?,?,?,?,?,?,?)", - int(time.time() * 1000), - card.id, - self.col.usn(), - ease, - -delay or card.ivl, - card.lastIvl, - card.factor, - card.timeTaken(), - type, - ) - - try: - log() - except: - # duplicate pk; retry in 10ms - time.sleep(0.01) - log() - - def _nextRevIvl(self, card: Card, ease: int, fuzz: bool) -> int: - "Next review interval for CARD, given EASE." - delay = self._daysLate(card) - conf = self._revConf(card) - fct = card.factor / 1000 - hardFactor = conf.get("hardFactor", 1.2) - if hardFactor > 1: - hardMin = card.ivl - else: - hardMin = 0 - ivl2 = self._constrainedIvl(card.ivl * hardFactor, conf, hardMin, fuzz) - if ease == BUTTON_TWO: - return ivl2 - - ivl3 = self._constrainedIvl((card.ivl + delay // 2) * fct, conf, ivl2, fuzz) - if ease == BUTTON_THREE: - return ivl3 - - ivl4 = self._constrainedIvl( - (card.ivl + delay) * fct * conf["ease4"], conf, ivl3, fuzz - ) - return ivl4 - - def _fuzzedIvl(self, ivl: int) -> int: - min, max = self._fuzzIvlRange(ivl) - return random.randint(min, max) - - def _fuzzIvlRange(self, ivl: int) -> List[int]: - if ivl < 2: - return [1, 1] - elif ivl == 2: - return [2, 3] - elif ivl < 7: - fuzz = int(ivl * 0.25) - elif ivl < 30: - fuzz = max(2, int(ivl * 0.15)) - else: - fuzz = max(4, int(ivl * 0.05)) - # fuzz at least a day - fuzz = max(fuzz, 1) - return [ivl - fuzz, ivl + fuzz] - - def _constrainedIvl( - self, ivl: float, conf: QueueConfig, prev: int, fuzz: bool - ) -> int: - ivl = int(ivl * conf.get("ivlFct", 1)) - if fuzz: - ivl = self._fuzzedIvl(ivl) - ivl = max(ivl, prev + 1, 1) - ivl = min(ivl, conf["maxIvl"]) - return int(ivl) - - def _daysLate(self, card: Card) -> int: - "Number of days later than scheduled." - due = card.odue if card.odid else card.due - return max(0, self.today - due) - - def _updateRevIvl(self, card: Card, ease: int) -> None: - card.ivl = self._nextRevIvl(card, ease, fuzz=True) - - def _updateEarlyRevIvl(self, card: Card, ease: int) -> None: - card.ivl = self._earlyReviewIvl(card, ease) - - # next interval for card when answered early+correctly - def _earlyReviewIvl(self, card: Card, ease: int) -> int: - assert card.odid and card.type == CARD_TYPE_REV - assert card.factor - assert ease > 1 - - elapsed = card.ivl - (card.odue - self.today) - - conf = self._revConf(card) - - easyBonus = 1 - # early 3/4 reviews shouldn't decrease previous interval - minNewIvl = 1 - - if ease == BUTTON_TWO: - factor = conf.get("hardFactor", 1.2) - # hard cards shouldn't have their interval decreased by more than 50% - # of the normal factor - minNewIvl = factor / 2 - elif ease == BUTTON_THREE: - factor = card.factor / 1000 - else: # ease == BUTTON_FOUR: - factor = card.factor / 1000 - ease4 = conf["ease4"] - # 1.3 -> 1.15 - easyBonus = ease4 - (ease4 - 1) / 2 - - ivl = max(elapsed * factor, 1) - - # cap interval decreases - ivl = max(card.ivl * minNewIvl, ivl) * easyBonus - - ivl = self._constrainedIvl(ivl, conf, prev=0, fuzz=False) - - return ivl - - # Daily limits - ########################################################################## - - def update_stats( - self, - deck_id: int, - new_delta: int = 0, - review_delta: int = 0, - milliseconds_delta: int = 0, - ) -> None: - self.col._backend.update_stats( - deck_id=deck_id, - new_delta=new_delta, - review_delta=review_delta, - millisecond_delta=milliseconds_delta, - ) - def counts_for_deck_today(self, deck_id: int) -> CountsForDeckToday: return self.col._backend.counts_for_deck_today(deck_id) @@ -1340,6 +841,10 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l del d["col"] return f"{super().__repr__()} {pprint.pformat(d, width=300)}" + # unit tests + def _fuzzIvlRange(self, ivl: int) -> Tuple[int, int]: + return (ivl, ivl) + # Legacy aliases and helpers ########################################################################## @@ -1412,6 +917,20 @@ due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? whe def remFromDyn(self, cids: List[int]) -> None: self.emptyDyn(None, f"id in {ids2str(cids)} and odid") + def update_stats( + self, + deck_id: int, + new_delta: int = 0, + review_delta: int = 0, + milliseconds_delta: int = 0, + ) -> None: + self.col._backend.update_stats( + deck_id=deck_id, + new_delta=new_delta, + review_delta=review_delta, + millisecond_delta=milliseconds_delta, + ) + def _updateStats(self, card: Card, type: str, cnt: int = 1) -> None: did = card.did if type == "new": @@ -1428,6 +947,21 @@ due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? whe ) return from_json_bytes(self.col._backend.deck_tree_legacy())[5] + def _newConf(self, card: Card) -> QueueConfig: + return self._home_config(card)["new"] + + def _lapseConf(self, card: Card) -> QueueConfig: + return self._home_config(card)["lapse"] + + def _revConf(self, card: Card) -> QueueConfig: + return self._home_config(card)["rev"] + + def _lrnConf(self, card: Card) -> QueueConfig: + if card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING): + return self._lapseConf(card) + else: + return self._newConf(card) + unsuspendCards = unsuspend_cards buryCards = bury_cards suspendCards = suspend_cards