Skip to main content

lean_ctx/core/memory_scheduler/
fsrs.rs

1//! FSRS-5 spaced-repetition model for knowledge-fact retention scheduling.
2//!
3//! Implements retrievability decay, stability updates, and optimal review
4//! intervals from the open-source FSRS-5 weight set.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// FSRS-5 model weights from the open-source FSRS research.
10const W: [f64; 17] = [
11    0.4, 0.6, 2.4, 5.8, // Initial stability (Again, Hard, Good, Easy).
12    4.93, 0.94, 0.86, 0.01, // Difficulty.
13    1.49, 0.14, 0.94, 2.18, // Stability update.
14    0.05, 0.34, 1.26, 0.29, // Additional.
15    2.61, // Stability decay.
16];
17
18/// Retention state for a knowledge fact under the FSRS-5 model.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MemoryState {
21    /// Unique identifier for the knowledge fact.
22    pub fact_key: String,
23    /// Number of days at which retrievability drops to 90 percent.
24    pub stability: f64,
25    /// Intrinsic difficulty of this fact, normalized to `0.0..=1.0`.
26    pub difficulty: f64,
27    /// Timestamp of the last review or use.
28    pub last_review: DateTime<Utc>,
29    /// Total number of reviews.
30    pub review_count: u32,
31    /// Review ratings where 1=Again, 2=Hard, 3=Good, and 4=Easy.
32    #[serde(default)]
33    pub rating_history: Vec<u8>,
34}
35
36/// Compute the probability that a fact is still remembered at `now`.
37///
38/// Based on FSRS-5: `R(t) = (1 + t / (9 · S))^(-1)` where `t` is elapsed
39/// days since [`MemoryState::last_review`] and `S` is [`MemoryState::stability`].
40pub(crate) fn retrievability(state: &MemoryState, now: DateTime<Utc>) -> f64 {
41    let elapsed_days = (now - state.last_review).num_seconds() as f64 / 86_400.0;
42    if elapsed_days <= 0.0 || state.stability <= 0.0 {
43        return 1.0;
44    }
45    (1.0 + elapsed_days / (9.0 * state.stability)).powf(-1.0)
46}
47
48/// Update a memory state after a review using a rating in the range 1 through 4.
49pub(crate) fn update_stability(state: &mut MemoryState, rating: u8) {
50    update_stability_at(state, rating, Utc::now());
51}
52
53fn update_stability_at(state: &mut MemoryState, rating: u8, now: DateTime<Utc>) {
54    let rating = rating.clamp(1, 4);
55    let current_retrievability = retrievability(state, now);
56    let old_stability = state.stability.max(f64::EPSILON);
57    let old_difficulty = state.difficulty;
58
59    state.difficulty =
60        (W[4] - W[5] * (f64::from(rating) - 3.0) + W[6] * (old_difficulty - W[4])).clamp(0.0, 1.0);
61
62    state.stability = if rating == 1 {
63        let forgotten_stability = W[9]
64            * state.difficulty.max(f64::EPSILON).powf(-W[10])
65            * ((old_stability + 1.0).powf(W[11]) - 1.0)
66            * W[12].exp();
67        forgotten_stability
68            .max(f64::EPSILON)
69            .min(old_stability * 0.9)
70    } else {
71        let stability_gain = W[8].exp()
72            * (11.0 - state.difficulty)
73            * old_stability.powf(-W[13])
74            * ((W[14] * (1.0 - current_retrievability)).exp() - 1.0);
75        old_stability * (1.0 + stability_gain.max(0.0))
76    };
77
78    state.last_review = now;
79    state.review_count = state.review_count.saturating_add(1);
80    state.rating_history.push(rating);
81    if state.rating_history.len() > 50 {
82        let drain_count = state.rating_history.len() - 50;
83        state.rating_history.drain(0..drain_count);
84    }
85}
86
87/// Compute the interval in days that reaches `target_retention`.
88///
89/// Inverts the FSRS-5 retrievability curve:
90/// `t = 9 · S · (1 / R_target - 1)` for `R_target` in `(0, 1)`.
91pub(crate) fn optimal_interval(state: &MemoryState, target_retention: f64) -> f64 {
92    if target_retention <= 0.0 || target_retention >= 1.0 || state.stability <= 0.0 {
93        return 0.0;
94    }
95    9.0 * state.stability * (1.0 / target_retention - 1.0)
96}
97
98/// Create an initial memory state from a fact key and first-review rating.
99pub(crate) fn initial_state(fact_key: String, rating: u8) -> MemoryState {
100    let rating = rating.clamp(1, 4);
101    let rating_index = usize::from(rating - 1);
102    MemoryState {
103        fact_key,
104        stability: W[rating_index],
105        difficulty: (W[4] - W[5] * (f64::from(rating) - 3.0)).clamp(0.0, 1.0),
106        last_review: Utc::now(),
107        review_count: 1,
108        rating_history: vec![rating],
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use chrono::{Duration, Utc};
115
116    use crate::core::memory_scheduler::fsrs::{
117        initial_state, optimal_interval, retrievability, update_stability_at,
118    };
119
120    #[test]
121    fn retrievability_is_one_immediately_after_review() {
122        let now = Utc::now();
123        let mut state = initial_state("fact".to_string(), 3);
124        state.last_review = now;
125
126        assert!((retrievability(&state, now) - 1.0).abs() < f64::EPSILON);
127    }
128
129    #[test]
130    fn retrievability_decreases_over_time() {
131        let now = Utc::now();
132        let mut state = initial_state("fact".to_string(), 3);
133        state.last_review = now;
134
135        let after_one_day = retrievability(&state, now + Duration::days(1));
136        let after_ten_days = retrievability(&state, now + Duration::days(10));
137        assert!(after_ten_days < after_one_day);
138    }
139
140    #[test]
141    fn retrievability_higher_stability_decays_slower() {
142        let now = Utc::now();
143        let mut low = initial_state("low".to_string(), 1);
144        low.last_review = now;
145        let mut high = initial_state("high".to_string(), 4);
146        high.last_review = now;
147
148        let review_time = now + Duration::days(7);
149        assert!(retrievability(&high, review_time) > retrievability(&low, review_time));
150    }
151
152    #[test]
153    fn optimal_interval_for_90_percent_retention() {
154        let state = initial_state("fact".to_string(), 3);
155
156        assert!((optimal_interval(&state, 0.9) - state.stability).abs() < 1.0e-12);
157    }
158
159    #[test]
160    fn update_stability_increases_on_good_rating() {
161        let now = Utc::now();
162        let mut state = initial_state("fact".to_string(), 3);
163        state.last_review = now - Duration::days(10);
164        let old_stability = state.stability;
165
166        update_stability_at(&mut state, 3, now);
167
168        assert!(state.stability > old_stability);
169    }
170
171    #[test]
172    fn update_stability_decreases_on_again_rating() {
173        let now = Utc::now();
174        let mut state = initial_state("fact".to_string(), 4);
175        state.last_review = now - Duration::days(10);
176        let old_stability = state.stability;
177
178        update_stability_at(&mut state, 1, now);
179
180        assert!(state.stability < old_stability);
181    }
182
183    #[test]
184    fn initial_state_sets_correct_stability_for_each_rating() {
185        let expected = [0.4, 0.6, 2.4, 5.8];
186
187        for (rating, stability) in (1_u8..=4).zip(expected) {
188            let state = initial_state(format!("fact-{rating}"), rating);
189            assert!((state.stability - stability).abs() < f64::EPSILON);
190        }
191    }
192}