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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module Helpers
5
+ # Round to 8 decimal places for TypeScript compatibility
6
+ def self.round8(value)
7
+ return value if value.nil?
8
+ (value * 100_000_000).round / 100_000_000.0
9
+ end
10
+
11
+ # Clamp value between min and max
12
+ def self.clamp(value, min, max)
13
+ [[value, min].max, max].min
14
+ end
15
+
16
+ # Add time offset to a date
17
+ # @param now [Time] Current time
18
+ # @param t [Numeric] Time offset value
19
+ # @param is_day [Boolean] If true, t is in days; if false, t is in minutes
20
+ # @return [Time] New time with offset applied
21
+ def self.date_scheduler(now, t, is_day = false)
22
+ if is_day
23
+ now + (t * 24 * 60 * 60) # Days to seconds
24
+ else
25
+ now + (t * 60) # Minutes to seconds
26
+ end
27
+ end
28
+
29
+ # Calculate difference between two dates
30
+ # @param now [Time] Current time
31
+ # @param pre [Time] Previous time
32
+ # @param unit [Symbol] :days or :minutes
33
+ # @return [Integer] Difference in specified units
34
+ def self.date_diff(now, pre, unit)
35
+ diff_seconds = now - pre
36
+
37
+ case unit
38
+ when :days
39
+ (diff_seconds / (24 * 60 * 60)).floor
40
+ when :minutes
41
+ (diff_seconds / 60).floor
42
+ else
43
+ raise ArgumentError, "Invalid unit: #{unit}. Use :days or :minutes"
44
+ end
45
+ end
46
+
47
+ # Calculate fuzz range for interval randomization
48
+ # @param interval [Numeric] Base interval
49
+ # @param elapsed_days [Integer] Days since last review
50
+ # @param maximum_interval [Integer] Maximum allowed interval
51
+ # @return [Hash] { min_ivl:, max_ivl: }
52
+ def self.get_fuzz_range(interval, elapsed_days, maximum_interval)
53
+ delta = 1.0
54
+
55
+ # Apply fuzzing factors based on interval ranges
56
+ if interval >= 2.5
57
+ delta += (interval - 2.5) * 0.15 if interval < 7.0
58
+ delta += (7.0 - 2.5) * 0.15 if interval >= 7.0
59
+ delta += (interval - 7.0) * 0.10 if interval >= 7.0 && interval < 20.0
60
+ delta += (20.0 - 7.0) * 0.10 if interval >= 20.0
61
+ delta += (interval - 20.0) * 0.05 if interval >= 20.0
62
+ end
63
+
64
+ # Clamp interval to maximum
65
+ interval = [interval, maximum_interval].min
66
+
67
+ min_ivl = [2, (interval - delta).round].max
68
+ max_ivl = [(interval + delta).round, maximum_interval].min
69
+
70
+ # Ensure min_ivl is greater than elapsed_days if interval exceeds it
71
+ min_ivl = [min_ivl, elapsed_days + 1].max if interval > elapsed_days
72
+
73
+ # Ensure min <= max
74
+ min_ivl = max_ivl if min_ivl > max_ivl
75
+
76
+ { min_ivl: min_ivl, max_ivl: max_ivl }
77
+ end
78
+
79
+ # Format date as YYYY-MM-DD HH:MM:SS
80
+ # @param time [Time] Time object
81
+ # @return [String] Formatted date string
82
+ def self.format_date(time)
83
+ time.strftime('%Y-%m-%d %H:%M:%S')
84
+ end
85
+
86
+ # Calculate day difference ignoring time
87
+ # @param last [Time] Last review time
88
+ # @param cur [Time] Current time
89
+ # @return [Integer] Day difference
90
+ def self.date_diff_in_days(last, cur)
91
+ (cur.to_date - last.to_date).to_i
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ # State enum - Card states
5
+ module State
6
+ NEW = 0
7
+ LEARNING = 1
8
+ REVIEW = 2
9
+ RELEARNING = 3
10
+
11
+ def self.valid?(value)
12
+ [NEW, LEARNING, REVIEW, RELEARNING].include?(value)
13
+ end
14
+
15
+ def self.from_string(str)
16
+ const_get(str.upcase.to_sym)
17
+ rescue NameError
18
+ raise ArgumentError, "Invalid state: #{str}"
19
+ end
20
+
21
+ def self.to_string(value)
22
+ case value
23
+ when NEW then 'New'
24
+ when LEARNING then 'Learning'
25
+ when REVIEW then 'Review'
26
+ when RELEARNING then 'Relearning'
27
+ else raise ArgumentError, "Invalid state value: #{value}"
28
+ end
29
+ end
30
+ end
31
+
32
+ # Rating enum - Review ratings
33
+ module Rating
34
+ MANUAL = 0
35
+ AGAIN = 1
36
+ HARD = 2
37
+ GOOD = 3
38
+ EASY = 4
39
+
40
+ def self.valid?(value)
41
+ (MANUAL..EASY).cover?(value)
42
+ end
43
+
44
+ def self.from_string(str)
45
+ const_get(str.upcase.to_sym)
46
+ rescue NameError
47
+ raise ArgumentError, "Invalid rating: #{str}"
48
+ end
49
+
50
+ def self.to_string(value)
51
+ case value
52
+ when MANUAL then 'Manual'
53
+ when AGAIN then 'Again'
54
+ when HARD then 'Hard'
55
+ when GOOD then 'Good'
56
+ when EASY then 'Easy'
57
+ else raise ArgumentError, "Invalid rating value: #{value}"
58
+ end
59
+ end
60
+ end
61
+
62
+ # Card class representing a flashcard
63
+ class Card
64
+ attr_accessor :due, :stability, :difficulty, :elapsed_days, :scheduled_days,
65
+ :learning_steps, :reps, :lapses, :state, :last_review
66
+
67
+ def initialize(
68
+ due:,
69
+ stability: 0.0,
70
+ difficulty: 0.0,
71
+ elapsed_days: 0,
72
+ scheduled_days: 0,
73
+ learning_steps: 0,
74
+ reps: 0,
75
+ lapses: 0,
76
+ state: State::NEW,
77
+ last_review: nil
78
+ )
79
+ @due = due
80
+ @stability = stability.to_f
81
+ @difficulty = difficulty.to_f
82
+ @elapsed_days = elapsed_days
83
+ @scheduled_days = scheduled_days
84
+ @learning_steps = learning_steps
85
+ @reps = reps
86
+ @lapses = lapses
87
+ @state = state
88
+ @last_review = last_review
89
+ end
90
+
91
+ def clone
92
+ Card.new(
93
+ due: @due.dup,
94
+ stability: @stability,
95
+ difficulty: @difficulty,
96
+ elapsed_days: @elapsed_days,
97
+ scheduled_days: @scheduled_days,
98
+ learning_steps: @learning_steps,
99
+ reps: @reps,
100
+ lapses: @lapses,
101
+ state: @state,
102
+ last_review: @last_review&.dup
103
+ )
104
+ end
105
+
106
+ def to_h
107
+ {
108
+ due: @due,
109
+ stability: @stability,
110
+ difficulty: @difficulty,
111
+ elapsed_days: @elapsed_days,
112
+ scheduled_days: @scheduled_days,
113
+ learning_steps: @learning_steps,
114
+ reps: @reps,
115
+ lapses: @lapses,
116
+ state: @state,
117
+ last_review: @last_review
118
+ }
119
+ end
120
+ end
121
+
122
+ # ReviewLog class for tracking review history
123
+ class ReviewLog
124
+ attr_accessor :rating, :state, :due, :stability, :difficulty,
125
+ :elapsed_days, :last_elapsed_days, :scheduled_days,
126
+ :learning_steps, :review
127
+
128
+ def initialize(
129
+ rating:,
130
+ state:,
131
+ due:,
132
+ stability:,
133
+ difficulty:,
134
+ elapsed_days:,
135
+ last_elapsed_days:,
136
+ scheduled_days:,
137
+ learning_steps:,
138
+ review:
139
+ )
140
+ @rating = rating
141
+ @state = state
142
+ @due = due
143
+ @stability = stability.to_f
144
+ @difficulty = difficulty.to_f
145
+ @elapsed_days = elapsed_days
146
+ @last_elapsed_days = last_elapsed_days
147
+ @scheduled_days = scheduled_days
148
+ @learning_steps = learning_steps
149
+ @review = review
150
+ end
151
+
152
+ def to_h
153
+ {
154
+ rating: @rating,
155
+ state: @state,
156
+ due: @due,
157
+ stability: @stability,
158
+ difficulty: @difficulty,
159
+ elapsed_days: @elapsed_days,
160
+ last_elapsed_days: @last_elapsed_days,
161
+ scheduled_days: @scheduled_days,
162
+ learning_steps: @learning_steps,
163
+ review: @review
164
+ }
165
+ end
166
+ end
167
+
168
+ # RecordLogItem - Container for card and log pair
169
+ RecordLogItem = Struct.new(:card, :log, keyword_init: true)
170
+
171
+ # Parameters class for FSRS parameters
172
+ class Parameters
173
+ attr_accessor :request_retention, :maximum_interval, :w, :enable_fuzz,
174
+ :enable_short_term, :learning_steps, :relearning_steps
175
+
176
+ def initialize(
177
+ request_retention: Constants::DEFAULT_REQUEST_RETENTION,
178
+ maximum_interval: Constants::DEFAULT_MAXIMUM_INTERVAL,
179
+ w: Constants::DEFAULT_WEIGHTS.dup,
180
+ enable_fuzz: Constants::DEFAULT_ENABLE_FUZZ,
181
+ enable_short_term: Constants::DEFAULT_ENABLE_SHORT_TERM,
182
+ learning_steps: Constants::DEFAULT_LEARNING_STEPS.dup,
183
+ relearning_steps: Constants::DEFAULT_RELEARNING_STEPS.dup
184
+ )
185
+ @request_retention = request_retention
186
+ @maximum_interval = maximum_interval
187
+ @w = w
188
+ @enable_fuzz = enable_fuzz
189
+ @enable_short_term = enable_short_term
190
+ @learning_steps = learning_steps
191
+ @relearning_steps = relearning_steps
192
+ end
193
+
194
+ def to_h
195
+ {
196
+ request_retention: @request_retention,
197
+ maximum_interval: @maximum_interval,
198
+ w: @w,
199
+ enable_fuzz: @enable_fuzz,
200
+ enable_short_term: @enable_short_term,
201
+ learning_steps: @learning_steps,
202
+ relearning_steps: @relearning_steps
203
+ }
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module ParameterUtils
5
+ # Clip parameters to valid ranges
6
+ # @param parameters [Array<Numeric>] Parameter array
7
+ # @param num_relearning_steps [Integer] Number of relearning steps
8
+ # @param enable_short_term [Boolean] Whether short-term learning is enabled
9
+ # @return [Array<Float>] Clipped parameters
10
+ def self.clip_parameters(parameters, num_relearning_steps, enable_short_term = true)
11
+ w17_w18_ceiling = Constants::W17_W18_CEILING
12
+
13
+ if [num_relearning_steps, 0].max > 1
14
+ # Calculate ceiling: w17 * w18 <= -[ln(w11) + ln(2^w13 - 1) + w14*0.3] / num_relearning_steps
15
+ value = -(
16
+ Math.log(parameters[11]) +
17
+ Math.log(2.0**parameters[13] - 1.0) +
18
+ parameters[14] * 0.3
19
+ ) / num_relearning_steps
20
+
21
+ w17_w18_ceiling = Helpers.clamp(Helpers.round8(value), 0.01, 2.0)
22
+ end
23
+
24
+ clamp_ranges = Constants.clamp_parameters(w17_w18_ceiling, enable_short_term)
25
+ clamp_ranges = clamp_ranges.slice(0, parameters.length)
26
+
27
+ clamp_ranges.each_with_index.map do |(min, max), index|
28
+ Helpers.clamp(parameters[index] || 0, min, max)
29
+ end
30
+ end
31
+
32
+ # Check if parameters are valid
33
+ # @param parameters [Array<Numeric>] Parameter array
34
+ # @return [Array<Numeric>] Same array if valid
35
+ # @raise [ArgumentError] If parameters are invalid
36
+ def self.check_parameters(parameters)
37
+ invalid = parameters.find { |param| !param.is_a?(Numeric) || !param.finite? }
38
+ if invalid
39
+ raise ArgumentError, "Non-finite or NaN value in parameters: #{parameters}"
40
+ elsif ![17, 19, 21].include?(parameters.length)
41
+ raise ArgumentError,
42
+ "Invalid parameter length: #{parameters.length}. Must be 17, 19 or 21 for FSRSv4, 5 and 6 respectively."
43
+ end
44
+
45
+ parameters
46
+ end
47
+
48
+ # Migrate parameters from v4/v5 to v6 format
49
+ # @param parameters [Array<Numeric>, nil] Parameter array
50
+ # @param num_relearning_steps [Integer] Number of relearning steps
51
+ # @param enable_short_term [Boolean] Whether short-term learning is enabled
52
+ # @return [Array<Float>] Migrated parameters (always 21 elements)
53
+ def self.migrate_parameters(parameters = nil, num_relearning_steps = 0, enable_short_term = true)
54
+ return Constants::DEFAULT_WEIGHTS.dup if parameters.nil?
55
+
56
+ case parameters.length
57
+ when 21
58
+ # v6: Just clip
59
+ clip_parameters(parameters.dup, num_relearning_steps, enable_short_term)
60
+ when 19
61
+ # v5: Clip and append [0.0, FSRS5_DEFAULT_DECAY]
62
+ warn '[FSRS-6] Auto fill w from 19 to 21 length'
63
+ clipped = clip_parameters(parameters.dup, num_relearning_steps, enable_short_term)
64
+ clipped + [0.0, Constants::FSRS5_DEFAULT_DECAY]
65
+ when 17
66
+ # v4: Clip, transform w[4], w[5], w[6], then append [0.0, 0.0, 0.0, FSRS5_DEFAULT_DECAY]
67
+ w = clip_parameters(parameters.dup, num_relearning_steps, enable_short_term)
68
+
69
+ # Transform parameters for v6
70
+ w[4] = Helpers.round8(w[5] * 2.0 + w[4])
71
+ w[5] = Helpers.round8(Math.log(w[5] * 3.0 + 1.0) / 3.0)
72
+ w[6] = Helpers.round8(w[6] + 0.5)
73
+
74
+ warn '[FSRS-6] Auto fill w from 17 to 21 length'
75
+ w + [0.0, 0.0, 0.0, Constants::FSRS5_DEFAULT_DECAY]
76
+ else
77
+ # Invalid length, use defaults
78
+ warn '[FSRS] Invalid parameters length, using default parameters'
79
+ Constants::DEFAULT_WEIGHTS.dup
80
+ end
81
+ end
82
+
83
+ # Generate FSRS parameters from partial input
84
+ # @param props [Hash] Partial parameters
85
+ # @return [Parameters] Complete parameters object
86
+ def self.generate_parameters(props = {})
87
+ learning_steps = props[:learning_steps] || Constants::DEFAULT_LEARNING_STEPS.dup
88
+ relearning_steps = props[:relearning_steps] || Constants::DEFAULT_RELEARNING_STEPS.dup
89
+ enable_short_term = props.key?(:enable_short_term) ? props[:enable_short_term] : Constants::DEFAULT_ENABLE_SHORT_TERM
90
+
91
+ w = migrate_parameters(
92
+ props[:w],
93
+ relearning_steps.length,
94
+ enable_short_term
95
+ )
96
+
97
+ Parameters.new(
98
+ request_retention: props[:request_retention] || Constants::DEFAULT_REQUEST_RETENTION,
99
+ maximum_interval: props[:maximum_interval] || Constants::DEFAULT_MAXIMUM_INTERVAL,
100
+ w: w,
101
+ enable_fuzz: props.key?(:enable_fuzz) ? props[:enable_fuzz] : Constants::DEFAULT_ENABLE_FUZZ,
102
+ enable_short_term: enable_short_term,
103
+ learning_steps: learning_steps,
104
+ relearning_steps: relearning_steps
105
+ )
106
+ end
107
+
108
+ # Create an empty card
109
+ # @param now [Time, nil] Current time (defaults to Time.now)
110
+ # @return [Card] New empty card
111
+ def self.create_empty_card(now = nil)
112
+ now ||= Time.now
113
+
114
+ Card.new(
115
+ due: now,
116
+ stability: 0.0,
117
+ difficulty: 0.0,
118
+ elapsed_days: 0,
119
+ scheduled_days: 0,
120
+ learning_steps: 0,
121
+ reps: 0,
122
+ lapses: 0,
123
+ state: State::NEW,
124
+ last_review: nil
125
+ )
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FsrsRuby
4
+ module Schedulers
5
+ # Base scheduler implementing template method pattern
6
+ class BaseScheduler
7
+ attr_reader :last, :current, :review_time, :algorithm, :strategies, :elapsed_days
8
+
9
+ def initialize(card, now, algorithm, strategies = {})
10
+ @last = card.is_a?(Card) ? card : TypeConverter.card(card)
11
+ @current = @last.clone
12
+ @review_time = now.is_a?(Time) ? now : TypeConverter.time(now)
13
+ @algorithm = algorithm
14
+ @strategies = strategies
15
+ @next_cache = {}
16
+
17
+ init
18
+ end
19
+
20
+ # Preview all possible outcomes
21
+ # @return [Hash] { Rating::AGAIN =>, Rating::HARD =>, Rating::GOOD =>, Rating::EASY => }
22
+ def preview
23
+ {
24
+ Rating::AGAIN => review(Rating::AGAIN),
25
+ Rating::HARD => review(Rating::HARD),
26
+ Rating::GOOD => review(Rating::GOOD),
27
+ Rating::EASY => review(Rating::EASY)
28
+ }
29
+ end
30
+
31
+ # Apply specific rating
32
+ # @param grade [Integer] Rating (1-4)
33
+ # @return [RecordLogItem] { card:, log: }
34
+ def review(grade)
35
+ raise ArgumentError, "Invalid grade: #{grade}" unless (Rating::AGAIN..Rating::EASY).cover?(grade)
36
+
37
+ return @next_cache[grade] if @next_cache.key?(grade)
38
+
39
+ result = case @current.state
40
+ when State::NEW
41
+ new_state(grade)
42
+ when State::LEARNING, State::RELEARNING
43
+ learning_state(grade)
44
+ when State::REVIEW
45
+ review_state(grade)
46
+ else
47
+ raise "Unknown state: #{@current.state}"
48
+ end
49
+
50
+ @next_cache[grade] = result
51
+ result
52
+ end
53
+
54
+ protected
55
+
56
+ def init
57
+ @elapsed_days = if @last.last_review
58
+ Helpers.date_diff(@review_time, @last.last_review, :days)
59
+ else
60
+ 0
61
+ end
62
+
63
+ @current.last_review = @review_time
64
+ @current.reps += 1
65
+
66
+ # Initialize seed strategy if provided
67
+ @seed_strategy = @strategies[:seed]
68
+ end
69
+
70
+ # Build review log
71
+ # @param rating [Integer] Rating given
72
+ # @return [ReviewLog]
73
+ def build_log(rating)
74
+ ReviewLog.new(
75
+ rating: rating,
76
+ state: @last.state,
77
+ due: @last.due,
78
+ stability: @last.stability,
79
+ difficulty: @last.difficulty,
80
+ elapsed_days: @elapsed_days,
81
+ last_elapsed_days: @last.scheduled_days,
82
+ scheduled_days: @current.scheduled_days,
83
+ learning_steps: @current.learning_steps,
84
+ review: @review_time
85
+ )
86
+ end
87
+
88
+ # Calculate next difficulty and stability
89
+ # @param interval [Integer] Elapsed interval
90
+ # @return [Hash] { difficulty:, stability: }
91
+ def next_ds(interval = 0)
92
+ @algorithm.next_state(
93
+ { difficulty: @last.difficulty, stability: @last.stability },
94
+ interval,
95
+ @current.state == State::NEW ? Rating::GOOD : @current.state
96
+ )
97
+ end
98
+
99
+ # Template methods (to be overridden by subclasses)
100
+ def new_state(grade)
101
+ raise NotImplementedError, "#{self.class} must implement #new_state"
102
+ end
103
+
104
+ def learning_state(grade)
105
+ raise NotImplementedError, "#{self.class} must implement #learning_state"
106
+ end
107
+
108
+ def review_state(grade)
109
+ raise NotImplementedError, "#{self.class} must implement #review_state"
110
+ end
111
+ end
112
+ end
113
+ end