active_recall 2.3.1 → 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: 25a05ad2ad88bc270c9e58cb8a2760b280677fb6d1d3c35ee76df8b021e1c6fd
4
- data.tar.gz: 1503c4d5cc0863a42d51a084c7651d79fe574f9d98fb60254fd2084b6cf4ec13
3
+ metadata.gz: 32cd4469689b6a3cdd5fb86aef54cdb886a41eeb0d3feafe8378faa5748fa267
4
+ data.tar.gz: a4e4711f9a8ebb85339b2b66c83f1ca9b9c54a360d9aa89a3aacb53998d9db4c
5
5
  SHA512:
6
- metadata.gz: aab226bca430aac827eb11c7c46b1765f6b367181e233a614748f509640589306d86cd3fc4ad32655b431f4b49d5beaa163f056b7dfb09ccd51a17e5d727cc9b
7
- data.tar.gz: c630d5a2fe83551b8929ef1a9f22b9408c908f3cb90a376b56dd056cdfa90e0c530f0c6aac02cae4d3a6d6891b457db6022258cf549687241783954a61ceb65f
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,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- active_recall (2.3.1)
4
+ active_recall (2.4.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
  fsrs (>= 0.9.2, < 1.0)
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:
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_recall (2.3.1)
4
+ active_recall (2.4.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
  fsrs (>= 0.9.2, < 1.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_recall (2.3.1)
4
+ active_recall (2.4.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
  fsrs (>= 0.9.2, < 1.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_recall (2.3.1)
4
+ active_recall (2.4.0)
5
5
  activerecord (>= 7.0, < 9.0)
6
6
  activesupport (>= 7.0, < 9.0)
7
7
  fsrs (>= 0.9.2, < 1.0)
@@ -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.1"
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.1
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Robert Gravina