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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63a48f6351a76013bbf2922ca75b507b6dccb1ab23867896217b53bc6a77e26f
4
- data.tar.gz: 9d8e3189bb8f5b1f15b284168e5973e87070bf223c0427b5a574cacd38c8e4b0
3
+ metadata.gz: 32cd4469689b6a3cdd5fb86aef54cdb886a41eeb0d3feafe8378faa5748fa267
4
+ data.tar.gz: a4e4711f9a8ebb85339b2b66c83f1ca9b9c54a360d9aa89a3aacb53998d9db4c
5
5
  SHA512:
6
- metadata.gz: efe496383029f35391f1b54533f525451c5ee8a02e440316b990b812923b2061fc94eeff2ed1d691b17a1838b2d266b51ec9dde7d3305d511b484edb53b606b7
7
- data.tar.gz: ff9f67e94b77d93175af60926ad889655862989c18a9b2fb07b8e75503526b8281f32cd3a982ab1d70933363454e40461a10dd2be72784bc5097243173ff5b1e
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.3.0)
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`. Each item's `easiness_factor` starts at `2.5` and is clamped to a minimum of `1.3`.
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:
@@ -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.3.0)
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.3.0)
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.3.0)
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 "active_recall/algorithms/fsrs/internal"
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 => Internal::Rating::AGAIN,
23
- 2 => Internal::Rating::HARD,
24
- 3 => Internal::Rating::GOOD,
25
- 4 => Internal::Rating::EASY
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(**kwargs)
37
- new(**kwargs).score
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 || Internal::State::NEW
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 = Internal::Card.new
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 = Internal::Scheduler.new
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 = case box
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 if @grade >= 3
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
- @interval = if @box == 0
83
- 1
84
- elsif @box == 1
85
- 6
86
- else
87
- # Apply exponential scaling based on the box number
88
- (6 * (old_ef**(@box - 1))).round
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecall
4
- VERSION = "2.3.0"
4
+ VERSION = "2.4.0"
5
5
  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.3.0
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