fsrs

py-fsrs

Py-FSRS is the official Python implementation of the FSRS scheduler algorithm, which can be used to develop spaced repetition systems.

 1"""
 2py-fsrs
 3-------
 4
 5Py-FSRS is the official Python implementation of the FSRS scheduler algorithm, which can be used to develop spaced repetition systems.
 6"""
 7
 8from fsrs.scheduler import Scheduler
 9from fsrs.state import State
10from fsrs.card import Card
11from fsrs.rating import Rating
12from fsrs.review_log import ReviewLog
13from typing import TYPE_CHECKING
14
15if TYPE_CHECKING:
16    from fsrs.optimizer import Optimizer
17
18
19# lazy load the Optimizer module due to heavy dependencies
20def __getattr__(name: str) -> type:
21    if name == "Optimizer":
22        global Optimizer
23        from fsrs.optimizer import Optimizer
24
25        return Optimizer
26    raise AttributeError
27
28
29__all__ = ["Scheduler", "Card", "Rating", "ReviewLog", "State", "Optimizer"]
@dataclass(init=False)
class Scheduler:
136@dataclass(init=False)
137class Scheduler:
138    """
139    The FSRS scheduler.
140
141    Enables the reviewing and future scheduling of cards according to the FSRS algorithm.
142
143    Attributes:
144        parameters: The model weights of the FSRS scheduler.
145        desired_retention: The desired retention rate of cards scheduled with the scheduler.
146        learning_steps: Small time intervals that schedule cards in the Learning state.
147        relearning_steps: Small time intervals that schedule cards in the Relearning state.
148        maximum_interval: The maximum number of days a Review-state card can be scheduled into the future.
149        enable_fuzzing: Whether to apply a small amount of random 'fuzz' to calculated intervals.
150    """
151
152    parameters: tuple[float, ...]
153    desired_retention: float
154    learning_steps: tuple[timedelta, ...]
155    relearning_steps: tuple[timedelta, ...]
156    maximum_interval: int
157    enable_fuzzing: bool
158
159    def __init__(
160        self,
161        parameters: Sequence[float] = DEFAULT_PARAMETERS,
162        desired_retention: float = 0.9,
163        learning_steps: tuple[timedelta, ...] | list[timedelta] = (
164            timedelta(minutes=1),
165            timedelta(minutes=10),
166        ),
167        relearning_steps: tuple[timedelta, ...] | list[timedelta] = (
168            timedelta(minutes=10),
169        ),
170        maximum_interval: int = 36500,
171        enable_fuzzing: bool = True,
172    ) -> None:
173        self._validate_parameters(parameters=parameters)
174
175        self.parameters = tuple(parameters)
176        self.desired_retention = desired_retention
177        self.learning_steps = tuple(learning_steps)
178        self.relearning_steps = tuple(relearning_steps)
179        self.maximum_interval = maximum_interval
180        self.enable_fuzzing = enable_fuzzing
181
182        self._DECAY = -self.parameters[20]
183        self._FACTOR = 0.9 ** (1 / self._DECAY) - 1
184
185    def _validate_parameters(self, *, parameters: Sequence[float]) -> None:
186        if len(parameters) != len(LOWER_BOUNDS_PARAMETERS):
187            raise ValueError(
188                f"Expected {len(LOWER_BOUNDS_PARAMETERS)} parameters, got {len(parameters)}."
189            )
190
191        error_messages = []
192        for index, (parameter, lower_bound, upper_bound) in enumerate(
193            zip(parameters, LOWER_BOUNDS_PARAMETERS, UPPER_BOUNDS_PARAMETERS)
194        ):
195            if not lower_bound <= parameter <= upper_bound:
196                error_message = f"parameters[{index}] = {parameter} is out of bounds: ({lower_bound}, {upper_bound})"
197                error_messages.append(error_message)
198
199        if len(error_messages) > 0:
200            raise ValueError(
201                "One or more parameters are out of bounds:\n"
202                + "\n".join(error_messages)
203            )
204
205    def get_card_retrievability(
206        self, card: Card, current_datetime: datetime | None = None
207    ) -> float:
208        """
209        Calculates a Card object's current retrievability for a given date and time.
210
211        The retrievability of a card is the predicted probability that the card is correctly recalled at the provided datetime.
212
213        Args:
214            card: The card whose retrievability is to be calculated
215            current_datetime: The current date and time
216
217        Returns:
218            The retrievability of the Card object.
219        """
220
221        if card.last_review is None:
222            return 0
223
224        if current_datetime is None:
225            current_datetime = datetime.now(timezone.utc)
226
227        elapsed_days = max(0, (current_datetime - card.last_review).days)
228
229        return (1 + self._FACTOR * elapsed_days / card.stability) ** self._DECAY
230
231    def review_card(
232        self,
233        card: Card,
234        rating: Rating,
235        review_datetime: datetime | None = None,
236        review_duration: int | None = None,
237    ) -> tuple[Card, ReviewLog]:
238        """
239        Reviews a card with a given rating at a given time for a specified duration.
240
241        Args:
242            card: The card being reviewed.
243            rating: The chosen rating for the card being reviewed.
244            review_datetime: The date and time of the review.
245            review_duration: The number of miliseconds it took to review the card or None if unspecified.
246
247        Returns:
248            A tuple containing the updated, reviewed card and its corresponding review log.
249
250        Raises:
251            ValueError: If the `review_datetime` argument is not timezone-aware and set to UTC.
252        """
253
254        if review_datetime is not None and (
255            (review_datetime.tzinfo is None) or (review_datetime.tzinfo != timezone.utc)
256        ):
257            raise ValueError("datetime must be timezone-aware and set to UTC")
258
259        card = copy(card)
260
261        if review_datetime is None:
262            review_datetime = datetime.now(timezone.utc)
263
264        days_since_last_review = (
265            (review_datetime - card.last_review).days if card.last_review else None
266        )
267
268        match card.state:
269            case State.Learning:
270                assert card.step is not None
271
272                # update the card's stability and difficulty
273                if card.stability is None or card.difficulty is None:
274                    card.stability = self._initial_stability(rating=rating)
275                    card.difficulty = self._initial_difficulty(
276                        rating=rating, clamp=True
277                    )
278
279                elif days_since_last_review is not None and days_since_last_review < 1:
280                    card.stability = self._short_term_stability(
281                        stability=card.stability, rating=rating
282                    )
283                    card.difficulty = self._next_difficulty(
284                        difficulty=card.difficulty, rating=rating
285                    )
286
287                else:
288                    card.stability = self._next_stability(
289                        difficulty=card.difficulty,
290                        stability=card.stability,
291                        retrievability=self.get_card_retrievability(
292                            card,
293                            current_datetime=review_datetime,
294                        ),
295                        rating=rating,
296                    )
297                    card.difficulty = self._next_difficulty(
298                        difficulty=card.difficulty, rating=rating
299                    )
300
301                # calculate the card's next interval
302                ## first if-clause handles edge case where the Card in the Learning state was previously
303                ## scheduled with a Scheduler with more learning_steps than the current Scheduler
304                if len(self.learning_steps) == 0 or (
305                    card.step >= len(self.learning_steps)
306                    and rating in (Rating.Hard, Rating.Good, Rating.Easy)
307                ):
308                    card.state = State.Review
309                    card.step = None
310
311                    next_interval_days = self._next_interval(stability=card.stability)
312                    next_interval = timedelta(days=next_interval_days)
313
314                else:
315                    match rating:
316                        case Rating.Again:
317                            card.step = 0
318                            next_interval = self.learning_steps[card.step]
319
320                        case Rating.Hard:
321                            # card step stays the same
322
323                            if card.step == 0 and len(self.learning_steps) == 1:
324                                next_interval = self.learning_steps[0] * 1.5
325                            elif card.step == 0 and len(self.learning_steps) >= 2:
326                                next_interval = (
327                                    self.learning_steps[0] + self.learning_steps[1]
328                                ) / 2.0
329                            else:
330                                next_interval = self.learning_steps[card.step]
331
332                        case Rating.Good:
333                            if card.step + 1 == len(
334                                self.learning_steps
335                            ):  # the last step
336                                card.state = State.Review
337                                card.step = None
338
339                                next_interval_days = self._next_interval(
340                                    stability=card.stability
341                                )
342                                next_interval = timedelta(days=next_interval_days)
343
344                            else:
345                                card.step += 1
346                                next_interval = self.learning_steps[card.step]
347
348                        case Rating.Easy:
349                            card.state = State.Review
350                            card.step = None
351
352                            next_interval_days = self._next_interval(
353                                stability=card.stability
354                            )
355                            next_interval = timedelta(days=next_interval_days)
356
357            case State.Review:
358                assert card.stability is not None
359                assert card.difficulty is not None
360
361                # update the card's stability and difficulty
362                if days_since_last_review is not None and days_since_last_review < 1:
363                    card.stability = self._short_term_stability(
364                        stability=card.stability, rating=rating
365                    )
366                else:
367                    card.stability = self._next_stability(
368                        difficulty=card.difficulty,
369                        stability=card.stability,
370                        retrievability=self.get_card_retrievability(
371                            card,
372                            current_datetime=review_datetime,
373                        ),
374                        rating=rating,
375                    )
376
377                card.difficulty = self._next_difficulty(
378                    difficulty=card.difficulty, rating=rating
379                )
380
381                # calculate the card's next interval
382                match rating:
383                    case Rating.Again:
384                        # if there are no relearning steps (they were left blank)
385                        if len(self.relearning_steps) == 0:
386                            next_interval_days = self._next_interval(
387                                stability=card.stability
388                            )
389                            next_interval = timedelta(days=next_interval_days)
390
391                        else:
392                            card.state = State.Relearning
393                            card.step = 0
394
395                            next_interval = self.relearning_steps[card.step]
396
397                    case Rating.Hard | Rating.Good | Rating.Easy:
398                        next_interval_days = self._next_interval(
399                            stability=card.stability
400                        )
401                        next_interval = timedelta(days=next_interval_days)
402
403            case State.Relearning:
404                assert card.stability is not None
405                assert card.difficulty is not None
406                assert card.step is not None
407
408                # update the card's stability and difficulty
409                if days_since_last_review is not None and days_since_last_review < 1:
410                    card.stability = self._short_term_stability(
411                        stability=card.stability, rating=rating
412                    )
413                    card.difficulty = self._next_difficulty(
414                        difficulty=card.difficulty, rating=rating
415                    )
416
417                else:
418                    card.stability = self._next_stability(
419                        difficulty=card.difficulty,
420                        stability=card.stability,
421                        retrievability=self.get_card_retrievability(
422                            card,
423                            current_datetime=review_datetime,
424                        ),
425                        rating=rating,
426                    )
427                    card.difficulty = self._next_difficulty(
428                        difficulty=card.difficulty, rating=rating
429                    )
430
431                # calculate the card's next interval
432                ## first if-clause handles edge case where the Card in the Relearning state was previously
433                ## scheduled with a Scheduler with more relearning_steps than the current Scheduler
434                if len(self.relearning_steps) == 0 or (
435                    card.step >= len(self.relearning_steps)
436                    and rating in (Rating.Hard, Rating.Good, Rating.Easy)
437                ):
438                    card.state = State.Review
439                    card.step = None
440
441                    next_interval_days = self._next_interval(stability=card.stability)
442                    next_interval = timedelta(days=next_interval_days)
443
444                else:
445                    match rating:
446                        case Rating.Again:
447                            card.step = 0
448                            next_interval = self.relearning_steps[card.step]
449
450                        case Rating.Hard:
451                            # card step stays the same
452
453                            if card.step == 0 and len(self.relearning_steps) == 1:
454                                next_interval = self.relearning_steps[0] * 1.5
455                            elif card.step == 0 and len(self.relearning_steps) >= 2:
456                                next_interval = (
457                                    self.relearning_steps[0] + self.relearning_steps[1]
458                                ) / 2.0
459                            else:
460                                next_interval = self.relearning_steps[card.step]
461
462                        case Rating.Good:
463                            if card.step + 1 == len(
464                                self.relearning_steps
465                            ):  # the last step
466                                card.state = State.Review
467                                card.step = None
468
469                                next_interval_days = self._next_interval(
470                                    stability=card.stability
471                                )
472                                next_interval = timedelta(days=next_interval_days)
473
474                            else:
475                                card.step += 1
476                                next_interval = self.relearning_steps[card.step]
477
478                        case Rating.Easy:
479                            card.state = State.Review
480                            card.step = None
481
482                            next_interval_days = self._next_interval(
483                                stability=card.stability
484                            )
485                            next_interval = timedelta(days=next_interval_days)
486
487        if self.enable_fuzzing and card.state == State.Review:
488            next_interval = self._get_fuzzed_interval(interval=next_interval)
489
490        card.due = review_datetime + next_interval
491        card.last_review = review_datetime
492
493        review_log = ReviewLog(
494            card_id=card.card_id,
495            rating=rating,
496            review_datetime=review_datetime,
497            review_duration=review_duration,
498        )
499
500        return card, review_log
501
502    def reschedule_card(self, card: Card, review_logs: list[ReviewLog]) -> Card:
503        """
504        Reschedules/updates the given card with the current scheduler provided that card's review logs.
505
506        If the current card was previously scheduled with a different scheduler, you may want to reschedule/update
507        it as if it had always been scheduled with this current scheduler. For example, you may want to reschedule
508        each of your cards with a new scheduler after computing the optimal parameters with the Optimizer.
509
510        Args:
511            card: The card to be rescheduled/updated.
512            review_logs: A list of that card's review logs (order doesn't matter).
513
514        Returns:
515            A new card that has been rescheduled/updated with this current scheduler.
516
517        Raises:
518            ValueError: If any of the review logs are for a card other than the one specified, this will raise an error.
519
520        """
521
522        for review_log in review_logs:
523            if review_log.card_id != card.card_id:
524                raise ValueError(
525                    f"ReviewLog card_id {review_log.card_id} does not match Card card_id {card.card_id}"
526                )
527
528        review_logs = sorted(review_logs, key=lambda log: log.review_datetime)
529
530        rescheduled_card = Card(card_id=card.card_id, due=card.due)
531
532        for review_log in review_logs:
533            rescheduled_card, _ = self.review_card(
534                card=rescheduled_card,
535                rating=review_log.rating,
536                review_datetime=review_log.review_datetime,
537            )
538
539        return rescheduled_card
540
541    def to_dict(
542        self,
543    ) -> SchedulerDict:
544        """
545        Returns a JSON-serializable dictionary representation of the Scheduler object.
546
547        This method is specifically useful for storing Scheduler objects in a database.
548
549        Returns:
550            A dictionary representation of the Scheduler object.
551        """
552
553        return {
554            "parameters": list(self.parameters),
555            "desired_retention": self.desired_retention,
556            "learning_steps": [
557                int(learning_step.total_seconds())
558                for learning_step in self.learning_steps
559            ],
560            "relearning_steps": [
561                int(relearning_step.total_seconds())
562                for relearning_step in self.relearning_steps
563            ],
564            "maximum_interval": self.maximum_interval,
565            "enable_fuzzing": self.enable_fuzzing,
566        }
567
568    @staticmethod
569    def from_dict(source_dict: SchedulerDict) -> Scheduler:
570        """
571        Creates a Scheduler object from an existing dictionary.
572
573        Args:
574            source_dict: A dictionary representing an existing Scheduler object.
575
576        Returns:
577            A Scheduler object created from the provided dictionary.
578        """
579
580        return Scheduler(
581            parameters=source_dict["parameters"],
582            desired_retention=source_dict["desired_retention"],
583            learning_steps=[
584                timedelta(seconds=learning_step)
585                for learning_step in source_dict["learning_steps"]
586            ],
587            relearning_steps=[
588                timedelta(seconds=relearning_step)
589                for relearning_step in source_dict["relearning_steps"]
590            ],
591            maximum_interval=source_dict["maximum_interval"],
592            enable_fuzzing=source_dict["enable_fuzzing"],
593        )
594
595    def _clamp_difficulty(self, *, difficulty: float) -> float:
596        if isinstance(difficulty, Real):
597            difficulty = min(max(difficulty, MIN_DIFFICULTY), MAX_DIFFICULTY)
598        else:  # type(difficulty) is torch.Tensor
599            difficulty = difficulty.clamp(min=MIN_DIFFICULTY, max=MAX_DIFFICULTY)  # type: ignore[attr-defined]
600
601        return difficulty
602
603    def _clamp_stability(self, *, stability: float) -> float:
604        if isinstance(stability, Real):
605            stability = max(stability, STABILITY_MIN)
606        else:  # type(stability) is torch.Tensor
607            stability = stability.clamp(min=STABILITY_MIN)  # type: ignore[attr-defined]
608
609        return stability
610
611    def _initial_stability(self, *, rating: Rating) -> float:
612        initial_stability = self.parameters[rating - 1]
613
614        initial_stability = self._clamp_stability(stability=initial_stability)
615
616        return initial_stability
617
618    def _initial_difficulty(self, *, rating: Rating, clamp: bool) -> float:
619        initial_difficulty = (
620            self.parameters[4] - (math.e ** (self.parameters[5] * (rating - 1))) + 1
621        )
622
623        if clamp:
624            initial_difficulty = self._clamp_difficulty(difficulty=initial_difficulty)
625
626        return initial_difficulty
627
628    def _next_interval(self, *, stability: float) -> int:
629        next_interval = (stability / self._FACTOR) * (
630            (self.desired_retention ** (1 / self._DECAY)) - 1
631        )
632
633        if not isinstance(next_interval, Real):  # type(next_interval) is torch.Tensor
634            next_interval = next_interval.detach().item()
635
636        next_interval = round(next_interval)  # intervals are full days
637
638        # must be at least 1 day long
639        next_interval = max(next_interval, 1)
640
641        # can not be longer than the maximum interval
642        next_interval = min(next_interval, self.maximum_interval)
643
644        return next_interval
645
646    def _short_term_stability(self, *, stability: float, rating: Rating) -> float:
647        short_term_stability_increase = (
648            math.e ** (self.parameters[17] * (rating - 3 + self.parameters[18]))
649        ) * (stability ** -self.parameters[19])
650
651        if rating in (Rating.Good, Rating.Easy):
652            if isinstance(short_term_stability_increase, Real):
653                short_term_stability_increase = max(short_term_stability_increase, 1.0)
654            else:  # type(short_term_stability_increase) is torch.Tensor
655                short_term_stability_increase = short_term_stability_increase.clamp(
656                    min=1.0
657                )
658
659        short_term_stability = stability * short_term_stability_increase
660
661        short_term_stability = self._clamp_stability(stability=short_term_stability)
662
663        return short_term_stability
664
665    def _next_difficulty(self, *, difficulty: float, rating: Rating) -> float:
666        def _linear_damping(*, delta_difficulty: float, difficulty: float) -> float:
667            return (10.0 - difficulty) * delta_difficulty / 9.0
668
669        def _mean_reversion(*, arg_1: float, arg_2: float) -> float:
670            return self.parameters[7] * arg_1 + (1 - self.parameters[7]) * arg_2
671
672        arg_1 = self._initial_difficulty(rating=Rating.Easy, clamp=False)
673
674        delta_difficulty = -(self.parameters[6] * (rating - 3))
675        arg_2 = difficulty + _linear_damping(
676            delta_difficulty=delta_difficulty, difficulty=difficulty
677        )
678
679        next_difficulty = _mean_reversion(arg_1=arg_1, arg_2=arg_2)
680
681        next_difficulty = self._clamp_difficulty(difficulty=next_difficulty)
682
683        return next_difficulty
684
685    def _next_stability(
686        self,
687        *,
688        difficulty: float,
689        stability: float,
690        retrievability: float,
691        rating: Rating,
692    ) -> float:
693        if rating == Rating.Again:
694            next_stability = self._next_forget_stability(
695                difficulty=difficulty,
696                stability=stability,
697                retrievability=retrievability,
698            )
699
700        elif rating in (Rating.Hard, Rating.Good, Rating.Easy):
701            next_stability = self._next_recall_stability(
702                difficulty=difficulty,
703                stability=stability,
704                retrievability=retrievability,
705                rating=rating,
706            )
707
708        next_stability = self._clamp_stability(stability=next_stability)
709
710        return next_stability
711
712    def _next_forget_stability(
713        self, *, difficulty: float, stability: float, retrievability: float
714    ) -> float:
715        next_forget_stability_long_term_params = (
716            self.parameters[11]
717            * (difficulty ** -self.parameters[12])
718            * (((stability + 1) ** (self.parameters[13])) - 1)
719            * (math.e ** ((1 - retrievability) * self.parameters[14]))
720        )
721
722        next_forget_stability_short_term_params = stability / (
723            math.e ** (self.parameters[17] * self.parameters[18])
724        )
725
726        return min(
727            next_forget_stability_long_term_params,
728            next_forget_stability_short_term_params,
729        )
730
731    def _next_recall_stability(
732        self,
733        *,
734        difficulty: float,
735        stability: float,
736        retrievability: float,
737        rating: Rating,
738    ) -> float:
739        hard_penalty = self.parameters[15] if rating == Rating.Hard else 1
740        easy_bonus = self.parameters[16] if rating == Rating.Easy else 1
741
742        return stability * (
743            1
744            + (math.e ** (self.parameters[8]))
745            * (11 - difficulty)
746            * (stability ** -self.parameters[9])
747            * ((math.e ** ((1 - retrievability) * self.parameters[10])) - 1)
748            * hard_penalty
749            * easy_bonus
750        )
751
752    def _get_fuzzed_interval(self, *, interval: timedelta) -> timedelta:
753        """
754        Takes the current calculated interval and adds a small amount of random fuzz to it.
755        For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days.
756
757        Args:
758            interval: The calculated next interval, before fuzzing.
759
760        Returns:
761            The new interval, after fuzzing.
762        """
763
764        interval_days = interval.days
765
766        if interval_days < 2.5:  # fuzz is not applied to intervals less than 2.5
767            return interval
768
769        def _get_fuzz_range(*, interval_days: int) -> tuple[int, int]:
770            """
771            Helper function that computes the possible upper and lower bounds of the interval after fuzzing.
772            """
773
774            delta = 1.0
775            for fuzz_range in FUZZ_RANGES:
776                delta += fuzz_range["factor"] * max(
777                    min(interval_days, fuzz_range["end"]) - fuzz_range["start"], 0.0
778                )
779
780            min_ivl = int(round(interval_days - delta))
781            max_ivl = int(round(interval_days + delta))
782
783            # make sure the min_ivl and max_ivl fall into a valid range
784            min_ivl = max(2, min_ivl)
785            max_ivl = min(max_ivl, self.maximum_interval)
786            min_ivl = min(min_ivl, max_ivl)
787
788            return min_ivl, max_ivl
789
790        min_ivl, max_ivl = _get_fuzz_range(interval_days=interval_days)
791
792        fuzzed_interval_days = (
793            random() * (max_ivl - min_ivl + 1)
794        ) + min_ivl  # the next interval is a random value between min_ivl and max_ivl
795
796        fuzzed_interval_days = min(round(fuzzed_interval_days), self.maximum_interval)
797
798        fuzzed_interval = timedelta(days=fuzzed_interval_days)
799
800        return fuzzed_interval

