active_recall 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b09e91a09c75f943e1bb2ced582d4520674053f5504f8e14fcfe370babbf4300
4
- data.tar.gz: 9acaaad5775d5ff38694260e9d66983f97cd614ff5c52fde3bc67c7e13d14b01
3
+ metadata.gz: 63a48f6351a76013bbf2922ca75b507b6dccb1ab23867896217b53bc6a77e26f
4
+ data.tar.gz: 9d8e3189bb8f5b1f15b284168e5973e87070bf223c0427b5a574cacd38c8e4b0
5
5
  SHA512:
6
- metadata.gz: 6fffb380767376c2ad2b4bec163b508b2a32a03a33b05eebe755e80e6b0c825c1fd2a53133ae33e2582379e55329ef7a8b32dfbc3565a4fd4bca6ca2a93c994e
7
- data.tar.gz: 982a9a742fb97e0cc9f0db89d6664f253c7f4534929b030d3ce78d9c3d164b08e2087cc31da79fbda4fb69b2e58e2bd05362a436d37743bebab574a1c529a78a
6
+ metadata.gz: efe496383029f35391f1b54533f525451c5ee8a02e440316b990b812923b2061fc94eeff2ed1d691b17a1838b2d266b51ec9dde7d3305d511b484edb53b606b7
7
+ data.tar.gz: ff9f67e94b77d93175af60926ad889655862989c18a9b2fb07b8e75503526b8281f32cd3a982ab1d70933363454e40461a10dd2be72784bc5097243173ff5b1e
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- active_recall (2.2.0)
4
+ active_recall (2.3.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
 
@@ -119,7 +119,7 @@ GEM
119
119
  net-smtp
120
120
  marcel (1.0.2)
121
121
  mini_mime (1.1.5)
122
- mini_portile2 (2.8.5)
122
+ mini_portile2 (2.8.9)
123
123
  minitest (5.18.1)
124
124
  mutex_m (0.2.0)
125
125
  net-imap (0.4.9.1)
@@ -135,6 +135,10 @@ GEM
135
135
  nokogiri (1.18.7)
136
136
  mini_portile2 (~> 2.8.2)
137
137
  racc (~> 1.4)
138
+ nokogiri (1.18.7-arm64-darwin)
139
+ racc (~> 1.4)
140
+ nokogiri (1.18.7-x86_64-darwin)
141
+ racc (~> 1.4)
138
142
  nokogiri (1.18.7-x86_64-linux-gnu)
139
143
  racc (~> 1.4)
140
144
  parallel (1.24.0)
@@ -219,6 +223,8 @@ GEM
219
223
  ruby2_keywords (0.0.5)
220
224
  sqlite3 (2.5.0)
221
225
  mini_portile2 (~> 2.8.0)
226
+ sqlite3 (2.5.0-arm64-darwin)
227
+ sqlite3 (2.5.0-x86_64-darwin)
222
228
  sqlite3 (2.5.0-x86_64-linux-gnu)
223
229
  standard (1.35.1)
224
230
  language_server-protocol (~> 3.17.0.2)
@@ -244,7 +250,9 @@ GEM
244
250
  zeitwerk (2.6.12)
245
251
 
246
252
  PLATFORMS
253
+ arm64-darwin
247
254
  ruby
255
+ x86_64-darwin
248
256
  x86_64-linux
249
257
 
250
258
  DEPENDENCIES
data/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # ActiveRecall
2
2
 
3
3
  **ActiveRecall** is a spaced-repetition system that allows you to treat arbitrary [ActiveRecord](https://github.com/rails/rails/tree/master/activerecord) models as if they were flashcards to be learned and reviewed.
4
- It it based on, and is intended to be backwards compatible with, the [okubo](https://github.com/rgravina/okubo) gem.
4
+ It is based on, and is intended to be backwards compatible with, the [okubo](https://github.com/rgravina/okubo) gem.
5
5
  The primary differentiating features are that it lets the user specify the scheduling algorithm and is fully compatible with (and requires) Rails 6+ and Ruby 3+.
6
6
 
7
7
  ## Installation
@@ -28,17 +28,13 @@ Or install it yourself as:
28
28
 
29
29
  $ gem install active_recall
30
30
 
31
- ## Usage
32
- You can configure the desired SRS algorithm during runtime:
33
- ```ruby
34
- ActiveRecall.configure do |config|
35
- config.algorithm_class = ActiveRecall::FibonacciSequence
36
- end
37
- ```
38
- Algorithms include `FibonacciSequence`, `LeitnerSystem`, `SoftLeitnerSystem`, and `SM2` (see [here](https://en.wikipedia.org/wiki/SuperMemo#Description_of_SM-2_algorithm)).
39
- For Rails applications, try doing this from within an [initializer file](https://guides.rubyonrails.org/configuring.html#using-initializer-files).
31
+ The generator creates all the migrations any algorithm needs (including the `easiness_factor` column for SM2 and the FSRS-specific columns), so you don't have to revisit migrations when you switch algorithms later.
40
32
 
41
- Assume you have an application allowing your users to study words in a foreign language. Using the `has_deck` method you can set up a deck of flashcards that the user will study:
33
+ ## Quick Start
34
+
35
+ The fastest way to get going — no algorithm choice, no grade scale, just right/wrong feedback. This uses the default `LeitnerSystem`.
36
+
37
+ Suppose you have an application allowing your users to study words in a foreign language. Use `has_deck` to set up a deck of flashcards:
42
38
 
43
39
  ```ruby
44
40
  class Word < ActiveRecord::Base
@@ -48,18 +44,76 @@ class User < ActiveRecord::Base
48
44
  has_deck :words
49
45
  end
50
46
 
51
- user = User.create!(:name => "Robert")
52
- word = Word.create!(:kanji => "日本語", :kana => "にほんご", :translation => "Japanese language")
47
+ user = User.create!(name: "Robert")
48
+ word = Word.create!(kanji: "日本語", kana: "にほんご", translation: "Japanese language")
49
+
50
+ user.words << word
51
+ user.words.untested #=> [word]
52
+
53
+ user.right_answer_for!(word)
54
+ user.words.known #=> [word]
55
+
56
+ user.wrong_answer_for!(word)
57
+ user.words.failed #=> [word]
58
+ ```
59
+
60
+ That's it. Want graded feedback (Again/Hard/Good/Easy) or modern scheduling? See [Choosing an Algorithm](#choosing-an-algorithm) below.
61
+
62
+ ## Choosing an Algorithm
63
+
64
+ > **Not sure which to pick?** Stick with the default `LeitnerSystem` — it works out of the box and only needs right/wrong feedback. Reach for `FSRS` when you want modern, evidence-based scheduling and are willing to collect 1–4 ratings ("Again / Hard / Good / Easy") from users.
65
+
66
+ The full menu, in increasing order of sophistication:
67
+
68
+ | Algorithm | Type | How you grade | Reach for it when |
69
+ |---|---|---|---|
70
+ | **`LeitnerSystem`** *(default — start here)* | binary | `right_answer_for!` / `wrong_answer_for!` | You want the simplest thing that works |
71
+ | `SoftLeitnerSystem` | binary | `right_answer_for!` / `wrong_answer_for!` | Leitner is too punishing on occasional lapses |
72
+ | `FibonacciSequence` | binary | `right_answer_for!` / `wrong_answer_for!` | You want faster-growing intervals than Leitner |
73
+ | `SM2` | gradable | `score!(0..5, item)` | You want the classic SuperMemo behavior users know from Anki |
74
+ | **`FSRS`** *(modern recommendation)* | gradable | `score!(1..4, item)` | You're building something serious and want best-in-class retention |
75
+
76
+ **Binary** algorithms expect right-or-wrong feedback (`user.right_answer_for!(item)` / `user.wrong_answer_for!(item)`). **Gradable** algorithms expect a numeric grade per review (`user.score!(grade, item)`). Mixing them — e.g. calling `right_answer_for!` while configured to use SM2 — raises `ActiveRecall::IncompatibleAlgorithmError`.
77
+
78
+ ## Configuration
79
+
80
+ Skip this section if you're sticking with the default `LeitnerSystem` — there's nothing to configure.
81
+
82
+ To switch algorithms, set `algorithm_class` from a Rails [initializer file](https://guides.rubyonrails.org/configuring.html#using-initializer-files):
83
+
84
+ ```ruby
85
+ # config/initializers/active_recall.rb
86
+ ActiveRecall.configure do |config|
87
+ config.algorithm_class = ActiveRecall::FSRS # or SM2, SoftLeitnerSystem, FibonacciSequence
88
+ end
89
+ ```
90
+
91
+ ### FSRS-specific configuration
92
+
93
+ FSRS exposes three optional knobs. All have sensible defaults; tune only if you have a reason to:
94
+
95
+ - `fsrs_request_retention` — target retention probability (default `0.9`). Lower → longer intervals, more forgetting tolerated.
96
+ - `fsrs_maximum_interval` — caps the scheduled interval, in days.
97
+ - `fsrs_weights` — array of FSRS weights for advanced tuning.
98
+
99
+ ```ruby
100
+ ActiveRecall.configure do |config|
101
+ config.algorithm_class = ActiveRecall::FSRS
102
+ config.fsrs_request_retention = 0.85
103
+ config.fsrs_maximum_interval = 365
104
+ end
53
105
  ```
54
106
 
55
- You can add words and record attempts to guess the word as right or wrong. Various methods exist to allow you to access subsets of this collection:
107
+ ## Usage with binary algorithms
108
+
109
+ Applies to `LeitnerSystem`, `SoftLeitnerSystem`, and `FibonacciSequence`.
56
110
 
57
111
  ```ruby
58
112
  # Initially adding a word
59
113
  user.words << word
60
114
  user.words.untested #=> [word]
61
115
 
62
- # Guessing a word correctly (when using a binary algorithm)
116
+ # Guessing a word correctly
63
117
  user.right_answer_for!(word)
64
118
  user.words.known #=> [word]
65
119
 
@@ -75,11 +129,11 @@ As time passes, words need to be reviewed to keep them fresh in memory:
75
129
 
76
130
  ```ruby
77
131
  # Three days later...
78
- user.words.known #=> []
132
+ user.words.known #=> []
79
133
  user.words.expired #=> [word]
80
134
  ```
81
135
 
82
- Guessing a word correctly several times in a row results in the word taking longer to expire, and demonstrates mastery of that word.
136
+ Guessing a word correctly several times in a row makes the word take longer to expire, demonstrating mastery:
83
137
 
84
138
  ```ruby
85
139
  user.right_answer_for!(word)
@@ -93,21 +147,55 @@ user.right_answer_for!(word)
93
147
  user.words.expired #=> [word]
94
148
  ```
95
149
 
96
- When using a gradable algorithm (rather than binary) such as the SM2 algorithm, you will need to supply your own grade along with the item:
150
+ ## Usage with SM2
151
+
152
+ [SM2](https://en.wikipedia.org/wiki/SuperMemo#Description_of_SM-2_algorithm) uses a 0–5 grade scale:
153
+
154
+ | Grade | Meaning |
155
+ |---|---|
156
+ | `5` | Perfect response |
157
+ | `4` | Correct response after a hesitation |
158
+ | `3` | Correct response recalled with serious difficulty |
159
+ | `2` | Incorrect response, but close |
160
+ | `1` | Incorrect response with familiarity |
161
+ | `0` | Complete blackout |
162
+
163
+ Grades **≥ 3** count as a success: the box advances and `times_right` increments. Grades **< 3** reset the box to `0` and increment `times_wrong`. Each item's `easiness_factor` starts at `2.5` and is clamped to a minimum of `1.3`.
164
+
165
+ ```ruby
166
+ user.words << word
167
+
168
+ user.score!(5, word) # perfect recall — box advances, EF rises
169
+ user.score!(2, word) # incorrect — box resets to 0
170
+ ```
171
+
172
+ Calling `user.right_answer_for!(word)` while SM2 is configured raises `ActiveRecall::IncompatibleAlgorithmError` — use `score!` instead.
173
+
174
+ ## Usage with FSRS
175
+
176
+ [FSRS](https://github.com/open-spaced-repetition/fsrs4anki) uses a 1–4 grade scale matching the familiar Anki buttons:
177
+
178
+ | Grade | Meaning |
179
+ |---|---|
180
+ | `1` | Again (lapse) |
181
+ | `2` | Hard |
182
+ | `3` | Good |
183
+ | `4` | Easy |
184
+
185
+ FSRS tracks `stability`, `difficulty`, `state`, and `lapses` per item. Those columns are added automatically by `rails generate active_recall` — no extra setup needed.
186
+
97
187
  ```ruby
98
- grade = 3
99
- user.score!(grade, word)
188
+ user.words << word
100
189
 
101
- # Using the binary-only methods will raise an error
102
- user.right_answer_for!(word)
103
- => ActiveRecall::IncompatibleAlgorithmError
190
+ user.score!(3, word) # "Good" typical successful recall
191
+ user.score!(1, word) # "Again" — counts as a lapse
104
192
  ```
105
193
 
106
- Reviewing
107
- ---------
194
+ Calling `user.right_answer_for!(word)` while FSRS is configured raises `ActiveRecall::IncompatibleAlgorithmError` — use `score!` instead.
195
+
196
+ ## Reviewing
108
197
 
109
- In addition to an `expired` method, ActiveRecall provides a suggested reviewing sequence for all unknown words in the deck.
110
- Words are randomly chosen from all untested words, failed, and finally expired in order of precedence.
198
+ In addition to the `expired` scope, ActiveRecall provides a suggested reviewing sequence for all unknown words in the deck. Words are randomly chosen from `untested`, `failed`, and `expired` items, in that order of precedence. This works the same for every algorithm.
111
199
 
112
200
  ```ruby
113
201
  user.words.review #=> [word]
@@ -0,0 +1,54 @@
1
+ # Vendored Licenses
2
+
3
+ This file records third-party source code that has been vendored into this
4
+ repository, along with the upstream license terms. ActiveRecall's own license
5
+ is in [LICENSE](LICENSE).
6
+
7
+ ## rb-fsrs
8
+
9
+ - **Vendored at:** [`lib/active_recall/algorithms/fsrs/internal.rb`](lib/active_recall/algorithms/fsrs/internal.rb)
10
+ - **Upstream:** https://github.com/open-spaced-repetition/rb-fsrs
11
+ - **Version:** 0.9.0 (commit pulled from the published gem)
12
+ - **License:** MIT
13
+ - **Reason for vendoring:** The published `fsrs` 0.9.0 gem pins
14
+ `activesupport ~> 7.0`, which excludes Rails 8. ActiveRecall supports
15
+ Rails 8, so the code was vendored under `ActiveRecall::FSRS::Internal`.
16
+ The constraint has been widened on rb-fsrs `master`; revisit the
17
+ dependency-vs-vendoring decision once a release with the wider
18
+ constraint ships.
19
+
20
+ ### Local divergences from upstream
21
+
22
+ - `Scheduler#schedule_new_state` uses `1.minute` / `5.minutes` /
23
+ `10.minutes` in place of upstream's bare integer arithmetic
24
+ (`now + 60`, `now + (5 * 60)`, `now + (10 * 60)`). With `now` as a
25
+ `DateTime`, the upstream form adds days, not seconds, scheduling new
26
+ cards rated Again/Hard/Good 60 / 300 / 600 days out instead of
27
+ 1 / 5 / 10 minutes. Tracks upstream PR
28
+ https://github.com/open-spaced-repetition/rb-fsrs/pull/9.
29
+
30
+ ### Upstream license text
31
+
32
+ ```
33
+ The MIT License (MIT)
34
+
35
+ Copyright (c) 2024 clayton
36
+
37
+ Permission is hereby granted, free of charge, to any person obtaining a copy
38
+ of this software and associated documentation files (the "Software"), to deal
39
+ in the Software without restriction, including without limitation the rights
40
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
41
+ copies of the Software, and to permit persons to whom the Software is
42
+ furnished to do so, subject to the following conditions:
43
+
44
+ The above copyright notice and this permission notice shall be included in
45
+ all copies or substantial portions of the Software.
46
+
47
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
48
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
49
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
50
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
51
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
52
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
53
+ THE SOFTWARE.
54
+ ```
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_recall (2.2.0)
4
+ active_recall (2.3.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
 
@@ -132,8 +132,15 @@ GEM
132
132
  net-smtp (0.4.0.1)
133
133
  net-protocol
134
134
  nio4r (2.7.0)
135
+ nokogiri (1.18.7)
136
+ mini_portile2 (~> 2.8.2)
137
+ racc (~> 1.4)
135
138
  nokogiri (1.18.7-arm64-darwin)
136
139
  racc (~> 1.4)
140
+ nokogiri (1.18.7-x86_64-darwin)
141
+ racc (~> 1.4)
142
+ nokogiri (1.18.7-x86_64-linux-gnu)
143
+ racc (~> 1.4)
137
144
  parallel (1.24.0)
138
145
  parser (3.3.1.0)
139
146
  ast (~> 2.4.1)
@@ -244,9 +251,10 @@ GEM
244
251
  zeitwerk (2.6.12)
245
252
 
246
253
  PLATFORMS
247
- arm64-darwin-23
248
- arm64-darwin-24
249
- arm64-darwin-25
254
+ arm64-darwin
255
+ ruby
256
+ x86_64-darwin
257
+ x86_64-linux
250
258
 
251
259
  DEPENDENCIES
252
260
  active_recall!
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_recall (2.2.0)
4
+ active_recall (2.3.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
 
@@ -132,8 +132,15 @@ GEM
132
132
  net-smtp (0.4.0.1)
133
133
  net-protocol
134
134
  nio4r (2.7.0)
135
+ nokogiri (1.18.7)
136
+ mini_portile2 (~> 2.8.2)
137
+ racc (~> 1.4)
135
138
  nokogiri (1.18.7-arm64-darwin)
136
139
  racc (~> 1.4)
140
+ nokogiri (1.18.7-x86_64-darwin)
141
+ racc (~> 1.4)
142
+ nokogiri (1.18.7-x86_64-linux-gnu)
143
+ racc (~> 1.4)
137
144
  parallel (1.24.0)
138
145
  parser (3.3.1.0)
139
146
  ast (~> 2.4.1)
@@ -244,9 +251,10 @@ GEM
244
251
  zeitwerk (2.6.12)
245
252
 
246
253
  PLATFORMS
247
- arm64-darwin-23
248
- arm64-darwin-24
249
- arm64-darwin-25
254
+ arm64-darwin
255
+ ruby
256
+ x86_64-darwin
257
+ x86_64-linux
250
258
 
251
259
  DEPENDENCIES
252
260
  active_recall!
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_recall (2.2.0)
4
+ active_recall (2.3.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
 
@@ -117,6 +117,7 @@ GEM
117
117
  net-smtp
118
118
  marcel (1.0.4)
119
119
  mini_mime (1.1.5)
120
+ mini_portile2 (2.8.9)
120
121
  minitest (5.25.4)
121
122
  net-imap (0.5.4)
122
123
  date
@@ -128,8 +129,15 @@ GEM
128
129
  net-smtp (0.5.0)
129
130
  net-protocol
130
131
  nio4r (2.7.4)
132
+ nokogiri (1.18.0)
133
+ mini_portile2 (~> 2.8.2)
134
+ racc (~> 1.4)
131
135
  nokogiri (1.18.0-arm64-darwin)
132
136
  racc (~> 1.4)
137
+ nokogiri (1.18.0-x86_64-darwin)
138
+ racc (~> 1.4)
139
+ nokogiri (1.18.0-x86_64-linux-gnu)
140
+ racc (~> 1.4)
133
141
  parallel (1.26.3)
134
142
  parser (3.3.6.0)
135
143
  ast (~> 2.4.1)
@@ -211,7 +219,11 @@ GEM
211
219
  rubocop-ast (>= 1.31.1, < 2.0)
212
220
  ruby-progressbar (1.13.0)
213
221
  securerandom (0.4.1)
222
+ sqlite3 (2.5.0)
223
+ mini_portile2 (~> 2.8.0)
214
224
  sqlite3 (2.5.0-arm64-darwin)
225
+ sqlite3 (2.5.0-x86_64-darwin)
226
+ sqlite3 (2.5.0-x86_64-linux-gnu)
215
227
  standard (1.43.0)
216
228
  language_server-protocol (~> 3.17.0.2)
217
229
  lint_roller (~> 1.0)
@@ -240,9 +252,10 @@ GEM
240
252
  zeitwerk (2.7.1)
241
253
 
242
254
  PLATFORMS
243
- arm64-darwin-23
244
- arm64-darwin-24
245
- arm64-darwin-25
255
+ arm64-darwin
256
+ ruby
257
+ x86_64-darwin
258
+ x86_64-linux
246
259
 
247
260
  DEPENDENCIES
248
261
  active_recall!
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Vendored from rb-fsrs 0.9.0 (https://github.com/open-spaced-repetition/rb-fsrs).
4
+ # MIT License, Copyright (c) clayton. See VENDORED_LICENSES.md for the full notice.
5
+ #
6
+ # Pulled into ActiveRecall::FSRS::Internal because the published rb-fsrs gem
7
+ # pins activesupport to ~> 7.0, which excludes Rails 8 — incompatible with
8
+ # this gem's Rails 8 support. Re-evaluate when an upstream release widens
9
+ # the constraint (master already does).
10
+ #
11
+ # Local divergence from upstream:
12
+ # - schedule_new_state uses 1.minute / 5.minutes / 10.minutes instead of
13
+ # bare integers (which DateTime treats as days). Tracks upstream PR
14
+ # https://github.com/open-spaced-repetition/rb-fsrs/pull/9; remove this
15
+ # patch once a release with that fix is available.
16
+
17
+ require "date"
18
+
19
+ module ActiveRecall
20
+ class FSRS
21
+ module Internal
22
+ class InvalidDateError < StandardError
23
+ def initialize(msg = "Date must be UTC and timezone-aware")
24
+ super
25
+ end
26
+ end
27
+
28
+ class SchedulingInfo
29
+ attr_accessor :card, :review_log
30
+
31
+ def initialize(card, review_log)
32
+ @card = card
33
+ @review_log = review_log
34
+ end
35
+ end
36
+
37
+ class ReviewLog
38
+ attr_accessor :rating, :scheduled_days, :elapsed_days, :review, :state
39
+
40
+ def initialize(rating, scheduled_days, elapsed_days, review, state)
41
+ @rating = rating
42
+ @scheduled_days = scheduled_days
43
+ @elapsed_days = elapsed_days
44
+ @review = review
45
+ @state = state
46
+ end
47
+ end
48
+
49
+ class Rating
50
+ AGAIN = 1
51
+ HARD = 2
52
+ GOOD = 3
53
+ EASY = 4
54
+ end
55
+
56
+ class State
57
+ NEW = 0
58
+ LEARNING = 1
59
+ REVIEW = 2
60
+ RELEARNING = 3
61
+ end
62
+
63
+ class Card
64
+ attr_accessor :due, :stability, :difficulty, :elapsed_days,
65
+ :scheduled_days, :reps, :lapses, :state, :last_review
66
+
67
+ def initialize
68
+ @due = DateTime.new
69
+ @stability = 0.0
70
+ @difficulty = 0.0
71
+ @elapsed_days = 0
72
+ @scheduled_days = 0
73
+ @reps = 0
74
+ @lapses = 0
75
+ @state = State::NEW
76
+ end
77
+
78
+ def get_retrievability(now)
79
+ decay = -0.5
80
+ factor = (0.9**(1 / decay)) - 1
81
+
82
+ return nil unless @state == State::REVIEW
83
+
84
+ elapsed_days = [0, (now - @last_review).to_i].max
85
+ (1 + (factor * elapsed_days / @stability))**decay
86
+ end
87
+
88
+ def deep_clone
89
+ Marshal.load(Marshal.dump(self))
90
+ end
91
+ end
92
+
93
+ class CardScheduler
94
+ attr_accessor :again, :hard, :good, :easy
95
+
96
+ def initialize(card)
97
+ @again = card.clone
98
+ @hard = card.clone
99
+ @good = card.clone
100
+ @easy = card.clone
101
+ end
102
+
103
+ def update_state(state)
104
+ case state
105
+ when State::NEW
106
+ update_new_state
107
+ when State::LEARNING, State::RELEARNING
108
+ update_learning_relearning_state(state)
109
+ when State::REVIEW
110
+ update_review_state
111
+ end
112
+ end
113
+
114
+ def schedule(now, hard_interval, good_interval, easy_interval)
115
+ update_schedule_days(hard_interval, good_interval, easy_interval)
116
+ update_due_dates(now, hard_interval, good_interval, easy_interval)
117
+ end
118
+
119
+ def record_log(card, now)
120
+ {
121
+ Rating::AGAIN => record_again_log(card, now),
122
+ Rating::HARD => record_hard_log(card, now),
123
+ Rating::GOOD => record_good_log(card, now),
124
+ Rating::EASY => record_easy_log(card, now)
125
+ }
126
+ end
127
+
128
+ private
129
+
130
+ def update_due_dates(now, hard_interval, good_interval, easy_interval)
131
+ @again.due = now + 5.minutes
132
+ @hard.due = hard_interval.positive? ? now + hard_interval.days : now + 10.minutes
133
+ @good.due = now + good_interval.days
134
+ @easy.due = now + easy_interval.days
135
+ end
136
+
137
+ def update_schedule_days(hard_interval, good_interval, easy_interval)
138
+ @again.scheduled_days = 0
139
+ @hard.scheduled_days = hard_interval
140
+ @good.scheduled_days = good_interval
141
+ @easy.scheduled_days = easy_interval
142
+ end
143
+
144
+ def update_new_state
145
+ @again.state = State::LEARNING
146
+ @hard.state = State::LEARNING
147
+ @good.state = State::LEARNING
148
+ @easy.state = State::REVIEW
149
+ end
150
+
151
+ def update_learning_relearning_state(state)
152
+ @again.state = state
153
+ @hard.state = state
154
+ @good.state = State::REVIEW
155
+ @easy.state = State::REVIEW
156
+ end
157
+
158
+ def update_review_state
159
+ @again.state = State::RELEARNING
160
+ @hard.state = State::REVIEW
161
+ @good.state = State::REVIEW
162
+ @easy.state = State::REVIEW
163
+ @again.lapses += 1
164
+ end
165
+
166
+ def record_again_log(card, now)
167
+ SchedulingInfo.new(
168
+ @again,
169
+ ReviewLog.new(Rating::AGAIN, @again.scheduled_days, card.elapsed_days, now, card.state)
170
+ )
171
+ end
172
+
173
+ def record_hard_log(card, now)
174
+ SchedulingInfo.new(
175
+ @hard,
176
+ ReviewLog.new(Rating::HARD, @hard.scheduled_days, card.elapsed_days, now, card.state)
177
+ )
178
+ end
179
+
180
+ def record_good_log(card, now)
181
+ SchedulingInfo.new(
182
+ @good,
183
+ ReviewLog.new(Rating::GOOD, @good.scheduled_days, card.elapsed_days, now, card.state)
184
+ )
185
+ end
186
+
187
+ def record_easy_log(card, now)
188
+ SchedulingInfo.new(
189
+ @easy,
190
+ ReviewLog.new(Rating::EASY, @easy.scheduled_days, card.elapsed_days, now, card.state)
191
+ )
192
+ end
193
+ end
194
+
195
+ class Parameters
196
+ attr_accessor :request_retention, :maximum_interval, :w
197
+
198
+ def initialize
199
+ @request_retention = 0.9
200
+ @maximum_interval = 36_500
201
+ @w = [
202
+ 0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14,
203
+ 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61
204
+ ]
205
+ end
206
+ end
207
+
208
+ class Scheduler
209
+ attr_accessor :p, :decay, :factor
210
+
211
+ def initialize
212
+ @p = Parameters.new
213
+ @decay = -0.5
214
+ @factor = (0.9**(1 / @decay)) - 1
215
+ end
216
+
217
+ def repeat(card, now)
218
+ raise InvalidDateError unless now.utc?
219
+
220
+ card = card.clone
221
+ card.elapsed_days = if card.state == State::NEW
222
+ 0
223
+ else
224
+ (now - card.last_review).to_i
225
+ end
226
+ card.last_review = now
227
+ card.reps += 1
228
+ card_scheduler = CardScheduler.new(card)
229
+ card_scheduler.update_state(card.state)
230
+
231
+ case card.state
232
+ when State::NEW
233
+ schedule_new_state(card_scheduler, now)
234
+ when State::LEARNING, State::RELEARNING
235
+ schedule_learning_relearning_state(card_scheduler, now)
236
+ when State::REVIEW
237
+ schedule_review_state(card_scheduler, card, now)
238
+ end
239
+ card_scheduler.record_log(card, now)
240
+ end
241
+
242
+ def schedule_new_state(s, now)
243
+ init_ds(s)
244
+ s.again.due = now + 1.minute
245
+ s.hard.due = now + 5.minutes
246
+ s.good.due = now + 10.minutes
247
+ easy_interval = next_interval(s.easy.stability)
248
+ s.easy.scheduled_days = easy_interval
249
+ s.easy.due = now + easy_interval.days
250
+ end
251
+
252
+ def schedule_learning_relearning_state(s, now)
253
+ hard_interval = 0
254
+ good_interval = next_interval(s.good.stability)
255
+ easy_interval = [next_interval(s.easy.stability), good_interval + 1].max
256
+ s.schedule(now, hard_interval, good_interval, easy_interval)
257
+ end
258
+
259
+ def schedule_review_state(s, card, now)
260
+ interval = card.elapsed_days
261
+ last_d = card.difficulty
262
+ last_s = card.stability
263
+ retrievability = forgetting_curve(interval, last_s)
264
+ next_ds(s, last_d, last_s, retrievability)
265
+ hard_interval = next_interval(s.hard.stability)
266
+ good_interval = next_interval(s.good.stability)
267
+ hard_interval = [hard_interval, good_interval].min
268
+ good_interval = [good_interval, hard_interval + 1].max
269
+ easy_interval = [next_interval(s.easy.stability), good_interval + 1].max
270
+ s.schedule(now, hard_interval, good_interval, easy_interval)
271
+ end
272
+
273
+ def init_ds(s)
274
+ s.again.difficulty = init_difficulty(Rating::AGAIN)
275
+ s.again.stability = init_stability(Rating::AGAIN)
276
+ s.hard.difficulty = init_difficulty(Rating::HARD)
277
+ s.hard.stability = init_stability(Rating::HARD)
278
+ s.good.difficulty = init_difficulty(Rating::GOOD)
279
+ s.good.stability = init_stability(Rating::GOOD)
280
+ s.easy.difficulty = init_difficulty(Rating::EASY)
281
+ s.easy.stability = init_stability(Rating::EASY)
282
+ end
283
+
284
+ def next_ds(s, last_d, last_s, retrievability)
285
+ s.again.difficulty = next_difficulty(last_d, Rating::AGAIN)
286
+ s.again.stability = next_forget_stability(last_d, last_s, retrievability)
287
+ s.hard.difficulty = next_difficulty(last_d, Rating::HARD)
288
+ s.hard.stability = next_recall_stability(last_d, last_s, retrievability, Rating::HARD)
289
+ s.good.difficulty = next_difficulty(last_d, Rating::GOOD)
290
+ s.good.stability = next_recall_stability(last_d, last_s, retrievability, Rating::GOOD)
291
+ s.easy.difficulty = next_difficulty(last_d, Rating::EASY)
292
+ s.easy.stability = next_recall_stability(last_d, last_s, retrievability, Rating::EASY)
293
+ end
294
+
295
+ def init_stability(r)
296
+ [p.w[r - 1], 0.1].max
297
+ end
298
+
299
+ def init_difficulty(r)
300
+ (p.w[4] - (p.w[5] * (r - 3))).clamp(1, 10)
301
+ end
302
+
303
+ def forgetting_curve(elapsed_days, stability)
304
+ (1 + (factor * elapsed_days / stability))**decay
305
+ end
306
+
307
+ def next_interval(s)
308
+ new_interval = s / factor * ((p.request_retention**(1 / decay)) - 1)
309
+ new_interval.round.clamp(1, p.maximum_interval)
310
+ end
311
+
312
+ def next_difficulty(d, r)
313
+ next_d = d - (p.w[6] * (r - 3))
314
+ mean_reversion(p.w[4], next_d).clamp(1, 10)
315
+ end
316
+
317
+ def mean_reversion(init, current)
318
+ (p.w[7] * init) + ((1 - p.w[7]) * current)
319
+ end
320
+
321
+ def next_recall_stability(d, s, r, rating)
322
+ hard_penalty = (rating == Rating::HARD) ? p.w[15] : 1
323
+ easy_bonus = (rating == Rating::EASY) ? p.w[16] : 1
324
+ s * (1 + (Math.exp(p.w[8]) * (11 - d) * (s**-p.w[9]) *
325
+ (Math.exp((1 - r) * p.w[10]) - 1) * hard_penalty * easy_bonus))
326
+ end
327
+
328
+ def next_forget_stability(d, s, r)
329
+ p.w[11] * (d**-p.w[12]) * (((s + 1)**p.w[13]) - 1) *
330
+ Math.exp((1 - r) * p.w[14])
331
+ end
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_recall/algorithms/fsrs/internal"
4
+
5
+ module ActiveRecall
6
+ class FSRS
7
+ REQUIRED_ATTRIBUTES = [
8
+ :box,
9
+ :stability,
10
+ :difficulty,
11
+ :state,
12
+ :lapses,
13
+ :elapsed_days,
14
+ :scheduled_days,
15
+ :times_right,
16
+ :times_wrong,
17
+ :last_reviewed,
18
+ :grade
19
+ ].freeze
20
+
21
+ GRADE_TO_RATING = {
22
+ 1 => Internal::Rating::AGAIN,
23
+ 2 => Internal::Rating::HARD,
24
+ 3 => Internal::Rating::GOOD,
25
+ 4 => Internal::Rating::EASY
26
+ }.freeze
27
+
28
+ def self.required_attributes
29
+ REQUIRED_ATTRIBUTES
30
+ end
31
+
32
+ def self.type
33
+ :gradable
34
+ end
35
+
36
+ def self.score(**kwargs)
37
+ new(**kwargs).score
38
+ end
39
+
40
+ def initialize(box:, stability:, difficulty:, state:, lapses:,
41
+ elapsed_days:, scheduled_days:, times_right:, times_wrong:,
42
+ last_reviewed:, grade:, current_time: Time.current)
43
+ @box = box || 0
44
+ @stability = stability
45
+ @difficulty = difficulty
46
+ @state = state || Internal::State::NEW
47
+ @lapses = lapses || 0
48
+ @elapsed_days = elapsed_days || 0
49
+ @scheduled_days = scheduled_days || 0
50
+ @times_right = times_right || 0
51
+ @times_wrong = times_wrong || 0
52
+ @last_reviewed = last_reviewed
53
+ @grade = grade
54
+ @current_time = current_time
55
+ end
56
+
57
+ def score
58
+ raise "Grade must be between 1-4!" unless GRADE_TO_RATING.key?(@grade)
59
+
60
+ now = to_utc_datetime(@current_time)
61
+ scheduling = scheduler.repeat(build_card, now)
62
+ result = scheduling[GRADE_TO_RATING[@grade]].card
63
+
64
+ {
65
+ box: result.reps,
66
+ stability: result.stability,
67
+ difficulty: result.difficulty,
68
+ state: result.state,
69
+ lapses: result.lapses,
70
+ elapsed_days: result.elapsed_days,
71
+ scheduled_days: result.scheduled_days,
72
+ last_reviewed: result.last_review,
73
+ next_review: result.due,
74
+ times_right: @times_right + ((@grade >= 2) ? 1 : 0),
75
+ times_wrong: @times_wrong + ((@grade == 1) ? 1 : 0)
76
+ }
77
+ end
78
+
79
+ private
80
+
81
+ def build_card
82
+ card = Internal::Card.new
83
+ card.stability = @stability if @stability
84
+ card.difficulty = @difficulty if @difficulty
85
+ card.state = @state
86
+ card.lapses = @lapses
87
+ card.elapsed_days = @elapsed_days
88
+ card.scheduled_days = @scheduled_days
89
+ card.reps = @box
90
+ card.last_review = to_utc_datetime(@last_reviewed) if @last_reviewed
91
+ card
92
+ end
93
+
94
+ def scheduler
95
+ scheduler = Internal::Scheduler.new
96
+ config = ActiveRecall.configuration
97
+ scheduler.p.request_retention = config.fsrs_request_retention if config.fsrs_request_retention
98
+ scheduler.p.maximum_interval = config.fsrs_maximum_interval if config.fsrs_maximum_interval
99
+ scheduler.p.w = config.fsrs_weights if config.fsrs_weights
100
+ scheduler
101
+ end
102
+
103
+ def to_utc_datetime(value)
104
+ case value
105
+ when DateTime then value.new_offset(0)
106
+ else value.utc.to_datetime
107
+ end
108
+ end
109
+ end
110
+ end
@@ -2,7 +2,10 @@
2
2
 
3
3
  module ActiveRecall
4
4
  class Configuration
5
- attr_accessor :algorithm_class
5
+ attr_accessor :algorithm_class,
6
+ :fsrs_request_retention,
7
+ :fsrs_maximum_interval,
8
+ :fsrs_weights
6
9
 
7
10
  def initialize
8
11
  @algorithm_class = LeitnerSystem
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecall
4
- VERSION = "2.2.0"
4
+ VERSION = "2.3.0"
5
5
  end
data/lib/active_recall.rb CHANGED
@@ -7,6 +7,7 @@ require "active_recall/algorithms/fibonacci_sequence"
7
7
  require "active_recall/algorithms/leitner_system"
8
8
  require "active_recall/algorithms/soft_leitner_system"
9
9
  require "active_recall/algorithms/sm2"
10
+ require "active_recall/algorithms/fsrs"
10
11
  require "active_recall/configuration"
11
12
  require "active_recall/models/deck"
12
13
  require "active_recall/models/item"
@@ -21,6 +21,7 @@ class ActiveRecallGenerator < Rails::Generators::Base
21
21
  create_migration_file_if_not_exist "create_active_recall_tables"
22
22
  create_migration_file_if_not_exist "add_active_recall_item_answer_counts"
23
23
  create_migration_file_if_not_exist "add_active_recall_item_easiness_factor"
24
+ create_migration_file_if_not_exist "add_active_recall_item_fsrs_fields"
24
25
  create_migration_file_if_not_exist "migrate_okubo_to_active_recall" if options["migrate_data"]
25
26
  end
26
27
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddActiveRecallItemFsrsFields < ActiveRecord::Migration[5.2]
4
+ def self.up
5
+ add_column :active_recall_items, :stability, :float
6
+ add_column :active_recall_items, :difficulty, :float
7
+ add_column :active_recall_items, :state, :integer, default: 0
8
+ add_column :active_recall_items, :lapses, :integer, default: 0
9
+ add_column :active_recall_items, :elapsed_days, :integer, default: 0
10
+ add_column :active_recall_items, :scheduled_days, :integer, default: 0
11
+ end
12
+
13
+ def self.down
14
+ remove_column :active_recall_items, :stability
15
+ remove_column :active_recall_items, :difficulty
16
+ remove_column :active_recall_items, :state
17
+ remove_column :active_recall_items, :lapses
18
+ remove_column :active_recall_items, :elapsed_days
19
+ remove_column :active_recall_items, :scheduled_days
20
+ end
21
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_recall
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gravina
@@ -173,6 +173,7 @@ files:
173
173
  - LICENSE
174
174
  - README.md
175
175
  - Rakefile
176
+ - VENDORED_LICENSES.md
176
177
  - active_recall.gemspec
177
178
  - bin/console
178
179
  - bin/flatten
@@ -187,6 +188,8 @@ files:
187
188
  - gemfiles/rails_8_0.gemfile.lock
188
189
  - lib/active_recall.rb
189
190
  - lib/active_recall/algorithms/fibonacci_sequence.rb
191
+ - lib/active_recall/algorithms/fsrs.rb
192
+ - lib/active_recall/algorithms/fsrs/internal.rb
190
193
  - lib/active_recall/algorithms/leitner_system.rb
191
194
  - lib/active_recall/algorithms/sm2.rb
192
195
  - lib/active_recall/algorithms/soft_leitner_system.rb
@@ -200,6 +203,7 @@ files:
200
203
  - lib/generators/active_recall/active_recall_generator.rb
201
204
  - lib/generators/active_recall/templates/add_active_recall_item_answer_counts.rb
202
205
  - lib/generators/active_recall/templates/add_active_recall_item_easiness_factor.rb
206
+ - lib/generators/active_recall/templates/add_active_recall_item_fsrs_fields.rb
203
207
  - lib/generators/active_recall/templates/create_active_recall_tables.rb
204
208
  - lib/generators/active_recall/templates/migrate_okubo_to_active_recall.rb
205
209
  - standard.yml