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,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
|
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: []
|