The FSRS scheduler.

Enables the reviewing and future scheduling of cards according to the FSRS algorithm.

Attributes: parameters: The model weights of the FSRS scheduler. desired_retention: The desired retention rate of cards scheduled with the scheduler. learning_steps: Small time intervals that schedule cards in the Learning state. relearning_steps: Small time intervals that schedule cards in the Relearning state. maximum_interval: The maximum number of days a Review-state card can be scheduled into the future. enable_fuzzing: Whether to apply a small amount of random 'fuzz' to calculated intervals.

Scheduler( parameters: Sequence[float] = (0.212, 1.2931, 2.3065, 8.2956, 6.4133, 0.8334, 3.0194, 0.001, 1.8722, 0.1666, 0.796, 1.4835, 0.0614, 0.2629, 1.6483, 0.6014, 1.8729, 0.5425, 0.0912, 0.0658, 0.1542), desired_retention: float = 0.9, learning_steps: tuple[datetime.timedelta, ...] | list[datetime.timedelta] = (datetime.timedelta(seconds=60), datetime.timedelta(seconds=600)), relearning_steps: tuple[datetime.timedelta, ...] | list[datetime.timedelta] = (datetime.timedelta(seconds=600),), maximum_interval: int = 36500, enable_fuzzing: bool = True)
159    def __init__(
160        self,
161        parameters: Sequence[float] = DEFAULT_PARAMETERS,
162        desired_retention: float = 0.9,
163        learning_steps: tuple[timedelta, ...] | list[timedelta] = (
164            timedelta(minutes=1),
165            timedelta(minutes=10),
166        ),
167        relearning_steps: tuple[timedelta, ...] | list[timedelta] = (
168            timedelta(minutes=10),
169        ),
170        maximum_interval: int = 36500,
171        enable_fuzzing: bool = True,
172    ) -> None:
173        self._validate_parameters(parameters=parameters)
174
175        self.parameters = tuple(parameters)
176        self.desired_retention = desired_retention
177        self.learning_steps = tuple(learning_steps)
178        self.relearning_steps = tuple(relearning_steps)
179        self.maximum_interval = maximum_interval
180        self.enable_fuzzing = enable_fuzzing
181
182        self._DECAY = -self.parameters[20]
183        self._FACTOR = 0.9 ** (1 / self._DECAY) - 1
parameters: tuple[float, ...]
desired_retention: float
learning_steps: tuple[datetime.timedelta, ...]
relearning_steps: tuple[datetime.timedelta, ...]
maximum_interval: int
enable_fuzzing: bool
def get_card_retrievability( self, card: Card, current_datetime: datetime.datetime | None = None) -> float:
205    def get_card_retrievability(
206        self, card: Card, current_datetime: datetime | None = None
207    ) -> float:
208        """
209        Calculates a Card object's current retrievability for a given date and time.
210
211        The retrievability of a card is the predicted probability that the card is correctly recalled at the provided datetime.
212
213        Args:
214            card: The card whose retrievability is to be calculated
215            current_datetime: The current date and time
216
217        Returns:
218            The retrievability of the Card object.
219        """
220
221        if card.last_review is None:
222            return 0
223
224        if current_datetime is None:
225            current_datetime = datetime.now(timezone.utc)
226
227        elapsed_days = max(0, (current_datetime - card.last_review).days)
228
229        return (1 + self._FACTOR * elapsed_days / card.stability) ** self._DECAY

