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