Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
f1045bf
Implements FSRS-7 based on srs-benchmark current state
JSchoreels Apr 5, 2026
4946146
Added optimization for S->S90 computation
JSchoreels Apr 6, 2026
f8d6613
Implements FSRS7-penalty
JSchoreels Apr 7, 2026
58050c2
Fix penalties
JSchoreels Apr 7, 2026
40c2633
Add benches for fsrs7
JSchoreels Apr 7, 2026
c7137fe
Optimized penalty computation with scalar/manual grad
JSchoreels Apr 7, 2026
4035cdb
Changes training params
JSchoreels Apr 7, 2026
9788ac9
Remove non penalty model
JSchoreels Apr 7, 2026
1dab8ea
Update docs/fsrs7-plots/generate_fsrs7_plots.py
JSchoreels Apr 7, 2026
a7299cf
Improve docs
JSchoreels Apr 7, 2026
bd3fbe9
Refactors to split fsrs6/fsrs7 logic, add traits to avoid doing if/el…
JSchoreels Apr 8, 2026
c38a697
Refactor FSRS into clearer v6/v7 paths with expanded tests/docs, add …
JSchoreels Apr 8, 2026
1f0e6ad
Allow fractional results for FSRS6 to keep [0,1] for same day review,…
JSchoreels Apr 8, 2026
e7e0669
Robustness for same day review
JSchoreels Apr 9, 2026
1ae42df
Fractional interval
JSchoreels Apr 9, 2026
353df07
Add test to ensure cards stability grow again even at very low value
JSchoreels Apr 27, 2026
b880033
Changed S_MIN to 0.0001
JSchoreels Apr 27, 2026
0c6752a
Delete superflous doc
JSchoreels May 8, 2026
afbca33
Codex Review + Fix for Simulator Priority of reviews
JSchoreels May 9, 2026
1b19fce
Restore FSRS-6 initialization parity before FSRS7 split
JSchoreels May 10, 2026
a334c8a
Tune FSRS training defaults
JSchoreels May 27, 2026
5d6e1ce
Revert "Tune FSRS training defaults"
JSchoreels May 27, 2026
e2725e5
Clamp training retrievability before BCE
JSchoreels May 28, 2026
d2936c6
Add FSRS-7 cost ADR policy
JSchoreels May 29, 2026
e0dda34
Use Newton Brent for FSRS-7 intervals
JSchoreels May 29, 2026
50cabb7
Allow Cost ADR training retention bounds
JSchoreels May 29, 2026
8fad19d
Default Cost ADR training retention bounds
JSchoreels May 29, 2026
2e8057b
Add ADR average retention calibration
JSchoreels May 29, 2026
72db3b9
Skip dense ADR calibration for narrow ranges
JSchoreels May 29, 2026
ec67ad5
Tune Cost ADR early stopping
JSchoreels May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@ cargo clippy -- -Dwarnings

install -d tests/data/
pushd tests/data/
wget https://github.com/open-spaced-repetition/fsrs-optimizer-burn/files/12394182/collection.anki21.zip
unzip *.zip
archive_url="https://github.com/open-spaced-repetition/fsrs-optimizer-burn/files/12394182/collection.anki21.zip"
archive_path="collection.anki21.zip"
if command -v wget >/dev/null 2>&1; then
wget "${archive_url}" -O "${archive_path}"
else
curl -fsSL "${archive_url}" -o "${archive_path}"
fi
unzip -o "${archive_path}"

RUSTDOCFLAGS="-D warnings" cargo doc --release

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ tmp/
*.anki21
*.anki21.zip
/tests

# Local ADR outputs generated from private collections.
/docs/fsrs7-adr-plots/
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ log = "0.4"
ndarray = "0.17.2"
priority-queue = "=2.7.0"
rand = "0.9.2"
rand_distr = "0.5.1"
rayon = "1.11.0"
serde = "1.0.228"
roots = "0.0.8"
serde = { version = "1.0.228", features = ["derive"] }
snafu = "0.8.9"
strum = { version = "0.27.2", features = ["derive"] }

Expand All @@ -60,6 +62,10 @@ harness = false
name = "parameters"
harness = false