Calculates a Card object's current retrievability for a given date and time.

The retrievability of a card is the predicted probability that the card is correctly recalled at the provided datetime.

Args: card: The card whose retrievability is to be calculated current_datetime: The current date and time

Returns: The retrievability of the Card object.

def review_card( self, card: Card, rating: Rating, review_datetime: datetime.datetime | None = None, review_duration: int | None = None) -> tuple[Card, ReviewLog]:
231    def review_card(
232        self,
233        card: Card,
234        rating: Rating,
235        review_datetime: datetime | None = None,
236        review_duration: int | None = None,
237    ) -> tuple[Card, ReviewLog]:
238        """
239        Reviews a card with a given rating at a given time for a specified duration.
240
241        Args:
242            card: The card being reviewed.
243            rating: The chosen rating for the card being reviewed.
244            review_datetime: The date and time of the review.
245            review_duration: The number of miliseconds it took to review the card or None if unspecified.
246
247        Returns:
248            A tuple containing the updated, reviewed card and its corresponding review log.
249
250        Raises:
251            ValueError: If the `review_datetime` argument is not timezone-aware and set to UTC.
252        """
253
254        if review_datetime is not None and (
255            (review_datetime.tzinfo is None) or (review_datetime.tzinfo != timezone.utc)
256        ):
257            raise ValueError("datetime must be timezone-aware and set to UTC")
258
259        card = copy(card)
260
261        if review_datetime is None:
262            review_datetime = datetime.now(timezone.utc)
263
264        days_since_last_review = (
265            (review_datetime - card.last_review).days if card.last_review else None
266        )
267
268        match card.state:
269            case State.Learning:
270                assert card.step is not None
271
272                # update the card's stability and difficulty
273                if card.stability is None or card.difficulty is None:
274                    card.stability = self._initial_stability(rating=rating)
275                    card.difficulty = self._initial_difficulty(
276                        rating=rating, clamp=True
277                    )
278
279                elif days_since_last_review is not None and days_since_last_review < 1:
280                    card.stability = self._short_term_stability(
281                        stability=card.stability, rating=rating
282                    )
283                    card.difficulty = self._next_difficulty(
284                        difficulty=card.difficulty, rating=rating
285                    )
286
287                else:
288                    card.stability = self._next_stability(
289                        difficulty=card.difficulty,
290                        stability=card.stability,
291                        retrievability=self.get_card_retrievability(
292                            card,
293                            current_datetime=review_datetime,
294                        ),
295                        rating=rating,
296                    )
297                    card.difficulty = self._next_difficulty(
298                        difficulty=card.difficulty, rating=rating
299                    )
300
301                # calculate the card's next interval
302                ## first if-clause handles edge case where the Card in the Learning state was previously
303                ## scheduled with a Scheduler with more learning_steps than the current Scheduler
304                if len(self.learning_steps) == 0 or (
305                    card.step >= len(self.learning_steps)
306                    and rating in (Rating.Hard, Rating.Good, Rating.Easy)
307                ):
308                    card.state = State.Review
309                    card.step = None
310
311                    next_interval_days = self._next_interval(stability=card.stability)
312                    next_interval = timedelta(days=next_interval_days)
313
314                else:
315                    match rating:
316                        case Rating.Again:
317                            card.step = 0
318                            next_interval = self.learning_steps[card.step]
319
320                        case Rating.Hard:
321                            # card step stays the same
322
323                            if card.step == 0 and len(self.learning_steps) == 1:
324                                next_interval = self.learning_steps[0] * 1.5
325                            elif card.step == 0 and len(self.learning_steps) >= 2:
326                                next_interval = (
327                                    self.learning_steps[0] + self.learning_steps[1]
328                                ) / 2.0
329                            else:
330                                next_interval = self.learning_steps[card.step]
331
332                        case Rating.Good:
333                            if card.step + 1 == len(
334                                self.learning_steps
335                            ):  # the last step
336                                card.state = State.Review
337                                card.step = None
338
339                                next_interval_days = self._next_interval(
340                                    stability=card.stability
341                                )
342                                next_interval = timedelta(days=next_interval_days)
343
344                            else:
345                                card.step += 1
346                                next_interval = self.learning_steps[card.step]
347
348                        case Rating.Easy:
349                            card.state = State.Review
350                            card.step = None
351
352                            next_interval_days = self._next_interval(
353                                stability=card.stability
354                            )
355                            next_interval = timedelta(days=next_interval_days)
356
357            case State.Review:
358                assert card.stability is not None
359                assert card.difficulty is not None
360
361                # update the card's stability and difficulty
362                if days_since_last_review is not None and days_since_last_review < 1:
363                    card.stability = self._short_term_stability(
364                        stability=card.stability, rating=rating
365                    )
366                else:
367                    card.stability = self._next_stability(
368                        difficulty=card.difficulty,
369                        stability=card.stability,
370                        retrievability=self.get_card_retrievability(
371                            card,
372                            current_datetime=review_datetime,
373                        ),
374                        rating=rating,
375                    )
376
377                card.difficulty = self._next_difficulty(
378                    difficulty=card.difficulty, rating=rating
379                )
380
381                # calculate the card's next interval
382                match rating:
383                    case Rating.Again:
384                        # if there are no relearning steps (they were left blank)
385                        if len(self.relearning_steps) == 0:
386                            next_interval_days = self._next_interval(
387                                stability=card.stability
388                            )
389                            next_interval = timedelta(days=next_interval_days)
390
391                        else:
392                            card.state = State.Relearning
393                            card.step = 0
394
395                            next_interval = self.relearning_steps[card.step]
396
397                    case Rating.Hard | Rating.Good | Rating.Easy:
398                        next_interval_days = self._next_interval(
399                            stability=card.stability
400                        )
401                        next_interval = timedelta(days=next_interval_days)
402
403            case State.Relearning:
404                assert card.stability is not None
405                assert card.difficulty is not None
406                assert card.step is not None
407
408                # update the card's stability and difficulty
409                if days_since_last_review is not None and days_since_last_review < 1:
410                    card.stability = self._short_term_stability(
411                        stability=card.stability, rating=rating
412                    )
413                    card.difficulty = self._next_difficulty(
414                        difficulty=card.difficulty, rating=rating
415                    )
416
417                else:
418                    card.stability = self._next_stability(
419                        difficulty=card.difficulty,
420                        stability=card.stability,
421                        retrievability=self.get_card_retrievability(
422                            card,
423                            current_datetime=review_datetime,
424                        ),
425                        rating=rating,
426                    )
427                    card.difficulty = self._next_difficulty(
428                        difficulty=card.difficulty, rating=rating
429                    )
430
431                # calculate the card's next interval
432                ## first if-clause handles edge case where the Card in the Relearning state was previously
433                ## scheduled with a Scheduler with more relearning_steps than the current Scheduler
434                if len(self.relearning_steps) == 0 or (
435                    card.step >= len(self.relearning_steps)
436                    and rating in (Rating.Hard, Rating.Good, Rating.Easy)
437                ):
438                    card.state = State.Review
439                    card.step = None
440
441                    next_interval_days = self._next_interval(stability=card.stability)
442                    next_interval = timedelta(days=next_interval_days)
443
444                else:
445                    match rating:
446                        case Rating.Again:
447                            card.step = 0
448                            next_interval = self.relearning_steps[card.step]
449
450                        case Rating.Hard:
451                            # card step stays the same
452
453                            if card.step == 0 and len(self.relearning_steps) == 1:
454                                next_interval = self.relearning_steps[0] * 1.5
455                            elif card.step == 0 and len(self.relearning_steps) >= 2:
456                                next_interval = (
457                                    self.relearning_steps[0] + self.relearning_steps[1]
458                                ) / 2.0
459                            else:
460                                next_interval = self.relearning_steps[card.step]
461
462                        case Rating.Good:
463                            if card.step + 1 == len(
464                                self.relearning_steps
465                            ):  # the last step
466                                card.state = State.Review
467                                card.step = None
468
469                                next_interval_days = self._next_interval(
470                                    stability=card.stability
471                                )
472                                next_interval = timedelta(days=next_interval_days)
473
474                            else:
475                                card.step += 1
476                                next_interval = self.relearning_steps[card.step]
477
478                        case Rating.Easy:
479                            card.state = State.Review
480                            card.step = None
481
482                            next_interval_days = self._next_interval(
483                                stability=card.stability
484                            )
485                            next_interval = timedelta(days=next_interval_days)
486
487        if self.enable_fuzzing and card.state == State.Review:
488            next_interval = self._get_fuzzed_interval(interval=next_interval)
489
490        card.due = review_datetime + next_interval
491        card.last_review = review_datetime
492
493        review_log = ReviewLog(
494            card_id=card.card_id,
495            rating=rating,
496            review_datetime=review_datetime,
497            review_duration=review_duration,
498        )
499
500        return card, review_log

