Class: FsrsRuby::Algorithm

Inherits:
Object
  • Object
show all
Defined in:
lib/fsrs_ruby/algorithm.rb

Overview

Core FSRS v6.0 algorithm implementation

Direct Known Subclasses

FSRSInstance

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(params = {}) ⇒ Algorithm

Returns a new instance of Algorithm.



9
10
11
12
13
# File 'lib/fsrs_ruby/algorithm.rb', line 9

def initialize(params = {})
  @parameters = ParameterUtils.generate_parameters(params)
  @interval_modifier = calculate_interval_modifier(@parameters.request_retention)
  @seed = nil
end

Instance Attribute Details

#interval_modifierObject (readonly)

Returns the value of attribute interval_modifier.



6
7
8
# File 'lib/fsrs_ruby/algorithm.rb', line 6

def interval_modifier
  @interval_modifier
end

#parametersObject

Returns the value of attribute parameters.



6
7
8
# File 'lib/fsrs_ruby/algorithm.rb', line 6

def parameters
  @parameters
end

#seedObject

Returns the value of attribute seed.



7
8
9
# File 'lib/fsrs_ruby/algorithm.rb', line 7

def seed
  @seed
end

Instance Method Details

#apply_fuzz(ivl, elapsed_days) ⇒ Integer

Apply fuzz using Alea PRNG

Parameters:

  • ivl (Numeric)

    Interval

  • elapsed_days (Integer)

    Days since last review

Returns:

  • (Integer)

    Fuzzed interval



145
146
147
148
149
150
151
152
153
# File 'lib/fsrs_ruby/algorithm.rb', line 145

def apply_fuzz(ivl, elapsed_days)
  return ivl.round unless @parameters.enable_fuzz && ivl >= 2.5

  prng = @seed ? FsrsRuby.alea(@seed) : FsrsRuby.alea(Time.now.to_i)
  fuzz_factor = prng.call

  fuzz_range = Helpers.get_fuzz_range(ivl, elapsed_days, @parameters.maximum_interval)
  (fuzz_factor * (fuzz_range[:max_ivl] - fuzz_range[:min_ivl] + 1) + fuzz_range[:min_ivl]).floor
end

#calculate_interval_modifier(request_retention) ⇒ Float

Calculate interval modifier from request_retention

Parameters:

  • request_retention (Float)

    Target retention rate (0, 1]

Returns:

  • (Float)

    Interval modifier

Raises:

  • (ArgumentError)


44
45
46
47
48
49
# File 'lib/fsrs_ruby/algorithm.rb', line 44

def calculate_interval_modifier(request_retention)
  raise ArgumentError, 'Requested retention rate should be in the range (0,1]' if request_retention <= 0 || request_retention > 1

  info = compute_decay_factor(@parameters.w)
  Helpers.round8((request_retention**(1.0 / info[:decay]) - 1) / info[:factor])
end

#compute_decay_factor(w) ⇒ Hash

Compute decay factor from w

Parameters:

  • w (Array<Float>, Float)

    Weights array or decay value

Returns:

  • (Hash)

    { decay:, factor: }



24
25
26
27
28
# File 'lib/fsrs_ruby/algorithm.rb', line 24

def compute_decay_factor(w)
  decay = w.is_a?(Array) ? -w[20] : -w
  factor = Math.exp(Math.log(0.9) / decay) - 1.0
  { decay: decay, factor: Helpers.round8(factor) }
end

#forgetting_curve(w, elapsed_days, stability) ⇒ Float

Forgetting curve formula

Parameters:

  • w (Array<Float>)

    Weights array

  • elapsed_days (Numeric)

    Days since last review

  • stability (Float)

    Stability (interval when R=90%)

Returns:

  • (Float)

    Retrievability (probability of recall)



35
36
37
38
39
# File 'lib/fsrs_ruby/algorithm.rb', line 35

def forgetting_curve(w, elapsed_days, stability)
  info = compute_decay_factor(w)
  result = (1 + (info[:factor] * elapsed_days) / stability)**info[:decay]
  Helpers.round8(result)
