fsrs_ruby 1.0.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.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module Schedulers
5
+ # Scheduler with short-term learning support
6
+ class BasicScheduler < BaseScheduler
7
+ def initialize(card, now, algorithm, strategies = {})
8
+ super
9
+ @learning_steps_strategy = strategies[:learning_steps] || method(:default_learning_steps)
10
+ end
11
+
12
+ protected
13
+
14
+ def default_learning_steps(parameters, state, cur_step)
15
+ Strategies.basic_learning_steps_strategy(parameters, state, cur_step)
16
+ end
17
+
18
+ # Get learning step info for current card and grade
19
+ def get_learning_info(card, grade)
20
+ @learning_steps_strategy.call(@algorithm.parameters, card.state, card.learning_steps || 0)[grade]
21
+ end
22
+
23
+ # Apply learning steps to next card
24
+ def apply_learning_steps(next_card, grade, to_state)
25
+ step_info = get_learning_info(@current, grade)
26
+
27
+ if step_info
28
+ scheduled_minutes = step_info[:scheduled_minutes]
29
+ next_steps = step_info[:next_step]
30
+
31
+ if scheduled_minutes > 0 && scheduled_minutes < 1440
32
+ # Schedule by minutes, stay in learning state
33
+ next_card.learning_steps = next_steps
34
+ next_card.scheduled_days = 0
35
+ next_card.state = to_state
36
+ next_card.due = Helpers.date_scheduler(@review_time, scheduled_minutes, false)
37
+ elsif scheduled_minutes >= 1440
38
+ # Promote to REVIEW, schedule by days
39
+ next_card.state = State::REVIEW
40
+ scheduled_days = (scheduled_minutes / 1440.0).round
41
+ next_card.scheduled_days = scheduled_days
42
+ next_card.due = Helpers.date_scheduler(@review_time, scheduled_days, true)
43
+ next_card.learning_steps = 0
44
+ else
45
+ # Negative or zero: promote to REVIEW with full interval
46
+ next_card.state = State::REVIEW
47
+ interval = @algorithm.next_interval(next_card.stability, @elapsed_days)
48
+ next_card.scheduled_days = interval
49
+ next_card.due = Helpers.date_scheduler(@review_time, interval, true)
50
+ next_card.learning_steps = 0
51
+ end
52
+ else
53
+ # No step info: promote to REVIEW
54
+ next_card.state = State::REVIEW
55
+ interval = @algorithm.next_interval(next_card.stability, @elapsed_days)
56
+ next_card.scheduled_days = interval
57
+ next_card.due = Helpers.date_scheduler(@review_time, interval, true)
58
+ next_card.learning_steps = 0
59
+ end
60
+ end
61
+
62
+ def new_state(grade)
63
+ next_card = @current.clone
64
+ state_result = @algorithm.next_state(
65
+ { difficulty: 0, stability: 0 },
66
+ 0,
67
+ grade
68
+ )
69
+
70
+ next_card.difficulty = state_result[:difficulty]
71
+ next_card.stability = state_result[:stability]
72
+
73
+ apply_learning_steps(next_card, grade, State::LEARNING)
74
+
75
+ log = build_log(grade)
76
+ RecordLogItem.new(card: next_card, log: log)
77
+ end
78
+
79
+ def learning_state(grade)
80
+ next_card = @current.clone
81
+ interval = @elapsed_days
82
+
83
+ state_result = @algorithm.next_state(
84
+ { difficulty: @last.difficulty, stability: @last.stability },
85
+ interval,
86
+ grade
87
+ )
88
+
89
+ next_card.difficulty = state_result[:difficulty]
90
+ next_card.stability = state_result[:stability]
91
+
92
+ to_state = @current.state == State::RELEARNING ? State::RELEARNING : State::LEARNING
93
+
94
+ if [Rating::AGAIN, Rating::HARD].include?(grade)
95
+ to_state = @current.state == State::RELEARNING ? State::RELEARNING : State::LEARNING
96
+ end
97
+
98
+ apply_learning_steps(next_card, grade, to_state)
99
+
100
+ log = build_log(grade)
101
+ RecordLogItem.new(card: next_card, log: log)
102
+ end
103
+
104
+ def review_state(grade)
105
+ interval = @elapsed_days
106
+ retrievability = @algorithm.forgetting_curve(@algorithm.parameters.w, interval, @last.stability)
107
+
108
+ next_card = @current.clone
109
+
110
+ state_result = @algorithm.next_state(
111
+ { difficulty: @last.difficulty, stability: @last.stability },
112
+ interval,
113
+ grade
114
+ )
115
+
116
+ next_card.difficulty = state_result[:difficulty]
117
+ next_card.stability = state_result[:stability]
118
+
119
+ if grade == Rating::AGAIN
120
+ next_card.lapses += 1
121
+ apply_learning_steps(next_card, grade, State::RELEARNING)
122
+ else
123
+ # Hard, Good, Easy: stay in REVIEW
124
+ next_card.state = State::REVIEW
125
+ next_card.learning_steps = 0
126
+
127
+ # Calculate stability for HARD rating to get correct hard_interval
128
+ hard_state = @algorithm.next_state(
129
+ { difficulty: @last.difficulty, stability: @last.stability },
130
+ interval,
131
+ Rating::HARD
132
+ )
133
+ hard_interval = @algorithm.next_interval(hard_state[:stability], interval)
134
+
135
+ # Calculate different intervals for each grade
136
+ if grade == Rating::HARD
137
+ next_card.scheduled_days = hard_interval
138
+ elsif grade == Rating::GOOD
139
+ good_interval = @algorithm.next_interval(next_card.stability, interval)
140
+ # Only enforce ordering if intervals would violate it and we're not at max_interval
141
+ if good_interval <= hard_interval && hard_interval < @algorithm.parameters.maximum_interval
142
+ good_interval = [good_interval, hard_interval + 1].max
143
+ end
144
+ next_card.scheduled_days = good_interval
145
+ else # EASY
146
+ good_state = @algorithm.next_state(
147
+ { difficulty: @last.difficulty, stability: @last.stability },
148
+ interval,
149
+ Rating::GOOD
150
+ )
151
+ good_interval = @algorithm.next_interval(good_state[:stability], interval)
152
+
153
+ easy_state = @algorithm.next_state(
154
+ { difficulty: @last.difficulty, stability: @last.stability },
155
+ interval,
156
+ Rating::EASY
157
+ )
158
+ easy_interval = @algorithm.next_interval(easy_state[:stability], interval)
159
+ # Only enforce ordering if intervals would violate it and we're not at max_interval
160
+ if easy_interval <= good_interval && good_interval < @algorithm.parameters.maximum_interval
161
+ easy_interval = [easy_interval, good_interval + 1].max
162
+ end
163
+ next_card.scheduled_days = easy_interval
164
+ end
165
+
166
+ next_card.due = Helpers.date_scheduler(@review_time, next_card.scheduled_days, true)
167
+ end
168
+
169
+ log = build_log(grade)
170
+ RecordLogItem.new(card: next_card, log: log)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module Schedulers
5
+ # Scheduler without short-term learning (no learning steps)
6
+ class LongTermScheduler < BaseScheduler
7
+ protected
8
+
9
+ def new_state(grade)
10
+ next_card = @current.clone
11
+
12
+ state_result = @algorithm.next_state(
13
+ { difficulty: 0, stability: 0 },
14
+ 0,
15
+ grade
16
+ )
17
+
18
+ next_card.difficulty = state_result[:difficulty]
19
+ next_card.stability = state_result[:stability]
20
+ next_card.state = State::REVIEW
21
+ next_card.learning_steps = 0
22
+
23
+ interval = @algorithm.next_interval(next_card.stability, 0)
24
+ next_card.scheduled_days = interval
25
+ next_card.due = Helpers.date_scheduler(@review_time, interval, true)
26
+
27
+ log = build_log(grade)
28
+ RecordLogItem.new(card: next_card, log: log)
29
+ end
30
+
31
+ def learning_state(grade)
32
+ # Treat learning as review
33
+ review_state(grade)
34
+ end
35
+
36
+ def review_state(grade)
37
+ interval = @elapsed_days
38
+ next_card = @current.clone
39
+
40
+ state_result = @algorithm.next_state(
41
+ { difficulty: @last.difficulty, stability: @last.stability },
42
+ interval,
43
+ grade
44
+ )
45
+
46
+ next_card.difficulty = state_result[:difficulty]
47
+ next_card.stability = state_result[:stability]
48
+ next_card.state = State::REVIEW
49
+ next_card.learning_steps = 0
50
+
51
+ next_card.lapses += 1 if grade == Rating::AGAIN
52
+
53
+ # Calculate intervals for all ratings using their respective stabilities
54
+ intervals = [Rating::AGAIN, Rating::HARD, Rating::GOOD, Rating::EASY].map do |g|
55
+ temp_state = @algorithm.next_state(
56
+ { difficulty: @last.difficulty, stability: @last.stability },
57
+ interval,
58
+ g
59
+ )
60
+ @algorithm.next_interval(temp_state[:stability], interval)
61
+ end
62
+
63
+ # Ensure ordering only when necessary (don't artificially inflate intervals)
64
+ # But respect maximum_interval - don't force intervals beyond it
65
+ max_interval = @algorithm.parameters.maximum_interval
66
+ intervals[0] = [intervals[0], intervals[1]].min # again <= hard
67
+ if intervals[1] <= intervals[0] && intervals[0] < max_interval
68
+ intervals[1] = [intervals[1], intervals[0] + 1].max
69
+ end # hard > again
70
+ if intervals[2] <= intervals[1] && intervals[1] < max_interval
71
+ intervals[2] = [intervals[2], intervals[1] + 1].max
72
+ end # good > hard
73
+ if intervals[3] <= intervals[2] && intervals[2] < max_interval
74
+ intervals[3] = [intervals[3], intervals[2] + 1].max
75
+ end # easy > good
76
+
77
+ scheduled_days = intervals[[Rating::AGAIN, Rating::HARD, Rating::GOOD, Rating::EASY].index(grade)]
78
+ next_card.scheduled_days = scheduled_days
79
+ next_card.due = Helpers.date_scheduler(@review_time, scheduled_days, true)
80
+
81
+ log = build_log(grade)
82
+ RecordLogItem.new(card: next_card, log: log)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module Strategies
5
+ # Convert step unit string to minutes
6
+ # @param step [String] Step string like "1m", "10m", "5h", "2d"
7
+ # @return [Integer] Minutes
8
+ def self.convert_step_unit_to_minutes(step)
9
+ unit = step[-1]
10
+ value = step[0...-1].to_i
11
+
12
+ raise ArgumentError, "Invalid step value: #{step}" if value < 0
13
+
14
+ case unit
15
+ when 'm'
16
+ value
17
+ when 'h'
18
+ value * 60
19
+ when 'd'
20
+ value * 1440
21
+ else
22
+ raise ArgumentError, "Invalid step unit: #{step}, expected m/h/d"
23
+ end
24
+ end
25
+
26
+ # Basic learning steps strategy
27
+ # @param parameters [Parameters] FSRS parameters
28
+ # @param state [Integer] Current state
29
+ # @param cur_step [Integer] Current step index
30
+ # @return [Hash] Mapping of ratings to { scheduled_minutes:, next_step: }
31
+ def self.basic_learning_steps_strategy(parameters, state, cur_step)
32
+ learning_steps = if [State::RELEARNING, State::REVIEW].include?(state)
33
+ parameters.relearning_steps
34
+ else
35
+ parameters.learning_steps
36
+ end
37
+
38
+ steps_length = learning_steps.length
39
+ return {} if steps_length.zero? || cur_step >= steps_length
40
+
41
+ first_step = learning_steps[0]
42
+
43
+ result = {}
44
+
45
+ if state == State::REVIEW
46
+ # Review → again: return first relearning step
47
+ result[Rating::AGAIN] = {
48
+ scheduled_minutes: convert_step_unit_to_minutes(first_step),
49
+ next_step: 0
50
+ }
51
+ else
52
+ # New, Learning, Relearning states
53
+ result[Rating::AGAIN] = {
54
+ scheduled_minutes: convert_step_unit_to_minutes(first_step),
55
+ next_step: 0
56
+ }
57
+
58
+ # Hard interval
59
+ hard_minutes = if steps_length == 1
60
+ (convert_step_unit_to_minutes(first_step) * 1.5).round
61
+ else
62
+ second_step = learning_steps[1]
63
+ ((convert_step_unit_to_minutes(first_step) + convert_step_unit_to_minutes(second_step)) / 2.0).round
64
+ end
65
+
66
+ result[Rating::HARD] = {
67
+ scheduled_minutes: hard_minutes,
68
+ next_step: cur_step
69
+ }
70
+
71
+ # Good: advance to next step if it exists
72
+ next_step_index = cur_step + 1
73
+ if next_step_index < steps_length
74
+ next_step = learning_steps[next_step_index]
75
+ result[Rating::GOOD] = {
76
+ scheduled_minutes: convert_step_unit_to_minutes(next_step).round,
77
+ next_step: next_step_index
78
+ }
79
+ end
80
+ end
81
+
82
+ result
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module Strategies
5
+ # Default seed strategy using review time and card properties
6
+ # @param scheduler [BaseScheduler] Scheduler instance
7
+ # @return [String] Seed string
8
+ def self.default_init_seed_strategy(scheduler)
9
+ time = scheduler.review_time.to_i
10
+ reps = scheduler.current.reps
11
+ mul = (scheduler.current.difficulty * scheduler.current.stability).round(2)
12
+ "#{time}_#{reps}_#{mul}"
13
+ end
14
+
15
+ # Generate seed strategy with card ID field
16
+ # @param card_id_field [String, Symbol] Field name for card ID
17
+ # @return [Proc] Seed strategy proc
18
+ def self.gen_seed_strategy_with_card_id(card_id_field)
19
+ ->(scheduler) do
20
+ card_id = scheduler.current.send(card_id_field)
21
+ reps = scheduler.current.reps || 0
22
+ "#{card_id}#{reps}"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ # Type conversion utilities for normalizing inputs
5
+ class TypeConverter
6
+ # Convert various time formats to Time object
7
+ # @param value [Time, Integer, String] Time value
8
+ # @return [Time] Normalized Time object
9
+ def self.time(value)
10
+ case value
11
+ when Time
12
+ value
13
+ when Integer
14
+ Time.at(value)
15
+ when String
16
+ Time.parse(value)
17
+ else
18
+ raise ArgumentError, "Invalid time: #{value}"
19
+ end
20
+ end
21
+
22
+ # Convert string or integer to State constant
23
+ # @param value [Integer, String, Symbol] State value
24
+ # @return [Integer] State constant
25
+ def self.state(value)
26
+ case value
27
+ when Integer
28
+ raise ArgumentError, "Invalid state: #{value}" unless State.valid?(value)
29
+ value
30
+ when String, Symbol
31
+ State.from_string(value.to_s)
32
+ else
33
+ raise ArgumentError, "Invalid state: #{value}"
34
+ end
35
+ end
36
+
37
+ # Convert string or integer to Rating constant
38
+ # @param value [Integer, String, Symbol] Rating value
39
+ # @return [Integer] Rating constant
40
+ def self.rating(value)
41
+ case value
42
+ when Integer
43
+ raise ArgumentError, "Invalid rating: #{value}" unless Rating.valid?(value)
44
+ value
45
+ when String, Symbol
46
+ Rating.from_string(value.to_s)
47
+ else
48
+ raise ArgumentError, "Invalid rating: #{value}"
49
+ end
50
+ end
51
+
52
+ # Normalize Card object
53
+ # @param card_input [Card, Hash] Card or hash with card data
54
+ # @return [Card] Normalized Card object
55
+ def self.card(card_input)
56
+ return card_input if card_input.is_a?(Card)
57
+
58
+ Card.new(
59
+ due: time(card_input[:due]),
60
+ stability: card_input[:stability] || 0.0,
61
+ difficulty: card_input[:difficulty] || 0.0,
62
+ elapsed_days: card_input[:elapsed_days] || 0,
63
+ scheduled_days: card_input[:scheduled_days] || 0,
64
+ learning_steps: card_input[:learning_steps] || 0,
65
+ reps: card_input[:reps] || 0,
66
+ lapses: card_input[:lapses] || 0,
67
+ state: state(card_input[:state] || State::NEW),
68
+ last_review: card_input[:last_review] ? time(card_input[:last_review]) : nil
69
+ )
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ VERSION = '1.0.0'
5
+ FSRS_VERSION = "v#{VERSION} using FSRS-6.0"
6
+ end
data/lib/fsrs_ruby.rb ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Main FSRS Ruby module
4
+ module FsrsRuby
5
+ class Error < StandardError; end
6
+ end
7
+
8
+ # Require all components in correct order
9
+ require_relative 'fsrs_ruby/version'
10
+ require_relative 'fsrs_ruby/constants'
11
+ require_relative 'fsrs_ruby/models'
12
+ require_relative 'fsrs_ruby/helpers'
13
+ require_relative 'fsrs_ruby/type_converter'
14
+ require_relative 'fsrs_ruby/alea'
15
+ require_relative 'fsrs_ruby/parameters'
16
+ require_relative 'fsrs_ruby/algorithm'
17
+ require_relative 'fsrs_ruby/strategies/learning_steps'
18
+ require_relative 'fsrs_ruby/strategies/seed'
19
+ require_relative 'fsrs_ruby/schedulers/base_scheduler'
20
+ require_relative 'fsrs_ruby/schedulers/basic_scheduler'
21
+ require_relative 'fsrs_ruby/schedulers/long_term_scheduler'
22
+ require_relative 'fsrs_ruby/fsrs_instance'
23
+
24
+ module FsrsRuby
25
+ # Factory method to create FSRS instance
26
+ # @param params [Hash] FSRS parameters
27
+ # @return [FSRSInstance] FSRS instance
28
+ def self.new(params = {})
29
+ FSRSInstance.new(params)
30
+ end
31
+
32
+ # Create an empty card
33
+ # @param now [Time, nil] Current time
34
+ # @return [Card] New empty card
35
+ def self.create_empty_card(now = nil)
36
+ ParameterUtils.create_empty_card(now)
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fsrs_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ondrej Rohon
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.12'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.12'
40
+ - !ruby/object:Gem::Dependency
41
+ name: simplecov
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.22'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.22'
54
+ description: " A complete Ruby port of the TypeScript FSRS v6.0 algorithm for spaced
55
+ repetition \n scheduling. Implements exponential difficulty, linear damping,
56
+ and 21-parameter \n optimization for optimal review timing in flashcard and learning
57
+ applications.\n"
58
+ email:
59
+ - ondrej.rohon@gmail.com
60
+ executables: []
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - LICENSE
66
+ - README.md
67
+ - TESTING.md
68
+ - VERIFICATION_REPORT.md
69
+ - VERIFICATION_SUMMARY.md
70
+ - lib/fsrs_ruby.rb
71
+ - lib/fsrs_ruby/alea.rb
72
+ - lib/fsrs_ruby/algorithm.rb
73
+ - lib/fsrs_ruby/constants.rb
74
+ - lib/fsrs_ruby/fsrs_instance.rb
75
+ - lib/fsrs_ruby/helpers.rb
76
+ - lib/fsrs_ruby/models.rb
77
+ - lib/fsrs_ruby/parameters.rb
78
+ - lib/fsrs_ruby/schedulers/base_scheduler.rb
79
+ - lib/fsrs_ruby/schedulers/basic_scheduler.rb
80
+ - lib/fsrs_ruby/schedulers/long_term_scheduler.rb
81
+ - lib/fsrs_ruby/strategies/learning_steps.rb
82
+ - lib/fsrs_ruby/strategies/seed.rb
83
+ - lib/fsrs_ruby/type_converter.rb
84
+ - lib/fsrs_ruby/version.rb
85
+ homepage: https://github.com/ondrejrohon/fsrs_ruby
86
+ licenses:
87
+ - MIT
88
+ metadata:
89
+ homepage_uri: https://github.com/ondrejrohon/fsrs_ruby
90
+ source_code_uri: https://github.com/ondrejrohon/fsrs_ruby
91
+ changelog_uri: https://github.com/ondrejrohon/fsrs_ruby/blob/main/CHANGELOG.md
92
+ bug_tracker_uri: https://github.com/ondrejrohon/fsrs_ruby/issues
93
+ documentation_uri: https://rubydoc.info/gems/fsrs_ruby
94
+ wiki_uri: https://github.com/ondrejrohon/fsrs_ruby/wiki
95
+ allowed_push_host: https://rubygems.org
96
+ rubygems_mfa_required: 'true'
97
+ post_install_message: " ✨ Thank you for installing fsrs_ruby! ✨\n\n This is
98
+ a Ruby port of FSRS v6.0 (Free Spaced Repetition Scheduler).\n\n \U0001F4D6 Documentation:
99
+ https://github.com/ondrejrohon/fsrs_ruby\n \U0001F41B Report issues: https://github.com/ondrejrohon/fsrs_ruby/issues\n\n
100
+ \ Cross-validated with 80%+ test coverage and 8-decimal precision.\n"
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: 3.1.0
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 3.6.9
116
+ specification_version: 4
117
+ summary: Ruby implementation of FSRS (Free Spaced Repetition Scheduler) v6.0
118
+ test_files: []