Nagori 名残
Ruby bindings for fsrs-rs
(the fsrs crate) — the FSRS-6 spaced-
repetition scheduler, optimizer, and simulator, in Rust.
Nagori is a thin, idiomatic wrapper: plain Ruby values in and out (hashes,
arrays, keyword args), the number crunching in Rust. Long-running calls
(compute_parameters, evaluate, simulate, optimal_retention) release the
GVL so they don't block other threads.
Built the same way as kabosu: rb_sys + magnus, precompiled for the usual platform matrix.
Install
# Gemfile
gem "nagori-fsrs"
During development against a checkout:
gem "nagori-fsrs", path: "../nagori-fsrs"
Concepts
- Rating is
1 = Again,2 = Hard,3 = Good,4 = Easy(symbols:again/:hard/:good/:easyare accepted anywhere a rating is taken). - A review is
{ rating:, delta_t: }, wheredelta_tis whole days since the previous review,0for the first review and for same-day re-reviews. - Intervals come back as fractional, unrounded
f32days. Nagori never rounds — rounding, fuzz, and load-balancing are application policy. - Parameters are the FSRS weight vector.
nil/empty usesNagori::DEFAULT_PARAMETERS; 17- (FSRS-4.5) and 19-length (FSRS-5) vectors are accepted and padded to the 21-length FSRS-6 shape.
Scheduling
require "nagori-fsrs"
fsrs = Nagori::FSRS.new # default parameters
# fsrs = Nagori::FSRS.new(my_21_floats) # or a trained vector
# The four answer buttons for a brand-new card (nil memory state):
fsrs.next_states(nil, 0.9, 0)
# => { again: { stability: 0.212, difficulty: 6.413, interval: 0.212 },
# hard: { ... }, good: { ... }, easy: { ... } }
# For a card with an existing memory state, 5 days elapsed:
fsrs.next_states({ stability: 10.0, difficulty: 5.0 }, 0.9, 5)
next_states(memory_state, desired_retention, days_elapsed) returns a hash of
the four buttons; each is { stability:, difficulty:, interval: }.
Replaying history → memory state
reviews = [
{ rating: 3, delta_t: 0 }, # first review, same day
{ rating: 3, delta_t: 5 },
{ rating: 4, delta_t: 20 }
]
fsrs.memory_state(reviews) # => { stability:, difficulty: }
# Batch (Anki import / FSRS-6 migration):
fsrs.memory_state_batch([reviews, other_reviews])
# => [{ stability:, difficulty: }, ...]
# No revlog, only SM-2 values:
fsrs.memory_state_from_sm2(2.5, 10.0, 0.9) # ease, interval, retention
Intervals & retrievability
fsrs.next_interval(nil, 0.9, 3) # new card, Good -> fractional days
fsrs.next_interval(100.0, 0.9, 3) # from a known stability
Nagori.current_retrievability({ stability: 10.0, difficulty: 5.0 }, 5.0)
# decay defaults to Nagori::FSRS6_DEFAULT_DECAY; pass a third arg to override
Optimizer
compute_parameters and evaluate take a training set: one item per
review, each item a prefix of a card's history (all prefixes of length ≥ 2).
fsrs-rs does not expand histories for you — pretraining keys off the
exactly-one-long-term-review prefixes.
# items: Array of Array-of-{rating:, delta_t:}
params = Nagori.compute_parameters(items, enable_short_term: true)
# => 21 floats (GVL released during training)
# Health check: does a candidate parameter set fit the data?
Nagori::FSRS.new(params).evaluate(items) # => { log_loss:, rmse_bins: }
Typical apply-only-if-better gate:
old_fit = Nagori::FSRS.new(current_params).evaluate(items)
new_fit = Nagori::FSRS.new(candidate_params).evaluate(items)
apply = new_fit[:log_loss] < old_fit[:log_loss]
Simulator
# Project reviews/day and memorized count over a horizon:
Nagori.simulate(Nagori::DEFAULT_PARAMETERS, 0.9,
config: { learn_span: 90, deck_size: 1000 }, seed: 42)
# => { memorized_cnt_per_day:, review_cnt_per_day:, learn_cnt_per_day:,
# cost_per_day:, correct_cnt_per_day:, introduced_cnt_per_day:,
# average_desired_retention: }
# Retention-slider preview (expected daily seconds of work):
Nagori.expected_workload(params, 0.95, config: { deck_size: 1000 })
# Suggested retention (CMRR); GVL released:
Nagori.optimal_retention(params, config: { learn_span: 365 })
# Calibrate a config from Anki-style revlog rows:
config = Nagori.extract_simulator_config(revlog_entries, day_cutoff)
Nagori.simulate(params, 0.9, config: config)
config is a plain hash; any omitted key falls back to fsrs-rs's default
SimulatorConfig. The closure hooks (post_scheduling_fn,
review_priority_fn) are intentionally not exposed. Each revlog entry is a hash
with :id, :cid, :usn, :button_chosen, :interval, :last_interval, :ease_factor, :taken_millis, :review_kind (review_kind 0..4).
Errors
Invalid input (bad parameter length, rating outside 1..4, negative delta_t,
malformed memory state) raises ArgumentError. Computation failures surface as
RuntimeError.
Version pinning
The wrapped crate is pinned exactly (fsrs = "=6.6.x" in
ext/nagori/Cargo.toml) because a crate bump can shift scheduler outputs. The
gem's golden tests assert exact stability/difficulty/interval values, so a bump
that changes results fails loudly. Bump the pin and the golden vectors together.
Development
bundle install
bundle exec rake compile
bundle exec rake test
Requires a Rust toolchain (stable). Tests are Minitest with golden vectors generated from fsrs-rs itself.
License & attribution
Nagori is released under the BSD 3-Clause License. It wraps and redistributes fsrs-rs (© 2023 Open Spaced Repetition), also BSD 3-Clause. See LICENSE for both copyright notices.