Skip to content

Commit

Permalink
Add SM-2 algorithm option (#25)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaysonvirissimo authored Dec 27, 2023
1 parent 382b9c8 commit 9077cd0
Show file tree
Hide file tree
Showing 19 changed files with 359 additions and 13 deletions.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
active_recall (1.8.6)
active_recall (1.9.0)
activerecord (>= 5.2.3, <= 7.1)
activesupport (>= 5.2.3, <= 7.1)

Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ ActiveRecall.configure do |config|
config.algorithm_class = ActiveRecall::FibonacciSequence
end
```
Algorithms include `FibonacciSequence`, `LeitnerSystem`, and `SoftLeitnerSystem`.
Algorithms include `FibonacciSequence`, `LeitnerSystem`, `SoftLeitnerSystem`, and `SM2` (see [here](https://en.wikipedia.org/wiki/SuperMemo#Description_of_SM-2_algorithm)).
For Rails applications, try doing this from within an [initializer file](https://guides.rubyonrails.org/configuring.html#using-initializer-files).

Assume you have an application allowing your users to study words in a foreign language. Using the `has_deck` method you can set up a deck of flashcards that the user will study:
Expand All @@ -59,7 +59,7 @@ You can add words and record attempts to guess the word as right or wrong. Vario
user.words << word
user.words.untested #=> [word]

# Guessing a word correctly
# Guessing a word correctly (when using a binary algorithm)
user.right_answer_for!(word)
user.words.known #=> [word]

Expand Down Expand Up @@ -93,6 +93,16 @@ user.right_answer_for!(word)
user.words.expired #=> [word]
```

When using a gradable algorithm (rather than binary) such as the SM2 algorithm, you will need to supply your own grade along with the item:
```ruby
grade = 3
user.score!(grade, word)

# Using the binary-only methods will raise an error
user.right_answer_for!(word)
=> ActiveRecall::IncompatibleAlgorithmError
```

Reviewing
---------

Expand Down
3 changes: 3 additions & 0 deletions lib/active_recall.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require "active_recall/algorithms/fibonacci_sequence"
require "active_recall/algorithms/leitner_system"
require "active_recall/algorithms/soft_leitner_system"
require "active_recall/algorithms/sm2"
require "active_recall/configuration"
require "active_recall/models/deck"
require "active_recall/models/item"
Expand All @@ -29,4 +30,6 @@ def self.configuration
def self.reset
@configuration = Configuration.new
end

class IncompatibleAlgorithmError < StandardError; end
end
9 changes: 9 additions & 0 deletions lib/active_recall/algorithms/fibonacci_sequence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

module ActiveRecall
class FibonacciSequence
def self.required_attributes
REQUIRED_ATTRIBUTES
end

def self.right(box:, times_right:, times_wrong:, current_time: Time.current)
new(
box: box,
Expand All @@ -11,6 +15,10 @@ def self.right(box:, times_right:, times_wrong:, current_time: Time.current)
).right
end

def self.type
:binary
end

def self.wrong(box:, times_right:, times_wrong:, current_time: Time.current)
new(
box: box,
Expand Down Expand Up @@ -51,6 +59,7 @@ def wrong

attr_reader :box, :current_time, :times_right, :times_wrong

REQUIRED_ATTRIBUTES = [:box, :times_right, :times_wrong].freeze
SEQUENCE = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765].freeze

def fibonacci_number_at(index)
Expand Down
11 changes: 10 additions & 1 deletion lib/active_recall/algorithms/leitner_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

module ActiveRecall
class LeitnerSystem
DELAYS = [3, 7, 14, 30, 60, 120, 240].freeze
def self.required_attributes
REQUIRED_ATTRIBUTES
end

def self.right(box:, times_right:, times_wrong:, current_time: Time.current)
new(
Expand All @@ -13,6 +15,10 @@ def self.right(box:, times_right:, times_wrong:, current_time: Time.current)
).right
end

def self.type
:binary
end

def self.wrong(box:, times_right:, times_wrong:, current_time: Time.current)
new(
box: box,
Expand Down Expand Up @@ -53,6 +59,9 @@ def wrong

attr_reader :box, :current_time, :times_right, :times_wrong

DELAYS = [3, 7, 14, 30, 60, 120, 240].freeze
REQUIRED_ATTRIBUTES = [:box, :times_right, :times_wrong].freeze

def next_review
(current_time + DELAYS[[DELAYS.count, box + 1].min - 1].days)
end
Expand Down
97 changes: 97 additions & 0 deletions lib/active_recall/algorithms/sm2.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# frozen_string_literal: true

module ActiveRecall
class SM2
MIN_EASINESS_FACTOR = 1.3

def self.required_attributes
REQUIRED_ATTRIBUTES
end

def self.score(box:, easiness_factor:, times_right:, times_wrong:, grade:, current_time: Time.current)
new(
box: box,
easiness_factor: easiness_factor,
times_right: times_right,
times_wrong: times_wrong,
grade: grade,
current_time: current_time
).score
end

def self.type
:gradable
end

def initialize(box:, easiness_factor:, times_right:, times_wrong:, grade:, current_time: Time.current)
@box = box
@easiness_factor = easiness_factor || 2.5
@times_right = times_right
@times_wrong = times_wrong
@grade = grade
@current_time = current_time
@interval = [1, box].max
end

def score
raise "Grade must be between 0-5!" unless GRADES.include?(@grade)
update_easiness_factor
update_repetition_and_interval

{
box: @box,
easiness_factor: @easiness_factor,
times_right: @times_right,
times_wrong: @times_wrong,
last_reviewed: @current_time,
next_review: next_review
}
end

private

GRADES = [
5, # Perfect response. The learner recalls the information without hesitation.
4, # Correct response after a hesitation. The learner recalls the information but with some difficulty.
3, # Correct response recalled with serious difficulty. The learner struggles but eventually recalls the information.
2, # Incorrect response, but the learner was very close to the correct answer. This might involve recalling some of the information correctly but not all of it.
1, # Incorrect response, but the learner feels they should have remembered it. This is typically used when the learner has a sense of familiarity with the material but fails to recall it correctly.
0 # Complete blackout. The learner does not recall the information at all.
].freeze
REQUIRED_ATTRIBUTES = [
:box,
:easiness_factor,
:grade,
:times_right,
:times_wrong
].freeze

def update_easiness_factor
@easiness_factor += (0.1 - (5 - @grade) * (0.08 + (5 - @grade) * 0.02))
@easiness_factor = [@easiness_factor, MIN_EASINESS_FACTOR].max
end

def update_repetition_and_interval
if @grade >= 3
@box += 1
@times_right += 1
@interval = case @box
when 1
1
when 2
6
else
(@interval || 1) * @easiness_factor
end
else
@box = 0
@times_wrong += 1
@interval = 1
end
end

def next_review
@current_time + @interval.days
end
end
end
11 changes: 10 additions & 1 deletion lib/active_recall/algorithms/soft_leitner_system.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

module ActiveRecall
class SoftLeitnerSystem
DELAYS = [3, 7, 14, 30, 60, 120, 240].freeze
def self.required_attributes
REQUIRED_ATTRIBUTES
end

def self.right(box:, times_right:, times_wrong:, current_time: Time.current)
new(
Expand All @@ -13,6 +15,10 @@ def self.right(box:, times_right:, times_wrong:, current_time: Time.current)
).right
end

def self.type
:binary
end

def self.wrong(box:, times_right:, times_wrong:, current_time: Time.current)
new(
box: box,
Expand Down Expand Up @@ -55,6 +61,9 @@ def wrong

private

DELAYS = [3, 7, 14, 30, 60, 120, 240].freeze
REQUIRED_ATTRIBUTES = [:box, :times_right, :times_wrong].freeze

attr_accessor :box
attr_reader :current_time, :times_right, :times_wrong

Expand Down
4 changes: 4 additions & 0 deletions lib/active_recall/item_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ def right_answer_for!(item)
def wrong_answer_for!(item)
deck.items.find_by(source_id: item.id).wrong!
end

def score!(grade, item)
deck.items.find_by(source_id: item.id).score!(grade)
end
end
end
26 changes: 23 additions & 3 deletions lib/active_recall/models/item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,34 @@ def self.known(current_time: Time.current)
where(["box > ? and next_review > ?", 0, current_time])
end

def score!(grade)
if algorithm_class.type == :gradable
update!(
algorithm_class.score(**scoring_attributes.merge(grade: grade))
).score
else
raise IncompatibleAlgorithmError, "#{algorithm_class.name} is a not an gradable algorithm, so is not compatible with the #score! method"
end
end

def source
source_type.constantize.find(source_id)
end

def right!
update!(algorithm_class.right(**scoring_attributes))
if algorithm_class.type == :binary
update!(algorithm_class.right(**scoring_attributes))
else
raise IncompatibleAlgorithmError, "#{algorithm_class.name} is not a binary algorithm, so is not compatible with the #right! method"
end
end

def wrong!
update!(algorithm_class.wrong(**scoring_attributes))
if algorithm_class.type == :binary
update!(algorithm_class.wrong(**scoring_attributes))
else
raise IncompatibleAlgorithmError, "#{algorithm_class.name} is not a binary algorithm, so is not compatible with the #wrong! method"
end
end

private
Expand All @@ -36,7 +54,9 @@ def algorithm_class
end

def scoring_attributes
attributes.symbolize_keys.slice(:box, :times_right, :times_wrong)
attributes
.symbolize_keys
.slice(*algorithm_class.required_attributes)
end
end
end
2 changes: 1 addition & 1 deletion lib/active_recall/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module ActiveRecall
VERSION = "1.8.6"
VERSION = "1.9.0"
end
1 change: 1 addition & 0 deletions lib/generators/active_recall/active_recall_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def self.next_migration_number(path)
def create_migration_files
create_migration_file_if_not_exist "create_active_recall_tables"
create_migration_file_if_not_exist "add_active_recall_item_answer_counts"
create_migration_file_if_not_exist "add_active_recall_item_easiness_factor"
create_migration_file_if_not_exist "migrate_okubo_to_active_recall" if options["migrate_data"]
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

class AddActiveRecallItemEasinessFactor < ActiveRecord::Migration[5.2]
def self.up
add_column :active_recall_items, :easiness_factor, :float, default: 2.5
end

def self.down
remove_column :active_recall_items, :easiness_factor
end
end
6 changes: 5 additions & 1 deletion spec/active_recall/algorithms/algorithm_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# frozen_string_literal: true

shared_examples_for "spaced repetition algorithms" do
shared_examples_for "binary spaced repetition algorithms" do
it { expect(described_class).to respond_to(:right) }
it { expect(described_class).to respond_to(:wrong) }

context ".type" do
it { expect(described_class.type).to eq(:binary) }
end

context "when given API-respecting arguments" do
let(:arguments) { {box: 0, times_right: 0, times_wrong: 0} }
let(:expected_keys) do
Expand Down
12 changes: 11 additions & 1 deletion spec/active_recall/algorithms/fibonacci_sequence_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@
require "spec_helper"

describe ActiveRecall::FibonacciSequence do
it_behaves_like "spaced repetition algorithms"
it_behaves_like "binary spaced repetition algorithms"

describe ".required_attributes" do
specify do
expect(described_class.required_attributes).to contain_exactly(
:box,
:times_right,
:times_wrong
)
end
end

describe ".right" do
subject { described_class.right(**arguments) }
Expand Down
12 changes: 11 additions & 1 deletion spec/active_recall/algorithms/leitner_system_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,15 @@
require "spec_helper"

describe ActiveRecall::LeitnerSystem do
it_behaves_like "spaced repetition algorithms"
it_behaves_like "binary spaced repetition algorithms"

describe ".required_attributes" do
specify do
expect(described_class.required_attributes).to contain_exactly(
:box,
:times_right,
:times_wrong
)
end
end
end
Loading

0 comments on commit 9077cd0

Please sign in to comment.