lean_ctx/core/memory_scheduler/
fsrs.rs1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9const W: [f64; 17] = [
11 0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61, ];
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct MemoryState {
21 pub fact_key: String,
23 pub stability: f64,
25 pub difficulty: f64,
27 pub last_review: DateTime<Utc>,
29 pub review_count: u32,
31 #[serde(default)]
33 pub rating_history: Vec<u8>,
34}
35
36pub(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
48pub(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
87pub(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
98pub(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}