end

#init_difficulty(g) ⇒ Float

CRITICAL: Exponential difficulty formula (NOT linear!)

Parameters:

  • g (Integer)

    Grade (1=Again, 2=Hard, 3=Good, 4=Easy)

Returns:

  • (Float)

    Initial difficulty (raw, not clamped)



61
62
63
64
# File 'lib/fsrs_ruby/algorithm.rb', line 61

def init_difficulty(g)
  d = @parameters.w[4] - Math.exp((g - 1) * @parameters.w[5]) + 1
  Helpers.round8(d)
end

#init_stability(g) ⇒ Float

Initial stability (simple lookup)

Parameters:

  • g (Integer)

    Grade (1=Again, 2=Hard, 3=Good, 4=Easy)

Returns:

  • (Float)

    Initial stability



54
55
56
# File 'lib/fsrs_ruby/algorithm.rb', line 54

def init_stability(g)
  [@parameters.w[g - 1], Constants::S_MIN].max
end

#linear_damping(delta_d, old_d) ⇒ Float

NEW IN v6: Linear damping

Parameters:

  • delta_d (Float)

    Difficulty change

  • old_d (Float)

    Old difficulty

Returns:

  • (Float)

    Damped difficulty change



70
71
72
# File 'lib/fsrs_ruby/algorithm.rb', line 70

def linear_damping(delta_d, old_d)
  Helpers.round8((delta_d * (10 - old_d)) / 9.0)
end

#mean_reversion(init, current) ⇒ Float

Mean reversion

Parameters:

  • init (Float)

    Initial difficulty

  • current (Float)

    Current difficulty

Returns:

  • (Float)

    Reverted difficulty



78
79
80
# File 'lib/fsrs_ruby/algorithm.rb', line 78

def mean_reversion(init, current)
  Helpers.round8(@parameters.w[7] * init + (1 - @parameters.w[7]) * current)
end

#next_difficulty(d, g) ⇒ Float

Next difficulty with linear damping

Parameters:

  • d (Float)

    Current difficulty

  • g (Integer)

    Grade

Returns:

  • (Float)

    Next difficulty [1, 10]



86
87
88
89
90
# File 'lib/fsrs_ruby/algorithm.rb', line 86

def next_difficulty(d, g)
  delta_d = -@parameters.w[6] * (g - 3)
  next_d = d + linear_damping(delta_d, d)
  Helpers.clamp(mean_reversion(init_difficulty(Rating::EASY), next_d), 1, 10)
end

#next_forget_stability(d, s, r) ⇒ Float

Next forget stability (for failed reviews)

Parameters:

  • d (Float)

    Difficulty

  • s (Float)

    Stability

  • r (Float)

    Retrievability

Returns:

  • (Float)

    New stability after forgetting



119
120
121
122
123
124
125
126
127
128
# File 'lib/fsrs_ruby/algorithm.rb', line 119

def next_forget_stability(d, s, r)
  new_s = (
    @parameters.w[11] *
    (d**-@parameters.w[12]) *
    ((s + 1)**@parameters.w[13] - 1) *
    Math.exp((1 - r) * @parameters.w[14])
  )

  Helpers.clamp(Helpers.round8(new_s), Constants::S_MIN, Constants::S_MAX)
end

#next_interval(s, elapsed_days = 0) ⇒ Integer

Calculate next interval

Parameters:

  • s (Float)

    Stability

  • elapsed_days (Integer) (defaults to: 0)

    Days since last review

Returns:

  • (Integer)

    Next interval in days



159
160
161
162
163
# File 'lib/fsrs_ruby/algorithm.rb', line 159

def next_interval(s, elapsed_days = 0)
  new_interval = [(s * @interval_modifier).round, 1].max
  new_interval = [new_interval, @parameters.maximum_interval].min
  apply_fuzz(new_interval, elapsed_days)
end

#next_recall_stability(d, s, r, g) ⇒ Float