Reviews a card with a given rating at a given time for a specified duration.

Args: card: The card being reviewed. rating: The chosen rating for the card being reviewed. review_datetime: The date and time of the review. review_duration: The number of miliseconds it took to review the card or None if unspecified.

Returns: A tuple containing the updated, reviewed card and its corresponding review log.

Raises: ValueError: If the review_datetime argument is not timezone-aware and set to UTC.

def reschedule_card( self, card: Card, review_logs: list[ReviewLog]) -> Card:
502    def reschedule_card(self, card: Card, review_logs: list[ReviewLog]) -> Card:
503        """
504        Reschedules/updates the given card with the current scheduler provided that card's review logs.
505
506        If the current card was previously scheduled with a different scheduler, you may want to reschedule/update
507        it as if it had always been scheduled with this current scheduler. For example, you may want to reschedule
508        each of your cards with a new scheduler after computing the optimal parameters with the Optimizer.
509
510        Args:
511            card: The card to be rescheduled/updated.
512            review_logs: A list of that card's review logs (order doesn't matter).
513
514        Returns:
515            A new card that has been rescheduled/updated with this current scheduler.
516
517        Raises:
518            ValueError: If any of the review logs are for a card other than the one specified, this will raise an error.
519
520        """
521
522        for review_log in review_logs:
523            if review_log.card_id != card.card_id:
524                raise ValueError(
525                    f"ReviewLog card_id {review_log.card_id} does not match Card card_id {card.card_id}"
526                )
527
528        review_logs = sorted(review_logs, key=lambda log: log.review_datetime)
529
530        rescheduled_card = Card(card_id=card.card_id, due=card.due)
531
532        for review_log in review_logs:
533            rescheduled_card, _ = self.review_card(
534                card=rescheduled_card,
535                rating=review_log.rating,
536                review_datetime=review_log.review_datetime,
537            )
538
539        return rescheduled_card

