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