[[bench]]
name = "fsrs7_penalty"
harness = false

[[example]]
name = "schedule"
path = "examples/schedule.rs"
Expand All @@ -71,3 +77,7 @@ path = "examples/migrate.rs"
[[example]]
name = "optimize"
path = "examples/optimize.rs"

[[example]]
name = "cost_adr"
path = "examples/cost_adr.rs"
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ let mut items = Vec::new();
let mut last = history[0].0;

for (date, rating) in history {
let delta_t = (date - last).num_days() as u32;
let delta_t = (date - last).num_days() as f32;
accumulated.push(FSRSReview { rating, delta_t });
items.push(FSRSItem {
reviews: accumulated.clone(),
Expand Down Expand Up @@ -95,8 +95,8 @@ let interval = 10.0;
let initial_state = fsrs.memory_state_from_sm2(ease_factor, interval, sm2_retention)?;

let reviews = vec![
FSRSReview { rating: 3, delta_t: 5 },
FSRSReview { rating: 4, delta_t: 10 },
FSRSReview { rating: 3, delta_t: 5.0 },
FSRSReview { rating: 4, delta_t: 10.0 },
];

let memory_state = fsrs.memory_state(
Expand Down
4 changes: 2 additions & 2 deletions benches/benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use itertools::Itertools;
pub(crate) fn calc_mem(inf: &FSRS, past_reviews: usize, card_cnt: usize) -> Vec<MemoryState> {
let review = FSRSReview {
rating: 3,
delta_t: 21,
delta_t: 21.0,
};
let reviews = repeat(review).take(past_reviews + 1).collect_vec();
(0..card_cnt)
Expand All @@ -36,7 +36,7 @@ pub(crate) fn calc_mem(inf: &FSRS, past_reviews: usize, card_cnt: usize) -> Vec<
pub(crate) fn calc_mem_batch(inf: &FSRS, past_reviews: usize, card_cnt: usize) -> Vec<MemoryState> {
let reviews = repeat(FSRSReview {
rating: 3,
delta_t: 21,
delta_t: 21.0,
})
.take(past_reviews)
.collect_vec();
Expand Down
71 changes: 71 additions & 0 deletions benches/fsrs7_penalty.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
use criterion::{Criterion, criterion_group, criterion_main};
use fsrs::{ComputeParametersInput, FSRSItem, FSRSReview, compute_parameters};
use std::hint::black_box;

fn synthetic_items() -> Vec<FSRSItem> {
let templates: &[&[(u32, u32)]] = &[
&[(0, 3), (1, 4), (4, 3), (14, 4), (31, 3), (50, 4)],
&[(0, 2), (1, 3), (3, 4), (11, 3), (27, 4), (45, 3), (63, 4)],
&[(0, 4), (7, 4), (23, 3), (40, 4), (59, 3)],
&[(0, 1), (1, 1), (2, 3), (5, 4), (15, 4), (31, 3), (50, 4)],
&[(0, 3), (2, 3), (7, 2), (9, 4), (21, 3), (35, 4), (55, 3)],
&[
(0, 1),
(1, 2),
(2, 3),
(3, 4),
(9, 3),
(19, 4),
(35, 3),
(55, 4),
],
];

templates
.iter()
.cycle()
.take(200)
.flat_map(|template| {
let mut reviews = Vec::with_capacity(template.len());
let mut items = Vec::with_capacity(template.len());
let mut last_day = template[0].0;
for (day, rating) in *template {
let delta_t = day - last_day;
reviews.push(FSRSReview {
rating: *rating,
delta_t: delta_t as f32,
});
items.push(FSRSItem {
reviews: reviews.clone(),
});
last_day = *day;
}
items
.into_iter()
.filter(|item| item.long_term_review_cnt() > 0)
.collect::<Vec<_>>()
})
.collect()
}

fn benchmark_compute_parameters_fsrs7_penalty(c: &mut Criterion) {
let items = synthetic_items();

let input = ComputeParametersInput {
train_set: items,
progress: None,
enable_short_term: true,
num_relearning_steps: None,
..Default::default()
};

let mut group = c.benchmark_group("fsrs7_penalty");
group.sample_size(10);
group.bench_function("compute_parameters", |b| {
b.iter(|| compute_parameters(black_box(input.clone())).unwrap())
});
group.finish();
}

criterion_group!(benches, benchmark_compute_parameters_fsrs7_penalty);
criterion_main!(benches);
8 changes: 5 additions & 3 deletions benches/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,12 @@ fn convert_to_fsrs_items(
.take(idx + 1)
.map(|r| FSRSReview {
rating: r.button_chosen as u32,
delta_t: r.last_interval.max(0) as u32,
delta_t: r.last_interval.max(0) as f32,
})
.collect();
(entry.id, FSRSItem { reviews })
})
.filter(|(_, item)| item.reviews.last().is_some_and(|r| r.delta_t > 0)) // Ensure last review has delta_t > 0
.filter(|(_, item)| item.reviews.last().is_some_and(|r| r.delta_t > 0.0)) // Ensure last review has delta_t > 0
.collect(),
)
}
Expand Down Expand Up @@ -185,7 +185,7 @@ fn prepare_training_data_inline(items: Vec<FSRSItem>) -> (Vec<FSRSItem>, Vec<FSR
let filtered_items: Vec<FSRSItem> = items
.into_iter()
.filter(|item| {
!item.reviews.is_empty() && item.reviews.len() > 1 && item.reviews[0].delta_t == 0
!item.reviews.is_empty() && item.reviews.len() > 1 && item.reviews[0].delta_t == 0.0
})
.collect();

Expand Down Expand Up @@ -229,6 +229,7 @@ fn benchmark_evaluate_with_time_series_splits(c: &mut Criterion) {
progress: None,
enable_short_term: true, // Default/typical value
num_relearning_steps: None, // Default/typical value
..Default::default()
};

let mut group = c.benchmark_group("parameters");
Expand All @@ -249,6 +250,7 @@ fn benchmark_compute_parameters(c: &mut Criterion) {
progress: None,
enable_short_term: true, // Default/typical value
num_relearning_steps: None, // Default/typical value
..Default::default()
};

let mut group = c.benchmark_group("parameters");
Expand Down
98 changes: 98 additions & 0 deletions docs/COMPATIBILITY.MD
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Compatibility Notes

## Default Constructor

- `FSRS::default()` and `FSRS::new(&[])` keep legacy behavior and instantiate
the FSRS-6 default parameter set.
- This preserves SM-2 bridge behavior through `memory_state_from_sm2()`.

## SM-2 Bridge

- `memory_state_from_sm2()` uses legacy FSRS-6 bridge slots (`w8`, `w9`,
`w10`, `w20`).
- For compatibility, callers that rely on the default constructor continue to
get the historical FSRS-6 conversion behavior.

## Interval Precision

- `FSRS::next_states_with_elapsed_days()` accepts elapsed time in days as
`f32`, including fractional intervals (for example, same-day reviews).
- This branch's `FSRSReview::delta_t` is `f32`. Upstream `main` still uses
`u32` in the older single-model training path, so direct optimizer parity
checks must build equivalent input by converting whole-day elapsed times to
the target branch's type. Fractional same-day/interday deltas are supported
here but cannot be represented in that upstream API.
- FSRS-6 keeps legacy day-based behavior internally by rounding elapsed days
for recall updates, while still using `f32` transport.
- FSRS-6 and FSRS-7 both return fractional intervals from scheduling APIs.
- FSRS-7 keeps full fractional behavior for elapsed days and output intervals.

## Stability Bounds

- Minimum stability is clamped at `0.0001` days.
- This same lower bound is used when initializing stability parameters and when
clamping inferred or updated memory states.

## Anki Learning And Relearning Steps

- `FSRS::next_states*()` returns model intervals. Anki may still show
learning or relearning step delays while a card is in the learning queue.
- For low desired retention and very low post-lapse stability, FSRS-7 can
calculate a minute-scale `Again` interval. Once a same-day `Good` is applied
to that state, core FSRS can produce an interval of hours or days; repeated
minute-scale `Good` delays are therefore a scheduler step behavior rather
than the model interval solver repeating the same result.

## Training Robustness

- FSRS-6 training derives initial stability from review data and applies the
legacy monotonic `smooth_and_fill()` pass to params `0..3` before returning
optimized parameters.
- FSRS-7 training uses FSRS-7-specific initial-stability fill/monotonic
normalization for params `0..3`; it does not reuse the FSRS-6 smoothing
helper.
- FSRS-7 parameter clipping is NaN-safe: non-finite values are converted to
finite bounds before clamping.
- Training classification clamps predicted retrievability to `[0.0001, 0.9999]`
before binary cross-entropy, avoiding `log(0)` at recall probability bounds.
- Negative elapsed days are normalized to `0.0` before FSRS-7 training and
state updates, matching the existing public retrievability behavior.
- Outlier filtering no longer assumes every training item has a long-term
review (`delta_t >= 1.0`), so same-day-only items no longer panic.
- Long-term `delta_t` grouping used by outlier filtering/initialization is
bucketed by day (floor), which avoids sparse fragmented groups when
training data contains fractional interday elapsed times.

## FSRS-6 Upstream Parity Snapshot

Parity was checked against `upstream/main` with whole-day training data, so the
`u32` upstream `delta_t` and current `f32` `delta_t` APIs receive equivalent
elapsed-day values. After restoring FSRS-6 data-derived initial stability and
legacy `smooth_and_fill()`, the large params `0..3` drift is gone. The remaining
params `0..3` difference is the lower stability bound change (`0.001` upstream,
`0.0001` current). Remaining params `4..20` differences are most likely from
optimizer/L2 config drift.

| Param | Before upstream | After current | Abs diff | Rel diff | Most likely explanation |
|---:|---:|---:|---:|---:|---|
| 0 | 0.0010 | 0.0001 | 0.0009 | 90.00% | `S_MIN` changed. |
| 1 | 0.0010 | 0.0001 | 0.0009 | 90.00% | `S_MIN` changed. |
| 2 | 0.0010 | 0.0001 | 0.0009 | 90.00% | `S_MIN` changed. |
| 3 | 0.0010 | 0.0001 | 0.0009 | 90.00% | `S_MIN` changed; previous init/smoothing drift is fixed. |
| 4 | 6.50 | 6.46 | 0.04 | 0.59% | Optimizer/config drift. |
| 5 | 0.73 | 0.74 | 0.01 | 0.88% | Optimizer/config drift. |
| 6 | 3.29 | 3.18 | 0.11 | 3.23% | Optimizer/config drift. |
| 7 | 0.0010 | 0.0010 | 0.00 | 0.00% | Matches. |
| 8 | 1.72 | 1.77 | 0.06 | 3.28% | Optimizer/config drift. |
| 9 | 0.07 | 0.10 | 0.03 | 41.79% | Small baseline amplifies relative difference; optimizer/config drift. |
| 10 | 0.64 | 0.70 | 0.06 | 8.98% | Optimizer/config drift. |
| 11 | 1.33 | 1.39 | 0.05 | 3.98% | Optimizer/config drift. |
| 12 | 0.18 | 0.15 | 0.03 | 18.42% | Small baseline amplifies relative difference; optimizer/config drift. |
| 13 | 0.10 | 0.16 | 0.06 | 59.65% | Small baseline amplifies relative difference; optimizer/config drift. |
| 14 | 1.52 | 1.57 | 0.05 | 3.14% | Optimizer/config drift. |
| 15 | 0.81 | 0.72 | 0.09 | 11.02% | Optimizer/config drift. |
| 16 | 2.14 | 2.01 | 0.13 | 6.28% | Largest residual; optimizer/config drift. |
| 17 | 0.55 | 0.53 | 0.01 | 2.56% | Optimizer/config drift. |
| 18 | 0.08 | 0.13 | 0.05 | 58.02% | Small baseline amplifies relative difference; optimizer/config drift. |
| 19 | 0.07 | 0.07 | 0.00 | 0.00% | Matches. |
| 20 | 0.10 | 0.10 | 0.00 | 0.00% | Matches. |
Loading