├── .gitignore ├── CHANGELOG.md ├── pubspec.yaml ├── LICENSE ├── analysis_options.yaml ├── .github └── workflows │ └── dart.yml ├── .idx └── dev.nix ├── example └── fsrs_example.dart ├── README.md ├── lib └── fsrs.dart └── test └── basic_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | 5 | # Avoid committing pubspec.lock for library packages; see 6 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 7 | pubspec.lock 8 | 9 | .packages 10 | build/ 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.1 2 | 3 | - fix: #24 compatability with flutter_test from sdk 4 | 5 | ## 2.0.0 6 | 7 | - FSRS-6 8 | 9 | ## 1.1.1 10 | 11 | - Add support json function on Card class 12 | 13 | ## 1.1.0 14 | 15 | - Fix coding standard. 16 | - Fix dart score analyzed 17 | - Improve documentation 18 | 19 | ## 1.0.0 20 | 21 | - Initial version. 22 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: fsrs 2 | description: A Dart package for FSRS, which implements the Free Spaced Repetition Scheduler algorithm. This package assists developers in incorporating FSRS into their flashcard applications. 3 | 4 | version: 2.0.1 5 | repository: https://github.com/open-spaced-repetition/dart-fsrs 6 | 7 | environment: 8 | sdk: ^3.3.0 9 | 10 | dependencies: 11 | meta: ^1.15.0 12 | 13 | dev_dependencies: 14 | lints: ^4.0.0 15 | test: ^1.24.0 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Open Spaced Repetition 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | analyzer: 16 | errors: 17 | no_leading_underscores_for_local_identifiers: ignore 18 | 19 | # Uncomment the following section to specify additional rules. 20 | 21 | # linter: 22 | # rules: 23 | # - camel_case_types 24 | 25 | # analyzer: 26 | # exclude: 27 | # - path/to/excluded/files/** 28 | 29 | # For more information about the core and recommended set of lints, see 30 | # https://dart.dev/go/core-lints 31 | 32 | # For additional information about configuring this file, see 33 | # https://dart.dev/guides/language/analysis-options 34 | -------------------------------------------------------------------------------- /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Dart 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | # Note: This workflow uses the latest stable version of the Dart SDK. 22 | # You can specify other versions if desired, see documentation here: 23 | # https://github.com/dart-lang/setup-dart/blob/main/README.md 24 | # - uses: dart-lang/setup-dart@v1 25 | - uses: dart-lang/setup-dart@9a04e6d73cca37bd455e0608d7e5092f881fd603 26 | 27 | - name: Install dependencies 28 | run: dart pub get 29 | 30 | # Uncomment this step to verify the use of 'dart format' on each commit. 31 | # - name: Verify formatting 32 | # run: dart format --output=none --set-exit-if-changed . 33 | 34 | # Consider passing '--fatal-infos' for slightly stricter analysis. 35 | - name: Analyze project source 36 | run: dart analyze 37 | 38 | # Your project will need to have tests in test/ and a dependency on 39 | # package:test for this step to succeed. Note that Flutter projects will 40 | # want to change this to 'flutter test'. 41 | - name: Run tests 42 | run: dart test 43 | -------------------------------------------------------------------------------- /.idx/dev.nix: -------------------------------------------------------------------------------- 1 | # To learn more about how to use Nix to configure your environment 2 | # see: https://developers.google.com/idx/guides/customize-idx-env 3 | { pkgs, ... }: { 4 | # Which nixpkgs channel to use. 5 | channel = "stable-24.11"; # or "unstable" 6 | 7 | # Use https://search.nixos.org/packages to find packages 8 | packages = [ 9 | # pkgs.go 10 | # pkgs.python311 11 | # pkgs.python311Packages.pip 12 | # pkgs.nodejs_20 13 | # pkgs.nodePackages.nodemon 14 | pkgs.flutter 15 | pkgs.fish 16 | pkgs.stdenv.cc 17 | pkgs.htop 18 | pkgs.fastfetch 19 | ]; 20 | 21 | # Sets environment variables in the workspace 22 | env = {}; 23 | idx = { 24 | # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" 25 | extensions = [ 26 | # "vscodevim.vim" 27 | "dart-code.dart-code" 28 | "Dart-Code.flutter" 29 | ]; 30 | 31 | # Enable previews 32 | previews = { 33 | enable = true; 34 | previews = { 35 | # web = { 36 | # # Example: run "npm run dev" with PORT set to IDX's defined port for previews, 37 | # # and show it in IDX's web preview panel 38 | # command = ["npm" "run" "dev"]; 39 | # manager = "web"; 40 | # env = { 41 | # # Environment variables to set for your server 42 | # PORT = "$PORT"; 43 | # }; 44 | # }; 45 | }; 46 | }; 47 | 48 | # Workspace lifecycle hooks 49 | workspace = { 50 | # Runs when a workspace is first created 51 | onCreate = { 52 | # Example: install JS dependencies from NPM 53 | # npm-install = "npm install"; 54 | "setup" = "flutter pub get; dart test"; 55 | }; 56 | # Runs when the workspace is (re)started 57 | onStart = { 58 | # Example: start a background task to watch and re-build backend code 59 | # watch-backend = "npm run watch-backend"; 60 | "setup" = "flutter pub get; dart test"; 61 | }; 62 | }; 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /example/fsrs_example.dart: -------------------------------------------------------------------------------- 1 | import 'package:fsrs/fsrs.dart'; 2 | 3 | void main() async { 4 | // note: the following arguments are also the defaults 5 | var scheduler = Scheduler( 6 | parameters: [ 7 | 0.2172, 8 | 1.1771, 9 | 3.2602, 10 | 16.1507, 11 | 7.0114, 12 | 0.57, 13 | 2.0966, 14 | 0.0069, 15 | 1.5261, 16 | 0.112, 17 | 1.0178, 18 | 1.849, 19 | 0.1133, 20 | 0.3127, 21 | 2.2934, 22 | 0.2191, 23 | 3.0004, 24 | 0.7536, 25 | 0.3332, 26 | 0.1437, 27 | 0.2, 28 | ], 29 | desiredRetention: 0.9, 30 | learningSteps: [ 31 | Duration(minutes: 1), 32 | Duration(minutes: 10), 33 | ], 34 | relearningSteps: [ 35 | Duration(minutes: 10), 36 | ], 37 | maximumInterval: 36500, 38 | enableFuzzing: true, 39 | ); 40 | 41 | final cardInitial = await Card.create(); 42 | 43 | // Rating.Again (==1) forgot the card 44 | // Rating.Hard (==2) remembered the card with serious difficulty 45 | // Rating.Good (==3) remembered the card after a hesitation 46 | // Rating.Easy (==4) remembered the card easily 47 | 48 | final rating = Rating.again; 49 | 50 | final (:card, :reviewLog) = scheduler.reviewCard(cardInitial, rating); 51 | 52 | print("Card rated ${reviewLog.rating} at ${reviewLog.reviewDateTime}"); 53 | 54 | final due = card.due; 55 | 56 | // how much time between when the card is due and now 57 | final timeDelta = due.difference(DateTime.now()); 58 | 59 | print("Card due on $due"); 60 | print("Card due in ${timeDelta.inSeconds} seconds"); 61 | 62 | final retrievability = scheduler.getCardRetrievability(card); 63 | 64 | print("There is a $retrievability probability that this card is remembered."); 65 | 66 | // serialize before storage 67 | final schedulerDict = scheduler.toMap(); 68 | final cardDict = card.toMap(); 69 | final reviewLogDict = reviewLog.toMap(); 70 | 71 | // deserialize from dict 72 | final newScheduler = Scheduler.fromMap(schedulerDict); 73 | final newCard = Card.fromMap(cardDict); 74 | final newReviewLog = ReviewLog.fromMap(reviewLogDict); 75 | 76 | print( 77 | "Are the original and deserialized schedulers equal? ${scheduler == newScheduler}"); 78 | print("Are the original and deserialized cards equal? ${card == newCard}"); 79 | print( 80 | "Are the original and deserialized review logs equal? ${reviewLog == newReviewLog}"); 81 | } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Open Spaced Repetition logo 3 |
4 |
5 | 6 | # Dart-FSRS 7 | 8 |
9 |
10 | 🧠🔄 Build your own Spaced Repetition System in Dart 🧠🔄 11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 | 20 | **Dart-FSRS is a dart package that allows developers to easily create their own spaced repetition system using the Free Spaced Repetition Scheduler algorithm.** 21 | 22 | ## Table of Contents 23 | 24 | - [Installation](#installation) 25 | - [Quickstart](#quickstart) 26 | - [Usage](#usage) 27 | - [Reference](#reference) 28 | - [License](#license) 29 | - [More Info](#more-info) 30 | - [Online development](#online-development) 31 | 32 | ## Installation 33 | 34 | Add the package to your `pubspec.yaml`: 35 | 36 | ```yaml 37 | dependencies: 38 | fsrs: ^2.0.0 39 | ``` 40 | 41 | and then run: 42 | 43 | ```bash 44 | dart pub get 45 | ``` 46 | 47 | Or just install it with dart cli: 48 | 49 | ```bash 50 | dart pub add fsrs 51 | ``` 52 | 53 | ## Quickstart 54 | 55 | Import and initialize the FSRS scheduler 56 | 57 | ```dart 58 | import 'package:fsrs/fsrs.dart'; 59 | 60 | var scheduler = Scheduler(); 61 | ``` 62 | 63 | Create a new Card object 64 | 65 | ```dart 66 | // note: all new cards are 'due' immediately upon creation 67 | final card = Card(cardId: 1); 68 | // alternatively, you can let fsrs generate a unique ID for you 69 | final card = await Card.create(); 70 | ``` 71 | 72 | Choose a rating and review the card with the scheduler 73 | 74 | ```dart 75 | // Rating.Again (==1) forgot the card 76 | // Rating.Hard (==2) remembered the card with serious difficulty 77 | // Rating.Good (==3) remembered the card after a hesitation 78 | // Rating.Easy (==4) remembered the card easily 79 | 80 | final rating = Rating.good; 81 | 82 | final (:card, :reviewLog) = scheduler.reviewCard(card, rating); 83 | 84 | print("Card rated ${reviewLog.rating} at ${reviewLog.reviewDateTime}"); 85 | // > Card rated 3 at 2024-11-30 17:46:58.856497Z 86 | ``` 87 | 88 | See when the card is due next 89 | 90 | ```dart 91 | final due = card.due; 92 | 93 | // how much time between when the card is due and now 94 | final timeDelta = due.difference(DateTime.now()); 95 | 96 | print("Card due on $due"); 97 | print("Card due in ${timeDelta.inSeconds} seconds"); 98 | 99 | // > Card due on 2024-12-01 17:46:58.856497Z 100 | // > Card due in 599 seconds 101 | ``` 102 | 103 | ## Usage 104 | 105 | ### Custom parameters 106 | 107 | You can initialize the FSRS scheduler with your own custom parameters. 108 | 109 | ```dart 110 | // note: the following arguments are also the defaults 111 | scheduler = Scheduler( 112 | parameters: [ 113 | 0.2172, 114 | 1.1771, 115 | 3.2602, 116 | 16.1507, 117 | 7.0114, 118 | 0.57, 119 | 2.0966, 120 | 0.0069, 121 | 1.5261, 122 | 0.112, 123 | 1.0178, 124 | 1.849, 125 | 0.1133, 126 | 0.3127, 127 | 2.2934, 128 | 0.2191, 129 | 3.0004, 130 | 0.7536, 131 | 0.3332, 132 | 0.1437, 133 | 0.2, 134 | ], 135 | desiredRetention: 0.9, 136 | learningSteps: [ 137 | Duration(minutes: 1), 138 | Duration(minutes: 10), 139 | ], 140 | relearningSteps: [ 141 | Duration(minutes: 10), 142 | ], 143 | maximumInterval: 36500, 144 | enableFuzzing: true, 145 | ); 146 | ``` 147 | 148 | #### Explanation of parameters 149 | 150 | `parameters` are a set of 21 model weights that affect how the FSRS scheduler will schedule future reviews. If you're not familiar with optimizing FSRS, it is best not to modify these default values. 151 | 152 | `desired_retention` is a value between 0 and 1 that sets the desired minimum retention rate for cards when scheduled with the scheduler. For example, with the default value of `desired_retention=0.9`, a card will be scheduled at a time in the future when the predicted probability of the user correctly recalling that card falls to 90%. A higher `desired_retention` rate will lead to more reviews and a lower rate will lead to fewer reviews. 153 | 154 | `learning_steps` are custom time intervals that schedule new cards in the Learning state. By default, cards in the Learning state have short intervals of 1 minute then 10 minutes. You can also disable `learning_steps` with `Scheduler(learning_steps=())` 155 | 156 | `relearning_steps` are analogous to `learning_steps` except they apply to cards in the Relearning state. Cards transition to the Relearning state if they were previously in the Review state, then were rated Again - this is also known as a 'lapse'. If you specify `Scheduler(relearning_steps=())`, cards in the Review state, when lapsed, will not move to the Relearning state, but instead stay in the Review state. 157 | 158 | `maximum_interval` sets the cap for the maximum days into the future the scheduler is capable of scheduling cards. For example, if you never want the scheduler to schedule a card more than one year into the future, you'd set `Scheduler(maximum_interval=365)`. 159 | 160 | `enable_fuzzing`, if set to True, will apply a small amount of random 'fuzz' to calculated intervals. For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days. 161 | 162 | ### Timezone 163 | 164 | **Dart-FSRS uses UTC only.** 165 | 166 | You can still specify custom datetimes, but they must use the UTC timezone. 167 | 168 | ### Retrievability 169 | 170 | You can calculate the current probability of correctly recalling a card (its 'retrievability') with 171 | 172 | ```dart 173 | final retrievability = scheduler.getCardRetrievability(card); 174 | 175 | print("There is a $retrievability probability that this card is remembered."); 176 | // > There is a 0.94 probability that this card is remembered. 177 | ``` 178 | 179 | ### Serialization 180 | 181 | `Scheduler`, `Card` and `ReviewLog` classes are all JSON-serializable via their `toMap` and `fromMap` methods for easy database storage: 182 | 183 | ```dart 184 | // serialize before storage 185 | final schedulerDict = scheduler.toMap(); 186 | final cardDict = card.toMap(); 187 | final reviewLogDict = reviewLog.toMap(); 188 | 189 | // deserialize from dict 190 | final newScheduler = Scheduler.fromMap(schedulerDict); 191 | final newCard = Card.fromMap(cardDict); 192 | final newReviewLog = ReviewLog.fromMap(reviewLogDict); 193 | ``` 194 | 195 | ## Reference 196 | 197 | Card objects have one of three possible states 198 | 199 | ```dart 200 | State.Learning # (==1) new card being studied for the first time 201 | State.Review # (==2) card that has "graduated" from the Learning state 202 | State.Relearning # (==3) card that has "lapsed" from the Review state 203 | ``` 204 | 205 | There are four possible ratings when reviewing a card object: 206 | 207 | ```dart 208 | Rating.Again # (==1) forgot the card 209 | Rating.Hard # (==2) remembered the card with serious difficulty 210 | Rating.Good # (==3) remembered the card after a hesitation 211 | Rating.Easy # (==4) remembered the card easily 212 | ``` 213 | 214 | ## License 215 | 216 | Distributed under the MIT License. See `LICENSE` for more information. 217 | 218 | ## More Info: 219 | 220 | Port from [open-spaced-repetition/py-fsrs@6fd0857](https://github.com/open-spaced-repetition/py-fsrs/tree/6fd0857) 221 | 222 | ## Online development 223 | 224 | 225 | -------------------------------------------------------------------------------- /lib/fsrs.dart: -------------------------------------------------------------------------------- 1 | /// This module defines each of the classes used in the fsrs package. 2 | /// 3 | /// Classes: 4 | /// State: Enum representing the learning state of a Card object. 5 | /// Rating: Enum representing the four possible ratings when reviewing a card. 6 | /// Card: Represents a flashcard in the FSRS system. 7 | /// ReviewLog: Represents the log entry of a Card that has been reviewed. 8 | /// Scheduler: The FSRS spaced-repetition scheduler. 9 | library; 10 | 11 | import 'dart:math' as math; 12 | 13 | import 'package:meta/meta.dart'; 14 | 15 | const defaultParameters = [ 16 | 0.2172, 17 | 1.1771, 18 | 3.2602, 19 | 16.1507, 20 | 7.0114, 21 | 0.57, 22 | 2.0966, 23 | 0.0069, 24 | 1.5261, 25 | 0.112, 26 | 1.0178, 27 | 1.849, 28 | 0.1133, 29 | 0.3127, 30 | 2.2934, 31 | 0.2191, 32 | 3.0004, 33 | 0.7536, 34 | 0.3332, 35 | 0.1437, 36 | 0.2, 37 | ]; 38 | 39 | const stabilityMin = 0.001; 40 | const lowerBoundsParameters = [ 41 | stabilityMin, 42 | stabilityMin, 43 | stabilityMin, 44 | stabilityMin, 45 | 1.0, 46 | 0.001, 47 | 0.001, 48 | 0.001, 49 | 0.0, 50 | 0.0, 51 | 0.001, 52 | 0.001, 53 | 0.001, 54 | 0.001, 55 | 0.0, 56 | 0.0, 57 | 1.0, 58 | 0.0, 59 | 0.0, 60 | 0.0, 61 | 0.1, 62 | ]; 63 | 64 | const initialStabilityMax = 100.0; 65 | const upperBoundsParameters = [ 66 | initialStabilityMax, 67 | initialStabilityMax, 68 | initialStabilityMax, 69 | initialStabilityMax, 70 | 10.0, 71 | 4.0, 72 | 4.0, 73 | 0.75, 74 | 4.5, 75 | 0.8, 76 | 3.5, 77 | 5.0, 78 | 0.25, 79 | 0.9, 80 | 4.0, 81 | 1.0, 82 | 6.0, 83 | 2.0, 84 | 2.0, 85 | 0.8, 86 | 0.8, 87 | ]; 88 | 89 | const minDifficulty = 1.0; 90 | const maxDifficulty = 10.0; 91 | 92 | const fuzzRanges = [ 93 | { 94 | 'start': 2.5, 95 | 'end': 7.0, 96 | 'factor': 0.15, 97 | }, 98 | { 99 | 'start': 7.0, 100 | 'end': 20.0, 101 | 'factor': 0.1, 102 | }, 103 | { 104 | 'start': 20.0, 105 | 'end': double.infinity, 106 | 'factor': 0.05, 107 | }, 108 | ]; 109 | 110 | /// {@template fsrs.state} 111 | /// Enum representing the learning state of a Card object. 112 | /// {@endtemplate} 113 | enum State { 114 | learning(1), 115 | review(2), 116 | relearning(3); 117 | 118 | /// {@macro fsrs.state} 119 | const State(this.value); 120 | 121 | /// {@macro fsrs.state} 122 | static State fromValue(int value) { 123 | switch (value) { 124 | case 1: 125 | return State.learning; 126 | case 2: 127 | return State.review; 128 | case 3: 129 | return State.relearning; 130 | default: 131 | throw ArgumentError('Invalid state value: $value'); 132 | } 133 | } 134 | 135 | final int value; 136 | } 137 | 138 | /// {@template fsrs.rating} 139 | /// Enum representing the four possible ratings when reviewing a card. 140 | /// {@endtemplate} 141 | enum Rating { 142 | again(1), 143 | hard(2), 144 | good(3), 145 | easy(4); 146 | 147 | /// {@macro fsrs.rating} 148 | const Rating(this.value); 149 | 150 | /// {@macro fsrs.rating} 151 | static Rating fromValue(int value) { 152 | switch (value) { 153 | case 1: 154 | return Rating.again; 155 | case 2: 156 | return Rating.hard; 157 | case 3: 158 | return Rating.good; 159 | case 4: 160 | return Rating.easy; 161 | default: 162 | throw ArgumentError('Invalid rating value: $value'); 163 | } 164 | } 165 | 166 | final int value; 167 | } 168 | 169 | /// {@template fsrs.card} 170 | /// Represents a flashcard in the FSRS system. 171 | /// {@endtemplate} 172 | class Card { 173 | /// The id of the card. Defaults to the epoch milliseconds of when the card was created. 174 | final int cardId; 175 | 176 | /// The card's current learning state. 177 | State state; 178 | 179 | /// The card's current learning or relearning step or null if the card is in the Review state. 180 | int? step; 181 | 182 | /// Core mathematical parameter used for future scheduling. 183 | double? stability; 184 | 185 | /// Core mathematical parameter used for future scheduling. 186 | double? difficulty; 187 | 188 | /// The date and time when the card is due next. 189 | DateTime due; 190 | 191 | /// The date and time of the card's last review. 192 | DateTime? lastReview; 193 | 194 | /// {@macro fsrs.card} 195 | Card({ 196 | required this.cardId, 197 | this.state = State.learning, 198 | this.step, 199 | this.stability, 200 | this.difficulty, 201 | DateTime? due, 202 | this.lastReview, 203 | }) : due = due ?? DateTime.now().toUtc() { 204 | if (state == State.learning && step == null) { 205 | step = 0; 206 | } 207 | } 208 | 209 | // The Python original constructor uses time.sleep which can't be used in 210 | // Dart's constructor. 211 | // This method is used to create a Card object matching the behavior of the 212 | // Python original constructor. 213 | /// {@macro fsrs.card} 214 | static Future create({ 215 | State state = State.learning, 216 | int? step, 217 | double? stability, 218 | double? difficulty, 219 | DateTime? due, 220 | DateTime? lastReview, 221 | }) async { 222 | // epoch milliseconds of when the card was created 223 | final cardId = DateTime.now().millisecondsSinceEpoch; 224 | 225 | // wait 1ms to prevent potential cardId collision on next Card creation 226 | await Future.delayed(const Duration(milliseconds: 1)); 227 | 228 | return Card( 229 | cardId: cardId, 230 | state: state, 231 | step: step, 232 | stability: stability, 233 | difficulty: difficulty, 234 | due: due, 235 | lastReview: lastReview, 236 | ); 237 | } 238 | 239 | @override 240 | String toString() { 241 | return 'Card(' 242 | 'cardId: $cardId, ' 243 | 'state: $state, ' 244 | 'step: $step, ' 245 | 'stability: $stability, ' 246 | 'difficulty: $difficulty, ' 247 | 'due: $due, ' 248 | 'lastReview: $lastReview)'; 249 | } 250 | 251 | @override 252 | bool operator ==(Object other) { 253 | if (identical(this, other)) return true; 254 | return other is Card && 255 | other.cardId == cardId && 256 | other.state == state && 257 | other.step == step && 258 | other.stability == stability && 259 | other.difficulty == difficulty && 260 | other.due == due && 261 | other.lastReview == lastReview; 262 | } 263 | 264 | @override 265 | int get hashCode { 266 | return Object.hash( 267 | cardId, state, step, stability, difficulty, due, lastReview); 268 | } 269 | 270 | /// Returns a JSON-serializable Map representation of the Card object. 271 | /// 272 | /// This method is specifically useful for storing Card objects in a database. 273 | Map toMap() { 274 | return { 275 | 'cardId': cardId, 276 | 'state': state.value, 277 | 'step': step, 278 | 'stability': stability, 279 | 'difficulty': difficulty, 280 | 'due': due.toIso8601String(), 281 | 'lastReview': lastReview?.toIso8601String(), 282 | }; 283 | } 284 | 285 | /// {@macro fsrs.card} 286 | /// 287 | /// Creates a Card object from an existing Map. 288 | static Card fromMap(Map sourceMap) { 289 | return Card( 290 | cardId: sourceMap['cardId'] as int, 291 | state: State.fromValue(sourceMap['state'] as int), 292 | step: sourceMap['step'] as int?, 293 | stability: sourceMap['stability'] as double?, 294 | difficulty: sourceMap['difficulty'] as double?, 295 | due: DateTime.parse(sourceMap['due'] as String), 296 | lastReview: sourceMap['lastReview'] != null 297 | ? DateTime.parse(sourceMap['lastReview'] as String) 298 | : null, 299 | ); 300 | } 301 | 302 | /// {@macro fsrs.card} 303 | /// 304 | /// Creates a copy of this Card with the given fields replaced with new values. 305 | Card copyWith({ 306 | int? cardId, 307 | State? state, 308 | int? step, 309 | double? stability, 310 | double? difficulty, 311 | DateTime? due, 312 | DateTime? lastReview, 313 | }) { 314 | return Card( 315 | cardId: cardId ?? this.cardId, 316 | state: state ?? this.state, 317 | step: step ?? this.step, 318 | stability: stability ?? this.stability, 319 | difficulty: difficulty ?? this.difficulty, 320 | due: due ?? this.due, 321 | lastReview: lastReview ?? this.lastReview, 322 | ); 323 | } 324 | } 325 | 326 | /// {@template fsrs.review_log} 327 | /// Represents the log entry of a Card object that has been reviewed. 328 | /// {@endtemplate} 329 | class ReviewLog { 330 | /// The id of the card being reviewed. 331 | final int cardId; 332 | 333 | /// The rating given to the card during the review. 334 | final Rating rating; 335 | 336 | /// The date and time of the review. 337 | final DateTime reviewDateTime; 338 | 339 | /// The number of milliseconds it took to review the card or null if unspecified. 340 | final int? reviewDuration; 341 | 342 | /// {@macro fsrs.review_log} 343 | const ReviewLog({ 344 | required this.cardId, 345 | required this.rating, 346 | required this.reviewDateTime, 347 | this.reviewDuration, 348 | }); 349 | 350 | @override 351 | String toString() { 352 | return 'ReviewLog(' 353 | 'cardId: $cardId, ' 354 | 'rating: $rating, ' 355 | 'reviewDateTime: $reviewDateTime, ' 356 | 'reviewDuration: $reviewDuration)'; 357 | } 358 | 359 | @override 360 | bool operator ==(Object other) { 361 | if (identical(this, other)) return true; 362 | return other is ReviewLog && 363 | other.cardId == cardId && 364 | other.rating == rating && 365 | other.reviewDateTime == reviewDateTime && 366 | other.reviewDuration == reviewDuration; 367 | } 368 | 369 | @override 370 | int get hashCode { 371 | return Object.hash(cardId, rating, reviewDateTime, reviewDuration); 372 | } 373 | 374 | /// Returns a JSON-serializable Map representation of the ReviewLog object. 375 | /// 376 | /// This method is specifically useful for storing ReviewLog objects in a database. 377 | Map toMap() { 378 | return { 379 | 'cardId': cardId, 380 | 'rating': rating.value, 381 | 'reviewDateTime': reviewDateTime.toIso8601String(), 382 | 'reviewDuration': reviewDuration, 383 | }; 384 | } 385 | 386 | /// {@macro fsrs.review_log} 387 | /// 388 | /// Creates a ReviewLog object from an existing Map. 389 | static ReviewLog fromMap(Map sourceMap) { 390 | return ReviewLog( 391 | cardId: sourceMap['cardId'] as int, 392 | rating: Rating.fromValue(sourceMap['rating'] as int), 393 | reviewDateTime: DateTime.parse(sourceMap['reviewDateTime'] as String), 394 | reviewDuration: sourceMap['reviewDuration'] as int?, 395 | ); 396 | } 397 | } 398 | 399 | /// {@template fsrs.scheduler} 400 | /// The FSRS scheduler. 401 | /// 402 | /// Enables the reviewing and future scheduling of cards according to the FSRS algorithm. 403 | /// {@endtemplate} 404 | class Scheduler { 405 | /// The model weights of the FSRS scheduler. 406 | final List parameters; 407 | 408 | /// The desired retention rate of cards scheduled with the scheduler. 409 | final double desiredRetention; 410 | 411 | /// Small time intervals that schedule cards in the Learning state. 412 | final List learningSteps; 413 | 414 | /// Small time intervals that schedule cards in the Relearning state. 415 | final List relearningSteps; 416 | 417 | /// The maximum number of days a Review-state card can be scheduled into the future. 418 | final int maximumInterval; 419 | 420 | /// Whether to apply a small amount of random 'fuzz' to calculated intervals. 421 | final bool enableFuzzing; 422 | 423 | late final double _decay; 424 | late final double _factor; 425 | 426 | late math.Random _fuzzRandom; 427 | 428 | /// {@macro fsrs.scheduler} 429 | Scheduler({ 430 | List parameters = defaultParameters, 431 | this.desiredRetention = 0.9, 432 | this.learningSteps = const [ 433 | Duration(minutes: 1), 434 | Duration(minutes: 10), 435 | ], 436 | this.relearningSteps = const [ 437 | Duration(minutes: 10), 438 | ], 439 | this.maximumInterval = 36500, 440 | this.enableFuzzing = true, 441 | }) : parameters = List.from(parameters) { 442 | _validateParameters(this.parameters); 443 | 444 | _decay = -this.parameters[20]; 445 | _factor = math.pow(0.9, 1 / _decay) - 1; 446 | 447 | _fuzzRandom = math.Random(); 448 | } 449 | 450 | /// {@macro fsrs.scheduler} 451 | /// 452 | /// Creates a Scheduler with custom random number generator. 453 | /// 454 | /// This is useful for testing purposes, where you want to control the randomness of the scheduler. 455 | @visibleForTesting 456 | factory Scheduler.customRandom( 457 | math.Random random, { 458 | List parameters = defaultParameters, 459 | double desiredRetention = 0.9, 460 | List learningSteps = const [ 461 | Duration(minutes: 1), 462 | Duration(minutes: 10), 463 | ], 464 | List relearningSteps = const [ 465 | Duration(minutes: 10), 466 | ], 467 | int maximumInterval = 36500, 468 | bool enableFuzzing = true, 469 | }) { 470 | final scheduler = Scheduler( 471 | parameters: parameters, 472 | desiredRetention: desiredRetention, 473 | learningSteps: learningSteps, 474 | relearningSteps: relearningSteps, 475 | maximumInterval: maximumInterval, 476 | enableFuzzing: enableFuzzing, 477 | ); 478 | 479 | scheduler._fuzzRandom = random; 480 | 481 | return scheduler; 482 | } 483 | 484 | void _validateParameters(List parameters) { 485 | if (parameters.length != lowerBoundsParameters.length) { 486 | throw ArgumentError( 487 | 'Expected ${lowerBoundsParameters.length} parameters, got ${parameters.length}.'); 488 | } 489 | 490 | final errorMessages = []; 491 | for (int i = 0; i < parameters.length; i++) { 492 | final parameter = parameters[i]; 493 | final lowerBound = lowerBoundsParameters[i]; 494 | final upperBound = upperBoundsParameters[i]; 495 | 496 | if (!(lowerBound <= parameter && parameter <= upperBound)) { 497 | final errorMessage = 498 | 'parameters[$i] = $parameter is out of bounds: ($lowerBound, $upperBound)'; 499 | errorMessages.add(errorMessage); 500 | } 501 | } 502 | 503 | if (errorMessages.isNotEmpty) { 504 | throw ArgumentError('One or more parameters are out of bounds:\n' 505 | '${errorMessages.join('\n')}'); 506 | } 507 | } 508 | 509 | @override 510 | String toString() { 511 | return 'Scheduler(' 512 | 'parameters: $parameters, ' 513 | 'desiredRetention: $desiredRetention, ' 514 | 'learningSteps: $learningSteps, ' 515 | 'relearningSteps: $relearningSteps, ' 516 | 'maximumInterval: $maximumInterval, ' 517 | 'enableFuzzing: $enableFuzzing)'; 518 | } 519 | 520 | @override 521 | bool operator ==(Object other) { 522 | if (identical(this, other)) return true; 523 | return other is Scheduler && 524 | _listEquals(other.parameters, parameters) && 525 | other.desiredRetention == desiredRetention && 526 | _listEquals(other.learningSteps, learningSteps) && 527 | _listEquals(other.relearningSteps, relearningSteps) && 528 | other.maximumInterval == maximumInterval && 529 | other.enableFuzzing == enableFuzzing; 530 | } 531 | 532 | @override 533 | int get hashCode { 534 | return Object.hash( 535 | Object.hashAll(parameters), 536 | desiredRetention, 537 | Object.hashAll(learningSteps), 538 | Object.hashAll(relearningSteps), 539 | maximumInterval, 540 | enableFuzzing, 541 | ); 542 | } 543 | 544 | /// Calculates a Card object's current retrievability for a given date and time. 545 | /// 546 | /// The retrievability of a card is the predicted probability that the card is correctly recalled at the provided datetime. 547 | /// 548 | /// Args: 549 | /// - card: The card whose retrievability is to be calculated 550 | /// - currentDateTime: The current date and time 551 | /// 552 | /// Returns: 553 | /// - The retrievability of the Card object. 554 | double getCardRetrievability(Card card, {DateTime? currentDateTime}) { 555 | if (card.lastReview == null) { 556 | return 0; 557 | } 558 | 559 | currentDateTime ??= DateTime.now().toUtc(); 560 | final elapsedDays = 561 | math.max(0, currentDateTime.difference(card.lastReview!).inDays); 562 | 563 | return math 564 | .pow(1 + _factor * elapsedDays / card.stability!, _decay) 565 | .toDouble(); 566 | } 567 | 568 | /// Reviews a card with a given rating at a given time for a specified duration. 569 | /// 570 | /// Args: 571 | /// - [card]: The card being reviewed. 572 | /// - [rating]: The chosen rating for the card being reviewed. 573 | /// - [reviewDateTime]: The date and time of the review. 574 | /// - [reviewDuration]: The number of milliseconds it took to review the card or null if unspecified. 575 | /// 576 | /// Returns: 577 | /// - A tuple containing the updated, reviewed card and its corresponding review log. 578 | /// 579 | /// Throws: 580 | /// - ArgumentError: If the [reviewDateTime] argument is not in UTC. 581 | ({Card card, ReviewLog reviewLog}) reviewCard( 582 | Card card, 583 | Rating rating, { 584 | DateTime? reviewDateTime, 585 | int? reviewDuration, 586 | }) { 587 | if (reviewDateTime != null && !reviewDateTime.isUtc) { 588 | throw ArgumentError('datetime must be in UTC'); 589 | } 590 | 591 | card = card.copyWith(); 592 | 593 | reviewDateTime ??= DateTime.now().toUtc(); 594 | 595 | final daysSinceLastReview = card.lastReview != null 596 | ? reviewDateTime.difference(card.lastReview!).inDays 597 | : null; 598 | 599 | Duration nextInterval; 600 | 601 | switch (card.state) { 602 | case State.learning: 603 | // update the card's stability and difficulty 604 | if (card.stability == null && card.difficulty == null) { 605 | card.stability = _initialStability(rating); 606 | card.difficulty = _initialDifficulty(rating); 607 | } else if (daysSinceLastReview != null && daysSinceLastReview < 1) { 608 | card.stability = _shortTermStability( 609 | stability: card.stability!, 610 | rating: rating, 611 | ); 612 | card.difficulty = _nextDifficulty( 613 | difficulty: card.difficulty!, 614 | rating: rating, 615 | ); 616 | } else { 617 | card.stability = _nextStability( 618 | difficulty: card.difficulty!, 619 | stability: card.stability!, 620 | retrievability: 621 | getCardRetrievability(card, currentDateTime: reviewDateTime), 622 | rating: rating, 623 | ); 624 | card.difficulty = _nextDifficulty( 625 | difficulty: card.difficulty!, 626 | rating: rating, 627 | ); 628 | } 629 | 630 | // calculate the card's next interval 631 | // first if-clause handles edge case where the Card in the Learning state was previously 632 | // scheduled with a Scheduler with more learningSteps than the current Scheduler 633 | if (learningSteps.isEmpty || 634 | (card.step! >= learningSteps.length && 635 | [Rating.hard, Rating.good, Rating.easy].contains(rating))) { 636 | card.state = State.review; 637 | card.step = null; 638 | 639 | final nextIntervalDays = _nextInterval(stability: card.stability!); 640 | nextInterval = Duration(days: nextIntervalDays); 641 | } else { 642 | switch (rating) { 643 | case Rating.again: 644 | card.step = 0; 645 | nextInterval = learningSteps[card.step!]; 646 | 647 | case Rating.hard: 648 | // card step stays the same 649 | 650 | if (card.step == 0 && learningSteps.length == 1) { 651 | nextInterval = learningSteps[0] * 1.5; 652 | } else if (card.step == 0 && learningSteps.length >= 2) { 653 | nextInterval = (learningSteps[0] + learningSteps[1]) ~/ 2; 654 | } else { 655 | nextInterval = learningSteps[card.step!]; 656 | } 657 | 658 | case Rating.good: 659 | if (card.step! + 1 == learningSteps.length) { 660 | // the last step 661 | card.state = State.review; 662 | card.step = null; 663 | 664 | final nextIntervalDays = 665 | _nextInterval(stability: card.stability!); 666 | nextInterval = Duration(days: nextIntervalDays); 667 | } else { 668 | card.step = card.step! + 1; 669 | nextInterval = learningSteps[card.step!]; 670 | } 671 | break; 672 | 673 | case Rating.easy: 674 | card.state = State.review; 675 | card.step = null; 676 | 677 | final nextIntervalDays = 678 | _nextInterval(stability: card.stability!); 679 | nextInterval = Duration(days: nextIntervalDays); 680 | break; 681 | } 682 | } 683 | break; 684 | 685 | case State.review: 686 | // update the card's stability and difficulty 687 | if (daysSinceLastReview != null && daysSinceLastReview < 1) { 688 | card.stability = _shortTermStability( 689 | stability: card.stability!, 690 | rating: rating, 691 | ); 692 | } else { 693 | card.stability = _nextStability( 694 | difficulty: card.difficulty!, 695 | stability: card.stability!, 696 | retrievability: 697 | getCardRetrievability(card, currentDateTime: reviewDateTime), 698 | rating: rating, 699 | ); 700 | } 701 | 702 | card.difficulty = _nextDifficulty( 703 | difficulty: card.difficulty!, 704 | rating: rating, 705 | ); 706 | 707 | // calculate the card's next interval 708 | switch (rating) { 709 | case Rating.again: 710 | // if there are no relearning steps (they were left blank) 711 | if (relearningSteps.isEmpty) { 712 | final nextIntervalDays = 713 | _nextInterval(stability: card.stability!); 714 | nextInterval = Duration(days: nextIntervalDays); 715 | } else { 716 | card.state = State.relearning; 717 | card.step = 0; 718 | nextInterval = relearningSteps[card.step!]; 719 | } 720 | 721 | case Rating.hard || Rating.good || Rating.easy: 722 | final nextIntervalDays = _nextInterval(stability: card.stability!); 723 | nextInterval = Duration(days: nextIntervalDays); 724 | } 725 | 726 | case State.relearning: 727 | // update the card's stability and difficulty 728 | if (daysSinceLastReview != null && daysSinceLastReview < 1) { 729 | card.stability = _shortTermStability( 730 | stability: card.stability!, 731 | rating: rating, 732 | ); 733 | card.difficulty = _nextDifficulty( 734 | difficulty: card.difficulty!, 735 | rating: rating, 736 | ); 737 | } else { 738 | card.stability = _nextStability( 739 | difficulty: card.difficulty!, 740 | stability: card.stability!, 741 | retrievability: 742 | getCardRetrievability(card, currentDateTime: reviewDateTime), 743 | rating: rating, 744 | ); 745 | card.difficulty = _nextDifficulty( 746 | difficulty: card.difficulty!, 747 | rating: rating, 748 | ); 749 | } 750 | 751 | // calculate the card's next interval 752 | // first if-clause handles edge case where the Card in the Relearning state was previously 753 | // scheduled with a Scheduler with more relearningSteps than the current Scheduler 754 | if (relearningSteps.isEmpty || 755 | (card.step! >= relearningSteps.length && 756 | [Rating.hard, Rating.good, Rating.easy].contains(rating))) { 757 | card.state = State.review; 758 | card.step = null; 759 | 760 | final nextIntervalDays = _nextInterval(stability: card.stability!); 761 | nextInterval = Duration(days: nextIntervalDays); 762 | } else { 763 | switch (rating) { 764 | case Rating.again: 765 | card.step = 0; 766 | nextInterval = relearningSteps[card.step!]; 767 | 768 | case Rating.hard: 769 | // card step stays the same 770 | 771 | if (card.step == 0 && relearningSteps.length == 1) { 772 | nextInterval = relearningSteps[0] * 1.5; 773 | } else if (card.step == 0 && relearningSteps.length >= 2) { 774 | nextInterval = (relearningSteps[0] + relearningSteps[1]) ~/ 2; 775 | } else { 776 | nextInterval = relearningSteps[card.step!]; 777 | } 778 | 779 | case Rating.good: 780 | if (card.step! + 1 == relearningSteps.length) { 781 | // the last step 782 | card.state = State.review; 783 | card.step = null; 784 | 785 | final nextIntervalDays = 786 | _nextInterval(stability: card.stability!); 787 | nextInterval = Duration(days: nextIntervalDays); 788 | } else { 789 | card.step = card.step! + 1; 790 | nextInterval = relearningSteps[card.step!]; 791 | } 792 | 793 | case Rating.easy: 794 | card.state = State.review; 795 | card.step = null; 796 | 797 | final nextIntervalDays = 798 | _nextInterval(stability: card.stability!); 799 | nextInterval = Duration(days: nextIntervalDays); 800 | } 801 | } 802 | } 803 | 804 | if (enableFuzzing && card.state == State.review) { 805 | nextInterval = _getFuzzedInterval(nextInterval); 806 | } 807 | 808 | card.due = reviewDateTime.add(nextInterval); 809 | card.lastReview = reviewDateTime; 810 | 811 | final reviewLog = ReviewLog( 812 | cardId: card.cardId, 813 | rating: rating, 814 | reviewDateTime: reviewDateTime, 815 | reviewDuration: reviewDuration, 816 | ); 817 | 818 | return (card: card, reviewLog: reviewLog); 819 | } 820 | 821 | /// Returns a JSON-serializable Map representation of the Scheduler object. 822 | /// 823 | /// This method is specifically useful for storing Scheduler objects in a database. 824 | Map toMap() { 825 | return { 826 | 'parameters': parameters, 827 | 'desiredRetention': desiredRetention, 828 | 'learningSteps': learningSteps.map((step) => step.inSeconds).toList(), 829 | 'relearningSteps': relearningSteps.map((step) => step.inSeconds).toList(), 830 | 'maximumInterval': maximumInterval, 831 | 'enableFuzzing': enableFuzzing, 832 | }; 833 | } 834 | 835 | /// {@macro fsrs.scheduler} 836 | /// 837 | /// Creates a Scheduler object from an existing Map. 838 | static Scheduler fromMap(Map sourceMap) { 839 | return Scheduler( 840 | parameters: List.from(sourceMap['parameters']), 841 | desiredRetention: sourceMap['desiredRetention'] as double, 842 | learningSteps: (sourceMap['learningSteps'] as List) 843 | .map((step) => Duration(seconds: step as int)) 844 | .toList(), 845 | relearningSteps: (sourceMap['relearningSteps'] as List) 846 | .map((step) => Duration(seconds: step as int)) 847 | .toList(), 848 | maximumInterval: sourceMap['maximumInterval'] as int, 849 | enableFuzzing: sourceMap['enableFuzzing'] as bool, 850 | ); 851 | } 852 | 853 | double _clampDifficulty(double difficulty) { 854 | return difficulty.clamp(minDifficulty, maxDifficulty); 855 | } 856 | 857 | double _clampStability(double stability) { 858 | return math.max(stability, stabilityMin); 859 | } 860 | 861 | double _initialStability(Rating rating) { 862 | var initialStability = parameters[rating.value - 1]; 863 | 864 | initialStability = _clampStability(initialStability); 865 | 866 | return initialStability; 867 | } 868 | 869 | double _initialDifficulty(Rating rating) { 870 | var initialDifficulty = 871 | parameters[4] - (math.exp(parameters[5] * (rating.value - 1))) + 1; 872 | 873 | initialDifficulty = _clampDifficulty(initialDifficulty); 874 | 875 | return initialDifficulty; 876 | } 877 | 878 | int _nextInterval({required double stability}) { 879 | num nextInterval = 880 | (stability / _factor) * (math.pow(desiredRetention, 1 / _decay) - 1); 881 | 882 | nextInterval = nextInterval.round(); // intervals are full days 883 | 884 | // must be at least 1 day long 885 | nextInterval = math.max(nextInterval, 1); 886 | 887 | // can not be longer than the maximum interval 888 | nextInterval = math.min(nextInterval, maximumInterval); 889 | 890 | return nextInterval.toInt(); 891 | } 892 | 893 | double _shortTermStability( 894 | {required double stability, required Rating rating}) { 895 | var shortTermStabilityIncrease = 896 | math.exp(parameters[17] * (rating.value - 3 + parameters[18])) * 897 | math.pow(stability, -parameters[19]); 898 | 899 | if ([Rating.good, Rating.easy].contains(rating)) { 900 | shortTermStabilityIncrease = math.max(shortTermStabilityIncrease, 1.0); 901 | } 902 | 903 | var shortTermStability = stability * shortTermStabilityIncrease; 904 | 905 | shortTermStability = _clampStability(shortTermStability); 906 | 907 | return shortTermStability; 908 | } 909 | 910 | double _nextDifficulty({required double difficulty, required Rating rating}) { 911 | double _linearDamping( 912 | {required double deltaDifficulty, required double difficulty}) { 913 | return (10.0 - difficulty) * deltaDifficulty / 9.0; 914 | } 915 | 916 | double _meanReversion({required double arg1, required double arg2}) { 917 | return parameters[7] * arg1 + (1 - parameters[7]) * arg2; 918 | } 919 | 920 | final arg1 = _initialDifficulty(Rating.easy); 921 | 922 | final deltaDifficulty = -(parameters[6] * (rating.value - 3)); 923 | final arg2 = difficulty + 924 | _linearDamping( 925 | deltaDifficulty: deltaDifficulty, difficulty: difficulty); 926 | 927 | var nextDifficulty = _meanReversion(arg1: arg1, arg2: arg2); 928 | 929 | nextDifficulty = _clampDifficulty(nextDifficulty); 930 | 931 | return nextDifficulty; 932 | } 933 | 934 | double _nextStability({ 935 | required double difficulty, 936 | required double stability, 937 | required double retrievability, 938 | required Rating rating, 939 | }) { 940 | double nextStability; 941 | 942 | if (rating == Rating.again) { 943 | nextStability = _nextForgetStability( 944 | difficulty: difficulty, 945 | stability: stability, 946 | retrievability: retrievability, 947 | ); 948 | } else { 949 | nextStability = _nextRecallStability( 950 | difficulty: difficulty, 951 | stability: stability, 952 | retrievability: retrievability, 953 | rating: rating, 954 | ); 955 | } 956 | 957 | nextStability = _clampStability(nextStability); 958 | 959 | return nextStability; 960 | } 961 | 962 | double _nextForgetStability({ 963 | required double difficulty, 964 | required double stability, 965 | required double retrievability, 966 | }) { 967 | final nextForgetStabilityLongTermParams = parameters[11] * 968 | math.pow(difficulty, -parameters[12]) * 969 | (math.pow((stability + 1), (parameters[13])) - 1) * 970 | math.exp((1 - retrievability) * parameters[14]); 971 | 972 | final nextForgetStabilityShortTermParams = 973 | stability / math.exp(parameters[17] * parameters[18]); 974 | 975 | return math.min( 976 | nextForgetStabilityLongTermParams, 977 | nextForgetStabilityShortTermParams, 978 | ); 979 | } 980 | 981 | double _nextRecallStability({ 982 | required double difficulty, 983 | required double stability, 984 | required double retrievability, 985 | required Rating rating, 986 | }) { 987 | final hardPenalty = rating == Rating.hard ? parameters[15] : 1.0; 988 | final easyBonus = rating == Rating.easy ? parameters[16] : 1.0; 989 | 990 | return stability * 991 | (1 + 992 | math.exp(parameters[8]) * 993 | (11 - difficulty) * 994 | math.pow(stability, -parameters[9]) * 995 | (math.exp((1 - retrievability) * parameters[10]) - 1) * 996 | hardPenalty * 997 | easyBonus); 998 | } 999 | 1000 | /// Takes the current calculated interval and adds a small amount of random fuzz to it. 1001 | /// For example, a card that would've been due in 50 days, after fuzzing, might be due in 49, or 51 days. 1002 | /// 1003 | /// Args: 1004 | /// - interval: The calculated next interval, before fuzzing. 1005 | /// 1006 | /// Returns: 1007 | /// - The new interval, after fuzzing. 1008 | Duration _getFuzzedInterval(Duration interval) { 1009 | final intervalDays = interval.inDays; 1010 | 1011 | // fuzz is not applied to intervals less than 2.5 1012 | if (intervalDays < 2.5) { 1013 | return interval; 1014 | } 1015 | 1016 | /// Helper function that computes the possible upper and lower bounds of the interval after fuzzing. 1017 | (int minIvL, int maxIvL) getFuzzRange(int intervalDays) { 1018 | var delta = 1.0; 1019 | for (final fuzzRange in fuzzRanges) { 1020 | delta += fuzzRange['factor']! * 1021 | math.max( 1022 | math.min(intervalDays, fuzzRange['end']!) - fuzzRange['start']!, 1023 | 0.0, 1024 | ); 1025 | } 1026 | 1027 | var minIvL = (intervalDays - delta).round().toInt(); 1028 | var maxIvL = (intervalDays + delta).round().toInt(); 1029 | 1030 | // make sure the minIvL and maxIvL fall into a valid range 1031 | minIvL = math.max(2, minIvL); 1032 | maxIvL = math.min(maxIvL, maximumInterval); 1033 | minIvL = math.min(minIvL, maxIvL); 1034 | 1035 | return (minIvL, maxIvL); 1036 | } 1037 | 1038 | final (minIvL, maxIvL) = getFuzzRange(intervalDays); 1039 | 1040 | num fuzzedIntervalDays = (_fuzzRandom.nextDouble() * 1041 | (maxIvL - minIvL + 1)) + 1042 | minIvL; // the next interval is a random value between minIvL and maxIvL 1043 | 1044 | fuzzedIntervalDays = math.min(fuzzedIntervalDays.round(), maximumInterval); 1045 | 1046 | final fuzzedInterval = Duration(days: fuzzedIntervalDays.toInt()); 1047 | 1048 | return fuzzedInterval; 1049 | } 1050 | } 1051 | 1052 | // Dart's Utils section 1053 | // This section contains utility functions that are not part of the main 1054 | // repository 1055 | 1056 | bool _listEquals(List a, List b) { 1057 | if (a.length != b.length) return false; 1058 | for (int i = 0; i < a.length; i++) { 1059 | if (a[i] != b[i]) return false; 1060 | } 1061 | return true; 1062 | } 1063 | -------------------------------------------------------------------------------- /test/basic_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:math'; 3 | 4 | import 'package:test/test.dart'; 5 | import 'package:fsrs/fsrs.dart'; // Assuming the ported library is in this package 6 | 7 | void main() { 8 | group('TestDartFSRS', () { 9 | test('test_review_card', () async { 10 | final scheduler = Scheduler(enableFuzzing: false); 11 | 12 | const ratings = [ 13 | Rating.good, 14 | Rating.good, 15 | Rating.good, 16 | Rating.good, 17 | Rating.good, 18 | Rating.good, 19 | Rating.again, 20 | Rating.again, 21 | Rating.good, 22 | Rating.good, 23 | Rating.good, 24 | Rating.good, 25 | Rating.good, 26 | ]; 27 | 28 | var card = await Card.create(); 29 | var reviewDateTime = DateTime.utc(2022, 11, 29, 12, 30, 0); 30 | 31 | final ivlHistory = []; 32 | for (final rating in ratings) { 33 | (card: card, reviewLog: _) = 34 | scheduler.reviewCard(card, rating, reviewDateTime: reviewDateTime); 35 | 36 | final ivl = card.due.difference(card.lastReview!).inDays; 37 | ivlHistory.add(ivl); 38 | 39 | reviewDateTime = card.due; 40 | } 41 | 42 | expect(ivlHistory, [ 43 | 0, 44 | 4, 45 | 14, 46 | 45, 47 | 135, 48 | 372, 49 | 0, 50 | 0, 51 | 2, 52 | 5, 53 | 10, 54 | 20, 55 | 40, 56 | ]); 57 | }); 58 | 59 | test('test_repeated_correct_reviews', () async { 60 | final scheduler = Scheduler(enableFuzzing: false); 61 | 62 | var card = await Card.create(); 63 | final reviewDateTimes = [ 64 | for (var i = 0; i < 10; i++) DateTime.utc(2022, 11, 29, 12, 30, 0, i) 65 | ]; 66 | 67 | for (final reviewDateTime in reviewDateTimes) { 68 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.easy, 69 | reviewDateTime: reviewDateTime); 70 | } 71 | 72 | expect(card.difficulty, 1.0); 73 | }); 74 | 75 | test('test_memo_state', () async { 76 | final scheduler = Scheduler(); 77 | 78 | const ratings = [ 79 | Rating.again, 80 | Rating.good, 81 | Rating.good, 82 | Rating.good, 83 | Rating.good, 84 | Rating.good, 85 | ]; 86 | const ivlHistory = [0, 0, 1, 3, 8, 21]; 87 | 88 | var card = await Card.create(); 89 | var reviewDateTime = DateTime.utc(2022, 11, 29, 12, 30, 0); 90 | 91 | for (var i = 0; i < ratings.length; i++) { 92 | final rating = ratings[i]; 93 | final ivl = ivlHistory[i]; 94 | 95 | reviewDateTime = reviewDateTime.add(Duration(days: ivl)); 96 | (card: card, reviewLog: _) = 97 | scheduler.reviewCard(card, rating, reviewDateTime: reviewDateTime); 98 | } 99 | 100 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good, 101 | reviewDateTime: reviewDateTime); 102 | 103 | expect(card.stability, closeTo(49.4472, 0.0001)); 104 | expect(card.difficulty, closeTo(6.8271, 0.0001)); 105 | }); 106 | 107 | test('test_repeat_default_arg', () async { 108 | final scheduler = Scheduler(); 109 | var card = await Card.create(); 110 | const rating = Rating.good; 111 | 112 | (card: card, reviewLog: _) = scheduler.reviewCard(card, rating); 113 | 114 | final due = card.due; 115 | final timeDelta = due.difference(DateTime.now().toUtc()); 116 | 117 | expect( 118 | timeDelta.inSeconds, greaterThan(500)); // due in approx. 8-10 minutes 119 | }); 120 | 121 | test('test_datetime', () async { 122 | final scheduler = Scheduler(); 123 | var card = await Card.create(); 124 | 125 | // new cards should be due immediately after creation 126 | expect( 127 | DateTime.now().toUtc().isAfter(card.due) || 128 | DateTime.now().toUtc().isAtSameMomentAs(card.due), 129 | isTrue); 130 | 131 | // In Dart, DateTime object is either UTC or local, there is no naive datetime. 132 | // The library should enforce UTC. 133 | // repeating a card with a non-utc datetime object should raise an ArgumentError 134 | expect( 135 | () => scheduler.reviewCard( 136 | card, 137 | Rating.good, 138 | reviewDateTime: DateTime(2022, 11, 29, 12, 30, 0), // non-UTC 139 | ), 140 | throwsArgumentError, 141 | ); 142 | 143 | // review a card with rating good before next tests 144 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good, 145 | reviewDateTime: DateTime.now().toUtc()); 146 | 147 | // card object's due and last_review attributes must be timezone aware and UTC 148 | expect(card.due.isUtc, isTrue); 149 | expect(card.lastReview!.isUtc, isTrue); 150 | 151 | // card object's due datetime should be later than its last review 152 | expect( 153 | card.due.isAfter(card.lastReview!) || 154 | card.due.isAtSameMomentAs(card.lastReview!), 155 | isTrue); 156 | }); 157 | 158 | test('test_Card_serialize', () async { 159 | final scheduler = Scheduler(); 160 | 161 | // create card object the normal way 162 | var card = await Card.create(); 163 | 164 | // card object's toMap method makes it JSON serializable 165 | expect(json.encode(card.toMap()), isA()); 166 | 167 | // we can reconstruct a copy of the card object equivalent to the original 168 | final cardDict = card.toMap(); 169 | final copiedCard = Card.fromMap(cardDict); 170 | 171 | expect(card, copiedCard); 172 | expect(card.toMap(), copiedCard.toMap()); 173 | 174 | // (x2) perform the above tests once more with a repeated card 175 | final (card: reviewedCard, reviewLog: _) = scheduler.reviewCard( 176 | card, Rating.good, 177 | reviewDateTime: DateTime.now().toUtc()); 178 | 179 | expect(json.encode(reviewedCard.toMap()), isA()); 180 | 181 | final reviewedCardDict = reviewedCard.toMap(); 182 | final copiedReviewedCard = Card.fromMap(reviewedCardDict); 183 | 184 | expect(reviewedCard, copiedReviewedCard); 185 | expect(reviewedCard.toMap(), copiedReviewedCard.toMap()); 186 | 187 | // original card and repeated card are different 188 | expect(card, isNot(reviewedCard)); 189 | expect(card.toMap(), isNot(reviewedCard.toMap())); 190 | }); 191 | 192 | test('test_ReviewLog_serialize', () async { 193 | final scheduler = Scheduler(); 194 | var card = await Card.create(); 195 | 196 | // review a card to get the review_log 197 | late final ReviewLog reviewLog; 198 | (card: card, reviewLog: reviewLog) = 199 | scheduler.reviewCard(card, Rating.again); 200 | 201 | // review_log object's toMap method makes it JSON serializable 202 | expect(json.encode(reviewLog.toMap()), isA()); 203 | 204 | // we can reconstruct a copy of the review_log object equivalent to the original 205 | final reviewLogDict = reviewLog.toMap(); 206 | final copiedReviewLog = ReviewLog.fromMap(reviewLogDict); 207 | expect(reviewLog, copiedReviewLog); 208 | expect(reviewLog.toMap(), copiedReviewLog.toMap()); 209 | 210 | // (x2) perform the above tests once more with a review_log from a reviewed card 211 | const rating = Rating.good; 212 | final nextResult = scheduler.reviewCard(card, rating, 213 | reviewDateTime: DateTime.now().toUtc()); 214 | final nextReviewLog = nextResult.reviewLog; 215 | 216 | expect(json.encode(nextReviewLog.toMap()), isA()); 217 | 218 | final nextReviewLogDict = nextReviewLog.toMap(); 219 | final copiedNextReviewLog = ReviewLog.fromMap(nextReviewLogDict); 220 | 221 | expect(nextReviewLog, copiedNextReviewLog); 222 | expect(nextReviewLog.toMap(), copiedNextReviewLog.toMap()); 223 | 224 | // original review log and next review log are different 225 | expect(reviewLog, isNot(nextReviewLog)); 226 | expect(reviewLog.toMap(), isNot(nextReviewLog.toMap())); 227 | }); 228 | 229 | test('test_custom_scheduler_args', () async { 230 | final scheduler = Scheduler( 231 | desiredRetention: 0.9, 232 | maximumInterval: 36500, 233 | enableFuzzing: false, 234 | ); 235 | var card = await Card.create(); 236 | var now = DateTime.utc(2022, 11, 29, 12, 30, 0); 237 | 238 | const ratings = [ 239 | Rating.good, 240 | Rating.good, 241 | Rating.good, 242 | Rating.good, 243 | Rating.good, 244 | Rating.good, 245 | Rating.again, 246 | Rating.again, 247 | Rating.good, 248 | Rating.good, 249 | Rating.good, 250 | Rating.good, 251 | Rating.good, 252 | ]; 253 | final ivlHistory = []; 254 | 255 | for (final rating in ratings) { 256 | (card: card, reviewLog: _) = 257 | scheduler.reviewCard(card, rating, reviewDateTime: now); 258 | final ivl = card.due.difference(card.lastReview!).inDays; 259 | ivlHistory.add(ivl); 260 | now = card.due; 261 | } 262 | 263 | expect(ivlHistory, [ 264 | 0, 265 | 4, 266 | 14, 267 | 45, 268 | 135, 269 | 372, 270 | 0, 271 | 0, 272 | 2, 273 | 5, 274 | 10, 275 | 20, 276 | 40, 277 | ]); 278 | 279 | // initialize another scheduler and verify parameters are properly set 280 | const parameters2 = [ 281 | 0.1456, 282 | 0.4186, 283 | 1.1104, 284 | 4.1315, 285 | 5.2417, 286 | 1.3098, 287 | 0.8975, 288 | 0.0010, 289 | 1.5674, 290 | 0.0567, 291 | 0.9661, 292 | 2.0275, 293 | 0.1592, 294 | 0.2446, 295 | 1.5071, 296 | 0.2272, 297 | 2.8755, 298 | 1.234, 299 | 0.56789, 300 | 0.1437, 301 | 0.2, 302 | ]; 303 | const desiredRetention2 = 0.85; 304 | const maximumInterval2 = 3650; 305 | final scheduler2 = Scheduler( 306 | parameters: parameters2, 307 | desiredRetention: desiredRetention2, 308 | maximumInterval: maximumInterval2, 309 | ); 310 | 311 | expect(scheduler2.parameters, parameters2); 312 | expect(scheduler2.desiredRetention, desiredRetention2); 313 | expect(scheduler2.maximumInterval, maximumInterval2); 314 | }); 315 | 316 | test('test_retrievability', () async { 317 | final scheduler = Scheduler(); 318 | var card = await Card.create(); 319 | 320 | // retrievabiliy of New card 321 | expect(card.state, State.learning); 322 | var retrievability = scheduler.getCardRetrievability(card, 323 | currentDateTime: DateTime.now().toUtc()); 324 | expect(retrievability, 0); 325 | 326 | // retrievabiliy of Learning card 327 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good); 328 | expect(card.state, State.learning); 329 | retrievability = scheduler.getCardRetrievability(card, 330 | currentDateTime: DateTime.now().toUtc()); 331 | expect(retrievability, inInclusiveRange(0, 1)); 332 | 333 | // retrievabiliy of Review card 334 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good); 335 | expect(card.state, State.review); 336 | retrievability = scheduler.getCardRetrievability(card, 337 | currentDateTime: DateTime.now().toUtc()); 338 | expect(retrievability, inInclusiveRange(0, 1)); 339 | 340 | // retrievabiliy of Relearning card 341 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.again); 342 | expect(card.state, State.relearning); 343 | retrievability = scheduler.getCardRetrievability(card, 344 | currentDateTime: DateTime.now().toUtc()); 345 | expect(retrievability, inInclusiveRange(0, 1)); 346 | }); 347 | 348 | test('test_Scheduler_serialize', () async { 349 | final scheduler = Scheduler(); 350 | 351 | // Scheduler objects are json-serializable through its .toMap() method 352 | expect(json.encode(scheduler.toMap()), isA()); 353 | 354 | // scheduler can be serialized and de-serialized while remaining the same 355 | final schedulerDict = scheduler.toMap(); 356 | final copiedScheduler = Scheduler.fromMap(schedulerDict); 357 | expect(scheduler, copiedScheduler); 358 | expect(scheduler.toMap(), copiedScheduler.toMap()); 359 | }); 360 | 361 | test('test_good_learning_steps', () async { 362 | final scheduler = Scheduler(); 363 | final createdAt = DateTime.now().toUtc(); 364 | var card = await Card.create(); 365 | 366 | expect(card.state, State.learning); 367 | expect(card.step, 0); 368 | 369 | (card: card, reviewLog: _) = 370 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 371 | 372 | expect(card.state, State.learning); 373 | expect(card.step, 1); 374 | expect((card.due.difference(createdAt).inSeconds / 100).round(), 375 | 6); // card is due in approx. 10 minutes (600 seconds) 376 | 377 | (card: card, reviewLog: _) = 378 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 379 | expect(card.state, State.review); 380 | expect(card.step, null); 381 | expect((card.due.difference(createdAt).inSeconds / 3600).round(), 382 | greaterThanOrEqualTo(24)); // card is due in over a day 383 | }); 384 | 385 | test('test_again_learning_steps', () async { 386 | final scheduler = Scheduler(); 387 | final createdAt = DateTime.now().toUtc(); 388 | var card = await Card.create(); 389 | 390 | expect(card.state, State.learning); 391 | expect(card.step, 0); 392 | 393 | (card: card, reviewLog: _) = 394 | scheduler.reviewCard(card, Rating.again, reviewDateTime: card.due); 395 | 396 | expect(card.state, State.learning); 397 | expect(card.step, 0); 398 | expect((card.due.difference(createdAt).inSeconds / 10).round(), 399 | 6); // card is due in approx. 1 minute (60 seconds) 400 | }); 401 | 402 | test('test_hard_learning_steps', () async { 403 | final scheduler = Scheduler(); 404 | final createdAt = DateTime.now().toUtc(); 405 | var card = await Card.create(); 406 | 407 | expect(card.state, State.learning); 408 | expect(card.step, 0); 409 | 410 | (card: card, reviewLog: _) = 411 | scheduler.reviewCard(card, Rating.hard, reviewDateTime: card.due); 412 | 413 | expect(card.state, State.learning); 414 | expect(card.step, 0); 415 | expect((card.due.difference(createdAt).inSeconds / 10).round(), 416 | 33); // card is due in approx. 5.5 minutes (330 seconds) 417 | }); 418 | 419 | test('test_easy_learning_steps', () async { 420 | final scheduler = Scheduler(); 421 | final createdAt = DateTime.now().toUtc(); 422 | var card = await Card.create(); 423 | 424 | expect(card.state, State.learning); 425 | expect(card.step, 0); 426 | 427 | (card: card, reviewLog: _) = 428 | scheduler.reviewCard(card, Rating.easy, reviewDateTime: card.due); 429 | 430 | expect(card.state, State.review); 431 | expect(card.step, null); 432 | expect((card.due.difference(createdAt).inSeconds / 86400).round(), 433 | greaterThanOrEqualTo(1)); // card is due in at least 1 full day 434 | }); 435 | 436 | test('test_review_state', () async { 437 | final scheduler = Scheduler(enableFuzzing: false); 438 | var card = await Card.create(); 439 | 440 | (card: card, reviewLog: _) = 441 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 442 | 443 | (card: card, reviewLog: _) = 444 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 445 | 446 | expect(card.state, State.review); 447 | expect(card.step, isNull); 448 | 449 | var prevDue = card.due; 450 | (card: card, reviewLog: _) = 451 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 452 | 453 | expect(card.state, State.review); 454 | expect((card.due.difference(prevDue).inSeconds / 3600).round(), 455 | greaterThanOrEqualTo(24)); // card is due in at least 1 full day 456 | 457 | // rate the card again 458 | prevDue = card.due; 459 | (card: card, reviewLog: _) = 460 | scheduler.reviewCard(card, Rating.again, reviewDateTime: card.due); 461 | 462 | expect(card.state, State.relearning); 463 | expect((card.due.difference(prevDue).inSeconds / 60).round(), 464 | 10); // card is due in 10 minutes 465 | }); 466 | 467 | test('test_relearning', () async { 468 | final scheduler = Scheduler(enableFuzzing: false); 469 | var card = await Card.create(); 470 | 471 | (card: card, reviewLog: _) = 472 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 473 | 474 | (card: card, reviewLog: _) = 475 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 476 | 477 | var prevDue = card.due; 478 | (card: card, reviewLog: _) = 479 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 480 | 481 | // rate the card again 482 | prevDue = card.due; 483 | (card: card, reviewLog: _) = 484 | scheduler.reviewCard(card, Rating.again, reviewDateTime: card.due); 485 | 486 | expect(card.state, State.relearning); 487 | expect(card.step, 0); 488 | expect((card.due.difference(prevDue).inSeconds / 60).round(), 489 | 10); // card is due in 10 minutes 490 | 491 | prevDue = card.due; 492 | (card: card, reviewLog: _) = 493 | scheduler.reviewCard(card, Rating.again, reviewDateTime: card.due); 494 | 495 | expect(card.state, State.relearning); 496 | expect(card.step, 0); 497 | expect((card.due.difference(prevDue).inSeconds / 60).round(), 498 | 10); // card is due in 10 minutes 499 | 500 | prevDue = card.due; 501 | (card: card, reviewLog: _) = 502 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 503 | 504 | expect(card.state, State.review); 505 | expect(card.step, isNull); 506 | expect((card.due.difference(prevDue).inSeconds / 3600).round(), 507 | greaterThanOrEqualTo(24)); // card is due in at least 1 full day 508 | }); 509 | 510 | test('test_fuzz', () async { 511 | // Note: Dart's Random may not produce the same sequence as Python's. 512 | // The goal is to verify that fuzzing introduces variability. 513 | 514 | // seed 1 515 | var scheduler = Scheduler.customRandom(Random(42)); 516 | var card = await Card.create(); 517 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good, 518 | reviewDateTime: DateTime.now().toUtc()); 519 | (card: card, reviewLog: _) = 520 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 521 | var prevDue = card.due; 522 | (card: card, reviewLog: _) = 523 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 524 | var interval = card.due.difference(prevDue); 525 | 526 | expect(interval.inDays, 13); 527 | 528 | // seed 2 529 | scheduler = Scheduler.customRandom(Random(12345)); 530 | card = await Card.create(); 531 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good, 532 | reviewDateTime: DateTime.now().toUtc()); 533 | (card: card, reviewLog: _) = 534 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 535 | prevDue = card.due; 536 | (card: card, reviewLog: _) = 537 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 538 | interval = card.due.difference(prevDue); 539 | 540 | // This is the original code from the Python version, but given that 541 | // Python and Dart have different random number generation behavior, 542 | // the expected value may not match exactly. 543 | // expect(interval.inDays, 12); 544 | // Adjusting the expected value based on the Dart random behavior. 545 | expect(interval.inDays, 21); 546 | }); 547 | 548 | test('test_no_learning_steps', () async { 549 | final scheduler = Scheduler(learningSteps: []); 550 | 551 | expect(scheduler.learningSteps, isEmpty); 552 | 553 | var card = await Card.create(); 554 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.again, 555 | reviewDateTime: DateTime.now().toUtc()); 556 | 557 | expect(card.state, State.review); 558 | final interval = card.due.difference(card.lastReview!).inDays; 559 | expect(interval, greaterThanOrEqualTo(1)); 560 | }); 561 | 562 | test('test_no_relearning_steps', () async { 563 | final scheduler = Scheduler(relearningSteps: []); 564 | 565 | expect(scheduler.relearningSteps, isEmpty); 566 | 567 | var card = await Card.create(); 568 | (card: card, reviewLog: _) = scheduler.reviewCard(card, Rating.good, 569 | reviewDateTime: DateTime.now().toUtc()); 570 | expect(card.state, State.learning); 571 | 572 | (card: card, reviewLog: _) = 573 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 574 | expect(card.state, State.review); 575 | 576 | (card: card, reviewLog: _) = 577 | scheduler.reviewCard(card, Rating.again, reviewDateTime: card.due); 578 | expect(card.state, State.review); 579 | 580 | final interval = card.due.difference(card.lastReview!).inDays; 581 | expect(interval, greaterThanOrEqualTo(1)); 582 | }); 583 | 584 | test('test_one_card_multiple_schedulers', () async { 585 | final schedulerWithTwoLearningSteps = Scheduler( 586 | learningSteps: const [Duration(minutes: 1), Duration(minutes: 10)]); 587 | final schedulerWithOneLearningStep = 588 | Scheduler(learningSteps: const [Duration(minutes: 1)]); 589 | final schedulerWithNoLearningSteps = Scheduler(learningSteps: const []); 590 | 591 | final schedulerWithTwoRelearningSteps = Scheduler( 592 | relearningSteps: const [Duration(minutes: 1), Duration(minutes: 10)]); 593 | final schedulerWithOneRelearningStep = 594 | Scheduler(relearningSteps: const [Duration(minutes: 1)]); 595 | final schedulerWithNoRelearningSteps = 596 | Scheduler(relearningSteps: const []); 597 | 598 | var card = await Card.create(); 599 | 600 | // learning-state tests 601 | expect(schedulerWithTwoLearningSteps.learningSteps.length, 2); 602 | (card: card, reviewLog: _) = schedulerWithTwoLearningSteps.reviewCard( 603 | card, Rating.good, 604 | reviewDateTime: DateTime.now().toUtc()); 605 | expect(card.state, State.learning); 606 | expect(card.step, 1); 607 | 608 | expect(schedulerWithOneLearningStep.learningSteps.length, 1); 609 | (card: card, reviewLog: _) = schedulerWithOneLearningStep.reviewCard( 610 | card, Rating.again, 611 | reviewDateTime: DateTime.now().toUtc()); 612 | expect(card.state, State.learning); 613 | expect(card.step, 0); 614 | 615 | expect(schedulerWithNoLearningSteps.learningSteps.length, 0); 616 | (card: card, reviewLog: _) = schedulerWithNoLearningSteps.reviewCard( 617 | card, Rating.hard, 618 | reviewDateTime: DateTime.now().toUtc()); 619 | expect(card.state, State.review); 620 | expect(card.step, isNull); 621 | 622 | // relearning-state tests 623 | expect(schedulerWithTwoRelearningSteps.relearningSteps.length, 2); 624 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 625 | card, Rating.again, 626 | reviewDateTime: DateTime.now().toUtc()); 627 | expect(card.state, State.relearning); 628 | expect(card.step, 0); 629 | 630 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 631 | card, Rating.good, 632 | reviewDateTime: DateTime.now().toUtc()); 633 | expect(card.state, State.relearning); 634 | expect(card.step, 1); 635 | 636 | expect(schedulerWithOneRelearningStep.relearningSteps.length, 1); 637 | (card: card, reviewLog: _) = schedulerWithOneRelearningStep.reviewCard( 638 | card, Rating.again, 639 | reviewDateTime: DateTime.now().toUtc()); 640 | expect(card.state, State.relearning); 641 | expect(card.step, 0); 642 | 643 | expect(schedulerWithNoRelearningSteps.relearningSteps.length, 0); 644 | (card: card, reviewLog: _) = schedulerWithNoRelearningSteps.reviewCard( 645 | card, Rating.hard, 646 | reviewDateTime: DateTime.now().toUtc()); 647 | expect(card.state, State.review); 648 | expect(card.step, isNull); 649 | }); 650 | 651 | test('test_maximum_interval', () async { 652 | const maximumInterval = 100; 653 | final scheduler = Scheduler(maximumInterval: maximumInterval); 654 | 655 | var card = await Card.create(); 656 | 657 | (card: card, reviewLog: _) = 658 | scheduler.reviewCard(card, Rating.easy, reviewDateTime: card.due); 659 | expect(card.due.difference(card.lastReview!).inDays, 660 | lessThanOrEqualTo(scheduler.maximumInterval)); 661 | 662 | (card: card, reviewLog: _) = 663 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 664 | expect(card.due.difference(card.lastReview!).inDays, 665 | lessThanOrEqualTo(scheduler.maximumInterval)); 666 | 667 | (card: card, reviewLog: _) = 668 | scheduler.reviewCard(card, Rating.easy, reviewDateTime: card.due); 669 | expect(card.due.difference(card.lastReview!).inDays, 670 | lessThanOrEqualTo(scheduler.maximumInterval)); 671 | 672 | (card: card, reviewLog: _) = 673 | scheduler.reviewCard(card, Rating.good, reviewDateTime: card.due); 674 | expect(card.due.difference(card.lastReview!).inDays, 675 | lessThanOrEqualTo(scheduler.maximumInterval)); 676 | }); 677 | 678 | test('test_class_repr', () async { 679 | // In Dart, this tests the toString() method. 680 | var card = await Card.create(); 681 | expect(card.toString(), isNotEmpty); 682 | 683 | final scheduler = Scheduler(); 684 | expect(scheduler.toString(), isNotEmpty); 685 | 686 | late final ReviewLog reviewLog; 687 | (card: card, reviewLog: reviewLog) = 688 | scheduler.reviewCard(card, Rating.good); 689 | expect(reviewLog.toString(), isNotEmpty); 690 | }); 691 | 692 | test('test_unique_card_ids', () async { 693 | final cardIds = []; 694 | for (var i = 0; i < 1000; i++) { 695 | final card = await Card.create(); 696 | final cardId = card.cardId; 697 | cardIds.add(cardId); 698 | } 699 | expect(cardIds.length, cardIds.toSet().length); 700 | }); 701 | 702 | test('test_stability_lower_bound', () async { 703 | /// Ensure that a Card object's stability is always >= stabilityMin 704 | final scheduler = Scheduler(); 705 | var card = await Card.create(); 706 | 707 | for (var i = 0; i < 1000; i++) { 708 | (card: card, reviewLog: _) = scheduler.reviewCard( 709 | card, 710 | Rating.again, 711 | reviewDateTime: card.due.add(const Duration(days: 1)), 712 | ); 713 | 714 | expect(card.stability, greaterThanOrEqualTo(stabilityMin)); 715 | } 716 | }); 717 | 718 | test('test_scheduler_parameter_validation', () async { 719 | // initializing a Scheduler object with valid parameters works 720 | final goodParameters = defaultParameters; 721 | expect(Scheduler(parameters: goodParameters), isA()); 722 | 723 | final parametersOneTooHigh = List.from(defaultParameters); 724 | parametersOneTooHigh[6] = 100; 725 | expect(() => Scheduler(parameters: parametersOneTooHigh), 726 | throwsArgumentError); 727 | 728 | final parametersOneTooLow = List.from(defaultParameters); 729 | parametersOneTooLow[10] = -42; 730 | expect(() => Scheduler(parameters: parametersOneTooLow), 731 | throwsArgumentError); 732 | 733 | final parametersTwoBad = List.from(defaultParameters); 734 | parametersTwoBad[0] = 0; 735 | parametersTwoBad[3] = 101; 736 | expect( 737 | () => Scheduler(parameters: parametersTwoBad), throwsArgumentError); 738 | 739 | final zeroParameters = []; 740 | expect(() => Scheduler(parameters: zeroParameters), throwsArgumentError); 741 | 742 | final oneTooFewParameters = 743 | defaultParameters.sublist(0, defaultParameters.length - 1); 744 | expect(() => Scheduler(parameters: oneTooFewParameters), 745 | throwsArgumentError); 746 | 747 | final tooManyParameters = [...defaultParameters, 1.0, 2.0, 3.0]; 748 | expect( 749 | () => Scheduler(parameters: tooManyParameters), throwsArgumentError); 750 | }); 751 | 752 | test('test_class_eq_methods', () async { 753 | final scheduler1 = Scheduler(); 754 | final scheduler2 = Scheduler(desiredRetention: 0.91); 755 | final scheduler1Copy = Scheduler.fromMap(scheduler1.toMap()); 756 | 757 | expect(scheduler1, isNot(equals(scheduler2))); 758 | expect(scheduler1, equals(scheduler1Copy)); 759 | 760 | final cardOrig = await Card.create(); 761 | final cardOrigCopy = Card.fromMap(cardOrig.toMap()); 762 | 763 | expect(cardOrig, equals(cardOrigCopy)); 764 | 765 | final review1Result = scheduler1.reviewCard(cardOrig, Rating.good); 766 | final cardReview1 = review1Result.card; 767 | final reviewLogReview1 = review1Result.reviewLog; 768 | 769 | final reviewLogReview1Copy = ReviewLog.fromMap(reviewLogReview1.toMap()); 770 | 771 | expect(cardOrig, isNot(equals(cardReview1))); 772 | expect(reviewLogReview1, equals(reviewLogReview1Copy)); 773 | 774 | final review2Result = scheduler1.reviewCard(cardReview1, Rating.good); 775 | final reviewLogReview2 = review2Result.reviewLog; 776 | 777 | expect(reviewLogReview1, isNot(equals(reviewLogReview2))); 778 | }); 779 | 780 | test('test_learning_card_rate_hard_one_learning_step', () async { 781 | const firstLearningStep = Duration(minutes: 10); 782 | 783 | final schedulerWithOneLearningStep = Scheduler( 784 | learningSteps: const [firstLearningStep], 785 | ); 786 | 787 | var card = await Card.create(); 788 | 789 | final initialDueDatetime = card.due; 790 | 791 | (card: card, reviewLog: _) = schedulerWithOneLearningStep.reviewCard( 792 | card, 793 | Rating.hard, 794 | reviewDateTime: card.due, 795 | ); 796 | 797 | expect(card.state, State.learning); 798 | 799 | final newDueDatetime = card.due; 800 | 801 | final intervalLength = newDueDatetime.difference(initialDueDatetime); 802 | 803 | final expectedIntervalLength = firstLearningStep * 1.5; 804 | 805 | const tolerance = Duration(seconds: 1); 806 | 807 | expect((intervalLength - expectedIntervalLength).abs(), 808 | lessThanOrEqualTo(tolerance)); 809 | }); 810 | 811 | test('test_learning_card_rate_hard_second_learning_step', () async { 812 | const firstLearningStep = Duration(minutes: 1); 813 | const secondLearningStep = Duration(minutes: 10); 814 | 815 | final schedulerWithTwoLearningSteps = Scheduler( 816 | learningSteps: const [firstLearningStep, secondLearningStep], 817 | ); 818 | 819 | var card = await Card.create(); 820 | 821 | expect(card.state, State.learning); 822 | expect(card.step, 0); 823 | 824 | (card: card, reviewLog: _) = schedulerWithTwoLearningSteps.reviewCard( 825 | card, 826 | Rating.good, 827 | reviewDateTime: card.due, 828 | ); 829 | 830 | expect(card.state, State.learning); 831 | expect(card.step, 1); 832 | 833 | final dueDatetimeAfterFirstReview = card.due; 834 | 835 | (card: card, reviewLog: _) = schedulerWithTwoLearningSteps.reviewCard( 836 | card, 837 | Rating.hard, 838 | reviewDateTime: dueDatetimeAfterFirstReview, 839 | ); 840 | 841 | final dueDatetimeAfterSecondReview = card.due; 842 | 843 | expect(card.state, State.learning); 844 | expect(card.step, 1); 845 | 846 | final intervalLength = 847 | dueDatetimeAfterSecondReview.difference(dueDatetimeAfterFirstReview); 848 | 849 | const expectedIntervalLength = secondLearningStep; 850 | 851 | const tolerance = Duration(seconds: 1); 852 | 853 | expect((intervalLength - expectedIntervalLength).abs(), 854 | lessThanOrEqualTo(tolerance)); 855 | }); 856 | 857 | test('test_long_term_stability_learning_state', () async { 858 | // NOTE: currently, this test is mostly to make sure that 859 | // the unit tests cover the case when a card in the relearning state 860 | // is not reviewed on the same day to run the non-same-day stability calculations 861 | 862 | final scheduler = Scheduler(); 863 | var card = await Card.create(); 864 | 865 | expect(card.state, State.learning); 866 | 867 | (card: card, reviewLog: _) = scheduler.reviewCard( 868 | card, 869 | Rating.easy, 870 | reviewDateTime: card.due, 871 | ); 872 | 873 | expect(card.state, State.review); 874 | 875 | (card: card, reviewLog: _) = scheduler.reviewCard( 876 | card, 877 | Rating.again, 878 | reviewDateTime: card.due, 879 | ); 880 | 881 | expect(card.state, State.relearning); 882 | 883 | final relearningCardDueDatetime = card.due; 884 | 885 | // a full day after its next due date 886 | final nextReviewDatetimeOneDayLate = 887 | relearningCardDueDatetime.add(const Duration(days: 1)); 888 | 889 | (card: card, reviewLog: _) = scheduler.reviewCard( 890 | card, 891 | Rating.good, 892 | reviewDateTime: nextReviewDatetimeOneDayLate, 893 | ); 894 | 895 | expect(card.state, State.review); 896 | }); 897 | 898 | test('test_relearning_card_rate_hard_one_relearning_step', () async { 899 | const firstRelearningStep = Duration(minutes: 10); 900 | 901 | final schedulerWithOneRelearningStep = Scheduler( 902 | relearningSteps: const [firstRelearningStep], 903 | ); 904 | 905 | var card = await Card.create(); 906 | 907 | (card: card, reviewLog: _) = schedulerWithOneRelearningStep.reviewCard( 908 | card, 909 | Rating.easy, 910 | reviewDateTime: card.due, 911 | ); 912 | 913 | expect(card.state, State.review); 914 | 915 | (card: card, reviewLog: _) = schedulerWithOneRelearningStep.reviewCard( 916 | card, 917 | Rating.again, 918 | reviewDateTime: card.due, 919 | ); 920 | 921 | expect(card.state, State.relearning); 922 | expect(card.step, 0); 923 | 924 | final prevDueDatetime = card.due; 925 | 926 | (card: card, reviewLog: _) = schedulerWithOneRelearningStep.reviewCard( 927 | card, 928 | Rating.hard, 929 | reviewDateTime: prevDueDatetime, 930 | ); 931 | 932 | expect(card.state, State.relearning); 933 | expect(card.step, 0); 934 | 935 | final newDueDatetime = card.due; 936 | 937 | final intervalLength = newDueDatetime.difference(prevDueDatetime); 938 | 939 | final expectedIntervalLength = firstRelearningStep * 1.5; 940 | 941 | const tolerance = Duration(seconds: 1); 942 | 943 | expect((intervalLength - expectedIntervalLength).abs(), 944 | lessThanOrEqualTo(tolerance)); 945 | }); 946 | 947 | test('test_relearning_card_rate_hard_two_relearning_steps', () async { 948 | const firstRelearningStep = Duration(minutes: 1); 949 | const secondRelearningStep = Duration(minutes: 10); 950 | 951 | final schedulerWithTwoRelearningSteps = Scheduler( 952 | relearningSteps: const [firstRelearningStep, secondRelearningStep], 953 | ); 954 | 955 | var card = await Card.create(); 956 | 957 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 958 | card, 959 | Rating.easy, 960 | reviewDateTime: card.due, 961 | ); 962 | 963 | expect(card.state, State.review); 964 | 965 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 966 | card, 967 | Rating.again, 968 | reviewDateTime: card.due, 969 | ); 970 | 971 | expect(card.state, State.relearning); 972 | expect(card.step, 0); 973 | 974 | var prevDueDatetime = card.due; 975 | 976 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 977 | card, 978 | Rating.hard, 979 | reviewDateTime: prevDueDatetime, 980 | ); 981 | 982 | expect(card.state, State.relearning); 983 | expect(card.step, 0); 984 | 985 | var newDueDatetime = card.due; 986 | 987 | var intervalLength = newDueDatetime.difference(prevDueDatetime); 988 | 989 | var expectedIntervalLength = 990 | (firstRelearningStep + secondRelearningStep) ~/ 2; 991 | 992 | var tolerance = Duration(seconds: 1); 993 | 994 | expect((intervalLength - expectedIntervalLength).abs(), 995 | lessThanOrEqualTo(tolerance)); 996 | 997 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 998 | card, 999 | Rating.good, 1000 | reviewDateTime: card.due, 1001 | ); 1002 | 1003 | expect(card.state, State.relearning); 1004 | expect(card.step, 1); 1005 | 1006 | prevDueDatetime = card.due; 1007 | 1008 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 1009 | card, 1010 | Rating.hard, 1011 | reviewDateTime: prevDueDatetime, 1012 | ); 1013 | 1014 | newDueDatetime = card.due; 1015 | 1016 | expect(card.state, State.relearning); 1017 | expect(card.step, 1); 1018 | 1019 | intervalLength = newDueDatetime.difference(prevDueDatetime); 1020 | 1021 | expectedIntervalLength = secondRelearningStep; 1022 | 1023 | tolerance = const Duration(seconds: 1); 1024 | 1025 | expect((intervalLength - expectedIntervalLength).abs(), 1026 | lessThanOrEqualTo(tolerance)); 1027 | 1028 | (card: card, reviewLog: _) = schedulerWithTwoRelearningSteps.reviewCard( 1029 | card, 1030 | Rating.easy, 1031 | reviewDateTime: prevDueDatetime, 1032 | ); 1033 | 1034 | expect(card.state, State.review); 1035 | expect(card.step, isNull); 1036 | }); 1037 | }); 1038 | } 1039 | --------------------------------------------------------------------------------