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

Returns a dictionary representation of the Scheduler object.

Returns: SchedulerDict: A dictionary representation of the Scheduler object.

@classmethod
def from_dict(cls, source_dict: fsrs.scheduler.SchedulerDict) -> Self:
582    @classmethod
583    def from_dict(cls, source_dict: SchedulerDict) -> Self:
584        """
585        Creates a Scheduler object from an existing dictionary.
586
587        Args:
588            source_dict: A dictionary representing an existing Scheduler object.
589
590        Returns:
591            Self: A Scheduler object created from the provided dictionary.
592        """
593
594        return cls(
595            parameters=source_dict["parameters"],
596            desired_retention=source_dict["desired_retention"],
597            learning_steps=[
598                timedelta(seconds=learning_step)
599                for learning_step in source_dict["learning_steps"]
600            ],
601            relearning_steps=[
602                timedelta(seconds=relearning_step)
603                for relearning_step in source_dict["relearning_steps"]
604            ],
605            maximum_interval=source_dict["maximum_interval"],
606            enable_fuzzing=source_dict["enable_fuzzing"],
607        )

Creates a Scheduler object from an existing dictionary.

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

Returns: Self: A Scheduler object created from the provided dictionary.

def to_json(self, indent: int | str | None = None) -> str:
609    def to_json(self, indent: int | str | None = None) -> str:
610        """
611        Returns a JSON-serialized string of the Scheduler object.
612
613        Args:
614            indent: Equivalent argument to the indent in json.dumps()
615
616        Returns:
617            str: A JSON-serialized string of the Scheduler object.
618        """
619
620        return json.dumps(self.to_dict(), indent=indent)

Returns a JSON-serialized string of the Scheduler object.

Args: indent: Equivalent argument to the indent in json.dumps()

Returns: str: A JSON-serialized string of the Scheduler object.

@classmethod
def from_json(cls, source_json: str) -> Self:
622    @classmethod
623    def from_json(cls, source_json: str) -> Self:
624        """
625        Creates a Scheduler object from a JSON-serialized string.
626
627        Args:
628            source_json: A JSON-serialized string of an existing Scheduler object.
629
630        Returns:
631            Self: A Scheduler object created from the JSON string.
632        """
633
634        source_dict: SchedulerDict = json.loads(source_json)
635        return cls.from_dict(source_dict=source_dict)

Creates a Scheduler object from a JSON-serialized string.

Args: source_json: A JSON-serialized string of an existing Scheduler object.

Returns: Self: A Scheduler object created from the JSON string.