Next recall stability (for successful reviews)

Parameters:

  • d (Float)

    Difficulty

  • s (Float)

    Stability

  • r (Float)

    Retrievability

  • g (Integer)

    Grade

Returns:

  • (Float)

    New stability after recall



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/fsrs_ruby/algorithm.rb', line 98

def next_recall_stability(d, s, r, g)
  hard_penalty = g == Rating::HARD ? @parameters.w[15] : 1
  easy_bonus = g == Rating::EASY ? @parameters.w[16] : 1

  new_s = s * (
    1 + Math.exp(@parameters.w[8]) *
    (11 - d) *
    (s**-@parameters.w[9]) *
    (Math.exp((1 - r) * @parameters.w[10]) - 1) *
    hard_penalty *
    easy_bonus
  )

  Helpers.clamp(Helpers.round8(new_s), Constants::S_MIN, Constants::S_MAX)
end

#next_short_term_stability(s, g) ⇒ Float

NEW IN v6: Short-term stability

Parameters:

  • s (Float)

    Stability

  • g (Integer)

    Grade

Returns:

  • (Float)

    New short-term stability



134
135
136
137
138
139
# File 'lib/fsrs_ruby/algorithm.rb', line 134

def next_short_term_stability(s, g)
  sinc = (s**-@parameters.w[19]) * Math.exp(@parameters.w[17] * (g - 3 + @parameters.w[18]))

  masked_sinc = g >= Rating::HARD ? [sinc, 1.0].max : sinc
  Helpers.clamp(Helpers.round8(s * masked_sinc), Constants::S_MIN, Constants::S_MAX)
end

#next_state(memory_state, t, g, r = nil) ⇒ Hash

Calculate next state of memory

Parameters:

  • memory_state (Hash, nil)

    Current state { difficulty:, stability: } or nil

  • t (Numeric)

    Time elapsed since last review

  • g (Integer)

    Grade (0=Manual, 1=Again, 2=Hard, 3=Good, 4=Easy)

  • r (Float, nil) (defaults to: nil)

    Optional retrievability value

Returns:

  • (Hash)

    { difficulty:, stability: }

Raises:

  • (ArgumentError)


171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/fsrs_ruby/algorithm.rb', line 171

def next_state(memory_state, t, g, r = nil)
  d = memory_state ? memory_state[:difficulty] : 0
  s = memory_state ? memory_state[:stability] : 0

  raise ArgumentError, "Invalid delta_t \"#{t}\"" if t < 0
  raise ArgumentError, "Invalid grade \"#{g}\"" if g < 0 || g > 4

  # First review
  if d == 0 && s == 0
    return {
      difficulty: Helpers.clamp(init_difficulty(g), 1, 10),
      stability: init_stability(g)
    }
  end

  # Manual grade
  if g == 0
    return { difficulty: d, stability: s }
  end

  # Validate state
  if d < 1 || s < Constants::S_MIN
    raise ArgumentError, "Invalid memory state { difficulty: #{d}, stability: #{s} }"
  end

  # Calculate retrievability if not provided
  r = forgetting_curve(@parameters.w, t, s) if r.nil?

  # Calculate possible next stabilities
  s_after_success = next_recall_stability(d, s, r, g)
  s_after_fail = next_forget_stability(d, s, r)
  s_after_short_term = next_short_term_stability(s, g)

  # Select appropriate stability
  new_s = s_after_success

  if g == Rating::AGAIN
    w_17 = @parameters.enable_short_term ? @parameters.w[17] : 0
    w_18 = @parameters.enable_short_term ? @parameters.w[18] : 0
    next_s_min = s / Math.exp(w_17 * w_18)
    new_s = Helpers.clamp(Helpers.round8(next_s_min), Constants::S_MIN, s_after_fail)
  end

  if t == 0 && @parameters.enable_short_term
    new_s = s_after_short_term
  end

  new_d = next_difficulty(d, g)
  { difficulty: new_d, stability: new_s }
end