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/:easy are accepted anywhere a rating is taken).
  • A review is { rating:, delta_t: }, where delta_t is whole days since the previous review, 0 for the first review and for same-day re-reviews.
  • Intervals come back as fractional, unrounded f32 days. Nagori never rounds — rounding, fuzz, and load-balancing are application policy.
  • Parameters are the FSRS weight vector. nil/empty uses Nagori::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.