@dataclass(init=False)
class Card:
 37@dataclass(init=False)
 38class Card:
 39    """
 40    Represents a flashcard in the FSRS system.
 41
 42    Attributes:
 43        card_id: The id of the card. Defaults to the epoch milliseconds of when the card was created.
 44        state: The card's current learning state.
 45        step: The card's current learning or relearning step or None if the card is in the Review state.
 46        stability: Core mathematical parameter used for future scheduling.
 47        difficulty: Core mathematical parameter used for future scheduling.
 48        due: The date and time when the card is due next.
 49        last_review: The date and time of the card's last review.
 50    """
 51
 52    card_id: int
 53    state: State
 54    step: int | None
 55    stability: float | None
 56    difficulty: float | None
 57    due: datetime
 58    last_review: datetime | None
 59
 60    def __init__(
 61        self,
 62        card_id: int | None = None,
 63        state: State = State.Learning,
 64        step: int | None = None,
 65        stability: float | None = None,
 66        difficulty: float | None = None,
 67        due: datetime | None = None,
 68        last_review: datetime | None = None,
 69    ) -> None:
 70        if card_id is None:
 71            # epoch milliseconds of when the card was created
 72            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
 73            # wait 1ms to prevent potential card_id collision on next Card creation
 74            time.sleep(0.001)
 75        self.card_id = card_id
 76
 77        self.state = state
 78
 79        if self.state == State.Learning and step is None:
 80            step = 0
 81        self.step = step
 82
 83        self.stability = stability
 84        self.difficulty = difficulty
 85
 86        if due is None:
 87            due = datetime.now(timezone.utc)
 88        self.due = due
 89
 90        self.last_review = last_review
 91
 92    def to_dict(self) -> CardDict:
 93        """
 94        Returns a dictionary representation of the Card object.
 95
 96        Returns:
 97            CardDict: 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    @classmethod
111    def from_dict(cls, source_dict: CardDict) -> Self:
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            Self: A Card object created from the provided dictionary.
120        """
121
122        return cls(
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        )
139
140    def to_json(self, indent: int | str | None = None) -> str:
141        """
142        Returns a JSON-serialized string of the Card object.
143
144        Args:
145            indent: Equivalent argument to the indent in json.dumps()
146
147        Returns:
148            str: A JSON-serialized string of the Card object.
149        """
150        return json.dumps(self.to_dict(), indent=indent)
151
152    @classmethod
153    def from_json(cls, source_json: str) -> Self:
154        """
155        Creates a Card object from a JSON-serialized string.
156
157        Args:
158            source_json: A JSON-serialized string of an existing Card object.
159
160        Returns:
161            Self: A Card object created from the JSON string.
162        """
163
164        source_dict: CardDict = json.loads(source_json)
165        return cls.from_dict(source_dict=source_dict)

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)
60    def __init__(
61        self,
62        card_id: int | None = None,
63        state: State = State.Learning,
64        step: int | None = None,
65        stability: float | None = None,
66        difficulty: float | None = None,
67        due: datetime | None = None,
68        last_review: datetime | None = None,
69    ) -> None:
70        if card_id is None:
71            # epoch milliseconds of when the card was created
72            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
73            # wait 1ms to prevent potential card_id collision on next Card creation
74            time.sleep(0.001)
75        self.card_id = card_id
76
77        self.state = state
78
79        if self.state == State.Learning and step is None:
80            step = 0
81        self.step = step
82
83        self.stability = stability
84        self.difficulty = difficulty
85
86        if due is None:
87            due = datetime.now(timezone.utc)
88        self.due = due
89
90        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:
 92    def to_dict(self) -> CardDict:
 93        """
 94        Returns a dictionary representation of the Card object.
 95
 96        Returns:
 97            CardDict: 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 dictionary representation of the Card object.

Returns: CardDict: A dictionary representation of the Card object.

@classmethod
def from_dict(cls, source_dict: fsrs.card.CardDict) -> Self:
110    @classmethod
111    def from_dict(cls, source_dict: CardDict) -> Self:
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            Self: A Card object created from the provided dictionary.
120        """
121
122        return cls(
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: Self: A Card object created from the provided dictionary.

def to_json(self, indent: int | str | None = None) -> str:
140    def to_json(self, indent: int | str | None = None) -> str:
141        """
142        Returns a JSON-serialized string of the Card object.
143
144        Args:
145            indent: Equivalent argument to the indent in json.dumps()
146
147        Returns:
148            str: A JSON-serialized string of the Card object.
149        """
150        return json.dumps(self.to_dict(), indent=indent)

Returns a JSON-serialized string of the Card object.

Args: indent: Equivalent argument to the indent in json.dumps()

Returns: str: A JSON-serialized string of the Card object.

@classmethod
def from_json(cls, source_json: str) -> Self:
152    @classmethod
153    def from_json(cls, source_json: str) -> Self:
154        """
155        Creates a Card object from a JSON-serialized string.
156
157        Args:
158            source_json: A JSON-serialized string of an existing Card object.
159
160        Returns:
161            Self: A Card object created from the JSON string.
162        """
163
164        source_dict: CardDict = json.loads(source_json)
165        return cls.from_dict(source_dict=source_dict)

Creates a Card object from a JSON-serialized string.

Args: source_json: A JSON-serialized string of an existing Card object.

Returns: Self: A Card object created from the JSON string.

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:
 33@dataclass
 34class ReviewLog:
 35    """
 36    Represents the log entry of a Card object that has been reviewed.
 37
 38    Attributes:
 39        card_id: The id of the card being reviewed.
 40        rating: The rating given to the card during the review.
 41        review_datetime: The date and time of the review.
 42        review_duration: The number of milliseconds it took to review the card or None if unspecified.
 43    """
 44
 45    card_id: int
 46    rating: Rating
 47    review_datetime: datetime
 48    review_duration: int | None
 49
 50    def to_dict(
 51        self,
 52    ) -> ReviewLogDict:
 53        """
 54        Returns a dictionary representation of the ReviewLog object.
 55
 56        Returns:
 57            ReviewLogDict: 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    @classmethod
 68    def from_dict(
 69        cls,
 70        source_dict: ReviewLogDict,
 71    ) -> Self:
 72        """
 73        Creates a ReviewLog object from an existing dictionary.
 74
 75        Args:
 76            source_dict: A dictionary representing an existing ReviewLog object.
 77
 78        Returns:
 79            Self: A ReviewLog object created from the provided dictionary.
 80        """
 81
 82        return cls(
 83            card_id=source_dict["card_id"],
 84            rating=Rating(int(source_dict["rating"])),
 85            review_datetime=datetime.fromisoformat(source_dict["review_datetime"]),
 86            review_duration=source_dict["review_duration"],
 87        )
 88
 89    def to_json(self, indent: int | str | None = None) -> str:
 90        """
 91        Returns a JSON-serialized string of the ReviewLog object.
 92
 93        Args:
 94            indent: Equivalent argument to the indent in json.dumps()
 95
 96        Returns:
 97            str: A JSON-serialized string of the ReviewLog object.
 98        """
 99
100        return json.dumps(self.to_dict(), indent=indent)
101
102    @classmethod
103    def from_json(cls, source_json: str) -> Self:
104        """
105        Creates a ReviewLog object from a JSON-serialized string.
106
107        Args:
108            source_json: A JSON-serialized string of an existing ReviewLog object.
109
110        Returns:
111            Self: A ReviewLog object created from the JSON string.
112        """
113
114        source_dict: ReviewLogDict = json.loads(source_json)
115        return cls.from_dict(source_dict=source_dict)

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:
50    def to_dict(
51        self,
52    ) -> ReviewLogDict:
53        """
54        Returns a dictionary representation of the ReviewLog object.
55
56        Returns:
57            ReviewLogDict: 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 dictionary representation of the ReviewLog object.

Returns: ReviewLogDict: A dictionary representation of the ReviewLog object.

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

Creates a ReviewLog object from an existing dictionary.

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

Returns: Self: A ReviewLog object created from the provided dictionary.

def to_json(self, indent: int | str | None = None) -> str:
 89    def to_json(self, indent: int | str | None = None) -> str:
 90        """
 91        Returns a JSON-serialized string of the ReviewLog object.
 92
 93        Args:
 94            indent: Equivalent argument to the indent in json.dumps()
 95
 96        Returns:
 97            str: A JSON-serialized string of the ReviewLog object.
 98        """
 99
100        return json.dumps(self.to_dict(), indent=indent)

Returns a JSON-serialized string of the ReviewLog object.

Args: indent: Equivalent argument to the indent in json.dumps()

Returns: str: A JSON-serialized string of the ReviewLog object.

@classmethod
def from_json(cls, source_json: str) -> Self:
102    @classmethod
103    def from_json(cls, source_json: str) -> Self:
104        """
105        Creates a ReviewLog object from a JSON-serialized string.
106
107        Args:
108            source_json: A JSON-serialized string of an existing ReviewLog object.
109
110        Returns:
111            Self: A ReviewLog object created from the JSON string.
112        """
113
114        source_dict: ReviewLogDict = json.loads(source_json)
115        return cls.from_dict(source_dict=source_dict)

Creates a ReviewLog object from a JSON-serialized string.

Args: source_json: A JSON-serialized string of an existing ReviewLog object.

Returns: Self: A ReviewLog object created from the JSON string.

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            )