Reschedules/updates the given card with the current scheduler provided that card's review logs.

If the current card was previously scheduled with a different scheduler, you may want to reschedule/update it as if it had always been scheduled with this current scheduler. For example, you may want to reschedule each of your cards with a new scheduler after computing the optimal parameters with the Optimizer.

Args: card: The card to be rescheduled/updated. review_logs: A list of that card's review logs (order doesn't matter).

Returns: A new card that has been rescheduled/updated with this current scheduler.

Raises: ValueError: If any of the review logs are for a card other than the one specified, this will raise an error.

def to_dict(self) -> fsrs.scheduler.SchedulerDict:
541    def to_dict(
542        self,
543    ) -> SchedulerDict:
544        """
545        Returns a JSON-serializable dictionary representation of the Scheduler object.
546
547        This method is specifically useful for storing Scheduler objects in a database.
548
549        Returns:
550            A dictionary representation of the Scheduler object.
551        """
552
553        return {
554            "parameters": list(self.parameters),
555            "desired_retention": self.desired_retention,
556            "learning_steps": [
557                int(learning_step.total_seconds())
558                for learning_step in self.learning_steps
559            ],
560            "relearning_steps": [
561                int(relearning_step.total_seconds())
562                for relearning_step in self.relearning_steps
563            ],
564            "maximum_interval": self.maximum_interval,
565            "enable_fuzzing": self.enable_fuzzing,
566        }

