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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE +21 -0
- data/README.md +198 -0
- data/TESTING.md +166 -0
- data/VERIFICATION_REPORT.md +247 -0
- data/VERIFICATION_SUMMARY.md +118 -0
- data/lib/fsrs_ruby/alea.rb +102 -0
- data/lib/fsrs_ruby/algorithm.rb +222 -0
- data/lib/fsrs_ruby/constants.rb +78 -0
- data/lib/fsrs_ruby/fsrs_instance.rb +125 -0
- data/lib/fsrs_ruby/helpers.rb +94 -0
- data/lib/fsrs_ruby/models.rb +206 -0
- data/lib/fsrs_ruby/parameters.rb +128 -0
- data/lib/fsrs_ruby/schedulers/base_scheduler.rb +113 -0
- data/lib/fsrs_ruby/schedulers/basic_scheduler.rb +174 -0
- data/lib/fsrs_ruby/schedulers/long_term_scheduler.rb +86 -0
- data/lib/fsrs_ruby/strategies/learning_steps.rb +85 -0
- data/lib/fsrs_ruby/strategies/seed.rb +26 -0
- data/lib/fsrs_ruby/type_converter.rb +72 -0
- data/lib/fsrs_ruby/version.rb +6 -0
- data/lib/fsrs_ruby.rb +38 -0
- metadata +118 -0
|
@@ -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
|