nagori-fsrs 0.1.0-x86_64-linux
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/LICENSE +33 -0
- data/README.md +169 -0
- data/lib/nagori/3.1/nagori.so +0 -0
- data/lib/nagori/3.2/nagori.so +0 -0
- data/lib/nagori/3.3/nagori.so +0 -0
- data/lib/nagori/3.4/nagori.so +0 -0
- data/lib/nagori/4.0/nagori.so +0 -0
- data/lib/nagori/version.rb +3 -0
- data/lib/nagori-fsrs.rb +2 -0
- data/lib/nagori.rb +233 -0
- metadata +114 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 302362c037692cd7f93735fff5006eef832e014f55395302ff30f96d70adaabe
|
|
4
|
+
data.tar.gz: 1feeafec2ace2dee429a15095dffa08e5c2bfb1159fcd7b67965f7371714a787
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6dca3dd247c4db075346f5a9e2a60ddb28e77433cbb4867de0bbcb3da71ccdc1f362eaa7fc92680014f7d418140c82065e59f2079ba4bbf9d3090eaaad791424
|
|
7
|
+
data.tar.gz: 3982242047ccfde18eac3e5351421c9e3c3cdaddd01b97e53522de3f8371708391036c35ace372300bed1c6301f8c0de97253932bc71c741e3cd6545b1fb53d2
|
data/LICENSE
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, David Afonso (Nagori Ruby bindings)
|
|
4
|
+
Copyright (c) 2023, Open Spaced Repetition (fsrs-rs, the wrapped `fsrs` crate)
|
|
5
|
+
|
|
6
|
+
Nagori is a Ruby binding for fsrs-rs (https://github.com/open-spaced-repetition/fsrs-rs),
|
|
7
|
+
which is distributed under the BSD 3-Clause License. This gem redistributes and
|
|
8
|
+
links against that crate; its copyright notice is retained above as required.
|
|
9
|
+
|
|
10
|
+
Redistribution and use in source and binary forms, with or without
|
|
11
|
+
modification, are permitted provided that the following conditions are met:
|
|
12
|
+
|
|
13
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
14
|
+
list of conditions and the following disclaimer.
|
|
15
|
+
|
|
16
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
17
|
+
this list of conditions and the following disclaimer in the documentation
|
|
18
|
+
and/or other materials provided with the distribution.
|
|
19
|
+
|
|
20
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
21
|
+
contributors may be used to endorse or promote products derived from
|
|
22
|
+
this software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
25
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
26
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
33
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Nagori 名残
|
|
2
|
+
|
|
3
|
+
Ruby bindings for [fsrs-rs](https://github.com/open-spaced-repetition/fsrs-rs)
|
|
4
|
+
(the [`fsrs`](https://crates.io/crates/fsrs) crate) — the **FSRS-6** spaced-
|
|
5
|
+
repetition scheduler, optimizer, and simulator, in Rust.
|
|
6
|
+
|
|
7
|
+
Nagori is a thin, idiomatic wrapper: plain Ruby values in and out (hashes,
|
|
8
|
+
arrays, keyword args), the number crunching in Rust. Long-running calls
|
|
9
|
+
(`compute_parameters`, `evaluate`, `simulate`, `optimal_retention`) release the
|
|
10
|
+
GVL so they don't block other threads.
|
|
11
|
+
|
|
12
|
+
Built the same way as [kabosu](https://github.com/davafons/kabosu):
|
|
13
|
+
[rb_sys](https://github.com/oxidize-rb/rb-sys) + [magnus](https://github.com/matsadler/magnus),
|
|
14
|
+
precompiled for the usual platform matrix.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem "nagori-fsrs"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
During development against a checkout:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem "nagori-fsrs", path: "../nagori-fsrs"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Concepts
|
|
30
|
+
|
|
31
|
+
- **Rating** is `1 = Again`, `2 = Hard`, `3 = Good`, `4 = Easy` (symbols
|
|
32
|
+
`:again/:hard/:good/:easy` are accepted anywhere a rating is taken).
|
|
33
|
+
- **A review** is `{ rating:, delta_t: }`, where `delta_t` is **whole days since
|
|
34
|
+
the previous review**, `0` for the first review and for same-day re-reviews.
|
|
35
|
+
- **Intervals** come back as **fractional, unrounded `f32` days**. Nagori never
|
|
36
|
+
rounds — rounding, fuzz, and load-balancing are application policy.
|
|
37
|
+
- **Parameters** are the FSRS weight vector. `nil`/empty uses
|
|
38
|
+
`Nagori::DEFAULT_PARAMETERS`; 17- (FSRS-4.5) and 19-length (FSRS-5) vectors are
|
|
39
|
+
accepted and padded to the 21-length FSRS-6 shape.
|
|
40
|
+
|
|
41
|
+
## Scheduling
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
require "nagori-fsrs"
|
|
45
|
+
|
|
46
|
+
fsrs = Nagori::FSRS.new # default parameters
|
|
47
|
+
# fsrs = Nagori::FSRS.new(my_21_floats) # or a trained vector
|
|
48
|
+
|
|
49
|
+
# The four answer buttons for a brand-new card (nil memory state):
|
|
50
|
+
fsrs.next_states(nil, 0.9, 0)
|
|
51
|
+
# => { again: { stability: 0.212, difficulty: 6.413, interval: 0.212 },
|
|
52
|
+
# hard: { ... }, good: { ... }, easy: { ... } }
|
|
53
|
+
|
|
54
|
+
# For a card with an existing memory state, 5 days elapsed:
|
|
55
|
+
fsrs.next_states({ stability: 10.0, difficulty: 5.0 }, 0.9, 5)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`next_states(memory_state, desired_retention, days_elapsed)` returns a hash of
|
|
59
|
+
the four buttons; each is `{ stability:, difficulty:, interval: }`.
|
|
60
|
+
|
|
61
|
+
## Replaying history → memory state
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
reviews = [
|
|
65
|
+
{ rating: 3, delta_t: 0 }, # first review, same day
|
|
66
|
+
{ rating: 3, delta_t: 5 },
|
|
67
|
+
{ rating: 4, delta_t: 20 }
|
|
68
|
+
]
|
|
69
|
+
fsrs.memory_state(reviews) # => { stability:, difficulty: }
|
|
70
|
+
|
|
71
|
+
# Batch (Anki import / FSRS-6 migration):
|
|
72
|
+
fsrs.memory_state_batch([reviews, other_reviews])
|
|
73
|
+
# => [{ stability:, difficulty: }, ...]
|
|
74
|
+
|
|
75
|
+
# No revlog, only SM-2 values:
|
|
76
|
+
fsrs.memory_state_from_sm2(2.5, 10.0, 0.9) # ease, interval, retention
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Intervals & retrievability
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
fsrs.next_interval(nil, 0.9, 3) # new card, Good -> fractional days
|
|
83
|
+
fsrs.next_interval(100.0, 0.9, 3) # from a known stability
|
|
84
|
+
|
|
85
|
+
Nagori.current_retrievability({ stability: 10.0, difficulty: 5.0 }, 5.0)
|
|
86
|
+
# decay defaults to Nagori::FSRS6_DEFAULT_DECAY; pass a third arg to override
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Optimizer
|
|
90
|
+
|
|
91
|
+
`compute_parameters` and `evaluate` take a **training set**: one item per
|
|
92
|
+
review, each item a *prefix* of a card's history (all prefixes of length ≥ 2).
|
|
93
|
+
fsrs-rs does not expand histories for you — pretraining keys off the
|
|
94
|
+
exactly-one-long-term-review prefixes.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# items: Array of Array-of-{rating:, delta_t:}
|
|
98
|
+
params = Nagori.compute_parameters(items, enable_short_term: true)
|
|
99
|
+
# => 21 floats (GVL released during training)
|
|
100
|
+
|
|
101
|
+
# Health check: does a candidate parameter set fit the data?
|
|
102
|
+
Nagori::FSRS.new(params).evaluate(items) # => { log_loss:, rmse_bins: }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Typical apply-only-if-better gate:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
old_fit = Nagori::FSRS.new(current_params).evaluate(items)
|
|
109
|
+
new_fit = Nagori::FSRS.new(candidate_params).evaluate(items)
|
|
110
|
+
apply = new_fit[:log_loss] < old_fit[:log_loss]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Simulator
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# Project reviews/day and memorized count over a horizon:
|
|
117
|
+
Nagori.simulate(Nagori::DEFAULT_PARAMETERS, 0.9,
|
|
118
|
+
config: { learn_span: 90, deck_size: 1000 }, seed: 42)
|
|
119
|
+
# => { memorized_cnt_per_day:, review_cnt_per_day:, learn_cnt_per_day:,
|
|
120
|
+
# cost_per_day:, correct_cnt_per_day:, introduced_cnt_per_day:,
|
|
121
|
+
# average_desired_retention: }
|
|
122
|
+
|
|
123
|
+
# Retention-slider preview (expected daily seconds of work):
|
|
124
|
+
Nagori.expected_workload(params, 0.95, config: { deck_size: 1000 })
|
|
125
|
+
|
|
126
|
+
# Suggested retention (CMRR); GVL released:
|
|
127
|
+
Nagori.optimal_retention(params, config: { learn_span: 365 })
|
|
128
|
+
|
|
129
|
+
# Calibrate a config from Anki-style revlog rows:
|
|
130
|
+
config = Nagori.extract_simulator_config(revlog_entries, day_cutoff)
|
|
131
|
+
Nagori.simulate(params, 0.9, config: config)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`config` is a plain hash; any omitted key falls back to fsrs-rs's default
|
|
135
|
+
`SimulatorConfig`. The closure hooks (`post_scheduling_fn`,
|
|
136
|
+
`review_priority_fn`) are intentionally not exposed. Each revlog entry is a hash
|
|
137
|
+
with `:id, :cid, :usn, :button_chosen, :interval, :last_interval, :ease_factor,
|
|
138
|
+
:taken_millis, :review_kind` (review_kind `0..4`).
|
|
139
|
+
|
|
140
|
+
## Errors
|
|
141
|
+
|
|
142
|
+
Invalid input (bad parameter length, rating outside `1..4`, negative `delta_t`,
|
|
143
|
+
malformed memory state) raises `ArgumentError`. Computation failures surface as
|
|
144
|
+
`RuntimeError`.
|
|
145
|
+
|
|
146
|
+
## Version pinning
|
|
147
|
+
|
|
148
|
+
The wrapped crate is pinned exactly (`fsrs = "=6.6.x"` in
|
|
149
|
+
`ext/nagori/Cargo.toml`) because a crate bump can shift scheduler outputs. The
|
|
150
|
+
gem's golden tests assert exact stability/difficulty/interval values, so a bump
|
|
151
|
+
that changes results fails loudly. Bump the pin and the golden vectors together.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
bundle install
|
|
157
|
+
bundle exec rake compile
|
|
158
|
+
bundle exec rake test
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Requires a Rust toolchain (stable). Tests are Minitest with golden vectors
|
|
162
|
+
generated from fsrs-rs itself.
|
|
163
|
+
|
|
164
|
+
## License & attribution
|
|
165
|
+
|
|
166
|
+
Nagori is released under the **BSD 3-Clause License**. It wraps and
|
|
167
|
+
redistributes [fsrs-rs](https://github.com/open-spaced-repetition/fsrs-rs)
|
|
168
|
+
(© 2023 Open Spaced Repetition), also BSD 3-Clause. See [LICENSE](LICENSE) for
|
|
169
|
+
both copyright notices.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/nagori-fsrs.rb
ADDED
data/lib/nagori.rb
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
require_relative "nagori/version"
|
|
2
|
+
|
|
3
|
+
# Load the native extension. Precompiled gems place it under a per-Ruby-version
|
|
4
|
+
# subdirectory (lib/nagori/3.3/nagori.bundle, etc.); a source build leaves it at
|
|
5
|
+
# lib/nagori/nagori.{bundle,so}. Try the version-suffixed path first, fall back
|
|
6
|
+
# to the flat path so dev/source builds keep working.
|
|
7
|
+
begin
|
|
8
|
+
major_minor = RUBY_VERSION.split(".").take(2).join(".")
|
|
9
|
+
require_relative "nagori/#{major_minor}/nagori"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
require_relative "nagori/nagori"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Ruby bindings for fsrs-rs (the `fsrs` crate), FSRS-6.
|
|
15
|
+
#
|
|
16
|
+
# Plain values in and out: reviews are `{ rating:, delta_t: }` hashes (rating
|
|
17
|
+
# 1=Again 2=Hard 3=Good 4=Easy; delta_t = whole days since the previous review,
|
|
18
|
+
# 0 for the first review and same-day re-reviews). Intervals come back as
|
|
19
|
+
# fractional, unrounded f32 days — rounding/fuzz policy lives in the caller.
|
|
20
|
+
module Nagori
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
|
|
23
|
+
# The 21-float FSRS-6 default weights (fallback when a user has no trained
|
|
24
|
+
# parameters yet).
|
|
25
|
+
DEFAULT_PARAMETERS = _default_parameters.freeze
|
|
26
|
+
|
|
27
|
+
# FSRS-6's default forgetting-curve decay; the default for
|
|
28
|
+
# `current_retrievability`'s decay argument. Equals DEFAULT_PARAMETERS[20].
|
|
29
|
+
FSRS6_DEFAULT_DECAY = 0.1542
|
|
30
|
+
|
|
31
|
+
# Rating encoding, matching Anki / Shirabe's Review::RATINGS.
|
|
32
|
+
RATINGS = { again: 1, hard: 2, good: 3, easy: 4 }.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Train 21 FSRS-6 parameters from review history. `items` is an array of
|
|
37
|
+
# cards, each a chronological array of `{ rating:, delta_t: }` reviews.
|
|
38
|
+
# Returns 21 floats. Releases the GVL — this is CPU-heavy.
|
|
39
|
+
def compute_parameters(items, enable_short_term: true, num_relearning_steps: nil)
|
|
40
|
+
unless num_relearning_steps.nil?
|
|
41
|
+
num_relearning_steps = Integer(num_relearning_steps)
|
|
42
|
+
raise ArgumentError, "num_relearning_steps must be >= 0" if num_relearning_steps.negative?
|
|
43
|
+
end
|
|
44
|
+
_compute_parameters(flatten_items(items), enable_short_term ? true : false, num_relearning_steps)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Retrievability (recall probability) of a memory state after `days_elapsed`
|
|
48
|
+
# days. `state` is `{ stability:, difficulty: }`.
|
|
49
|
+
def current_retrievability(state, days_elapsed, decay = FSRS6_DEFAULT_DECAY)
|
|
50
|
+
stability, difficulty = memory_pair(state)
|
|
51
|
+
_current_retrievability(stability, difficulty, Float(days_elapsed), Float(decay))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Project reviews/day and memorized count over the horizon. Returns a hash of
|
|
55
|
+
# per-day arrays (`:memorized_cnt_per_day`, `:review_cnt_per_day`,
|
|
56
|
+
# `:learn_cnt_per_day`, `:cost_per_day`, `:correct_cnt_per_day`,
|
|
57
|
+
# `:introduced_cnt_per_day`, `:average_desired_retention`). Releases the GVL.
|
|
58
|
+
def simulate(parameters, desired_retention, config: nil, seed: nil, existing_cards: nil)
|
|
59
|
+
result = _simulate(
|
|
60
|
+
params_array(parameters),
|
|
61
|
+
Float(desired_retention),
|
|
62
|
+
stringify_keys(config),
|
|
63
|
+
seed.nil? ? nil : Integer(seed),
|
|
64
|
+
cards_array(existing_cards)
|
|
65
|
+
)
|
|
66
|
+
symbolize_keys(result)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Expected daily workload (seconds) at the given retention. Powers the
|
|
70
|
+
# retention-slider preview.
|
|
71
|
+
def expected_workload(parameters, desired_retention, config: nil)
|
|
72
|
+
_expected_workload(params_array(parameters), Float(desired_retention), stringify_keys(config))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Suggested retention (CMRR) minimizing workload for the memorized target.
|
|
76
|
+
# Releases the GVL.
|
|
77
|
+
def optimal_retention(parameters, config: nil)
|
|
78
|
+
_optimal_retention(params_array(parameters), stringify_keys(config))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Calibrate a SimulatorConfig from Anki-style revlog rows. Each entry is a
|
|
82
|
+
# hash with keys :id, :cid, :usn, :button_chosen, :interval, :last_interval,
|
|
83
|
+
# :ease_factor, :taken_millis, :review_kind (0..4). Returns a config hash
|
|
84
|
+
# suitable for `simulate`/`expected_workload`.
|
|
85
|
+
def extract_simulator_config(revlog_entries, day_cutoff, smooth: true)
|
|
86
|
+
entries = Array(revlog_entries).map { |entry| stringify_keys(entry) }
|
|
87
|
+
config = _extract_simulator_config(entries, Integer(day_cutoff), smooth ? true : false)
|
|
88
|
+
symbolize_keys(config)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ── Shared input coercion (also used by Nagori::FSRS) ──
|
|
92
|
+
|
|
93
|
+
def rating_int(rating)
|
|
94
|
+
value = rating.is_a?(Symbol) ? RATINGS[rating] : rating
|
|
95
|
+
value = Integer(value) if value
|
|
96
|
+
unless value&.between?(1, 4)
|
|
97
|
+
raise ArgumentError, "rating must be 1..4 or #{RATINGS.keys.inspect}, got #{rating.inspect}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
value
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def flatten_reviews(reviews)
|
|
104
|
+
Array(reviews).flat_map do |review|
|
|
105
|
+
rating = rating_int(review.fetch(:rating) { review[:rating] })
|
|
106
|
+
delta_t = Integer(review.fetch(:delta_t) { review[:delta_t] })
|
|
107
|
+
raise ArgumentError, "delta_t must be >= 0, got #{delta_t}" if delta_t.negative?
|
|
108
|
+
|
|
109
|
+
[rating, delta_t]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def flatten_items(items)
|
|
114
|
+
Array(items).map { |reviews| flatten_reviews(reviews) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def memory_pair(state)
|
|
118
|
+
raise ArgumentError, "memory state is required" if state.nil?
|
|
119
|
+
|
|
120
|
+
[Float(state.fetch(:stability)), Float(state.fetch(:difficulty))]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def memory_pair_or_empty(state)
|
|
124
|
+
state.nil? ? [] : memory_pair(state)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def params_array(parameters)
|
|
128
|
+
return [] if parameters.nil?
|
|
129
|
+
|
|
130
|
+
Array(parameters).map { |value| Float(value) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def stringify_keys(hash)
|
|
134
|
+
hash&.to_h { |key, value| [key.to_s, value] }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def symbolize_keys(hash)
|
|
138
|
+
hash.to_h { |key, value| [key.to_sym, value] }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def cards_array(cards)
|
|
142
|
+
cards.nil? ? nil : Array(cards).map { |card| stringify_keys(card) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A configured FSRS-6 scheduler. Build once per parameter set and reuse.
|
|
146
|
+
class FSRS
|
|
147
|
+
# nil/empty parameters use DEFAULT_PARAMETERS; 17/19/21-length vectors are
|
|
148
|
+
# accepted (padded internally). Invalid input raises ArgumentError.
|
|
149
|
+
def self.new(parameters = nil)
|
|
150
|
+
_new(Nagori.params_array(parameters))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The 21-float parameter vector (input padded to FSRS-6 length).
|
|
154
|
+
def parameters
|
|
155
|
+
_parameters
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Memory states and intervals for each answer button. `memory_state` is nil
|
|
159
|
+
# for a new card or `{ stability:, difficulty: }`. Returns
|
|
160
|
+
# `{ again:, hard:, good:, easy: }`, each `{ stability:, difficulty:,
|
|
161
|
+
# interval: }` with interval in fractional, unrounded days.
|
|
162
|
+
def next_states(memory_state, desired_retention, days_elapsed)
|
|
163
|
+
raw = _next_states(
|
|
164
|
+
Nagori.memory_pair_or_empty(memory_state),
|
|
165
|
+
Float(desired_retention),
|
|
166
|
+
Integer(days_elapsed)
|
|
167
|
+
)
|
|
168
|
+
{
|
|
169
|
+
again: state_at(raw, 0),
|
|
170
|
+
hard: state_at(raw, 3),
|
|
171
|
+
good: state_at(raw, 6),
|
|
172
|
+
easy: state_at(raw, 9)
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Memory state after replaying `reviews` (array of `{ rating:, delta_t: }`).
|
|
177
|
+
# `starting_state` seeds a truncated history (e.g. from SM-2). Returns
|
|
178
|
+
# `{ stability:, difficulty: }`.
|
|
179
|
+
def memory_state(reviews, starting_state = nil)
|
|
180
|
+
pair = _memory_state(Nagori.flatten_reviews(reviews), Nagori.memory_pair_or_empty(starting_state))
|
|
181
|
+
{ stability: pair[0], difficulty: pair[1] }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Batch form of #memory_state. `items` is an array of review arrays;
|
|
185
|
+
# `starting_states` is a matching array of `{ stability:, difficulty: }` or
|
|
186
|
+
# nil (defaults to all-new). Returns an array of `{ stability:, difficulty: }`.
|
|
187
|
+
def memory_state_batch(items, starting_states = nil)
|
|
188
|
+
flat_items = Nagori.flatten_items(items)
|
|
189
|
+
starts = normalize_starting_states(starting_states, flat_items.length)
|
|
190
|
+
_memory_state_batch(flat_items, starts).each_slice(2).map do |stability, difficulty|
|
|
191
|
+
{ stability: stability, difficulty: difficulty }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Approximate a memory state from SM-2 values for cards imported without a
|
|
196
|
+
# full revlog. Returns `{ stability:, difficulty: }`.
|
|
197
|
+
def memory_state_from_sm2(ease_factor, interval, sm2_retention)
|
|
198
|
+
pair = _memory_state_from_sm2(Float(ease_factor), Float(interval), Float(sm2_retention))
|
|
199
|
+
{ stability: pair[0], difficulty: pair[1] }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Interval (fractional days) for a memory state at the desired retention.
|
|
203
|
+
# `stability` is nil for a new card (initial stability for `rating` is used;
|
|
204
|
+
# rating is otherwise ignored).
|
|
205
|
+
def next_interval(stability, desired_retention, rating)
|
|
206
|
+
stab = stability.nil? ? [] : [Float(stability)]
|
|
207
|
+
_next_interval(stab, Float(desired_retention), Nagori.rating_int(rating))
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Model fit over `items` (array of review arrays). Returns
|
|
211
|
+
# `{ log_loss:, rmse_bins: }`. Releases the GVL.
|
|
212
|
+
def evaluate(items)
|
|
213
|
+
log_loss, rmse_bins = _evaluate(Nagori.flatten_items(items))
|
|
214
|
+
{ log_loss: log_loss, rmse_bins: rmse_bins }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def state_at(raw, offset)
|
|
220
|
+
{ stability: raw[offset], difficulty: raw[offset + 1], interval: raw[offset + 2] }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def normalize_starting_states(starting_states, count)
|
|
224
|
+
return Array.new(count) { [] } if starting_states.nil?
|
|
225
|
+
|
|
226
|
+
unless starting_states.length == count
|
|
227
|
+
raise ArgumentError, "starting_states length #{starting_states.length} != items length #{count}"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
starting_states.map { |state| Nagori.memory_pair_or_empty(state) }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nagori-fsrs
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: x86_64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- davafons
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-02 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake-compiler
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '1.2'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '1.2'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rubocop
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '1.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '1.0'
|
|
69
|
+
description: 'Nagori provides Ruby bindings for fsrs-rs (the `fsrs` crate), a Rust
|
|
70
|
+
implementation of the Free Spaced Repetition Scheduler (FSRS-6): scheduling, memory-state
|
|
71
|
+
replay, parameter optimization, and simulation.'
|
|
72
|
+
email:
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- LICENSE
|
|
78
|
+
- README.md
|
|
79
|
+
- lib/nagori-fsrs.rb
|
|
80
|
+
- lib/nagori.rb
|
|
81
|
+
- lib/nagori/3.1/nagori.so
|
|
82
|
+
- lib/nagori/3.2/nagori.so
|
|
83
|
+
- lib/nagori/3.3/nagori.so
|
|
84
|
+
- lib/nagori/3.4/nagori.so
|
|
85
|
+
- lib/nagori/4.0/nagori.so
|
|
86
|
+
- lib/nagori/version.rb
|
|
87
|
+
homepage: https://github.com/davafons/nagori-fsrs
|
|
88
|
+
licenses:
|
|
89
|
+
- BSD-3-Clause
|
|
90
|
+
metadata:
|
|
91
|
+
rubygems_mfa_required: 'true'
|
|
92
|
+
post_install_message:
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.1'
|
|
101
|
+
- - "<"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: 4.1.dev
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubygems_version: 3.5.23
|
|
111
|
+
signing_key:
|
|
112
|
+
specification_version: 4
|
|
113
|
+
summary: Ruby bindings for fsrs-rs, the FSRS-6 spaced-repetition scheduler and optimizer
|
|
114
|
+
test_files: []
|