Returns a JSON-serializable dictionary representation of the Scheduler object.

This method is specifically useful for storing Scheduler objects in a database.

Returns: A dictionary representation of the Scheduler object.

@staticmethod
def from_dict(source_dict: fsrs.scheduler.SchedulerDict) -> Scheduler:
568    @staticmethod
569    def from_dict(source_dict: SchedulerDict) -> Scheduler:
570        """
571        Creates a Scheduler object from an existing dictionary.
572
573        Args:
574            source_dict: A dictionary representing an existing Scheduler object.
575
576        Returns:
577            A Scheduler object created from the provided dictionary.
578        """
579
580        return Scheduler(
581            parameters=source_dict["parameters"],
582            desired_retention=source_dict["desired_retention"],
583            learning_steps=[
584                timedelta(seconds=learning_step)
585                for learning_step in source_dict["learning_steps"]
586            ],
587            relearning_steps=[
588                timedelta(seconds=relearning_step)
589                for relearning_step in source_dict["relearning_steps"]
590            ],
591            maximum_interval=source_dict["maximum_interval"],
592            enable_fuzzing=source_dict["enable_fuzzing"],
593        )

Creates a Scheduler object from an existing dictionary.

Args: source_dict: A dictionary representing an existing Scheduler object.

Returns: A Scheduler object created from the provided dictionary.

