├── .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 |

3 |
4 |
5 |
6 | # Dart-FSRS
7 |
8 |
9 |
10 | 🧠🔄 Build your own Spaced Repetition System in Dart 🧠🔄
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------