Repository: open-spaced-repetition/py-fsrs

Issue:
Cards are moved to Review too quickly
Hi,

Thanks so much for the great algorithm and implementation—it's really cool!

I’d like to use it in my app, but I’ve noticed an issue. It moves new cards to "Review" and increases the Due time too quickly. When I compared it to my Anki app with the same settings, the results were quite different:

![image](https://github.com/user-attachments/assets/ec18b55f-bbf0-4b27-9998-7417a7c2a1ce)

**Anki app:**
1. Added a new card
2. Again (<1m), Hard (<6m), Good (<10m), Easy ( 7d) -> Again
3. Again (<1m), Hard (<6m), Good (<10m), Easy ( 2d) -> Hard
4. Again (<1m), Hard (<6m), Good (<10m), Easy ( 2d) -> Good
5. Again (<1m), Hard (<10m), Good (<1d), Easy ( 2d) -> Hard
6. Again (<1m), Hard (<10m), Good (<1d), Easy ( 2d) -> Easy
7. Moved to Review

**py-fsrs script**
```python
test_w = (
    0.5614,
    1.2546,
    3.5878,
    7.9731,
    5.1043,
    1.1303,
    0.8230,
    0.0465,
    1.6290,
    0.1350,
    1.0045,
    2.1320,
    0.0839,
    0.3204,
    1.3547,
    0.2190,
    2.7849,
    0.4891,  # not used in the Anki app
    0.6468,  # not used in the Anki app
)

f = FSRS(w=test_w)
card = Card()
now = datetime.now(timezone.utc)

ratings = (
    Rating.Again,
    Rating.Hard,
    Rating.Good,
    Rating.Hard,
    Rating.Easy,
)

for rating in ratings:
    card, _ = f.review_card(card, rating, now)
    print(f"Rating: `{Rating(rating).name}` -> State: `{State(card.state).name}`,\t+{card.due - now}  Due: {card.due}, Difficulty: {card.difficulty}, Reps: {card.reps}, Lapses: {card.lapses}")
```

```shell
Rating: `Again` -> State: `Learning`,	+0:01:00  Due: 2024-09-20 19:09:43.358569+00:00, Difficulty: 5.1043, Reps: 1, Lapses: 0
Rating: `Hard` -> State: `Learning`,	+0:10:00  Due: 2024-09-20 19:18:43.358569+00:00, Difficulty: 5.698180550000001, Reps: 2, Lapses: 0
Rating: `Good` -> State: `Review`,	+1 day, 0:00:00  Due: 2024-09-21 19:08:43.358569+00:00, Difficulty: 5.479715154425001, Reps: 3, Lapses: 0
Rating: `Hard` -> State: `Review`,	+1 day, 0:00:00  Due: 2024-09-21 19:08:43.358569+00:00, Difficulty: 6.056138899744239, Reps: 4, Lapses: 0
Rating: `Easy` -> State: `Review`,	+3 days, 0:00:00  Due: 2024-09-23 19:08:43.358569+00:00, Difficulty: 5.036297940906132, Reps: 5, Lapses: 0
```

Discussion:
@joshdavham: Hi Nikita

The behavior you're seeing in Anki is likely due to the [Learning steps](https://docs.ankiweb.net/deck-options.html#learning-steps) that you have set in your deck options. Currently this implementation does not support learning/relearning steps the way Anki does.

We will be adding more scheduler options in the future however. Would the ability to configure learning steps be something you'd like to see in a future version?@geomags3: Hi Josh

Thank you for the quick response.

Yes, I'd love to be able to configure learning steps in the future. But for the time being, can I tweak these steps in the library?

I see there are only two places with hardcoded "minutes" in the code:

- **repeat** method:
```python
            s.again.due = now + timedelta(minutes=1)
            s.hard.due = now + timedelta(minutes=5)
            s.good.due = now + timedelta(minutes=10)
```

and **schedule** method:
```python
        self.again.due = now + timedelta(minutes=5)
        if hard_interval > 0:
            self.hard.due = now + timedelta(days=hard_interval)
        else:
            self.hard.due = now + timedelta(minutes=10)
```

Can I set `self.again.due` to `now + timedelta(minutes=1)`, and 10 mins for `self.hard.due`, or is it not that simple?@joshdavham: Could you be a bit more specific? Which lines exactly are you hoping to change?@joshdavham: #64 You can now specify custom learning and relearning steps if you'd like to keep the scheduler from spacing out reviews too much initially.

Solution patch:
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
index c927ba0..bfefb32 100644
--- a/.github/workflows/mypy.yml
+++ b/.github/workflows/mypy.yml
@@ -13,7 +13,7 @@ jobs:
       - name: Setup Python
         uses: actions/setup-python@v4
         with:
-          python-version: 3.9
+          python-version: "3.10"
           architecture: x64
       - name: Checkout
         uses: actions/checkout@v3
diff --git a/ALGORITHM.md b/ALGORITHM.md
deleted file mode 100644
index 11eb455..0000000
--- a/ALGORITHM.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# The FSRS Scheduler Algorithm
-
-## Overview
-
-The FSRS scheduler algorithm is a Markov chain with several state variables and four different transition events. It is based on a variant of the **DSR** (Difficulty, Stability, Retrievability) model which is used to predict human memory states.
-
-![The FSRS Scheduler Algorithm State Diagram](fsrs-diagram.png)
-
-## The Algorithm
-
-### <ins>Variables</ins>
-
-$DECAY=-0.5,$
-
-$FACTOR = 0.9^{\frac{1}{DECAY}}-1 = \frac{19}{81}$,
-
-$G$: Grade (card rating)
-- $1$: again
-- $2$: hard
-- $3$: good
-- $4$: easy
-
-State:
-- New
-- Learning
-- Review
-- Relearning
-
-Scheduler weights: $(w_0, w_1,... w_{18})$
-
-`HARD PENALTY` = $w_{15}$
-
-`EASY BONUS` = $w_{16}$
-
-$S$: Stability
-
-$D$: Difficulty, $D \in [0,10]$
-
-$R$: Retrievability (probability of recall)
-
-$r$: Request retention
-
-$t$: Days since last review ("elapsed days")
-
-$I$: Interval ("scheduled days"). Number of days before the card is due next
-
-
-### <ins>Functions</ins>
-
-<ins>Initial difficulty</ins>: 
-
-$D_0(G) = w_4-e^{w_5\cdot(G-1)}+1$
-
-<ins>Initial stability</ins>: 
-
-$S_0(G) = w_{G-1}$
-
-<ins>Next interval</ins>: 
-
-$I(r,S) = \frac{S}{FACTOR}\cdot\left(r^{\frac{1}{DECAY}}-1\right)$
-
-<ins>Mean reversion</ins>: 
-
-$MR(a,b) = w_7\cdot a + (1-w_7)\cdot b$
-
-<ins>Short-term stability</ins>: 
-
-$S^\prime_s(S,G) = S\cdot e^{w_{17}\cdot (G-3+w_{18})}$
-
-<ins>Forgetting curve</ins>:  
-
-$R(t,S) = \left(1+FACTOR\cdot \frac{t}{S}\right)^{DECAY}$
-
-Note that this function can be used to calculate a card's current retrievability.
-
-<ins>Next forget stability</ins>: 
-
-$S^\prime_f(D,S,R) = w_{11} \cdot D^{-w_{12}}\cdot \Big[(S+1)^{w_{13}} -1 \Big]\cdot e^{w_{14}\cdot (1-R)}$
-
-<ins>Next recall stability</ins>: 
-
-$$S^\prime_r(D,S,R,G) = S\cdot \left[1+e^{w_{8}}\cdot (11-D)\cdot S^{-w_9}\cdot (e^{w_{10}\cdot (1-R)}-1)\cdot \textrm{HARD PENALTY(if $G$=2)}\cdot \textrm{EASY BONUS(if $G$=4)} \right]$$
-
-<ins>Next stability</ins>:
-
-$$S^\prime(D,S,R,G) = 
-\begin{cases} 
-S^\prime_f\big(D,S,R\big) & \text{if } G = \text{1} \\
-S^\prime_r\big(D,S,R,G\big) & \text{if } G = 2,3,4
-\end{cases}$$
-
-<ins>Next difficulty</ins>: 
-
-$D^\prime(D,G) = MR\Big(D_0(4), D-w_6\cdot (G-3)\Big)$
-
----
-### State transitions
-
-**<ins>New card rated Easy:</ins>**
-
-State: New -> Review
-
-$S=S_0(4)$
-
-$D=D_0(4)$
-
-$I = I(r,S)$
-
-**<ins>New card rated Again / Hard / Good</ins>:**
-
-State: New -> Learning
-
-$S = S_0(G)$
-
-$D = D_0(G)$
-
-$I = 0$
-
-**<ins>Learning / Relearning card rated Again / Hard:</ins>**
-
-State: Learning / Relearning -> Learning / Relearning (*stays the same*)
-
-$S = S^\prime_s(S,G)$
-
-$D = D^\prime(D,G)$
-
-$I=0$
-
-**<ins>Learning / Relearning card rated Good / Easy:</ins>**
-
-State: Learning / Relearning -> Review
-
-$S = S^\prime_s(S,G)$
-
-$D = D^\prime(D,G)$
-
-$I = I(r,S)$
-
-**<ins>Review card rated Again:</ins>**
-
-State: Review -> Relearning
-
-$S = S^\prime\big(D,S,R(t,S),G\big)$
-
-$D = D^\prime(D,1)$
-
-$I=0$
-
-**<ins>Review card rated Hard / Good / Easy:</ins>**
-
-State: Review -> Review (*stays the same*)
-
-$S = S^\prime\big(D,S,R(t,S),G\big)$
-
-$D = D^\prime(D,G)$
-
-$I = I(r,S)$
-
-## Further reading:
-
-If you are interested in learning more about the FSRS algorithm, you can learn more here:
-
-- [FSRS4Anki Wiki](https://github.com/open-spaced-repetition/fsrs4anki/wiki)
-- [A Stochastic Shortest Path Algorithm for Optimizing Spaced Repetition Scheduling](https://dl.acm.org/doi/10.1145/3534678.3539081?cid=99660547150)
-- [Optimizing Spaced Repetition Schedule by Capturing the Dynamics of Memory](https://ieeexplore.ieee.org/document/10059206)
\ No newline at end of file
diff --git a/README.md b/README.md
index 847e81b..2cff904 100644
--- a/README.md
+++ b/README.md
@@ -35,30 +35,32 @@ pip install fsrs
 Import and initialize the FSRS scheduler
 
 ```python
-from fsrs import FSRS, Card, Rating
+from fsrs import Scheduler, Card, Rating, ReviewLog
 
-f = FSRS()
+scheduler = Scheduler()
 ```
 
 Create a new Card object
 ```python
-# all new cards are 'due' immediately upon creation
+# note: all new cards are 'due' immediately upon creation
 card = Card()
 ```
 
-Choose a rating and review the card
+Choose a rating and review the card with the scheduler
 ```python
-# you can choose one of the four possible ratings
 """
-Rating.Again # forget; incorrect response
-Rating.Hard # recall; correct response recalled with serious difficulty
-Rating.Good # recall; correct response after a hesitation
-Rating.Easy # recall; perfect response
+Rating.Again # (==0) forgot the card
+Rating.Hard # (==1) remembered the card, but with serious difficulty
+Rating.Good # (==2) remembered the card after a hesitation
+Rating.Easy # (==3) remembered the card easily
 """
 
 rating = Rating.Good
 
-card, review_log = f.review_card(card, rating)
+card, review_log = scheduler.review_card(card, rating)
+
+print(f"Card rated {review_log.rating} at {review_log.review_datetime}")
+# > Card rated 3 at 2024-11-30 17:46:58.856497+00:00
 ```
 
 See when the card is due next
@@ -70,117 +72,119 @@ due = card.due
 # how much time between when the card is due and now
 time_delta = due - datetime.now(timezone.utc)
 
-print(f"Card due: at {repr(due)}")
+print(f"Card due on {due}")
 print(f"Card due in {time_delta.seconds} seconds")
 
 """
-> Card due: at datetime.datetime(2024, 7, 12, 18, 16, 4, 429428, tzinfo=datetime.timezone.utc)
-> Card due in: 599 seconds
+> Card due on 2024-11-30 18:42:36.070712+00:00
+> Card due in 599 seconds
 """
 ```
 
 ## Usage
 
-### Custom scheduler
+### Custom parameters
 
-You can initialize the FSRS scheduler with your own custom weights as well as desired retention rate and maximum interval.
+You can initialize the FSRS scheduler with your own custom parameters.
 
 ```python
-f = FSRS(
-    w=(
-        0.4197,
-        1.1869,
-        3.0412,
-        15.2441,
-        7.1434,
-        0.6477,
-        1.0007,
-        0.0674,
-        1.6597,
-        0.1712,
-        1.1178,
-        2.0225,
-        0.0904,
-        0.3025,
-        2.1214,
-        0.2498,
-        2.9466,
-        0.4891,
-        0.6468,
-    ),
-    request_retention=0.85,
-    maximum_interval=3650,
+from datetime import timedelta
+
+# note: the following arguments are also the defaults
+scheduler = Scheduler(
+    parameters = (
+            0.4072,
+            1.1829,
+            3.1262,
+            15.4722,
+            7.2102,
+            0.5316,
+            1.0651,
+            0.0234,
+            1.616,
+            0.1544,
+            1.0824,
+            1.9813,
+            0.0953,
+            0.2975,
+            2.2042,
+            0.2407,
+            2.9466,
+            0.5034,
+            0.6567,
+        ),
+    desired_retention = 0.9,
+    learning_steps = (timedelta(minutes=1), timedelta(minutes=10)),
+    relearning_steps = (timedelta(minutes=10),),
+    maximum_interval = 36500,
+    enable_fuzzing = True
 )
 ```
 
-### Advanced reviewing of cards
+#### Explanation of parameters
 
-Aside from using the convenience method `review_card`, there is also the `repeat` method:
+`parameters` are a set of 19 model weights that affect how the FSRS scheduler will schedule future reviews. If you're not familiar with optimizing FSRS, it is best not to modify these default values.
 
-```python
-from datetime import datetime, timezone
+`desired_retention` is a value between 0 and 1 that sets the desired minimum retention rate for cards when scheduled with the scheduler. For example, with the default value of `desired_retention=0.9`, a card will be scheduled at a time in the future when the predicted probability of the user correctly recalling that card falls to 90%. A higher `desired_retention` rate will lead to more reviews and a lower rate will lead to fewer reviews.
 
-# custom review time (must be UTC)
-review_time = datetime(2024, 7, 13, 20, 7, 56, 150101, tzinfo=timezone.utc)
+`learning_steps` are custom time intervals that schedule new cards in the Learning state. By default, cards in the Learning state have short intervals of 1 minute then 10 minutes. You can also disable `learning_steps` with `Scheduler(learning_steps=())`
 
-scheduling_cards = f.repeat(card, review_time)
+`relearning_steps` are analogous to `learning_steps` except they apply to cards in the Relearning state. Cards transition to the Relearning state if they were previously in the Review state, then were rated Again - this is also known as a 'lapse'. If you specify `Scheduler(relearning_steps=())`, cards in the Review state, when lapsed, will not move to the Relearning state, but instead stay in the Review state.
 
-# can get updated cards for each possible rating
-card_Again = scheduling_cards[Rating.Again].card
-card_Hard = scheduling_cards[Rating.Hard].card
-card_Good = scheduling_cards[Rating.Good].card
-card_Easy = scheduling_cards[Rating.Easy].card
+`maximum_interval` sets the cap for the maximum days into the future the scheduler is capable of scheduling cards. For example, if you never want the scheduler to schedule a card more than one year into the future, you'd set `Scheduler(maximum_interval=365)`.
 
-# get next review interval for each rating
-scheduled_days_Again = card_Again.scheduled_days
-scheduled_days_Hard = card_Hard.scheduled_days
-scheduled_days_Good = card_Good.scheduled_days
-scheduled_days_Easy = card_Easy.scheduled_days
+`enable_fuzzing`, if set to True, will apply a small amount of random 'fuzz' to calculated intervals. For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days.
 
-# choose a rating and update the card
-rating = Rating.Good
-card = scheduling_cards[rating].card
+### Timezone
+
+**Py-FSRS uses UTC only.** 
+
+You can still specify custom datetimes, but they must use the UTC timezone.
 
-# get the corresponding review log for the review
-review_log = scheduling_cards[rating].review_log
+### Retrievability
+
+You can calculate the current probability of correctly recalling a card (its 'retrievability') with
+
+```python
+retrievability = card.get_retrievability()
+
+print(f"There is a {retrievability} probability that this card is remembered.")
+# > There is a 0.94 probability that this card is remembered.
 ```
 
 ### Serialization
 
-`Card` and `ReviewLog` objects are JSON-serializable via their `to_dict` and `from_dict` methods for easy database storage:
+`Scheduler`, `Card` and `ReviewLog` objects are all JSON-serializable via their `to_dict` and `from_dict` methods for easy database storage:
 
 ```python
 # serialize before storage
+scheduler_dict = scheduler.to_dict()
 card_dict = card.to_dict()
 review_log_dict = review_log.to_dict()
 
 # deserialize from dict
+new_scheduler = Scheduler.from_dict(scheduler_dict)
 new_card = Card.from_dict(card_dict)
 new_review_log = ReviewLog.from_dict(review_log_dict)
 ```
 
 ## Reference
 
-Card objects have one of four possible states
+Card objects have one of three possible states
 ```python
-State.New # Never been studied
-State.Learning # Been studied for the first time recently
-State.Review # Graduate from learning state
-State.Relearning # Forgotten in review state
+State.Learning # new card being studied for the first time
+State.Review # card that has "graduated" from the Learning state
+State.Relearning # card that has lapsed from the Review state
 ```
 
 There are four possible ratings when reviewing a card object:
 ```python
-Rating.Again # forget; incorrect response
-Rating.Hard # recall; correct response recalled with serious difficulty
-Rating.Good # recall; correct response after a hesitation
-Rating.Easy # recall; perfect response
+Rating.Again # (==0) forgot the card
+Rating.Hard # (==1) remembered the card, but with serious difficulty
+Rating.Good # (==2) remembered the card after a hesitation
+Rating.Easy # (==3) remembered the card easily
 ```
 
-## Algorithm
-
-For a brief overview of the FSRS algorithm, please see [ALGORITHM.md](ALGORITHM.md).
-
 ## Contribute
 
 Checkout [CONTRIBUTING](CONTRIBUTING.md) to help improve Py-FSRS!
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index dca343b..032e8a0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 
 [project]
 name = "fsrs"
-version = "3.1.0"
+version = "4.0.0"
 description = "Free Spaced Repetition Scheduler"
 readme = "README.md"
 authors = [{ name = "Jarrett Ye", email = "jarrett.ye@outlook.com" }]
@@ -15,7 +15,7 @@ classifiers = [
 ]
 keywords = ["spaced-repetition", "flashcard"]
 dependencies = []
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 
 [project.urls]
 Homepage = "https://github.com/open-spaced-repetition/py-fsrs"
diff --git a/src/fsrs/__init__.py b/src/fsrs/__init__.py
index 15f266d..7f8b450 100644
--- a/src/fsrs/__init__.py
+++ b/src/fsrs/__init__.py
@@ -5,4 +5,4 @@ py-fsrs
 Py-FSRS is the official Python implementation of the FSRS scheduler algorithm, which can be used to develop spaced repetition systems.
 """
 
-from .fsrs import FSRS, Card, ReviewLog, State, Rating
+from .fsrs import Scheduler, Card, Rating, ReviewLog, State
diff --git a/src/fsrs/fsrs.py b/src/fsrs/fsrs.py
index 0227b16..31b280d 100644
--- a/src/fsrs/fsrs.py
+++ b/src/fsrs/fsrs.py
@@ -2,231 +2,802 @@
 fsrs.fsrs
 ---------
 
-This module defines the FSRS scheduler.
+This module defines each of the classes used in the fsrs package.
 
 Classes:
-    FSRS: The FSRS scheduler.
+    State: Enum representing the learning state of a Card object.
+    Rating: Enum representing the four possible ratings when reviewing a card.
+    Card: Represents a flashcard in the FSRS system.
+    ReviewLog: Represents the log entry of a Card that has been reviewed.
+    Scheduler: The FSRS spaced-repetition scheduler.
 """
 
-from .models import (
-    Card,
-    ReviewLog,
-    Rating,
-    State,
-    SchedulingCards,
-    SchedulingInfo,
-    Parameters,
-)
 import math
 from datetime import datetime, timezone, timedelta
-from typing import Optional
-import copy
+from copy import deepcopy
+from typing import Any
+from enum import IntEnum
+import random
+
+DECAY = -0.5
+FACTOR = 0.9 ** (1 / DECAY) - 1
+
+FUZZ_RANGES = [
+    {
+        "start": 2.5,
+        "end": 7.0,
+        "factor": 0.15,
+    },
+    {
+        "start": 7.0,
+        "end": 20.0,
+        "factor": 0.1,
+    },
+    {
+        "start": 20.0,
+        "end": math.inf,
+        "factor": 0.05,
+    },
+]
+
+
+class State(IntEnum):
+    """
+    Enum representing the learning state of a Card object.
+    """
+
+    Learning = 1
+    Review = 2
+    Relearning = 3
 
 
-class FSRS:
+class Rating(IntEnum):
+    """
+    Enum representing the four possible ratings when reviewing a card.
     """
-    The FSRS scheduler.
 
-    Enables the reviewing and future scheduling of cards according to the FSRS algorithm.
+    Again = 1
+    Hard = 2
+    Good = 3
+    Easy = 4
+
+
+class Card:
+    """
+    Represents a flashcard in the FSRS system.
 
     Attributes:
-        p (Parameters): Object for configuring the scheduler's model weights, desired retention and maximum interval.
-        DECAY (float): Constant used to model the forgetting curve and compute the length of a Card's next interval after being repeated.
-        FACTOR (float): Constant used to model the forgetting curve and compute the length of a Card's next interval after being repeated.
+        card_id (int): The id of the card. Defaults to the epoch miliseconds of when the card was created.
+        state (State): The card's current learning state.
+        step (int | None): The card's current learning or relearning step or None if the card is in the Review state.
+        stability (float | None): Core mathematical parameter used for future scheduling.
+        difficulty (float | None): Core mathematical parameter used for future scheduling.
+        due (datetime): The date and time when the card is due next.
+        last_review (datetime | None): The date and time of the card's last review.
     """
 
-    p: Parameters
-    DECAY: float
-    FACTOR: float
+    card_id: int
+    state: State
+    step: int | None
+    stability: float | None
+    difficulty: float | None
+    due: datetime
+    last_review: datetime | None
 
     def __init__(
         self,
-        w: Optional[tuple[float, ...]] = None,
-        request_retention: Optional[float] = None,
-        maximum_interval: Optional[int] = None,
+        card_id: int | None = None,
+        state: State = State.Learning,
+        step: int | None = None,
+        stability: float | None = None,
+        difficulty: float | None = None,
+        due: datetime | None = None,
+        last_review: datetime | None = None,
     ) -> None:
+        if card_id is None:
+            # epoch miliseconds of when the card was created
+            card_id = int(datetime.now(timezone.utc).timestamp() * 1000)
+        self.card_id = card_id
+
+        self.state = state
+
+        if self.state == State.Learning and step is None:
+            step = 0
+        self.step = step
+
+        self.stability = stability
+        self.difficulty = difficulty
+
+        if due is None:
+            due = datetime.now(timezone.utc)
+        self.due = due
+
+        self.last_review = last_review
+
+    def to_dict(self) -> dict[str, int | float | str | None]:
+        """
+        Returns a JSON-serializable dictionary representation of the Card object.
+
+        This method is specifically useful for storing Card objects in a database.
+
+        Returns:
+            dict: A dictionary representation of the Card object.
+        """
+
+        return_dict = {
+            "card_id": self.card_id,
+            "state": self.state.value,
+            "step": self.step,
+            "stability": self.stability,
+            "difficulty": self.difficulty,
+            "due": self.due.isoformat(),
+            "last_review": self.last_review.isoformat() if self.last_review else None,
+        }
+
+        return return_dict
+
+    @staticmethod
+    def from_dict(source_dict: dict[str, Any]) -> "Card":
+        """
+        Creates a Card object from an existing dictionary.
+
+        Args:
+            source_dict (dict[str, Any]): A dictionary representing an existing Card object.
+
+        Returns:
+            Card: A Card object created from the provided dictionary.
+        """
+
+        card_id = int(source_dict["card_id"])
+        state = State(int(source_dict["state"]))
+        step = source_dict["step"]
+        stability = (
+            float(source_dict["stability"]) if source_dict["stability"] else None
+        )
+        difficulty = (
+            float(source_dict["difficulty"]) if source_dict["difficulty"] else None
+        )
+        due = datetime.fromisoformat(source_dict["due"])
+        last_review = (
+            datetime.fromisoformat(source_dict["last_review"])
+            if source_dict["last_review"]
+            else None
+        )
+
+        return Card(
+            card_id=card_id,
+            state=state,
+            step=step,
+            stability=stability,
+            difficulty=difficulty,
+            due=due,
+            last_review=last_review,
+        )
+
+    def get_retrievability(self, current_datetime: datetime | None = None) -> float:
         """
-        Initializes the FSRS scheduler.
+        Calculates the 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:
-            w (Optional[tuple[float, ...]]): The 19 model weights of the FSRS scheduler.
-            request_retention (Optional[float]): The desired retention of the scheduler. Corresponds to the maximum retrievability a Card object can have before it is due.
-            maximum_interval (Optional[int]): The maximum number of days into the future a Card object can be scheduled for next review.
+            current_datetime (datetime): The current date and time
+
+        Returns:
+            float: The retrievability of the Card object.
         """
-        self.p = Parameters(w, request_retention, maximum_interval)
-        self.DECAY = -0.5
-        self.FACTOR = 0.9 ** (1 / self.DECAY) - 1
+
+        if self.last_review is None:
+            return 0
+
+        if current_datetime is None:
+            current_datetime = datetime.now(timezone.utc)
+
+        if self.state in (State.Learning, State.Review, State.Relearning):
+            elapsed_days = max(0, (current_datetime - self.last_review).days)
+            return (1 + FACTOR * elapsed_days / self.stability) ** DECAY
+        else:
+            return 0
+
+
+class ReviewLog:
+    """
+    Represents the log entry of a Card object that has been reviewed.
+
+    Attributes:
+        card (Card): Copy of the card object that was reviewed.
+        rating (Rating): The rating given to the card during the review.
+        review_datetime (datetime): The date and time of the review.
+        review_duration (int | None): The number of miliseconds it took to review the card or None if unspecified.
+    """
+
+    card: Card
+    rating: Rating
+    review_datetime: datetime
+    review_duration: int | None
+
+    def __init__(
+        self,
+        card: Card,
+        rating: Rating,
+        review_datetime: datetime,
+        review_duration: int | None = None,
+    ) -> None:
+        self.card = deepcopy(card)
+        self.rating = rating
+        self.review_datetime = review_datetime
+        self.review_duration = review_duration
+
+    def to_dict(self) -> dict[str, dict[str, Any] | int | str | None]:
+        """
+        Returns a JSON-serializable dictionary representation of the ReviewLog object.
+
+        This method is specifically useful for storing ReviewLog objects in a database.
+
+        Returns:
+            dict: A dictionary representation of the Card object.
+        """
+
+        return_dict = {
+            "card": self.card.to_dict(),
+            "rating": self.rating.value,
+            "review_datetime": self.review_datetime.isoformat(),
+            "review_duration": self.review_duration,
+        }
+
+        return return_dict
+
+    @staticmethod
+    def from_dict(source_dict: dict[str, Any]) -> "ReviewLog":
+        """
+        Creates a ReviewLog object from an existing dictionary.
+
+        Args:
+            source_dict (dict[str, Any]): A dictionary representing an existing ReviewLog object.
+
+        Returns:
+            ReviewLog: A ReviewLog object created from the provided dictionary.
+        """
+
+        card = Card.from_dict(source_dict["card"])
+        rating = Rating(int(source_dict["rating"]))
+        review_datetime = datetime.fromisoformat(source_dict["review_datetime"])
+        review_duration = source_dict["review_duration"]
+
+        return ReviewLog(
+            card=card,
+            rating=rating,
+            review_datetime=review_datetime,
+            review_duration=review_duration,
+        )
+
+
+class Scheduler:
+    """
+    The FSRS scheduler.
+
+    Enables the reviewing and future scheduling of cards according to the FSRS algorithm.
+
+    Attributes:
+        parameters (tuple[float, ...]): The 19 model weights of the FSRS scheduler.
+        desired_retention (float): The desired retention rate of cards scheduled with the scheduler.
+        learning_steps (list[timedelta]): Small time intervals that schedule cards in the Learning state.
+        relearning_steps (list[timedelta]): Small time intervals that schedule cards in the Relearning state.
+        maximum_interval (int): The maximum number of days a Review-state card can be scheduled into the future.
+        enable_fuzzing (bool): Whether to apply a small amount of random 'fuzz' to calculated intervals.
+    """
+
+    parameters: tuple[float, ...]
+    desired_retention: float
+    learning_steps: tuple[timedelta, ...]
+    relearning_steps: tuple[timedelta, ...]
+    maximum_interval: int
+    enable_fuzzing: bool
+
+    def __init__(
+        self,
+        parameters: tuple[float, ...] | list[float] = (
+            0.4072,
+            1.1829,
+            3.1262,
+            15.4722,
+            7.2102,
+            0.5316,
+            1.0651,
+            0.0234,
+            1.616,
+            0.1544,
+            1.0824,
+            1.9813,
+            0.0953,
+            0.2975,
+            2.2042,
+            0.2407,
+            2.9466,
+            0.5034,
+            0.6567,
+        ),
+        desired_retention: float = 0.9,
+        learning_steps: tuple[timedelta, ...] | list[timedelta] = (
+            timedelta(minutes=1),
+            timedelta(minutes=10),
+        ),
+        relearning_steps: tuple[timedelta, ...] | list[timedelta] = (
+            timedelta(minutes=10),
+        ),
+        maximum_interval: int = 36500,
+        enable_fuzzing: bool = True,
+    ) -> None:
+        self.parameters = tuple(parameters)
+        self.desired_retention = desired_retention
+        self.learning_steps = tuple(learning_steps)
+        self.relearning_steps = tuple(relearning_steps)
+        self.maximum_interval = maximum_interval
+        self.enable_fuzzing = enable_fuzzing
 
     def review_card(
-        self, card: Card, rating: Rating, now: Optional[datetime] = None
+        self,
+        card: Card,
+        rating: Rating,
+        review_datetime: datetime | None = None,
+        review_duration: int | None = None,
     ) -> tuple[Card, ReviewLog]:
         """
-        Reviews a card for a given rating.
+        Reviews a card with a given rating at a given time for a specified duration.
 
         Args:
             card (Card): The card being reviewed.
             rating (Rating): The chosen rating for the card being reviewed.
-            now (Optional[datetime]): The date and time of the review.
+            review_datetime (datetime | None): The date and time of the review.
+            review_duration (int | None): The number of miliseconds it took to review the card or None if unspecified.
 
         Returns:
-            tuple: A tuple containing the updated, reviewed card and its corresponding review log.
+            tuple[Card, ReviewLog]: A tuple containing the updated, reviewed card and its corresponding review log.
 
         Raises:
-            ValueError: If the `now` argument is not timezone-aware and set to UTC.
+            ValueError: If the `review_datetime` argument is not timezone-aware and set to UTC.
         """
-        scheduling_cards = self.repeat(card, now)
 
-        card = scheduling_cards[rating].card
-        review_log = scheduling_cards[rating].review_log
+        if review_datetime is not None and (
+            (review_datetime.tzinfo is None) or (review_datetime.tzinfo != timezone.utc)
+        ):
+            raise ValueError("datetime must be timezone-aware and set to UTC")
 
-        return card, review_log
+        card = deepcopy(card)
 
-    def repeat(
-        self, card: Card, now: Optional[datetime] = None
-    ) -> dict[Rating, SchedulingInfo]:
-        if now is None:
-            now = datetime.now(timezone.utc)
+        if review_datetime is None:
+            review_datetime = datetime.now(timezone.utc)
 
-        if (now.tzinfo is None) or (now.tzinfo != timezone.utc):
-            raise ValueError("datetime must be timezone-aware and set to UTC")
+        days_since_last_review = (
+            (review_datetime - card.last_review).days if card.last_review else None
+        )
+
+        review_log = ReviewLog(
+            card=card,
+            rating=rating,
+            review_datetime=review_datetime,
+            review_duration=review_duration,
+        )
+
+        if card.state == State.Learning:
+            assert type(card.step) == int
+
+            # update the card's stability and difficulty
+            if card.stability is None and card.difficulty is None:
+                card.stability = self._initial_stability(rating)
+                card.difficulty = self._initial_difficulty(rating)
+
+            elif days_since_last_review is not None and days_since_last_review < 1:
+                assert type(card.stability) == float  # mypy
+                assert type(card.difficulty) == float  # mypy
+                card.stability = self._short_term_stability(
+                    stability=card.stability, rating=rating
+                )
+                card.difficulty = self._next_difficulty(
+                    difficulty=card.difficulty, rating=rating
+                )
+
+            else:
+                assert type(card.stability) == float  # mypy
+                assert type(card.difficulty) == float  # mypy
+                card.stability = self._next_stability(
+                    difficulty=card.difficulty,
+                    stability=card.stability,
+                    retrievability=card.get_retrievability(
+                        current_datetime=review_datetime
+                    ),
+                    rating=rating,
+                )
+                card.difficulty = self._next_difficulty(
+                    difficulty=card.difficulty, rating=rating
+                )
+
+            # calculate the card's next interval
+            # len(self.learning_steps) == 0: no learning steps defined so move card to Review state
+            # card.step > len(self.learning_steps): handles the edge-case when a card was originally scheduled with a scheduler with more
+            # learnning steps than the current scheduler
+            if len(self.learning_steps) == 0 or card.step > len(self.learning_steps):
+                card.state = State.Review
+                card.step = None
+
+                next_interval_days = self._next_interval(stability=card.stability)
+                next_interval = timedelta(days=next_interval_days)
+
+            else:
+                if rating == Rating.Again:
+                    card.step = 0
+                    next_interval = self.learning_steps[card.step]
+
+                elif rating == Rating.Hard:
+                    # card step stays the same
+
+                    if card.step == 0 and len(self.learning_steps) == 1:
+                        next_interval = self.learning_steps[0] * 1.5
+                    elif card.step == 0 and len(self.learning_steps) >= 2:
+                        next_interval = (
+                            self.learning_steps[0] + self.learning_steps[1]
+                        ) / 2.0
+                    else:
+                        next_interval = self.learning_steps[card.step]
+
+                elif rating == Rating.Good:
+                    if card.step + 1 == len(self.learning_steps):  # the last step
+                        card.state = State.Review
+                        card.step = None
+
+                        next_interval_days = self._next_interval(
+                            stability=card.stability
+                        )
+                        next_interval = timedelta(days=next_interval_days)
+
+                    else:
+                        card.step += 1
+                        next_interval = self.learning_steps[card.step]
+
+                elif rating == Rating.Easy:
+                    card.state = State.Review
+                    card.step = None
+
+                    next_interval_days = self._next_interval(stability=card.stability)
+                    next_interval = timedelta(days=next_interval_days)
+
+            card.due = review_datetime + next_interval
+            card.last_review = review_datetime
 
-        card = copy.deepcopy(card)
-        if card.state == State.New:
-            card.elapsed_days = 0
-        else:
-            card.elapsed_days = (now - card.last_review).days
-        card.last_review = now
-        card.reps += 1
-        s = SchedulingCards(card)
-        s.update_state(card.state)
-
-        if card.state == State.New:
-            self.init_ds(s)
-
-            s.again.due = now + timedelta(minutes=1)
-            s.hard.due = now + timedelta(minutes=5)
-            s.good.due = now + timedelta(minutes=10)
-            easy_interval = self.next_interval(s.easy.stability)
-            s.easy.scheduled_days = easy_interval
-            s.easy.due = now + timedelta(days=easy_interval)
-        elif card.state == State.Learning or card.state == State.Relearning:
-            interval = card.elapsed_days
-            last_d = card.difficulty
-            last_s = card.stability
-            retrievability = self.forgetting_curve(interval, last_s)
-            self.next_ds(s, last_d, last_s, retrievability, card.state)
-
-            hard_interval = 0
-            good_interval = self.next_interval(s.good.stability)
-            easy_interval = max(self.next_interval(s.easy.stability), good_interval + 1)
-            s.schedule(now, hard_interval, good_interval, easy_interval)
         elif card.state == State.Review:
-            interval = card.elapsed_days
-            last_d = card.difficulty
-            last_s = card.stability
-            retrievability = self.forgetting_curve(interval, last_s)
-            self.next_ds(s, last_d, last_s, retrievability, card.state)
-
-            hard_interval = self.next_interval(s.hard.stability)
-            good_interval = self.next_interval(s.good.stability)
-            hard_interval = min(hard_interval, good_interval)
-            good_interval = max(good_interval, hard_interval + 1)
-            easy_interval = max(self.next_interval(s.easy.stability), good_interval + 1)
-            s.schedule(now, hard_interval, good_interval, easy_interval)
-        return s.record_log(card, now)
-
-    def init_ds(self, s: SchedulingCards) -> None:
-        s.again.difficulty = self.init_difficulty(Rating.Again)
-        s.again.stability = self.init_stability(Rating.Again)
-        s.hard.difficulty = self.init_difficulty(Rating.Hard)
-        s.hard.stability = self.init_stability(Rating.Hard)
-        s.good.difficulty = self.init_difficulty(Rating.Good)
-        s.good.stability = self.init_stability(Rating.Good)
-        s.easy.difficulty = self.init_difficulty(Rating.Easy)
-        s.easy.stability = self.init_stability(Rating.Easy)
-
-    def next_ds(
-        self,
-        s: SchedulingCards,
-        last_d: float,
-        last_s: float,
-        retrievability: float,
-        state: State,
-    ) -> None:
-        s.again.difficulty = self.next_difficulty(last_d, Rating.Again)
-        s.hard.difficulty = self.next_difficulty(last_d, Rating.Hard)
-        s.good.difficulty = self.next_difficulty(last_d, Rating.Good)
-        s.easy.difficulty = self.next_difficulty(last_d, Rating.Easy)
-
-        if state == State.Learning or state == State.Relearning:
-            # compute short term stabilities
-            s.again.stability = self.short_term_stability(last_s, Rating.Again)
-            s.hard.stability = self.short_term_stability(last_s, Rating.Hard)
-            s.good.stability = self.short_term_stability(last_s, Rating.Good)
-            s.easy.stability = self.short_term_stability(last_s, Rating.Easy)
-
-        elif state == State.Review:
-            s.again.stability = self.next_forget_stability(
-                last_d, last_s, retrievability
-            )
-            s.hard.stability = self.next_recall_stability(
-                last_d, last_s, retrievability, Rating.Hard
-            )
-            s.good.stability = self.next_recall_stability(
-                last_d, last_s, retrievability, Rating.Good
-            )
-            s.easy.stability = self.next_recall_stability(
-                last_d, last_s, retrievability, Rating.Easy
-            )
+            assert type(card.stability) == float  # mypy
+            assert type(card.difficulty) == float  # mypy
+
+            # update the card's stability and difficulty
+            if days_since_last_review is not None and days_since_last_review < 1:
+                card.stability = self._short_term_stability(
+                    stability=card.stability, rating=rating
+                )
+                card.difficulty = self._next_difficulty(
+                    difficulty=card.difficulty, rating=rating
+                )
+
+            else:
+                card.stability = self._next_stability(
+                    difficulty=card.difficulty,
+                    stability=card.stability,
+                    retrievability=card.get_retrievability(
+                        current_datetime=review_datetime
+                    ),
+                    rating=rating,
+                )
+                card.difficulty = self._next_difficulty(
+                    difficulty=card.difficulty, rating=rating
+                )
+
+            # calculate the card's next interval
+            if rating == Rating.Again:
+                # if there are no relearning steps (they were left blank)
+                if len(self.relearning_steps) == 0:
+                    next_interval_days = self._next_interval(stability=card.stability)
+                    next_interval = timedelta(days=next_interval_days)
+
+                else:
+                    card.state = State.Relearning
+                    card.step = 0
+
+                    next_interval = self.relearning_steps[card.step]
+
+            elif rating in (Rating.Hard, Rating.Good, Rating.Easy):
+                next_interval_days = self._next_interval(stability=card.stability)
+
+                if self.enable_fuzzing:
+                    next_interval_days = self._get_fuzzed_interval(next_interval_days)
+
+                next_interval = timedelta(days=next_interval_days)
+
+            card.due = review_datetime + next_interval
+            card.last_review = review_datetime
+
+        elif card.state == State.Relearning:
+            assert type(card.step) == int
+            assert type(card.stability) == float  # mypy
+            assert type(card.difficulty) == float  # mypy
+
+            # update the card's stability and difficulty
+            if days_since_last_review is not None and days_since_last_review < 1:
+                card.stability = self._short_term_stability(
+                    stability=card.stability, rating=rating
+                )
+                card.difficulty = self._next_difficulty(
+                    difficulty=card.difficulty, rating=rating
+                )
+
+            else:
+                card.stability = self._next_stability(
+                    difficulty=card.difficulty,
+                    stability=card.stability,
+                    retrievability=card.get_retrievability(
+                        current_datetime=review_datetime
+                    ),
+                    rating=rating,
+                )
+                card.difficulty = self._next_difficulty(
+                    difficulty=card.difficulty, rating=rating
+                )
+
+            # calculate the card's next interval
+            # len(self.learning_steps) == 0: no learning steps defined so move card to Review state
+            # card.step > len(self.learning_steps): handles the edge-case when a card was originally scheduled with a scheduler with more
+            # learnning steps than the current scheduler
+            if len(self.relearning_steps) == 0 or card.step > len(
+                self.relearning_steps
+            ):
+                card.state = State.Review
+                card.step = None
+
+                next_interval_days = self._next_interval(stability=card.stability)
+                next_interval = timedelta(days=next_interval_days)
+
+            else:
+                if rating == Rating.Again:
+                    card.step = 0
+                    next_interval = self.relearning_steps[card.step]
+
+                elif rating == Rating.Hard:
+                    # card step stays the same
+
+                    if card.step == 0 and len(self.relearning_steps) == 1:
+                        next_interval = self.relearning_steps[0] * 1.5
+                    elif card.step == 0 and len(self.relearning_steps) >= 2:
+                        next_interval = (
+                            self.relearning_steps[0] + self.relearning_steps[1]
+                        ) / 2.0
+                    else:
+                        next_interval = self.relearning_steps[card.step]
+
+                elif rating == Rating.Good:
+                    if card.step + 1 == len(self.relearning_steps):  # the last step
+                        card.state = State.Review
+                        card.step = None
+
+                        next_interval_days = self._next_interval(
+                            stability=card.stability
+                        )
+                        next_interval = timedelta(days=next_interval_days)
+
+                    else:
+                        card.step += 1
+                        next_interval = self.relearning_steps[card.step]
+
+                elif rating == Rating.Easy:
+                    card.state = State.Review
+                    card.step = None
+
+                    next_interval_days = self._next_interval(stability=card.stability)
+                    next_interval = timedelta(days=next_interval_days)
+
+            card.due = review_datetime + next_interval
+            card.last_review = review_datetime
+
+        return card, review_log
+
+    def to_dict(self) -> dict[str, Any]:
+        """
+        Returns a JSON-serializable dictionary representation of the Scheduler object.
+
+        This method is specifically useful for storing Scheduler objects in a database.
+
+        Returns:
+            dict: A dictionary representation of the Scheduler object.
+        """
+
+        return_dict = {
+            "parameters": self.parameters,
+            "desired_retention": self.desired_retention,
+            "learning_steps": [
+                int(learning_step.total_seconds())
+                for learning_step in self.learning_steps
+            ],
+            "relearning_steps": [
+                int(relearning_step.total_seconds())
+                for relearning_step in self.relearning_steps
+            ],
+            "maximum_interval": self.maximum_interval,
+            "enable_fuzzing": self.enable_fuzzing,
+        }
+
+        return return_dict
+
+    @staticmethod
+    def from_dict(source_dict: dict[str, Any]) -> "Scheduler":
+        """
+        Creates a Scheduler object from an existing dictionary.
+
+        Args:
+            source_dict (dict[str, Any]): A dictionary representing an existing Scheduler object.
+
+        Returns:
+            Scheduler: A Scheduler object created from the provided dictionary.
+        """
+
+        parameters = source_dict["parameters"]
+        desired_retention = source_dict["desired_retention"]
+        learning_steps = [
+            timedelta(seconds=learning_step)
+            for learning_step in source_dict["learning_steps"]
+        ]
+        relearning_steps = [
+            timedelta(seconds=relearning_step)
+            for relearning_step in source_dict["relearning_steps"]
+        ]
+        maximum_interval = source_dict["maximum_interval"]
+        enable_fuzzing = source_dict["enable_fuzzing"]
+
+        return Scheduler(
+            parameters=parameters,
+            desired_retention=desired_retention,
+            learning_steps=learning_steps,
+            relearning_steps=relearning_steps,
+            maximum_interval=maximum_interval,
+            enable_fuzzing=enable_fuzzing,
+        )
 
-    def init_stability(self, r: Rating) -> float:
-        return max(self.p.w[r - 1], 0.1)
+    def _initial_stability(self, rating: Rating) -> float:
+        initial_stability = self.parameters[rating - 1]
 
-    def init_difficulty(self, r: Rating) -> float:
-        # compute initial difficulty and clamp it between 1 and 10
-        return min(max(self.p.w[4] - math.exp(self.p.w[5] * (r - 1)) + 1, 1), 10)
+        # initial_stability >= 0.1
+        initial_stability = max(initial_stability, 0.1)
 
-    def forgetting_curve(self, elapsed_days: int, stability: float) -> float:
-        return (1 + self.FACTOR * elapsed_days / stability) ** self.DECAY
+        return initial_stability
 
-    def next_interval(self, s: float) -> int:
-        new_interval = (
-            s / self.FACTOR * (self.p.request_retention ** (1 / self.DECAY) - 1)
+    def _initial_difficulty(self, rating: Rating) -> float:
+        initial_difficulty = (
+            self.parameters[4] - math.exp(self.parameters[5] * (rating - 1)) + 1
         )
-        return min(max(round(new_interval), 1), self.p.maximum_interval)
 
-    def next_difficulty(self, d: float, r: Rating) -> float:
-        next_d = d - self.p.w[6] * (r - 3)
+        # bound initial_difficulty between 1 and 10
+        initial_difficulty = min(max(initial_difficulty, 1), 10)
 
-        return min(
-            max(self.mean_reversion(self.init_difficulty(Rating.Easy), next_d), 1), 10
+        return initial_difficulty
+
+    def _next_interval(self, stability: float) -> int:
+        next_interval = (stability / FACTOR) * (
+            (self.desired_retention ** (1 / DECAY)) - 1
+        )
+
+        next_interval = round(next_interval)  # intervals are full days
+
+        # must be at least 1 day long
+        next_interval = max(next_interval, 1)
+
+        # can not be longer than the maximum interval
+        next_interval = min(next_interval, self.maximum_interval)
+
+        return next_interval
+
+    def _short_term_stability(self, stability: float, rating: Rating) -> float:
+        return stability * math.exp(
+            self.parameters[17] * (rating - 3 + self.parameters[18])
         )
 
-    def short_term_stability(self, stability: float, rating: Rating) -> float:
-        return stability * math.exp(self.p.w[17] * (rating - 3 + self.p.w[18]))
+    def _next_difficulty(self, difficulty: float, rating: Rating) -> float:
+        def mean_reversion(arg_1: float, arg_2: float) -> float:
+            return self.parameters[7] * arg_1 + (1 - self.parameters[7]) * arg_2
+
+        arg_1 = self._initial_difficulty(Rating.Easy)
+        arg_2 = difficulty - (self.parameters[6] * (rating - 3))
+
+        next_difficulty = mean_reversion(arg_1, arg_2)
+
+        # bound next_difficulty between 1 and 10
+        next_difficulty = min(max(next_difficulty, 1), 10)
 
-    def mean_reversion(self, init: float, current: float) -> float:
-        return self.p.w[7] * init + (1 - self.p.w[7]) * current
+        return next_difficulty
+
+    def _next_stability(
+        self, difficulty: float, stability: float, retrievability: float, rating: Rating
+    ) -> float:
+        if rating == Rating.Again:
+            next_stability = self._next_forget_stability(
+                difficulty=difficulty,
+                stability=stability,
+                retrievability=retrievability,
+            )
+
+        elif rating in (Rating.Hard, Rating.Good, Rating.Easy):
+            next_stability = self._next_recall_stability(
+                difficulty=difficulty,
+                stability=stability,
+                retrievability=retrievability,
+                rating=rating,
+            )
+
+        return next_stability
+
+    def _next_forget_stability(
+        self, difficulty: float, stability: float, retrievability: float
+    ) -> float:
+        return (
+            self.parameters[11]
+            * math.pow(difficulty, -self.parameters[12])
+            * (math.pow(stability + 1, self.parameters[13]) - 1)
+            * math.exp((1 - retrievability) * self.parameters[14])
+        )
 
-    def next_recall_stability(
-        self, d: float, s: float, r: float, rating: Rating
+    def _next_recall_stability(
+        self, difficulty: float, stability: float, retrievability: float, rating: Rating
     ) -> float:
-        hard_penalty = self.p.w[15] if rating == Rating.Hard else 1
-        easy_bonus = self.p.w[16] if rating == Rating.Easy else 1
-        return s * (
+        hard_penalty = self.parameters[15] if rating == Rating.Hard else 1
+        easy_bonus = self.parameters[16] if rating == Rating.Easy else 1
+
+        return stability * (
             1
-            + math.exp(self.p.w[8])
-            * (11 - d)
-            * math.pow(s, -self.p.w[9])
-            * (math.exp((1 - r) * self.p.w[10]) - 1)
+            + math.exp(self.parameters[8])
+            * (11 - difficulty)
+            * math.pow(stability, -self.parameters[9])
+            * (math.exp((1 - retrievability) * self.parameters[10]) - 1)
             * hard_penalty
             * easy_bonus
         )
 
-    def next_forget_stability(self, d: float, s: float, r: float) -> float:
-        return (
-            self.p.w[11]
-            * math.pow(d, -self.p.w[12])
-            * (math.pow(s + 1, self.p.w[13]) - 1)
-            * math.exp((1 - r) * self.p.w[14])
-        )
+    def _get_fuzzed_interval(self, interval: int) -> int:
+        """
+        Takes the current calculated interval and adds a small amount of random fuzz to it.
+        For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days.
+
+        Args:
+            interval (int): The calculated next interval, before fuzzing.
+
+        Returns:
+            int: The new interval, after fuzzing.
+        """
+
+        if interval < 2.5:  # fuzz is not applied to intervals less than 2.5
+            return interval
+
+        def _get_fuzz_range(interval: int) -> tuple[int, int]:
+            """
+            Helper function that computes the possible upper and lower bounds of the interval after fuzzing.
+            """
+
+            delta = 1.0
+            for fuzz_range in FUZZ_RANGES:
+                delta += fuzz_range["factor"] * max(
+                    min(interval, fuzz_range["end"]) - fuzz_range["start"], 0.0
+                )
+
+            min_ivl = int(round(interval - delta))
+            max_ivl = int(round(interval + delta))
+
+            # make sure the min_ivl and max_ivl fall into a valid range
+            min_ivl = max(2, min_ivl)
+            max_ivl = min(max_ivl, self.maximum_interval)
+            min_ivl = min(min_ivl, max_ivl)
+
+            return min_ivl, max_ivl
+
+        min_ivl, max_ivl = _get_fuzz_range(interval)
+
+        fuzzed_interval = (
+            random.random() * (max_ivl - min_ivl + 1)
+        ) + min_ivl  # the next interval is a random value between min_ivl and max_ivl
+
+        fuzzed_interval = min(round(fuzzed_interval), self.maximum_interval)
+
+        return fuzzed_interval
diff --git a/src/fsrs/models.py b/src/fsrs/models.py
deleted file mode 100644
index 3a2418a..0000000
--- a/src/fsrs/models.py
+++ /dev/null
@@ -1,457 +0,0 @@
-"""
-fsrs.models
------------
-
-This module defines the core classes used by the FSRS scheduler.
-
-Classes:
-    State: Enum representing the learning state of a Card object.
-    Rating: Enum representing the four possible ratings when reviewing a card.
-    ReviewLog: Represents the log entry of Card that has been reviewed.
-    Card: Represents a flashcard in the FSRS system.
-    SchedulingInfo: Simple data class that bundles together an updated Card object and it's corresponding ReviewLog object.
-    SchedulingCards: Manages the scheduling of a Card object for each of the four potential ratings.
-    Parameters: The parameters used to configure the FSRS scheduler.
-"""
-
-from dataclasses import dataclass
-from datetime import datetime, timedelta, timezone
-import copy
-from typing import Any, Optional, Union
-from enum import IntEnum
-
-
-class State(IntEnum):
-    """
-    Enum representing the learning state of a Card object.
-    """
-
-    New = 0
-    Learning = 1
-    Review = 2
-    Relearning = 3
-
-
-class Rating(IntEnum):
-    """
-    Enum representing the four possible ratings when reviewing a card.
-    """
-
-    Again = 1
-    Hard = 2
-    Good = 3
-    Easy = 4
-
-
-class ReviewLog:
-    """
-    Represents the log entry of Card that has been reviewed.
-
-    Attributes:
-        rating (Rating): The rating given to the card during the review.
-        scheduled_days (int): The number of days until the card is due next.
-        elapsed_days (int): The number of days since the card was last reviewed.
-        review (datetime): The date and time of the review.
-        state (State): The learning state of the card before the review.
-    """
-
-    rating: Rating
-    scheduled_days: int
-    elapsed_days: int
-    review: datetime
-    state: State
-
-    def __init__(
-        self,
-        rating: Rating,
-        scheduled_days: int,
-        elapsed_days: int,
-        review: datetime,
-        state: State,
-    ) -> None:
-        """
-        Creates and initializes a ReviewLog object.
-
-        Args:
-            rating (Rating): The rating given to the card during the review.
-            scheduled_days (int): The number of days until the card is due next.
-            elapsed_days (int): The number of days since the card was last reviewed.
-            review (datetime): The date and time of the review.
-            state (State): The learning state of the card before the review.
-        """
-        self.rating = rating
-        self.scheduled_days = scheduled_days
-        self.elapsed_days = elapsed_days
-        self.review = review
-        self.state = state
-
-    def to_dict(self) -> dict[str, Union[int, str]]:
-        """
-        Returns a JSON-serializable dictionary representation of the ReviewLog object.
-
-        This method is specifically useful for storing ReviewLog objects in a database.
-
-        Returns:
-            dict: A dictionary representation of the ReviewLog object.
-        """
-        return_dict = {
-            "rating": self.rating.value,
-            "scheduled_days": self.scheduled_days,
-            "elapsed_days": self.elapsed_days,
-            "review": self.review.isoformat(),
-            "state": self.state.value,
-        }
-
-        return return_dict
-
-    @staticmethod
-    def from_dict(source_dict: dict[str, Any]) -> "ReviewLog":
-        """
-        Creates a ReviewLog object from an existing dictionary.
-
-        Args:
-            source_dict (dict[str, Any]): A dictionary representing an existing ReviewLog object.
-
-        Returns:
-            ReviewLog: A ReviewLog object created from the provided dictionary.
-        """
-        rating = Rating(int(source_dict["rating"]))
-        scheduled_days = int(source_dict["scheduled_days"])
-        elapsed_days = int(source_dict["elapsed_days"])
-        review = datetime.fromisoformat(source_dict["review"])
-        state = State(int(source_dict["state"]))
-
-        return ReviewLog(
-            rating,
-            scheduled_days,
-            elapsed_days,
-            review,
-            state,
-        )
-
-
-class Card:
-    """
-    Represents a flashcard in the FSRS system.
-
-    Attributes:
-        due (datetime): The date and time when the card is due next.
-        stability (float): Core FSRS parameter used for scheduling.
-        difficulty (float): Core FSRS parameter used for scheduling.
-        elapsed_days (int): The number of days since the card was last reviewed.
-        scheduled_days (int): The number of days until the card is due next.
-        reps (int): The number of times the card has been reviewed in its history.
-        lapses (int): The number of times the card has been lapsed in its history.
-        state (State): The card's current learning state.
-        last_review (datetime): The date and time of the card's last review.
-    """
-
-    due: datetime
-    stability: float
-    difficulty: float
-    elapsed_days: int
-    scheduled_days: int
-    reps: int
-    lapses: int
-    state: State
-    last_review: datetime
-
-    def __init__(
-        self,
-        due: Optional[datetime] = None,
-        stability: float = 0,
-        difficulty: float = 0,
-        elapsed_days: int = 0,
-        scheduled_days: int = 0,
-        reps: int = 0,
-        lapses: int = 0,
-        state: State = State.New,
-        last_review: Optional[datetime] = None,
-    ) -> None:
-        """
-        Creates and initializes a Card object.
-
-        Note that each of the arguments for this method are optional and can be omitted when creating a new Card.
-
-        Args:
-            due (Optional[datetime]): The date and time when the card is due next.
-            stability (float): Core FSRS parameter used for scheduling.
-            difficulty (float): Core FSRS parameter used for scheduling.
-            elapsed_days (int): The number of days since the card was last reviewed.
-            scheduled_days (int): The number of days until the card is due next.
-            reps (int): The number of times the card has been reviewed in its history.
-            lapses (int): The number of times the card has been lapsed in its history.
-            state (State): The card's current learning state.
-            last_review (Optional[datetime]): The date and time of the card's last review.
-        """
-        if due is None:
-            self.due = datetime.now(timezone.utc)
-        else:
-            self.due = due
-
-        self.stability = stability
-        self.difficulty = difficulty
-        self.elapsed_days = elapsed_days
-        self.scheduled_days = scheduled_days
-        self.reps = reps
-        self.lapses = lapses
-        self.state = state
-
-        if last_review is not None:
-            self.last_review = last_review
-
-    def to_dict(self) -> dict[str, Any]:
-        """
-        Returns a JSON-serializable dictionary representation of the Card object.
-
-        This method is specifically useful for storing Card objects in a database.
-
-        Returns:
-            dict: A dictionary representation of the Card object.
-        """
-        return_dict = {
-            "due": self.due.isoformat(),
-            "stability": self.stability,
-            "difficulty": self.difficulty,
-            "elapsed_days": self.elapsed_days,
-            "scheduled_days": self.scheduled_days,
-            "reps": self.reps,
-            "lapses": self.lapses,
-            "state": self.state.value,
-        }
-
-        if hasattr(self, "last_review"):
-            return_dict["last_review"] = self.last_review.isoformat()
-
-        return return_dict
-
-    @staticmethod
-    def from_dict(source_dict: dict[str, Any]) -> "Card":
-        """
-        Creates a Card object from an existing dictionary.
-
-        Args:
-            source_dict (dict[str, Any]): A dictionary representing an existing Card object.
-
-        Returns:
-            ReviewLog: A Card object created from the provided dictionary.
-        """
-        due = datetime.fromisoformat(source_dict["due"])
-        stability = float(source_dict["stability"])
-        difficulty = float(source_dict["difficulty"])
-        elapsed_days = int(source_dict["elapsed_days"])
-        scheduled_days = int(source_dict["scheduled_days"])
-        reps = int(source_dict["reps"])
-        lapses = int(source_dict["lapses"])
-        state = State(int(source_dict["state"]))
-
-        if "last_review" in source_dict:
-            last_review = datetime.fromisoformat(source_dict["last_review"])
-        else:
-            last_review = None
-
-        return Card(
-            due,
-            stability,
-            difficulty,
-            elapsed_days,
-            scheduled_days,
-            reps,
-            lapses,
-            state,
-            last_review,
-        )
-
-    def get_retrievability(self, now: Optional[datetime] = None) -> float:
-        """
-        Calculates the Card object's current retrievability for a given date and time.
-
-        Args:
-            now (datetime): The current date and time
-
-        Returns:
-            float: The retrievability of the Card object.
-        """
-        DECAY = -0.5
-        FACTOR = 0.9 ** (1 / DECAY) - 1
-
-        if now is None:
-            now = datetime.now(timezone.utc)
-
-        if self.state in (State.Learning, State.Review, State.Relearning):
-            elapsed_days = max(0, (now - self.last_review).days)
-            return (1 + FACTOR * elapsed_days / self.stability) ** DECAY
-        else:
-            return 0
-
-
-@dataclass
-class SchedulingInfo:
-    """
-    Simple data class that bundles together an updated Card object and it's corresponding ReviewLog object.
-
-    This class is specifically used to provide an updated card and it's review log after a card has been reviewed.
-    """
-
-    card: Card
-    review_log: ReviewLog
-
-
-class SchedulingCards:
-    """
-    Manages the scheduling of a Card object for each of the four potential ratings.
-
-    A SchedulingCards object is created from an existing card and creates four new potential cards which
-    are updated according to whether the card will be chosen to be reviewed as Again, Hard, Good or Easy.
-
-    Attributes:
-        again (Card): An updated Card object that was rated Again.
-        hard (Card): An updated Card object that was rated Hard.
-        good (Card): An updated Card object that was rated Good.
-        easy (Card): An updated Card object that was rated Easy.
-    """
-
-    again: Card
-    hard: Card
-    good: Card
-    easy: Card
-
-    def __init__(self, card: Card) -> None:
-        self.again = copy.deepcopy(card)
-        self.hard = copy.deepcopy(card)
-        self.good = copy.deepcopy(card)
-        self.easy = copy.deepcopy(card)
-
-    def update_state(self, state: State) -> None:
-        if state == State.New:
-            self.again.state = State.Learning
-            self.hard.state = State.Learning
-            self.good.state = State.Learning
-            self.easy.state = State.Review
-        elif state == State.Learning or state == State.Relearning:
-            self.again.state = state
-            self.hard.state = state
-            self.good.state = State.Review
-            self.easy.state = State.Review
-        elif state == State.Review:
-            self.again.state = State.Relearning
-            self.hard.state = State.Review
-            self.good.state = State.Review
-            self.easy.state = State.Review
-            self.again.lapses += 1
-
-    def schedule(
-        self,
-        now: datetime,
-        hard_interval: int,
-        good_interval: int,
-        easy_interval: int,
-    ) -> None:
-        self.again.scheduled_days = 0
-        self.hard.scheduled_days = hard_interval
-        self.good.scheduled_days = good_interval
-        self.easy.scheduled_days = easy_interval
-        self.again.due = now + timedelta(minutes=5)
-        if hard_interval > 0:
-            self.hard.due = now + timedelta(days=hard_interval)
-        else:
-            self.hard.due = now + timedelta(minutes=10)
-        self.good.due = now + timedelta(days=good_interval)
-        self.easy.due = now + timedelta(days=easy_interval)
-
-    def record_log(self, card: Card, now: datetime) -> dict[Rating, SchedulingInfo]:
-        return {
-            Rating.Again: SchedulingInfo(
-                self.again,
-                ReviewLog(
-                    Rating.Again,
-                    self.again.scheduled_days,
-                    card.elapsed_days,
-                    now,
-                    card.state,
-                ),
-            ),
-            Rating.Hard: SchedulingInfo(
-                self.hard,
-                ReviewLog(
-                    Rating.Hard,
-                    self.hard.scheduled_days,
-                    card.elapsed_days,
-                    now,
-                    card.state,
-                ),
-            ),
-            Rating.Good: SchedulingInfo(
-                self.good,
-                ReviewLog(
-                    Rating.Good,
-                    self.good.scheduled_days,
-                    card.elapsed_days,
-                    now,
-                    card.state,
-                ),
-            ),
-            Rating.Easy: SchedulingInfo(
-                self.easy,
-                ReviewLog(
-                    Rating.Easy,
-                    self.easy.scheduled_days,
-                    card.elapsed_days,
-                    now,
-                    card.state,
-                ),
-            ),
-        }
-
-
-class Parameters:
-    """
-    The parameters used to configure the FSRS scheduler.
-
-    Attributes:
-        request_retention (float): The desired retention of the scheduler. Corresponds to the maximum retrievability a Card object can have before it is due.
-        maximum_interval (int): The maximum number of days into the future a Card object can be scheduled for next review.
-        w (tuple[float, ...]): The 19 model weights of the FSRS scheduler.
-    """
-
-    request_retention: float
-    maximum_interval: int
-    w: tuple[float, ...]
-
-    def __init__(
-        self,
-        w: Optional[tuple[float, ...]] = None,
-        request_retention: Optional[float] = None,
-        maximum_interval: Optional[int] = None,
-    ) -> None:
-        self.w = (
-            w
-            if w is not None
-            else (
-                0.4072,
-                1.1829,
-                3.1262,
-                15.4722,
-                7.2102,
-                0.5316,
-                1.0651,
-                0.0234,
-                1.616,
-                0.1544,
-                1.0824,
-                1.9813,
-                0.0953,
-                0.2975,
-                2.2042,
-                0.2407,
-                2.9466,
-                0.5034,
-                0.6567,
-            )
-        )
-        self.request_retention = (
-            request_retention if request_retention is not None else 0.9
-        )
-        self.maximum_interval = (
-            maximum_interval if maximum_interval is not None else 36500
-        )


Test patch:
diff --git a/tests/test_fsrs.py b/tests/test_fsrs.py
index dc224e5..3bb343a 100644
--- a/tests/test_fsrs.py
+++ b/tests/test_fsrs.py
@@ -1,9 +1,10 @@
-from fsrs import FSRS, Card, ReviewLog, State, Rating
+from fsrs import Scheduler, Card, ReviewLog, State, Rating
 from datetime import datetime, timedelta, timezone
 import json
 import pytest
+import random
 
-test_w = (
+test_parameters = (
     0.4197,
     1.1869,
     3.0412,
@@ -28,9 +29,7 @@ test_w = (
 
 class TestPyFSRS:
     def test_review_card(self):
-        f = FSRS(w=test_w)
-        card = Card()
-        now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
+        scheduler = Scheduler(parameters=test_parameters, enable_fuzzing=False)
 
         ratings = (
             Rating.Good,
@@ -47,15 +46,21 @@ class TestPyFSRS:
             Rating.Good,
             Rating.Good,
         )
-        ivl_history = []
 
+        card = Card()
+        review_datetime = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
+
+        ivl_history = []
         for rating in ratings:
-            card, _ = f.review_card(card, rating, now)
-            ivl = card.scheduled_days
+            card, _ = scheduler.review_card(
+                card=card, rating=rating, review_datetime=review_datetime
+            )
+
+            ivl = (card.due - card.last_review).days
             ivl_history.append(ivl)
-            now = card.due
 
-        print(ivl_history)
+            review_datetime = card.due
+
         assert ivl_history == [
             0,
             4,
@@ -73,11 +78,8 @@ class TestPyFSRS:
         ]
 
     def test_memo_state(self):
-        f = FSRS(w=test_w)
-        card = Card()
-        now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
+        scheduler = Scheduler(parameters=test_parameters)
 
-        scheduling_cards = f.repeat(card, now)
         ratings = (
             Rating.Again,
             Rating.Good,
@@ -87,34 +89,44 @@ class TestPyFSRS:
             Rating.Good,
         )
         ivl_history = [0, 0, 1, 3, 8, 21]
+
+        card = Card()
+        review_datetime = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
+
         for rating, ivl in zip(ratings, ivl_history):
-            card = scheduling_cards[rating].card
-            now += timedelta(days=ivl)
-            scheduling_cards = f.repeat(card, now)
+            card, _ = scheduler.review_card(
+                card=card, rating=rating, review_datetime=review_datetime
+            )
 
-        assert round(scheduling_cards[Rating.Good].card.stability, 4) == 71.4554
-        assert round(scheduling_cards[Rating.Good].card.difficulty, 4) == 5.0976
+            review_datetime += timedelta(days=ivl)
 
-    def test_repeat_default_arg(self):
-        f = FSRS()
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=review_datetime
+        )
+
+        assert round(card.stability) == 72
+        assert round(card.difficulty, 4) == 5.0976
 
-        card_object = Card()
+    def test_repeat_default_arg(self):
+        scheduler = Scheduler()
 
-        # repeat time is not specified
-        scheduling_cards = f.repeat(card_object)
+        card = Card()
 
-        card_rating = Rating.Good
+        rating = Rating.Good
 
-        card_object = scheduling_cards[card_rating].card
+        card, _ = scheduler.review_card(
+            card=card,
+            rating=rating,
+        )
 
-        due = card_object.due
+        due = card.due
 
         time_delta = due - datetime.now(timezone.utc)
 
         assert time_delta.seconds > 500  # due in approx. 8-10 minutes
 
     def test_datetime(self):
-        f = FSRS()
+        scheduler = Scheduler()
         card = Card()
 
         # new cards should be due immediately after creation
@@ -126,11 +138,16 @@ class TestPyFSRS:
 
         # repeating a card with a non-utc, non-timezone-aware datetime object should raise a Value Error
         with pytest.raises(ValueError):
-            f.repeat(card, datetime(2022, 11, 29, 12, 30, 0, 0))
-
-        # repeat a card with rating good before next tests
-        scheduling_cards = f.repeat(card, datetime.now(timezone.utc))
-        card = scheduling_cards[Rating.Good].card
+            scheduler.review_card(
+                card=card,
+                rating=Rating.Good,
+                review_datetime=datetime(2022, 11, 29, 12, 30, 0, 0),
+            )
+
+        # review a card with rating good before next tests
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
 
         # card object's due and last_review attributes must be timezone aware and UTC
         assert card.due.tzinfo == timezone.utc
@@ -139,7 +156,7 @@ class TestPyFSRS:
         assert card.due >= card.last_review
 
     def test_Card_serialize(self):
-        f = FSRS()
+        scheduler = Scheduler()
 
         # create card object the normal way
         card = Card()
@@ -159,34 +176,32 @@ class TestPyFSRS:
         assert card.to_dict() == copied_card.to_dict()
 
         # (x2) perform the above tests once more with a repeated card
-        scheduling_cards = f.repeat(card, datetime.now(timezone.utc))
-        repeated_card = scheduling_cards[Rating.Good].card
+        reviewed_card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
 
         with pytest.raises(TypeError):
-            json.dumps(repeated_card.__dict__)
+            json.dumps(reviewed_card.__dict__)
 
-        assert type(json.dumps(repeated_card.to_dict())) == str
+        assert type(json.dumps(reviewed_card.to_dict())) == str
 
-        repeated_card_dict = repeated_card.to_dict()
-        copied_repeated_card = Card.from_dict(repeated_card_dict)
+        reviewed_card_dict = reviewed_card.to_dict()
+        copied_reviewed_card = Card.from_dict(reviewed_card_dict)
 
-        assert vars(repeated_card) == vars(copied_repeated_card)
-        assert repeated_card.to_dict() == copied_repeated_card.to_dict()
+        assert vars(reviewed_card) == vars(copied_reviewed_card)
+        assert reviewed_card.to_dict() == copied_reviewed_card.to_dict()
 
         # original card and repeated card are different
-        assert vars(card) != vars(repeated_card)
-        assert card.to_dict() != repeated_card.to_dict()
+        assert vars(card) != vars(reviewed_card)
+        assert card.to_dict() != reviewed_card.to_dict()
 
     def test_ReviewLog_serialize(self):
-        f = FSRS()
+        scheduler = Scheduler()
 
         card = Card()
 
-        # repeat a card to get the review_log
-        scheduling_cards = f.repeat(card)
-        rating = Rating.Again
-        card = scheduling_cards[rating].card
-        review_log = scheduling_cards[rating].review_log
+        # review a card to get the review_log
+        card, review_log = scheduler.review_card(card=card, rating=Rating.Again)
 
         # ReviewLog object is not naturally JSON serializable
         with pytest.raises(TypeError):
@@ -198,14 +213,13 @@ class TestPyFSRS:
         # we can reconstruct a copy of the review_log object equivalent to the original
         review_log_dict = review_log.to_dict()
         copied_review_log = ReviewLog.from_dict(review_log_dict)
-        assert vars(review_log) == vars(copied_review_log)
         assert review_log.to_dict() == copied_review_log.to_dict()
 
-        # (x2) perform the above tests once more with a review_log from a repeated card
-        scheduling_cards = f.repeat(card, datetime.now(timezone.utc))
+        # (x2) perform the above tests once more with a review_log from a reviewed card
         rating = Rating.Good
-        card = scheduling_cards[rating].card
-        next_review_log = scheduling_cards[rating].review_log
+        card, next_review_log = scheduler.review_card(
+            card=card, rating=rating, review_datetime=datetime.now(timezone.utc)
+        )
 
         with pytest.raises(TypeError):
             json.dumps(next_review_log.__dict__)
@@ -215,16 +229,14 @@ class TestPyFSRS:
         next_review_log_dict = next_review_log.to_dict()
         copied_next_review_log = ReviewLog.from_dict(next_review_log_dict)
 
-        assert vars(next_review_log) == vars(copied_next_review_log)
         assert next_review_log.to_dict() == copied_next_review_log.to_dict()
 
         # original review log and next review log are different
-        assert vars(review_log) != vars(next_review_log)
         assert review_log.to_dict() != next_review_log.to_dict()
 
     def test_custom_scheduler_args(self):
-        f = FSRS(
-            w=(
+        scheduler = Scheduler(
+            parameters=(
                 0.4197,
                 1.1869,
                 3.0412,
@@ -245,8 +257,9 @@ class TestPyFSRS:
                 0,
                 0.6468,
             ),
-            request_retention=0.9,
+            desired_retention=0.9,
             maximum_interval=36500,
+            enable_fuzzing=False,
         )
         card = Card()
         now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
@@ -269,16 +282,15 @@ class TestPyFSRS:
         ivl_history = []
 
         for rating in ratings:
-            card, _ = f.review_card(card, rating, now)
-            ivl = card.scheduled_days
+            card, _ = scheduler.review_card(card, rating, now)
+            ivl = (card.due - card.last_review).days
             ivl_history.append(ivl)
             now = card.due
 
-        print(ivl_history)
         assert ivl_history == [0, 3, 13, 50, 163, 473, 0, 0, 12, 34, 91, 229, 541]
 
         # initialize another scheduler and verify parameters are properly set
-        w2 = (
+        parameters2 = (
             0.1456,
             0.4186,
             1.1104,
@@ -299,42 +311,400 @@ class TestPyFSRS:
             1.234,
             5.6789,
         )
-        request_retention2 = 0.85
+        desired_retention2 = 0.85
         maximum_interval2 = 3650
-        f2 = FSRS(
-            w=w2,
-            request_retention=request_retention2,
+        scheduler2 = Scheduler(
+            parameters=parameters2,
+            desired_retention=desired_retention2,
             maximum_interval=maximum_interval2,
         )
 
-        assert f2.p.w == w2
-        assert f2.p.request_retention == request_retention2
-        assert f2.p.maximum_interval == maximum_interval2
+        assert scheduler2.parameters == parameters2
+        assert scheduler2.desired_retention == desired_retention2
+        assert scheduler2.maximum_interval == maximum_interval2
 
     def test_retrievability(self):
-        f = FSRS()
+        scheduler = Scheduler()
 
         card = Card()
 
         # retrievabiliy of New card
-        assert card.state == State.New
+        assert card.state == State.Learning
         retrievability = card.get_retrievability()
         assert retrievability == 0
 
         # retrievabiliy of Learning card
-        card, _ = f.review_card(card, Rating.Good)
+        card, _ = scheduler.review_card(card, Rating.Good)
         assert card.state == State.Learning
         retrievability = card.get_retrievability()
         assert 0 <= retrievability <= 1
 
         # retrievabiliy of Review card
-        card, _ = f.review_card(card, Rating.Good)
+        card, _ = scheduler.review_card(card, Rating.Good)
         assert card.state == State.Review
         retrievability = card.get_retrievability()
         assert 0 <= retrievability <= 1
 
         # retrievabiliy of Relearning card
-        card, _ = f.review_card(card, Rating.Again)
+        card, _ = scheduler.review_card(card, Rating.Again)
         assert card.state == State.Relearning
         retrievability = card.get_retrievability()
         assert 0 <= retrievability <= 1
+
+    def test_Scheduler_serialize(self):
+        scheduler = Scheduler()
+
+        # Scheduler objects are json-serializable through its .to_dict() method
+        assert type(json.dumps(scheduler.to_dict())) == str
+
+        # scheduler can be serialized and de-serialized while remaining the same
+        scheduler_dict = scheduler.to_dict()
+        copied_scheduler = Scheduler.from_dict(scheduler_dict)
+        assert vars(scheduler) == vars(copied_scheduler)
+        assert scheduler.to_dict() == copied_scheduler.to_dict()
+
+    def test_good_learning_steps(self):
+        scheduler = Scheduler()
+
+        created_at = datetime.now(timezone.utc)
+        card = Card()
+
+        assert card.state == State.Learning
+        assert card.step == 0
+
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Learning
+        assert card.step == 1
+        assert (
+            round((card.due - created_at).total_seconds() / 100) == 6
+        )  # card is due in approx. 10 minutes (600 seconds)
+
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+        assert card.state == State.Review
+        assert card.step is None
+        assert (
+            round((card.due - created_at).total_seconds() / 3600) >= 24
+        )  # card is due in over a day
+
+    def test_again_learning_steps(self):
+        scheduler = Scheduler()
+
+        created_at = datetime.now(timezone.utc)
+        card = Card()
+
+        assert card.state == State.Learning
+        assert card.step == 0
+
+        rating = Rating.Again
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Learning
+        assert card.step == 0
+        assert (
+            round((card.due - created_at).total_seconds() / 10) == 6
+        )  # card is due in approx. 1 minute (60 seconds)
+
+    def test_hard_learning_steps(self):
+        scheduler = Scheduler()
+
+        created_at = datetime.now(timezone.utc)
+        card = Card()
+
+        assert card.state == State.Learning
+        assert card.step == 0
+
+        rating = Rating.Hard
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Learning
+        assert card.step == 0
+        assert (
+            round((card.due - created_at).total_seconds() / 10) == 33
+        )  # card is due in approx. 5.5 minutes (330 seconds)
+
+    def test_easy_learning_steps(self):
+        scheduler = Scheduler()
+
+        created_at = datetime.now(timezone.utc)
+        card = Card()
+
+        assert card.state == State.Learning
+        assert card.step == 0
+
+        rating = Rating.Easy
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Review
+        assert card.step is None
+        assert (
+            round((card.due - created_at).total_seconds() / 86400) >= 1
+        )  # card is due in at least 1 full day
+
+    def test_review_state(self):
+        scheduler = Scheduler(enable_fuzzing=False)
+
+        card = Card()
+
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Review
+        assert card.step is None
+
+        prev_due = card.due
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Review
+        assert (
+            round((card.due - prev_due).total_seconds() / 3600) >= 24
+        )  # card is due in at least 1 full day
+
+        # rate the card again
+        prev_due = card.due
+        rating = Rating.Again
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Relearning
+        assert (
+            round((card.due - prev_due).total_seconds() / 60) == 10
+        )  # card is due in 10 minutes
+
+    def test_relearning(self):
+        scheduler = Scheduler(enable_fuzzing=False)
+
+        card = Card()
+
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        prev_due = card.due
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        # rate the card again
+        prev_due = card.due
+        rating = Rating.Again
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Relearning
+        assert card.step == 0
+        assert (
+            round((card.due - prev_due).total_seconds() / 60) == 10
+        )  # card is due in 10 minutes
+
+        prev_due = card.due
+        rating = Rating.Again
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Relearning
+        assert card.step == 0
+        assert (
+            round((card.due - prev_due).total_seconds() / 60) == 10
+        )  # card is due in 10 minutes
+
+        prev_due = card.due
+        rating = Rating.Good
+        card, _ = scheduler.review_card(
+            card=card, rating=rating, review_datetime=card.due
+        )
+
+        assert card.state == State.Review
+        assert card.step is None
+        assert (
+            round((card.due - prev_due).total_seconds() / 3600) >= 24
+        )  # card is due in at least 1 full day
+
+    def test_fuzz(self):
+        scheduler = Scheduler()
+
+        # seed 1
+        random.seed(42)
+
+        card = Card()
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        prev_due = card.due
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        interval = card.due - prev_due
+
+        assert interval.days == 16
+
+        # seed 2
+        random.seed(12345)
+
+        card = Card()
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        prev_due = card.due
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        interval = card.due - prev_due
+
+        assert interval.days == 15
+
+    def test_no_learning_steps(self):
+        scheduler = Scheduler(learning_steps=())
+
+        assert len(scheduler.learning_steps) == 0
+
+        card = Card()
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Again, review_datetime=datetime.now(timezone.utc)
+        )
+
+        assert card.state == State.Review
+        interval = (card.due - card.last_review).days
+        assert interval >= 1
+
+    def test_no_relearning_steps(self):
+        scheduler = Scheduler(relearning_steps=())
+
+        assert len(scheduler.relearning_steps) == 0
+
+        card = Card()
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Learning
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        assert card.state == State.Review
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Again, review_datetime=card.due
+        )
+        assert card.state == State.Review
+
+        interval = (card.due - card.last_review).days
+        assert interval >= 1
+
+    def test_one_card_multiple_schedulers(self):
+        scheduler_with_two_learning_steps = Scheduler(
+            learning_steps=(timedelta(minutes=1), timedelta(minutes=10))
+        )
+        scheduler_with_no_learning_steps = Scheduler(learning_steps=())
+
+        card = Card()
+
+        assert len(scheduler_with_two_learning_steps.learning_steps) == 2
+        card, _ = scheduler_with_two_learning_steps.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Learning
+        assert card.step == 1
+
+        assert len(scheduler_with_no_learning_steps.learning_steps) == 0
+        card, _ = scheduler_with_no_learning_steps.review_card(
+            card=card, rating=Rating.Again, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Review
+        assert card.step is None
+
+        scheduler_with_two_relearning_steps = Scheduler(
+            relearning_steps=(
+                timedelta(minutes=1),
+                timedelta(minutes=10),
+                timedelta(minutes=15),
+            )
+        )
+        scheduler_with_no_relearning_steps = Scheduler(relearning_steps=())
+
+        assert len(scheduler_with_two_relearning_steps.relearning_steps) == 3
+        card, _ = scheduler_with_two_relearning_steps.review_card(
+            card=card, rating=Rating.Again, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Relearning
+        assert card.step == 0
+
+        card, _ = scheduler_with_two_relearning_steps.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Relearning
+        assert card.step == 1
+
+        card, _ = scheduler_with_two_relearning_steps.review_card(
+            card=card, rating=Rating.Good, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Relearning
+        assert card.step == 2
+
+        card, _ = scheduler_with_no_relearning_steps.review_card(
+            card=card, rating=Rating.Again, review_datetime=datetime.now(timezone.utc)
+        )
+        assert card.state == State.Review
+        assert card.step is None
+
+    def test_maximum_interval(self):
+        maximum_interval = 100
+        scheduler = Scheduler(maximum_interval=maximum_interval)
+
+        card = Card()
+
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Easy, review_datetime=card.due
+        )
+        assert (card.due - card.last_review).days <= scheduler.maximum_interval
+
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        assert (card.due - card.last_review).days <= scheduler.maximum_interval
+
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Easy, review_datetime=card.due
+        )
+        assert (card.due - card.last_review).days <= scheduler.maximum_interval
+
+        card, _ = scheduler.review_card(
+            card=card, rating=Rating.Good, review_datetime=card.due
+        )
+        assert (card.due - card.last_review).days <= scheduler.maximum_interval