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 typing import TYPE_CHECKING
 9
10from fsrs.card import Card
11from fsrs.rating import Rating
12from fsrs.review_log import ReviewLog
13from fsrs.scheduler import Scheduler
14from fsrs.state import State
15
16if TYPE_CHECKING:
17    from fsrs.optimizer import Optimizer
18
19
20# lazy load the Optimizer module due to heavy dependencies
21def __getattr__(name: str) -> type:
22    if name == "Optimizer":
23        global Optimizer
24        from fsrs.optimizer import Optimizer
25
26        return Optimizer
27    raise AttributeError
28
29
30__all__ = ["Card", "Optimizer", "Rating", "ReviewLog", "Scheduler", "State"]
@dataclass(init=False)
class Card:
 40@dataclass(init=False)
 41class Card:
 42    """
 43    Represents a flashcard in the FSRS system.
 44
 45    Attributes:
 46        card_id: The id of the card. Defaults to the epoch milliseconds of when the card was created.
 47        state: The card's current learning state.
 48        step: The card's current learning or relearning step or None if the card is in the Review state.
 49        stability: Core mathematical parameter used for future scheduling.
 50        difficulty: Core mathematical parameter used for future scheduling.
 51        due: The date and time when the card is due next.
 52        last_review: The date and time of the card's last review.
 53    """
 54
 55    card_id: int
 56    state: State
 57    step: int | None
 58    stability: float | None
 59    difficulty: float | None
 60    due: datetime
 61    last_review: datetime | None
 62
 63    def __init__(
 64        self,
 65        card_id: int | None = None,
 66        state: State = State.Learning,
 67        step: int | None = None,
 68        stability: float | None = None,
 69        difficulty: float | None = None,
 70        due: datetime | None = None,
 71        last_review: datetime | None = None,
 72    ) -> None:
 73        if card_id is None:
 74            # epoch milliseconds of when the card was created
 75            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
 76            # wait 1ms to prevent potential card_id collision on next Card creation
 77            time.sleep(0.001)
 78        self.card_id = card_id
 79
 80        self.state = state
 81
 82        if self.state == State.Learning and step is None:
 83            step = 0
 84        self.step = step
 85
 86        self.stability = stability
 87        self.difficulty = difficulty
 88
 89        if due is None:
 90            due = datetime.now(timezone.utc)
 91        self.due = due
 92
 93        self.last_review = last_review
 94
 95    def to_dict(self) -> CardDict:
 96        """
 97        Returns a dictionary representation of the Card object.
 98
 99        Returns:
100            CardDict: A dictionary representation of the Card object.
101        """
102
103        return {
104            "card_id": self.card_id,
105            "state": self.state.value,
106            "step": self.step,
107            "stability": self.stability,
108            "difficulty": self.difficulty,
109            "due": self.due.isoformat(),
110            "last_review": self.last_review.isoformat() if self.last_review else None,
111        }
112
113    @classmethod
114    def from_dict(cls, source_dict: CardDict) -> Self:
115        """
116        Creates a Card object from an existing dictionary.
117
118        Args:
119            source_dict: A dictionary representing an existing Card object.
120
121        Returns:
122            Self: A Card object created from the provided dictionary.
123        """
124
125        return cls(
126            card_id=int(source_dict["card_id"]),
127            state=State(int(source_dict["state"])),
128            step=source_dict["step"],
129            stability=(
130                float(source_dict["stability"]) if source_dict["stability"] else None
131            ),
132            difficulty=(
133                float(source_dict["difficulty"]) if source_dict["difficulty"] else None
134            ),
135            due=datetime.fromisoformat(source_dict["due"]),
136            last_review=(
137                datetime.fromisoformat(source_dict["last_review"])
138                if source_dict["last_review"]
139                else None
140            ),
141        )
142
143    def to_json(self, indent: int | str | None = None) -> str:
144        """
145        Returns a JSON-serialized string of the Card object.
146
147        Args:
148            indent: Equivalent argument to the indent in json.dumps()
149
150        Returns:
151            str: A JSON-serialized string of the Card object.
152        """
153        return json.dumps(self.to_dict(), indent=indent)
154
155    @classmethod
156    def from_json(cls, source_json: str) -> Self:
157        """
158        Creates a Card object from a JSON-serialized string.
159
160        Args:
161            source_json: A JSON-serialized string of an existing Card object.
162
163        Returns:
164            Self: A Card object created from the JSON string.
165        """
166
167        source_dict: CardDict = json.loads(source_json)
168        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)
63    def __init__(
64        self,
65        card_id: int | None = None,
66        state: State = State.Learning,
67        step: int | None = None,
68        stability: float | None = None,
69        difficulty: float | None = None,
70        due: datetime | None = None,
71        last_review: datetime | None = None,
72    ) -> None:
73        if card_id is None:
74            # epoch milliseconds of when the card was created
75            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
76            # wait 1ms to prevent potential card_id collision on next Card creation
77            time.sleep(0.001)
78        self.card_id = card_id
79
80        self.state = state
81
82        if self.state == State.Learning and step is None:
83            step = 0
84        self.step = step
85
86        self.stability = stability
87        self.difficulty = difficulty
88
89        if due is None:
90            due = datetime.now(timezone.utc)
91        self.due = due
92
93        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:
 95    def to_dict(self) -> CardDict:
 96        """
 97        Returns a dictionary representation of the Card object.
 98
 99        Returns:
100            CardDict: A dictionary representation of the Card object.
101        """
102
103        return {
104            "card_id": self.card_id,
105            "state": self.state.value,
106            "step": self.step,
107            "stability": self.stability,
108            "difficulty": self.difficulty,
109            "due": self.due.isoformat(),
110            "last_review": self.last_review.isoformat() if self.last_review else None,
111        }

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

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:
143    def to_json(self, indent: int | str | None = None) -> str:
144        """
145        Returns a JSON-serialized string of the Card object.
146
147        Args:
148            indent: Equivalent argument to the indent in json.dumps()
149
150        Returns:
151            str: A JSON-serialized string of the Card object.
152        """
153        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:
155    @classmethod
156    def from_json(cls, source_json: str) -> Self:
157        """
158        Creates a Card object from a JSON-serialized string.
159
160        Args:
161            source_json: A JSON-serialized string of an existing Card object.
162
163        Returns:
164            Self: A Card object created from the JSON string.
165        """
166
167        source_dict: CardDict = json.loads(source_json)
168        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 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            )
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:
 36@dataclass
 37class ReviewLog:
 38    """
 39    Represents the log entry of a Card object that has been reviewed.
 40
 41    Attributes:
 42        card_id: The id of the card being reviewed.
 43        rating: The rating given to the card during the review.
 44        review_datetime: The date and time of the review.
 45        review_duration: The number of milliseconds it took to review the card or None if unspecified.
 46    """
 47
 48    card_id: int
 49    rating: Rating
 50    review_datetime: datetime
 51    review_duration: int | None
 52
 53    def to_dict(
 54        self,
 55    ) -> ReviewLogDict:
 56        """
 57        Returns a dictionary representation of the ReviewLog object.
 58
 59        Returns:
 60            ReviewLogDict: A dictionary representation of the ReviewLog object.
 61        """
 62
 63        return {
 64            "card_id": self.card_id,
 65            "rating": int(self.rating),
 66            "review_datetime": self.review_datetime.isoformat(),
 67            "review_duration": self.review_duration,
 68        }
 69
 70    @classmethod
 71    def from_dict(
 72        cls,
 73        source_dict: ReviewLogDict,
 74    ) -> Self:
 75        """
 76        Creates a ReviewLog object from an existing dictionary.
 77
 78        Args:
 79            source_dict: A dictionary representing an existing ReviewLog object.
 80
 81        Returns:
 82            Self: A ReviewLog object created from the provided dictionary.
 83        """
 84
 85        return cls(
 86            card_id=source_dict["card_id"],
 87            rating=Rating(int(source_dict["rating"])),
 88            review_datetime=datetime.fromisoformat(source_dict["review_datetime"]),
 89            review_duration=source_dict["review_duration"],
 90        )
 91
 92    def to_json(self, indent: int | str | None = None) -> str:
 93        """
 94        Returns a JSON-serialized string of the ReviewLog object.
 95
 96        Args:
 97            indent: Equivalent argument to the indent in json.dumps()
 98
 99        Returns:
100            str: A JSON-serialized string of the ReviewLog object.
101        """
102
103        return json.dumps(self.to_dict(), indent=indent)
104
105    @classmethod
106    def from_json(cls, source_json: str) -> Self:
107        """
108        Creates a ReviewLog object from a JSON-serialized string.
109
110        Args:
111            source_json: A JSON-serialized string of an existing ReviewLog object.
112
113        Returns:
114            Self: A ReviewLog object created from the JSON string.
115        """
116
117        source_dict: ReviewLogDict = json.loads(source_json)
118        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:
53    def to_dict(
54        self,
55    ) -> ReviewLogDict:
56        """
57        Returns a dictionary representation of the ReviewLog object.
58
59        Returns:
60            ReviewLogDict: A dictionary representation of the ReviewLog object.
61        """
62
63        return {
64            "card_id": self.card_id,
65            "rating": int(self.rating),
66            "review_datetime": self.review_datetime.isoformat(),
67            "review_duration": self.review_duration,
68        }

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

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:
 92    def to_json(self, indent: int | str | None = None) -> str:
 93        """
 94        Returns a JSON-serialized string of the ReviewLog object.
 95
 96        Args:
 97            indent: Equivalent argument to the indent in json.dumps()
 98
 99        Returns:
100            str: A JSON-serialized string of the ReviewLog object.
101        """
102
103        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:
105    @classmethod
106    def from_json(cls, source_json: str) -> Self:
107        """
108        Creates a ReviewLog object from a JSON-serialized string.
109
110        Args:
111            source_json: A JSON-serialized string of an existing ReviewLog object.
112
113        Returns:
114            Self: A ReviewLog object created from the JSON string.
115        """
116
117        source_dict: ReviewLogDict = json.loads(source_json)
118        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.

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

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

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:
611    def to_json(self, indent: int | str | None = None) -> str:
612        """
613        Returns a JSON-serialized string of the Scheduler object.
614
615        Args:
616            indent: Equivalent argument to the indent in json.dumps()
617
618        Returns:
619            str: A JSON-serialized string of the Scheduler object.
620        """
621
622        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:
624    @classmethod
625    def from_json(cls, source_json: str) -> Self:
626        """
627        Creates a Scheduler object from a JSON-serialized string.
628
629        Args:
630            source_json: A JSON-serialized string of an existing Scheduler object.
631
632        Returns:
633            Self: A Scheduler object created from the JSON string.
634        """
635
636        source_dict: SchedulerDict = json.loads(source_json)
637        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.

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>