active_recall 2.3.0 → 2.4.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/CLAUDE.md +1 -0
- data/Gemfile.lock +4 -1
- data/README.md +8 -2
- data/active_recall.gemspec +4 -0
- data/gemfiles/rails_7_0.gemfile.lock +4 -1
- data/gemfiles/rails_7_1.gemfile.lock +4 -1
- data/gemfiles/rails_8_0.gemfile.lock +4 -1
- data/lib/active_recall/algorithms/fsrs.rb +10 -10
- data/lib/active_recall/algorithms/sm2.rb +27 -16
- data/lib/active_recall/models/item.rb +7 -1
- data/lib/active_recall/version.rb +1 -1
- metadata +21 -3
- data/VENDORED_LICENSES.md +0 -54
- data/lib/active_recall/algorithms/fsrs/internal.rb +0 -335
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32cd4469689b6a3cdd5fb86aef54cdb886a41eeb0d3feafe8378faa5748fa267
|
|
4
|
+
data.tar.gz: a4e4711f9a8ebb85339b2b66c83f1ca9b9c54a360d9aa89a3aacb53998d9db4c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: feb48c5d0f80cc200dbd61322e647c3af982e92833e4727c3d801c5b77e6316af1d83315ee1bce4479f9aad537a026ba331d075a271e6a726d8c07cc0fdebf34
|
|
7
|
+
data.tar.gz: a2ffe9ade4ab0703572e7e5168b0326a46601c5ce84fc136f82433927787c395d754a47ccef7020a264ad6f15e6dcd4620a2d7298db7e948cb7f7afb219520de
|
data/CLAUDE.md
CHANGED
|
@@ -95,6 +95,7 @@ bundle exec rake release
|
|
|
95
95
|
- Binary algorithms: LeitnerSystem (default), SoftLeitnerSystem, FibonacciSequence
|
|
96
96
|
- Gradable algorithm: SM2
|
|
97
97
|
- All algorithms are stateless; they accept current state and return new state as a hash
|
|
98
|
+
- SM2 follows the canonical recurrence `I(1)=1, I(2)=6, I(n)=round(I(n-1) * EF)`, recovering the prior interval from the card's `last_reviewed`/`next_review` (no extra columns), updates EF on every grade (clamped to 1.3), and schedules failures one day out. Because SM2 reads those timestamps as its own state, switching algorithms on already-reviewed cards is unsupported without a state reset/migration.
|
|
98
99
|
|
|
99
100
|
5. **Configuration** (lib/active_recall/configuration.rb)
|
|
100
101
|
- Global configuration via `ActiveRecall.configure`
|
data/Gemfile.lock
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
active_recall (2.
|
|
4
|
+
active_recall (2.4.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
|
+
fsrs (>= 0.9.2, < 1.0)
|
|
7
8
|
|
|
8
9
|
GEM
|
|
9
10
|
remote: https://rubygems.org/
|
|
@@ -98,6 +99,8 @@ GEM
|
|
|
98
99
|
drb (2.2.0)
|
|
99
100
|
ruby2_keywords
|
|
100
101
|
erubi (1.12.0)
|
|
102
|
+
fsrs (0.9.2)
|
|
103
|
+
activesupport (>= 7.0, < 9.0)
|
|
101
104
|
globalid (1.2.1)
|
|
102
105
|
activesupport (>= 6.1)
|
|
103
106
|
i18n (1.14.1)
|
data/README.md
CHANGED
|
@@ -160,17 +160,23 @@ user.words.expired #=> [word]
|
|
|
160
160
|
| `1` | Incorrect response with familiarity |
|
|
161
161
|
| `0` | Complete blackout |
|
|
162
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`.
|
|
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`. The `easiness_factor` is updated on **every** grade (including failures), starts at `2.5`, and is clamped to a minimum of `1.3` — so a poor grade lowers the EF and shortens future intervals.
|
|
164
|
+
|
|
165
|
+
Intervals follow the canonical SM-2 recurrence: `I(1) = 1`, `I(2) = 6`, and `I(n) = round(I(n − 1) × EF)` for `n > 2`. Fractional intervals use ordinary rounding (matching the published SuperMemo 2 source), so six consecutive perfect reviews schedule at `1, 6, 16, 45, 131, 393` days. The prior interval is recovered from the card's stored `last_reviewed`/`next_review`, so **no extra columns are required**.
|
|
166
|
+
|
|
167
|
+
A failed card is scheduled one day out (`next_review = now + 1 day`) and does **not** reappear in `review`/`failed` until that day arrives.
|
|
164
168
|
|
|
165
169
|
```ruby
|
|
166
170
|
user.words << word
|
|
167
171
|
|
|
168
172
|
user.score!(5, word) # perfect recall — box advances, EF rises
|
|
169
|
-
user.score!(2, word) # incorrect — box resets to 0
|
|
173
|
+
user.score!(2, word) # incorrect — box resets to 0, EF drops, due again tomorrow
|
|
170
174
|
```
|
|
171
175
|
|
|
172
176
|
Calling `user.right_answer_for!(word)` while SM2 is configured raises `ActiveRecall::IncompatibleAlgorithmError` — use `score!` instead.
|
|
173
177
|
|
|
178
|
+
> **Switching algorithms on existing cards is unsupported.** Each card's `box`, `next_review`, and `last_reviewed` are written by whichever algorithm scored it, and SM-2 derives its prior interval from those timestamps. Pointing SM-2 at cards previously scheduled by another algorithm will misread their state. Choose an algorithm before reviewing, or reset/migrate card state before switching.
|
|
179
|
+
|
|
174
180
|
## Usage with FSRS
|
|
175
181
|
|
|
176
182
|
[FSRS](https://github.com/open-spaced-repetition/fsrs4anki) uses a 1–4 grade scale matching the familiar Anki buttons:
|
data/active_recall.gemspec
CHANGED
|
@@ -41,5 +41,9 @@ Gem::Specification.new do |spec|
|
|
|
41
41
|
spec.add_development_dependency "standard"
|
|
42
42
|
spec.add_runtime_dependency "activerecord", ">= 7.0", "< 9.0"
|
|
43
43
|
spec.add_runtime_dependency "activesupport", ">= 7.0", "< 9.0"
|
|
44
|
+
# fsrs 0.9.2 is the first release that widens activesupport to allow Rails 8
|
|
45
|
+
# and ships the new-card scheduling fix (minute-vs-day in schedule_new_state).
|
|
46
|
+
# Do not relax this floor without verifying both still hold.
|
|
47
|
+
spec.add_runtime_dependency "fsrs", ">= 0.9.2", "< 1.0"
|
|
44
48
|
spec.required_ruby_version = ">= 3.2"
|
|
45
49
|
end
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
active_recall (2.
|
|
4
|
+
active_recall (2.4.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
|
+
fsrs (>= 0.9.2, < 1.0)
|
|
7
8
|
|
|
8
9
|
GEM
|
|
9
10
|
remote: https://rubygems.org/
|
|
@@ -98,6 +99,8 @@ GEM
|
|
|
98
99
|
drb (2.2.0)
|
|
99
100
|
ruby2_keywords
|
|
100
101
|
erubi (1.12.0)
|
|
102
|
+
fsrs (0.9.2)
|
|
103
|
+
activesupport (>= 7.0, < 9.0)
|
|
101
104
|
globalid (1.2.1)
|
|
102
105
|
activesupport (>= 6.1)
|
|
103
106
|
i18n (1.14.1)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
active_recall (2.
|
|
4
|
+
active_recall (2.4.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
|
+
fsrs (>= 0.9.2, < 1.0)
|
|
7
8
|
|
|
8
9
|
GEM
|
|
9
10
|
remote: https://rubygems.org/
|
|
@@ -98,6 +99,8 @@ GEM
|
|
|
98
99
|
drb (2.2.0)
|
|
99
100
|
ruby2_keywords
|
|
100
101
|
erubi (1.12.0)
|
|
102
|
+
fsrs (0.9.2)
|
|
103
|
+
activesupport (>= 7.0, < 9.0)
|
|
101
104
|
globalid (1.2.1)
|
|
102
105
|
activesupport (>= 6.1)
|
|
103
106
|
i18n (1.14.1)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: ..
|
|
3
3
|
specs:
|
|
4
|
-
active_recall (2.
|
|
4
|
+
active_recall (2.4.0)
|
|
5
5
|
activerecord (>= 7.0, < 9.0)
|
|
6
6
|
activesupport (>= 7.0, < 9.0)
|
|
7
|
+
fsrs (>= 0.9.2, < 1.0)
|
|
7
8
|
|
|
8
9
|
GEM
|
|
9
10
|
remote: https://rubygems.org/
|
|
@@ -95,6 +96,8 @@ GEM
|
|
|
95
96
|
diff-lcs (1.5.1)
|
|
96
97
|
drb (2.2.1)
|
|
97
98
|
erubi (1.13.1)
|
|
99
|
+
fsrs (0.9.2)
|
|
100
|
+
activesupport (>= 7.0, < 9.0)
|
|
98
101
|
globalid (1.2.1)
|
|
99
102
|
activesupport (>= 6.1)
|
|
100
103
|
i18n (1.14.6)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "fsrs"
|
|
4
4
|
|
|
5
5
|
module ActiveRecall
|
|
6
6
|
class FSRS
|
|
@@ -19,10 +19,10 @@ module ActiveRecall
|
|
|
19
19
|
].freeze
|
|
20
20
|
|
|
21
21
|
GRADE_TO_RATING = {
|
|
22
|
-
1 =>
|
|
23
|
-
2 =>
|
|
24
|
-
3 =>
|
|
25
|
-
4 =>
|
|
22
|
+
1 => Fsrs::Rating::AGAIN,
|
|
23
|
+
2 => Fsrs::Rating::HARD,
|
|
24
|
+
3 => Fsrs::Rating::GOOD,
|
|
25
|
+
4 => Fsrs::Rating::EASY
|
|
26
26
|
}.freeze
|
|
27
27
|
|
|
28
28
|
def self.required_attributes
|
|
@@ -33,8 +33,8 @@ module ActiveRecall
|
|
|
33
33
|
:gradable
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def self.score(**
|
|
37
|
-
new(**
|
|
36
|
+
def self.score(**)
|
|
37
|
+
new(**).score
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def initialize(box:, stability:, difficulty:, state:, lapses:,
|
|
@@ -43,7 +43,7 @@ module ActiveRecall
|
|
|
43
43
|
@box = box || 0
|
|
44
44
|
@stability = stability
|
|
45
45
|
@difficulty = difficulty
|
|
46
|
-
@state = state ||
|
|
46
|
+
@state = state || Fsrs::State::NEW
|
|
47
47
|
@lapses = lapses || 0
|
|
48
48
|
@elapsed_days = elapsed_days || 0
|
|
49
49
|
@scheduled_days = scheduled_days || 0
|
|
@@ -79,7 +79,7 @@ module ActiveRecall
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
81
|
def build_card
|
|
82
|
-
card =
|
|
82
|
+
card = Fsrs::Card.new
|
|
83
83
|
card.stability = @stability if @stability
|
|
84
84
|
card.difficulty = @difficulty if @difficulty
|
|
85
85
|
card.state = @state
|
|
@@ -92,7 +92,7 @@ module ActiveRecall
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def scheduler
|
|
95
|
-
scheduler =
|
|
95
|
+
scheduler = Fsrs::Scheduler.new
|
|
96
96
|
config = ActiveRecall.configuration
|
|
97
97
|
scheduler.p.request_retention = config.fsrs_request_retention if config.fsrs_request_retention
|
|
98
98
|
scheduler.p.maximum_interval = config.fsrs_maximum_interval if config.fsrs_maximum_interval
|
|
@@ -8,13 +8,15 @@ module ActiveRecall
|
|
|
8
8
|
REQUIRED_ATTRIBUTES
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def self.score(box:, easiness_factor:, times_right:, times_wrong:, grade:, current_time: Time.current)
|
|
11
|
+
def self.score(box:, easiness_factor:, times_right:, times_wrong:, grade:, last_reviewed: nil, next_review: nil, current_time: Time.current)
|
|
12
12
|
new(
|
|
13
13
|
box: box,
|
|
14
14
|
easiness_factor: easiness_factor,
|
|
15
15
|
times_right: times_right,
|
|
16
16
|
times_wrong: times_wrong,
|
|
17
17
|
grade: grade,
|
|
18
|
+
last_reviewed: last_reviewed,
|
|
19
|
+
next_review: next_review,
|
|
18
20
|
current_time: current_time
|
|
19
21
|
).score
|
|
20
22
|
end
|
|
@@ -23,24 +25,22 @@ module ActiveRecall
|
|
|
23
25
|
:gradable
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def initialize(box:, easiness_factor:, times_right:, times_wrong:, grade:, current_time: Time.current)
|
|
28
|
+
def initialize(box:, easiness_factor:, times_right:, times_wrong:, grade:, last_reviewed: nil, next_review: nil, current_time: Time.current)
|
|
27
29
|
@box = box # box serves as repetition number n
|
|
28
30
|
@easiness_factor = easiness_factor || 2.5
|
|
29
31
|
@times_right = times_right
|
|
30
32
|
@times_wrong = times_wrong
|
|
31
33
|
@grade = grade
|
|
34
|
+
@last_reviewed = last_reviewed # the card's prior review time
|
|
35
|
+
@previous_next_review = next_review # the card's prior scheduled due date
|
|
32
36
|
@current_time = current_time
|
|
33
|
-
@interval =
|
|
34
|
-
when 0 then 1 # First review
|
|
35
|
-
when 1 then 6 # Second review
|
|
36
|
-
else 17 # Will be overwritten for boxes > 1
|
|
37
|
-
end
|
|
37
|
+
@interval = 1
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def score
|
|
41
41
|
raise "Grade must be between 0-5!" unless GRADES.include?(@grade)
|
|
42
42
|
old_ef = @easiness_factor
|
|
43
|
-
update_easiness_factor
|
|
43
|
+
update_easiness_factor
|
|
44
44
|
update_repetition_and_interval(old_ef)
|
|
45
45
|
|
|
46
46
|
{
|
|
@@ -69,7 +69,9 @@ module ActiveRecall
|
|
|
69
69
|
:easiness_factor,
|
|
70
70
|
:grade,
|
|
71
71
|
:times_right,
|
|
72
|
-
:times_wrong
|
|
72
|
+
:times_wrong,
|
|
73
|
+
:last_reviewed,
|
|
74
|
+
:next_review
|
|
73
75
|
].freeze
|
|
74
76
|
|
|
75
77
|
def update_easiness_factor
|
|
@@ -79,13 +81,13 @@ module ActiveRecall
|
|
|
79
81
|
|
|
80
82
|
def update_repetition_and_interval(old_ef)
|
|
81
83
|
if @grade >= 3
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
# Canonical SM-2 recurrence: I(1)=1, I(2)=6, I(n)=round(I(n-1) * EF).
|
|
85
|
+
# EF here is the value from before this review's update (old_ef), which
|
|
86
|
+
# reproduces the published Delphi sequence (1, 6, 16, 45, 131, 393).
|
|
87
|
+
@interval = case @box
|
|
88
|
+
when 0 then 1
|
|
89
|
+
when 1 then 6
|
|
90
|
+
else (previous_interval * old_ef).round
|
|
89
91
|
end
|
|
90
92
|
|
|
91
93
|
@box += 1
|
|
@@ -97,6 +99,15 @@ module ActiveRecall
|
|
|
97
99
|
end
|
|
98
100
|
end
|
|
99
101
|
|
|
102
|
+
# Recover the card's previously scheduled interval (in days) from its stored
|
|
103
|
+
# timestamps. Falls back to I(2)=6 when the prior schedule is unavailable
|
|
104
|
+
# (e.g. a card whose history predates this calculation).
|
|
105
|
+
def previous_interval
|
|
106
|
+
return 6 unless @last_reviewed && @previous_next_review
|
|
107
|
+
|
|
108
|
+
[((@previous_next_review - @last_reviewed) / 1.day).round, 1].max
|
|
109
|
+
end
|
|
110
|
+
|
|
100
111
|
def next_review
|
|
101
112
|
@current_time + @interval.days
|
|
102
113
|
end
|
|
@@ -6,9 +6,15 @@ module ActiveRecall
|
|
|
6
6
|
|
|
7
7
|
belongs_to :deck
|
|
8
8
|
|
|
9
|
-
scope :failed, -> { where(["box = ? and last_reviewed is not null", 0]) }
|
|
10
9
|
scope :untested, -> { where(["box = ? and last_reviewed is null", 0]) }
|
|
11
10
|
|
|
11
|
+
# Lapsed (box 0, already reviewed) cards that are due now. A null next_review
|
|
12
|
+
# means "review immediately" (binary algorithms reset this way); a future
|
|
13
|
+
# next_review (e.g. SM-2's one-day failure interval) is excluded until due.
|
|
14
|
+
def self.failed(current_time: Time.current)
|
|
15
|
+
where(["box = ? and last_reviewed is not null and (next_review is null or next_review <= ?)", 0, current_time])
|
|
16
|
+
end
|
|
17
|
+
|
|
12
18
|
def self.expired(current_time: Time.current)
|
|
13
19
|
where(["box > ? and next_review <= ?", 0, current_time])
|
|
14
20
|
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.
|
|
4
|
+
version: 2.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Robert Gravina
|
|
@@ -154,6 +154,26 @@ dependencies:
|
|
|
154
154
|
- - "<"
|
|
155
155
|
- !ruby/object:Gem::Version
|
|
156
156
|
version: '9.0'
|
|
157
|
+
- !ruby/object:Gem::Dependency
|
|
158
|
+
name: fsrs
|
|
159
|
+
requirement: !ruby/object:Gem::Requirement
|
|
160
|
+
requirements:
|
|
161
|
+
- - ">="
|
|
162
|
+
- !ruby/object:Gem::Version
|
|
163
|
+
version: 0.9.2
|
|
164
|
+
- - "<"
|
|
165
|
+
- !ruby/object:Gem::Version
|
|
166
|
+
version: '1.0'
|
|
167
|
+
type: :runtime
|
|
168
|
+
prerelease: false
|
|
169
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
170
|
+
requirements:
|
|
171
|
+
- - ">="
|
|
172
|
+
- !ruby/object:Gem::Version
|
|
173
|
+
version: 0.9.2
|
|
174
|
+
- - "<"
|
|
175
|
+
- !ruby/object:Gem::Version
|
|
176
|
+
version: '1.0'
|
|
157
177
|
description: A spaced-repetition system to be used with ActiveRecord models
|
|
158
178
|
email:
|
|
159
179
|
- robert.gravina@gmail.com
|
|
@@ -173,7 +193,6 @@ files:
|
|
|
173
193
|
- LICENSE
|
|
174
194
|
- README.md
|
|
175
195
|
- Rakefile
|
|
176
|
-
- VENDORED_LICENSES.md
|
|
177
196
|
- active_recall.gemspec
|
|
178
197
|
- bin/console
|
|
179
198
|
- bin/flatten
|
|
@@ -189,7 +208,6 @@ files:
|
|
|
189
208
|
- lib/active_recall.rb
|
|
190
209
|
- lib/active_recall/algorithms/fibonacci_sequence.rb
|
|
191
210
|
- lib/active_recall/algorithms/fsrs.rb
|
|
192
|
-
- lib/active_recall/algorithms/fsrs/internal.rb
|
|
193
211
|
- lib/active_recall/algorithms/leitner_system.rb
|
|
194
212
|
- lib/active_recall/algorithms/sm2.rb
|
|
195
213
|
- lib/active_recall/algorithms/soft_leitner_system.rb
|
data/VENDORED_LICENSES.md
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
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,335 +0,0 @@
|
|
|
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
|