@dataclass(init=False)
class Card:
 35@dataclass(init=False)
 36class Card:
 37    """
 38    Represents a flashcard in the FSRS system.
 39
 40    Attributes:
 41        card_id: The id of the card. Defaults to the epoch milliseconds of when the card was created.
 42        state: The card's current learning state.
 43        step: The card's current learning or relearning step or None if the card is in the Review state.
 44        stability: Core mathematical parameter used for future scheduling.
 45        difficulty: Core mathematical parameter used for future scheduling.
 46        due: The date and time when the card is due next.
 47        last_review: The date and time of the card's last review.
 48    """
 49
 50    card_id: int
 51    state: State
 52    step: int | None
 53    stability: float | None
 54    difficulty: float | None
 55    due: datetime
 56    last_review: datetime | None
 57
 58    def __init__(
 59        self,
 60        card_id: int | None = None,
 61        state: State = State.Learning,
 62        step: int | None = None,
 63        stability: float | None = None,
 64        difficulty: float | None = None,
 65        due: datetime | None = None,
 66        last_review: datetime | None = None,
 67    ) -> None:
 68        if card_id is None:
 69            # epoch milliseconds of when the card was created
 70            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
 71            # wait 1ms to prevent potential card_id collision on next Card creation
 72            time.sleep(0.001)
 73        self.card_id = card_id
 74
 75        self.state = state
 76
 77        if self.state == State.Learning and step is None:
 78            step = 0
 79        self.step = step
 80
 81        self.stability = stability
 82        self.difficulty = difficulty
 83
 84        if due is None:
 85            due = datetime.now(timezone.utc)
 86        self.due = due
 87
 88        self.last_review = last_review
 89
 90    def to_dict(self) -> CardDict:
 91        """
 92        Returns a JSON-serializable dictionary representation of the Card object.
 93
 94        This method is specifically useful for storing Card objects in a database.
 95
 96        Returns:
 97            A dictionary representation of the Card object.
 98        """
 99
100        return {
101            "card_id": self.card_id,
102            "state": self.state.value,
103            "step": self.step,
104            "stability": self.stability,
105            "difficulty": self.difficulty,
106            "due": self.due.isoformat(),
107            "last_review": self.last_review.isoformat() if self.last_review else None,
108        }
109
110    @staticmethod
111    def from_dict(source_dict: CardDict) -> Card:
112        """
113        Creates a Card object from an existing dictionary.
114
115        Args:
116            source_dict: A dictionary representing an existing Card object.
117
118        Returns:
119            A Card object created from the provided dictionary.
120        """
121
122        return Card(
123            card_id=int(source_dict["card_id"]),
124            state=State(int(source_dict["state"])),
125            step=source_dict["step"],
126            stability=(
127                float(source_dict["stability"]) if source_dict["stability"] else None
128            ),
129            difficulty=(
130                float(source_dict["difficulty"]) if source_dict["difficulty"] else None
131            ),
132            due=datetime.fromisoformat(source_dict["due"]),
133            last_review=(
134                datetime.fromisoformat(source_dict["last_review"])
135                if source_dict["last_review"]
136                else None
137            ),
138        )

Represents a flashcard in the FSRS system.

Attributes: card_id: The id of the card. Defaults to the epoch milliseconds of when the card was created. state: The card's current learning state. step: The card's current learning or relearning step or None if the card is in the Review state. stability: Core mathematical parameter used for future scheduling. difficulty: Core mathematical parameter used for future scheduling. due: The date and time when the card is due next. last_review: The date and time of the card's last review.

Card( card_id: int | None = None, state: State = <State.Learning: 1>, step: int | None = None, stability: float | None = None, difficulty: float | None = None, due: datetime.datetime | None = None, last_review: datetime.datetime | None = None)
58    def __init__(
59        self,
60        card_id: int | None = None,
61        state: State = State.Learning,
62        step: int | None = None,
63        stability: float | None = None,
64        difficulty: float | None = None,
65        due: datetime | None = None,
66        last_review: datetime | None = None,
67    ) -> None:
68        if card_id is None:
69            # epoch milliseconds of when the card was created
70            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
71            # wait 1ms to prevent potential card_id collision on next Card creation
72            time.sleep(0.001)
73        self.card_id = card_id
74
75        self.state = state
76
77        if self.state == State.Learning and step is None:
78            step = 0
79        self.step = step
80
81        self.stability = stability
82        self.difficulty = difficulty
83
84        if due is None:
85            due = datetime.now(timezone.utc)
86        self.due = due
87
88        self.last_review = last_review
card_id: int
state: State
step: int | None
stability: float | None
difficulty: float | None
due: datetime.datetime
last_review: datetime.datetime | None
def to_dict(self) -> fsrs.card.CardDict:
 90    def to_dict(self) -> CardDict:
 91        """
 92        Returns a JSON-serializable dictionary representation of the Card object.
 93
 94        This method is specifically useful for storing Card objects in a database.
 95
 96        Returns:
 97            A dictionary representation of the Card object.
 98        """
 99
