Skip to content

Commit 4f1788e

Browse files
authored
Add method for rescheduling cards (#138)
* add reschedule_card method * add tests for new reschedule_card method * update README * bump minor 6.1.1 -> 6.2.0 * fix typo * small fix to README
1 parent a4fef86 commit 4f1788e

4 files changed

Lines changed: 215 additions & 36 deletions

File tree

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,10 +217,20 @@ scheduler = Scheduler(optimal_parameters)
217217
optimal_retention = optimizer.compute_optimal_retention(optimal_parameters)
218218

219219
# initialize a new scheduler with both optimized parameters and retention
220-
scheduler = Scheduler(optimal_parameters, optimal_retention)
220+
optimal_scheduler = Scheduler(optimal_parameters, optimal_retention)
221221
```
222222

223-
Note: The computed optimal parameters and retention may be slightly different than the numbers computed by Anki for the same set of review logs. This is because the two implementations are slightly different and updated at different times. If you're interested in the official Rust-based Anki implementation, please see [here](https://github.com/open-spaced-repetition/fsrs-rs).
223+
> [!NOTE]
224+
> The computed optimal parameters and retention may be slightly different than the numbers computed by Anki for the same set of review logs. This is because the two implementations are slightly different and updated at different times. If you're interested in the official Rust-based Anki implementation, please see [here](https://github.com/open-spaced-repetition/fsrs-rs).
225+
226+
### Reschedule cards after optimization
227+
228+
After creating a new scheduler with optimized parameters, you may want to reschedule/update each of your previous cards with this new scheduler.
229+
230+
```python
231+
# repeat the following for each of your cards
232+
rescheduled_card = optimal_scheduler.reschedule_card(card, review_logs_for_that_card)
233+
```
224234

225235
## Reference
226236

fsrs/scheduler.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,45 @@ def review_card(
498498

499499
return card, review_log
500500

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "fsrs"
7-
version = "6.1.1"
7+
version = "6.2.0"
88
description = "Free Spaced Repetition Scheduler"
99
readme = "README.md"
1010
authors = [

tests/test_basic.py

Lines changed: 163 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,34 @@
88
import random
99
from copy import deepcopy
1010
import sys
11+
import re
12+
13+
TEST_RATINGS_1 = (
14+
Rating.Good,
15+
Rating.Good,
16+
Rating.Good,
17+
Rating.Good,
18+
Rating.Good,
19+
Rating.Good,
20+
Rating.Again,
21+
Rating.Again,
22+
Rating.Good,
23+
Rating.Good,
24+
Rating.Good,
25+
Rating.Good,
26+
Rating.Good,
27+
)
1128

1229

1330
class TestPyFSRS:
1431
def test_review_card(self):
1532
scheduler = Scheduler(enable_fuzzing=False)
1633

17-
ratings = (
18-
Rating.Good,
19-
Rating.Good,
20-
Rating.Good,
21-
Rating.Good,
22-
Rating.Good,
23-
Rating.Good,
24-
Rating.Again,
25-
Rating.Again,
26-
Rating.Good,
27-
Rating.Good,
28-
Rating.Good,
29-
Rating.Good,
30-
Rating.Good,
31-
)
32-
3334
card = Card()
3435
review_datetime = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
3536

3637
ivl_history = []
37-
for rating in ratings:
38+
for rating in TEST_RATINGS_1:
3839
card, _ = scheduler.review_card(
3940
card=card, rating=rating, review_datetime=review_datetime
4041
)
@@ -222,24 +223,9 @@ def test_custom_scheduler_args(self):
222223
card = Card()
223224
now = datetime(2022, 11, 29, 12, 30, 0, 0, timezone.utc)
224225

225-
ratings = (
226-
Rating.Good,
227-
Rating.Good,
228-
Rating.Good,
229-
Rating.Good,
230-
Rating.Good,
231-
Rating.Good,
232-
Rating.Again,
233-
Rating.Again,
234-
Rating.Good,
235-
Rating.Good,
236-
Rating.Good,
237-
Rating.Good,
238-
Rating.Good,
239-
)
240226
ivl_history = []
241227

242-
for rating in ratings:
228+
for rating in TEST_RATINGS_1:
243229
card, _ = scheduler.review_card(card, rating, now)
244230
ivl = (card.due - card.last_review).days
245231
ivl_history.append(ivl)
@@ -1011,6 +997,150 @@ def test_relearning_card_rate_hard_two_relearning_steps(self):
1011997
assert card.state == State.Review
1012998
assert card.step is None
1013999

1000+
def test_reschedule_card_same_scheduler(self):
1001+
scheduler = Scheduler(enable_fuzzing=False)
1002+
1003+
card = Card()
1004+
1005+
review_logs = []
1006+
for rating in TEST_RATINGS_1:
1007+
card, review_log = scheduler.review_card(
1008+
card=card, rating=rating, review_datetime=card.due
1009+
)
1010+
review_logs.append(review_log)
1011+
1012+
rescheduled_card = scheduler.reschedule_card(card=card, review_logs=review_logs)
1013+
1014+
assert card is not rescheduled_card
1015+
assert card == rescheduled_card
1016+
1017+
def test_reschedule_card_different_parameters(self):
1018+
scheduler = Scheduler(enable_fuzzing=False)
1019+
1020+
card = Card()
1021+
1022+
review_logs = []
1023+
for rating in TEST_RATINGS_1:
1024+
card, review_log = scheduler.review_card(
1025+
card=card, rating=rating, review_datetime=card.due
1026+
)
1027+
review_logs.append(review_log)
1028+
1029+
DIFFERENT_PARAMETERS = [
1030+
0.12340357383516173,
1031+
1.2931,
1032+
2.397673571899466,
1033+
8.2956,
1034+
6.686820427099132,
1035+
0.45021679958387956,
1036+
3.077875127553957,
1037+
0.053520395733247045,
1038+
1.6539992229052127,
1039+
0.1466206769107436,
1040+
0.6300772488850335,
1041+
1.611965002575047,
1042+
0.012840136810798864,
1043+
0.34853762746216305,
1044+
1.8878958285806287,
1045+
0.8546376191171063,
1046+
1.8729,
1047+
0.6748536823468675,
1048+
0.20451266082721842,
1049+
0.22622814695113844,
1050+
0.46030603398979064,
1051+
]
1052+
assert scheduler.parameters != DIFFERENT_PARAMETERS
1053+
scheduler_with_different_parameters = Scheduler(parameters=DIFFERENT_PARAMETERS)
1054+
rescheduled_card = scheduler_with_different_parameters.reschedule_card(
1055+
card=card, review_logs=review_logs
1056+
)
1057+
1058+
assert card.card_id == rescheduled_card.card_id
1059+
assert card.state == rescheduled_card.state
1060+
assert card.step == rescheduled_card.step
1061+
assert card.stability != rescheduled_card.stability
1062+
assert card.difficulty != rescheduled_card.difficulty
1063+
assert card.last_review == rescheduled_card.last_review
1064+
assert card.due != rescheduled_card.due
1065+
1066+
def test_reschedule_card_different_desired_retention(self):
1067+
scheduler = Scheduler(enable_fuzzing=False)
1068+
1069+
card = Card()
1070+
1071+
review_logs = []
1072+
for rating in TEST_RATINGS_1:
1073+
card, review_log = scheduler.review_card(
1074+
card=card, rating=rating, review_datetime=card.due
1075+
)
1076+
review_logs.append(review_log)
1077+
1078+
DIFFERENT_DESIRED_RETENTION = 0.8
1079+
assert scheduler.desired_retention != DIFFERENT_DESIRED_RETENTION
1080+
scheduler_with_different_retention = Scheduler(
1081+
desired_retention=DIFFERENT_DESIRED_RETENTION
1082+
)
1083+
rescheduled_card = scheduler_with_different_retention.reschedule_card(
1084+
card=card, review_logs=review_logs
1085+
)
1086+
1087+
assert card.card_id == rescheduled_card.card_id
1088+
assert card.state == rescheduled_card.state
1089+
assert card.step == rescheduled_card.step
1090+
assert card.stability == rescheduled_card.stability
1091+
assert card.difficulty == rescheduled_card.difficulty
1092+
assert card.last_review == rescheduled_card.last_review
1093+
assert card.due < rescheduled_card.due
1094+
1095+
def test_reschedule_card_different_learning_steps(self):
1096+
scheduler = Scheduler(enable_fuzzing=False)
1097+
1098+
card = Card()
1099+
1100+
review_logs = []
1101+
for rating in TEST_RATINGS_1:
1102+
card, review_log = scheduler.review_card(
1103+
card=card, rating=rating, review_datetime=card.due
1104+
)
1105+
review_logs.append(review_log)
1106+
1107+
DIFFERENT_LEARNING_STEPS = [timedelta(minutes=1)] * len(review_logs)
1108+
assert scheduler.learning_steps != DIFFERENT_LEARNING_STEPS
1109+
scheduler_with_different_retention = Scheduler(
1110+
learning_steps=DIFFERENT_LEARNING_STEPS
1111+
)
1112+
rescheduled_card = scheduler_with_different_retention.reschedule_card(
1113+
card=card, review_logs=review_logs
1114+
)
1115+
1116+
assert card.card_id == rescheduled_card.card_id
1117+
assert card.state != rescheduled_card.state
1118+
assert card.step != rescheduled_card.step
1119+
assert card.stability == rescheduled_card.stability
1120+
assert card.difficulty == rescheduled_card.difficulty
1121+
assert card.last_review == rescheduled_card.last_review
1122+
assert card.due > rescheduled_card.due
1123+
1124+
def test_reschedule_card_wrong_review_logs(self):
1125+
scheduler = Scheduler(enable_fuzzing=False)
1126+
1127+
card = Card()
1128+
1129+
review_logs = []
1130+
for rating in TEST_RATINGS_1:
1131+
card, review_log = scheduler.review_card(
1132+
card=card, rating=rating, review_datetime=card.due
1133+
)
1134+
review_logs.append(review_log)
1135+
1136+
DIFFERENT_CARD_ID = 123
1137+
assert card.card_id != DIFFERENT_CARD_ID
1138+
review_logs[0].card_id = DIFFERENT_CARD_ID
1139+
1140+
EXPECTED_ERROR_MESSAGE = f"ReviewLog card_id {DIFFERENT_CARD_ID} does not match Card card_id {card.card_id}"
1141+
with pytest.raises(ValueError, match=re.escape(EXPECTED_ERROR_MESSAGE)):
1142+
_ = scheduler.reschedule_card(card=card, review_logs=review_logs)
1143+
10141144
def test_Optimizer_lazy_loading(self):
10151145
assert "fsrs.scheduler" in sys.modules
10161146
assert "fsrs.card" in sys.modules

0 commit comments

Comments
 (0)