active_recall 2.1.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 +4 -4
- data/.github/workflows/tests.yml +5 -1
- data/.tool-versions +1 -1
- data/CLAUDE.md +194 -0
- data/Gemfile +14 -1
- data/Gemfile.lock +13 -4
- data/README.md +115 -27
- data/VENDORED_LICENSES.md +54 -0
- data/gemfiles/rails_7_0.gemfile +1 -0
- data/gemfiles/rails_7_0.gemfile.lock +17 -4
- data/gemfiles/rails_7_1.gemfile +1 -0
- data/gemfiles/rails_7_1.gemfile.lock +17 -4
- data/gemfiles/rails_8_0.gemfile +1 -0
- data/gemfiles/rails_8_0.gemfile.lock +17 -3
- data/lib/active_recall/algorithms/fibonacci_sequence.rb +4 -5
- data/lib/active_recall/algorithms/fsrs/internal.rb +335 -0
- data/lib/active_recall/algorithms/fsrs.rb +110 -0
- data/lib/active_recall/algorithms/sm2.rb +3 -1
- data/lib/active_recall/configuration.rb +4 -1
- data/lib/active_recall/version.rb +1 -1
- data/lib/active_recall.rb +1 -0
- data/lib/generators/active_recall/active_recall_generator.rb +1 -0
- data/lib/generators/active_recall/templates/add_active_recall_item_fsrs_fields.rb +21 -0
- data/standard.yml +1 -1
- metadata +8 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
active_recall (2.
|
|
4
|
+
active_recall (2.3.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
7
|
|
|
@@ -119,6 +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.8)
|
|
122
123
|
minitest (5.21.2)
|
|
123
124
|
mutex_m (0.2.0)
|
|
124
125
|
net-imap (0.4.9.1)
|
|
@@ -131,7 +132,14 @@ GEM
|
|
|
131
132
|
net-smtp (0.4.0.1)
|
|
132
133
|
net-protocol
|
|
133
134
|
nio4r (2.7.0)
|
|
134
|
-
nokogiri (1.
|
|
135
|
+
nokogiri (1.18.7)
|
|
136
|
+
mini_portile2 (~> 2.8.2)
|
|
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)
|
|
142
|
+
nokogiri (1.18.7-x86_64-linux-gnu)
|
|
135
143
|
racc (~> 1.4)
|
|
136
144
|
parallel (1.24.0)
|
|
137
145
|
parser (3.3.1.0)
|
|
@@ -216,7 +224,8 @@ GEM
|
|
|
216
224
|
rubocop-ast (>= 1.30.0, < 2.0)
|
|
217
225
|
ruby-progressbar (1.13.0)
|
|
218
226
|
ruby2_keywords (0.0.5)
|
|
219
|
-
sqlite3 (1.7.3
|
|
227
|
+
sqlite3 (1.7.3)
|
|
228
|
+
mini_portile2 (~> 2.8.0)
|
|
220
229
|
standard (1.35.1)
|
|
221
230
|
language_server-protocol (~> 3.17.0.2)
|
|
222
231
|
lint_roller (~> 1.0)
|
|
@@ -242,11 +251,15 @@ GEM
|
|
|
242
251
|
zeitwerk (2.6.12)
|
|
243
252
|
|
|
244
253
|
PLATFORMS
|
|
245
|
-
arm64-darwin
|
|
254
|
+
arm64-darwin
|
|
255
|
+
ruby
|
|
256
|
+
x86_64-darwin
|
|
257
|
+
x86_64-linux
|
|
246
258
|
|
|
247
259
|
DEPENDENCIES
|
|
248
260
|
active_recall!
|
|
249
261
|
appraisal
|
|
262
|
+
nokogiri (>= 1.16.2)
|
|
250
263
|
rails (~> 7.1)
|
|
251
264
|
rake (>= 12.0)
|
|
252
265
|
rdoc
|
data/gemfiles/rails_8_0.gemfile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
active_recall (2.
|
|
4
|
+
active_recall (2.3.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
7
|
|
|
@@ -117,7 +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.
|
|
120
|
+
mini_portile2 (2.8.9)
|
|
121
121
|
minitest (5.25.4)
|
|
122
122
|
net-imap (0.5.4)
|
|
123
123
|
date
|
|
@@ -129,8 +129,15 @@ GEM
|
|
|
129
129
|
net-smtp (0.5.0)
|
|
130
130
|
net-protocol
|
|
131
131
|
nio4r (2.7.4)
|
|
132
|
+
nokogiri (1.18.0)
|
|
133
|
+
mini_portile2 (~> 2.8.2)
|
|
134
|
+
racc (~> 1.4)
|
|
132
135
|
nokogiri (1.18.0-arm64-darwin)
|
|
133
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)
|
|
134
141
|
parallel (1.26.3)
|
|
135
142
|
parser (3.3.6.0)
|
|
136
143
|
ast (~> 2.4.1)
|
|
@@ -214,6 +221,9 @@ GEM
|
|
|
214
221
|
securerandom (0.4.1)
|
|
215
222
|
sqlite3 (2.5.0)
|
|
216
223
|
mini_portile2 (~> 2.8.0)
|
|
224
|
+
sqlite3 (2.5.0-arm64-darwin)
|
|
225
|
+
sqlite3 (2.5.0-x86_64-darwin)
|
|
226
|
+
sqlite3 (2.5.0-x86_64-linux-gnu)
|
|
217
227
|
standard (1.43.0)
|
|
218
228
|
language_server-protocol (~> 3.17.0.2)
|
|
219
229
|
lint_roller (~> 1.0)
|
|
@@ -242,11 +252,15 @@ GEM
|
|
|
242
252
|
zeitwerk (2.7.1)
|
|
243
253
|
|
|
244
254
|
PLATFORMS
|
|
245
|
-
arm64-darwin
|
|
255
|
+
arm64-darwin
|
|
256
|
+
ruby
|
|
257
|
+
x86_64-darwin
|
|
258
|
+
x86_64-linux
|
|
246
259
|
|
|
247
260
|
DEPENDENCIES
|
|
248
261
|
active_recall!
|
|
249
262
|
appraisal
|
|
263
|
+
nokogiri (>= 1.16.2)
|
|
250
264
|
rails (~> 8.0)
|
|
251
265
|
rake (>= 12.0)
|
|
252
266
|
rdoc
|
|
@@ -63,11 +63,10 @@ module ActiveRecall
|
|
|
63
63
|
SEQUENCE = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765].freeze
|
|
64
64
|
|
|
65
65
|
def fibonacci_number_at(index)
|
|
66
|
-
if (0...SEQUENCE.length).cover?(index)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
end
|
|
66
|
+
return SEQUENCE[index] if (0...SEQUENCE.length).cover?(index)
|
|
67
|
+
|
|
68
|
+
@fibonacci_cache ||= {}
|
|
69
|
+
@fibonacci_cache[index] ||= fibonacci_number_at(index - 1) + fibonacci_number_at(index - 2)
|
|
71
70
|
end
|
|
72
71
|
|
|
73
72
|
def next_review
|
|
@@ -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
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module ActiveRecall
|
|
2
4
|
class SM2
|
|
3
5
|
MIN_EASINESS_FACTOR = 1.3
|
|
@@ -38,7 +40,7 @@ module ActiveRecall
|
|
|
38
40
|
def score
|
|
39
41
|
raise "Grade must be between 0-5!" unless GRADES.include?(@grade)
|
|
40
42
|
old_ef = @easiness_factor
|
|
41
|
-
update_easiness_factor
|
|
43
|
+
update_easiness_factor if @grade >= 3
|
|
42
44
|
update_repetition_and_interval(old_ef)
|
|
43
45
|
|
|
44
46
|
{
|
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
|
data/standard.yml
CHANGED