100        return {
101            "card_id": self.card_id,
102            "state": self.state.value,
103            "step": self.step,
104            "stability": self.stability,
105            "difficulty": self.difficulty,
106            "due": self.due.isoformat(),
107            "last_review": self.last_review.isoformat() if self.last_review else None,
108        }

Returns a JSON-serializable dictionary representation of the Card object.

This method is specifically useful for storing Card objects in a database.

Returns: A dictionary representation of the Card object.

@staticmethod
def from_dict(source_dict: fsrs.card.CardDict) -> Card:
110    @staticmethod
111    def from_dict(source_dict: CardDict) -> Card:
112        """
113        Creates a Card object from an existing dictionary.
114
115        Args:
116            source_dict: A dictionary representing an existing Card object.
117
118        Returns:
119            A Card object created from the provided dictionary.
120        """
121
122        return Card(
123            card_id=int(source_dict["card_id"]),
124            state=State(int(source_dict["state"])),
125            step=source_dict["step"],
126            stability=(
127                float(source_dict["stability"]) if source_dict["stability"] else None
128            ),
129            difficulty=(
130                float(source_dict["difficulty"]) if source_dict["difficulty"] else None
131            ),
132            due=datetime.fromisoformat(source_dict["due"]),
133            last_review=(
134                datetime.fromisoformat(source_dict["last_review"])
135                if source_dict["last_review"]
136                else None
137            ),
138        )

Creates a Card object from an existing dictionary.

Args: source_dict: A dictionary representing an existing Card object.

Returns: A Card object created from the provided dictionary.

class Rating(enum.IntEnum):
 5class Rating(IntEnum):
 6    """
 7    Enum representing the four possible ratings when reviewing a card.
 8    """
 9
10    Again = 1
11    Hard = 2
12    Good = 3
13    Easy = 4

Enum representing the four possible ratings when reviewing a card.

Again = <Rating.Again: 1>
Hard = <Rating.Hard: 2>
Good = <Rating.Good: 3>
Easy = <Rating.Easy: 4>
@dataclass
class ReviewLog:
31@dataclass
32class ReviewLog:
33    """
34    Represents the log entry of a Card object that has been reviewed.
35
36    Attributes:
37        card_id: The id of the card being reviewed.
38        rating: The rating given to the card during the review.
39        review_datetime: The date and time of the review.
40        review_duration: The number of milliseconds it took to review the card or None if unspecified.
41    """
42
43    card_id: int
44    rating: Rating
45    review_datetime: datetime
46    review_duration: int | None
47
48    def to_dict(
49        self,
50    ) -> ReviewLogDict:
51        """
52        Returns a JSON-serializable dictionary representation of the ReviewLog object.
53
54        This method is specifically useful for storing ReviewLog objects in a database.
55
56        Returns:
57            A dictionary representation of the ReviewLog object.
58        """
59
60        return {
61            "card_id": self.card_id,
62            "rating": int(self.rating),
63            "review_datetime": self.review_datetime.isoformat(),
64            "review_duration": self.review_duration,
65        }
66
67    @staticmethod
68    def from_dict(
69        source_dict: ReviewLogDict,
70    ) -> ReviewLog:
71        """
72        Creates a ReviewLog object from an existing dictionary.
73
74        Args:
75            source_dict: A dictionary representing an existing ReviewLog object.
76
77        Returns:
78            A ReviewLog object created from the provided dictionary.
79        """
80
81        return ReviewLog(
82            card_id=source_dict["card_id"],
83            rating=Rating(int(source_dict["rating"])),
84            review_datetime=datetime.fromisoformat(source_dict["review_datetime"]),
85            review_duration=source_dict["review_duration"],
86        )

Represents the log entry of a Card object that has been reviewed.

Attributes: card_id: The id of the card being reviewed. rating: The rating given to the card during the review. review_datetime: The date and time of the review. review_duration: The number of milliseconds it took to review the card or None if unspecified.

ReviewLog( card_id: int, rating: Rating, review_datetime: datetime.datetime, review_duration: int | None)
card_id: int
rating: Rating
review_datetime: datetime.datetime
review_duration: int | None
def to_dict(self) -> fsrs.review_log.ReviewLogDict:
48    def to_dict(
49        self,
50    ) -> ReviewLogDict:
51        """
52        Returns a JSON-serializable dictionary representation of the ReviewLog object.
53
54        This method is specifically useful for storing ReviewLog objects in a database.
55
56        Returns:
57            A dictionary representation of the ReviewLog object.
58        """
59
60        return {
61            "card_id": self.card_id,
62            "rating": int(self.rating),
63            "review_datetime": self.review_datetime.isoformat(),
64            "review_duration": self.review_duration,
65        }

Returns a JSON-serializable dictionary representation of the ReviewLog object.

This method is specifically useful for storing ReviewLog objects in a database.

Returns: A dictionary representation of the ReviewLog object.

@staticmethod
def from_dict(source_dict: fsrs.review_log.ReviewLogDict) -> ReviewLog:
67    @staticmethod
68    def from_dict(
69        source_dict: ReviewLogDict,
70    ) -> ReviewLog:
71        """
72        Creates a ReviewLog object from an existing dictionary.
73
74        Args:
75            source_dict: A dictionary representing an existing ReviewLog object.
76
77        Returns:
78            A ReviewLog object created from the provided dictionary.
79        """
80
81        return ReviewLog(
82            card_id=source_dict["card_id"],
83            rating=Rating(int(source_dict["rating"])),
84            review_datetime=datetime.fromisoformat(source_dict["review_datetime"]),
85            review_duration=source_dict["review_duration"],
86        )

Creates a ReviewLog object from an existing dictionary.

Args: source_dict: A dictionary representing an existing ReviewLog object.

Returns: A ReviewLog object created from the provided dictionary.

class State(enum.IntEnum):
 5class State(IntEnum):
 6    """
 7    Enum representing the learning state of a Card object.
 8    """
 9
10    Learning = 1
11    Review = 2
12    Relearning = 3

Enum representing the learning state of a Card object.

Learning = <State.Learning: 1>
Review = <State.Review: 2>
Relearning = <State.Relearning: 3>
class Optimizer:
668    class Optimizer:
669        def __init__(self, *args, **kwargs) -> None:
670            raise ImportError(
671                'Optimizer is not installed.\nInstall it with: pip install "fsrs[optimizer]"'
672            )
Optimizer(*args, **kwargs)
669        def __init__(self, *args, **kwargs) -> None:
670            raise ImportError(
671                'Optimizer is not installed.\nInstall it with: pip install "fsrs[optimizer]"'
672            )