diff --git a/README.md b/README.md index 3d86439..ab540e7 100644 --- a/README.md +++ b/README.md @@ -88,23 +88,25 @@ You can initialize the FSRS scheduler with your own custom weights as well as de ```python f = FSRS( w=( - 1.14, - 1.01, - 5.44, - 14.67, - 5.3024, - 1.5662, - 1.2503, - 0.0028, - 1.5489, - 0.1763, - 0.9953, - 2.7473, - 0.0179, - 0.3105, - 0.3976, - 0.0, - 2.0902, + 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, diff --git a/pyproject.toml b/pyproject.toml index 3829194..29f03ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fsrs" -version = "2.5.1" +version = "3.0.0" description = "Free Spaced Repetition Scheduler" readme = "README.md" authors = [{ name = "Jarrett Ye", email = "jarrett.ye@outlook.com" }] diff --git a/src/fsrs/fsrs.py b/src/fsrs/fsrs.py index 2259060..5ca61d9 100644 --- a/src/fsrs/fsrs.py +++ b/src/fsrs/fsrs.py @@ -58,17 +58,22 @@ def repeat( 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) + 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) @@ -89,28 +94,45 @@ def init_ds(self, s: SchedulingCards) -> None: s.easy.stability = self.init_stability(Rating.Easy) def next_ds( - self, s: SchedulingCards, last_d: float, last_s: float, retrievability: float + self, + s: SchedulingCards, + last_d: float, + last_s: float, + retrievability: float, + state, ): s.again.difficulty = self.next_difficulty(last_d, Rating.Again) - s.again.stability = self.next_forget_stability(last_d, last_s, retrievability) s.hard.difficulty = self.next_difficulty(last_d, Rating.Hard) - s.hard.stability = self.next_recall_stability( - last_d, last_s, retrievability, Rating.Hard - ) s.good.difficulty = self.next_difficulty(last_d, Rating.Good) - s.good.stability = self.next_recall_stability( - last_d, last_s, retrievability, Rating.Good - ) s.easy.difficulty = self.next_difficulty(last_d, Rating.Easy) - s.easy.stability = self.next_recall_stability( - last_d, last_s, retrievability, 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 + ) def init_stability(self, r: int) -> float: return max(self.p.w[r - 1], 0.1) def init_difficulty(self, r: int) -> float: - return min(max(self.p.w[4] - self.p.w[5] * (r - 3), 1), 10) + # 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) def forgetting_curve(self, elapsed_days: int, stability: float) -> float: return (1 + self.FACTOR * elapsed_days / stability) ** self.DECAY @@ -123,7 +145,13 @@ def next_interval(self, s: float) -> int: def next_difficulty(self, d: float, r: int) -> float: next_d = d - self.p.w[6] * (r - 3) - return min(max(self.mean_reversion(self.p.w[4], next_d), 1), 10) + + return min( + max(self.mean_reversion(self.init_difficulty(Rating.Easy), next_d), 1), 10 + ) + + def short_term_stability(self, stability, rating): + return stability * math.exp(self.p.w[17] * (rating - 3 + self.p.w[18])) def mean_reversion(self, init: float, current: float) -> float: return self.p.w[7] * init + (1 - self.p.w[7]) * current diff --git a/src/fsrs/models.py b/src/fsrs/models.py index cf8ac52..8cf1b52 100644 --- a/src/fsrs/models.py +++ b/src/fsrs/models.py @@ -278,23 +278,25 @@ def __init__( w if w is not None else ( - 0.4872, - 1.4003, - 3.7145, - 13.8206, - 5.1618, - 1.2298, - 0.8975, - 0.031, - 1.6474, - 0.1367, - 1.0461, - 2.1072, - 0.0793, - 0.3246, - 1.587, - 0.2272, - 2.8755, + 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 = ( diff --git a/tests/test_fsrs.py b/tests/test_fsrs.py index 23134c9..60f9c36 100644 --- a/tests/test_fsrs.py +++ b/tests/test_fsrs.py @@ -4,44 +4,33 @@ import pytest -def print_scheduling_cards(scheduling_cards): - print("again.card:", scheduling_cards[Rating.Again].card.__dict__) - print("again.review_log:", scheduling_cards[Rating.Again].review_log.__dict__) - print("hard.card:", scheduling_cards[Rating.Hard].card.__dict__) - print("hard.review_log:", scheduling_cards[Rating.Hard].review_log.__dict__) - print("good.card:", scheduling_cards[Rating.Good].card.__dict__) - print("good.review_log:", scheduling_cards[Rating.Good].review_log.__dict__) - print("easy.card:", scheduling_cards[Rating.Easy].card.__dict__) - print("easy.review_log:", scheduling_cards[Rating.Easy].review_log.__dict__) - print() - - class TestPyFSRS: - def test_repeat(self): - f = FSRS() - f.p.w = ( - 1.14, - 1.01, - 5.44, - 14.67, - 5.3024, - 1.5662, - 1.2503, - 0.0028, - 1.5489, - 0.1763, - 0.9953, - 2.7473, - 0.0179, - 0.3105, - 0.3976, - 0.0, - 2.0902, + def test_review_card(self): + 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, + ) ) card = Card() now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc) - scheduling_cards = f.repeat(card, now) - print_scheduling_cards(scheduling_cards) ratings = ( Rating.Good, @@ -61,15 +50,27 @@ def test_repeat(self): ivl_history = [] for rating in ratings: - card = scheduling_cards[rating].card + card, _ = f.review_card(card, rating, now) ivl = card.scheduled_days ivl_history.append(ivl) now = card.due - scheduling_cards = f.repeat(card, now) - print_scheduling_cards(scheduling_cards) print(ivl_history) - assert ivl_history == [0, 5, 16, 43, 106, 236, 0, 0, 12, 25, 47, 85, 147] + assert ivl_history == [ + 0, + 4, + 17, + 62, + 198, + 563, + 0, + 0, + 9, + 27, + 74, + 190, + 457, + ] def test_repeat_default_arg(self): f = FSRS() @@ -198,89 +199,34 @@ def test_ReviewLog_serialize(self): assert vars(review_log) != vars(next_review_log) assert review_log.to_dict() != next_review_log.to_dict() - def test_review_card(self): - f = FSRS() - f.p.w = ( - 1.14, - 1.01, - 5.44, - 14.67, - 5.3024, - 1.5662, - 1.2503, - 0.0028, - 1.5489, - 0.1763, - 0.9953, - 2.7473, - 0.0179, - 0.3105, - 0.3976, - 0.0, - 2.0902, - ) - card = Card() - now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc) - # scheduling_cards = f.repeat(card, now) - # print_scheduling_cards(scheduling_cards) - - ratings = ( - Rating.Good, - Rating.Good, - Rating.Good, - Rating.Good, - Rating.Good, - Rating.Good, - Rating.Again, - Rating.Again, - Rating.Good, - Rating.Good, - Rating.Good, - Rating.Good, - Rating.Good, - ) - ivl_history = [] - - for rating in ratings: - # card = scheduling_cards[rating].card - card, _ = f.review_card(card, rating, now) - ivl = card.scheduled_days - ivl_history.append(ivl) - now = card.due - # scheduling_cards = f.repeat(card, now) - # print_scheduling_cards(scheduling_cards) - - print(ivl_history) - assert ivl_history == [0, 5, 16, 43, 106, 236, 0, 0, 12, 25, 47, 85, 147] - def test_custom_scheduler_args(self): f = FSRS( w=( - 1.14, - 1.01, - 5.44, - 14.67, - 5.3024, - 1.5662, - 1.2503, - 0.0028, - 1.5489, - 0.1763, - 0.9953, - 2.7473, - 0.0179, - 0.3105, - 0.3976, - 0.0, - 2.0902, + 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, + 0.6468, ), request_retention=0.9, maximum_interval=36500, ) card = Card() now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc) - # scheduling_cards = f.repeat(card, now) - # print_scheduling_cards(scheduling_cards) ratings = ( Rating.Good, @@ -300,16 +246,13 @@ def test_custom_scheduler_args(self): ivl_history = [] for rating in ratings: - # card = scheduling_cards[rating].card card, _ = f.review_card(card, rating, now) ivl = card.scheduled_days ivl_history.append(ivl) now = card.due - # scheduling_cards = f.repeat(card, now) - # print_scheduling_cards(scheduling_cards) print(ivl_history) - assert ivl_history == [0, 5, 16, 43, 106, 236, 0, 0, 12, 25, 47, 85, 147] + 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 = ( @@ -330,6 +273,8 @@ def test_custom_scheduler_args(self): 1.5071, 0.2272, 2.8755, + 1.234, + 5.6789, ) request_retention2 = 0.85 maximum_interval2 = 3650