1use std::fs;
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18const INITIAL_INTERVAL_DAYS: f32 = 1.0;
22const MIN_EASE: f32 = 1.3;
24const MAX_EASE: f32 = 5.0;
26const DEFAULT_EASE: f32 = 2.5;
28const MASTERED_THRESHOLD: u16 = 15;
30const FRESH_WINDOW_MS: u64 = 7 * 86_400_000;
32const SPACING_BOOST_FACTOR: f32 = 0.05;
34
35#[derive(Clone, Debug)]
39pub struct SpacedBlock {
40 pub block_idx: u32,
41 pub recall_count: u16,
42 pub last_recall_ms: u64,
43 pub interval_days: f32,
44 pub ease_factor: f32,
45 pub importance: u8,
46}
47
48impl SpacedBlock {
49 pub fn is_due(&self, now_ms: u64) -> bool {
51 let elapsed_days = (now_ms.saturating_sub(self.last_recall_ms)) as f32 / 86_400_000.0;
52 elapsed_days >= self.interval_days
53 }
54
55 pub fn overdue_days(&self, now_ms: u64) -> f32 {
57 let elapsed_days = (now_ms.saturating_sub(self.last_recall_ms)) as f32 / 86_400_000.0;
58 elapsed_days - self.interval_days
59 }
60
61 pub fn record_recall(&mut self, quality: u8, now_ms: u64) {
64 let q = quality.min(5) as f32;
65 self.recall_count += 1;
66 self.last_recall_ms = now_ms;
67
68 if q < 3.0 {
69 self.interval_days = 1.0;
71 self.ease_factor = (self.ease_factor - 0.2).max(MIN_EASE);
72 } else {
73 self.ease_factor = (self.ease_factor + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02)))
75 .clamp(MIN_EASE, MAX_EASE);
76
77 if self.recall_count <= 1 {
78 self.interval_days = 1.0;
79 } else if self.recall_count == 2 {
80 self.interval_days = 6.0;
81 } else {
82 self.interval_days = (self.interval_days * self.ease_factor).min(365.0 * 5.0);
83 }
85 }
86 }
87
88 pub fn spacing_boost(&self, now_ms: u64) -> f32 {
91 if !self.is_due(now_ms) {
92 return 0.0;
93 }
94 let overdue = self.overdue_days(now_ms).max(0.0);
95 let base = SPACING_BOOST_FACTOR;
97 let extra = (overdue * 0.01).min(SPACING_BOOST_FACTOR);
98 base + extra
99 }
100}
101
102pub struct SpacedRepetition {
104 pub blocks: Vec<SpacedBlock>,
105}
106
107impl SpacedRepetition {
108 pub fn load_or_init(output_dir: &Path) -> Self {
110 let path = output_dir.join("spaced.bin");
111 if let Ok(data) = fs::read(&path) {
112 if data.len() >= 8 && &data[0..4] == b"SPR1" {
113 let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
114 let rec_size = 23;
115 let mut blocks = Vec::with_capacity(count);
116 let mut off = 8;
117 for _ in 0..count {
118 if off + rec_size > data.len() {
119 break;
120 }
121 let block_idx = u32::from_le_bytes(data[off..off + 4].try_into().unwrap());
122 let recall_count =
123 u16::from_le_bytes(data[off + 4..off + 6].try_into().unwrap());
124 let last_recall_ms =
125 u64::from_le_bytes(data[off + 6..off + 14].try_into().unwrap());
126 let interval_days =
127 f32::from_le_bytes(data[off + 14..off + 18].try_into().unwrap());
128 let ease_factor =
129 f32::from_le_bytes(data[off + 18..off + 22].try_into().unwrap());
130 let importance = data[off + 22];
131 off += rec_size;
132 blocks.push(SpacedBlock {
133 block_idx,
134 recall_count,
135 last_recall_ms,
136 interval_days,
137 ease_factor,
138 importance,
139 });
140 }
141 return SpacedRepetition { blocks };
142 }
143 }
144 SpacedRepetition { blocks: Vec::new() }
145 }
146
147 pub fn save(&self, output_dir: &Path) -> Result<(), String> {
149 let path = output_dir.join("spaced.bin");
150 let rec_size = 23;
151 let mut buf = Vec::with_capacity(8 + self.blocks.len() * rec_size);
152 buf.extend_from_slice(b"SPR1");
153 buf.extend_from_slice(&(self.blocks.len() as u32).to_le_bytes());
154 for b in &self.blocks {
155 buf.extend_from_slice(&b.block_idx.to_le_bytes());
156 buf.extend_from_slice(&b.recall_count.to_le_bytes());
157 buf.extend_from_slice(&b.last_recall_ms.to_le_bytes());
158 buf.extend_from_slice(&b.interval_days.to_le_bytes());
159 buf.extend_from_slice(&b.ease_factor.to_le_bytes());
160 buf.push(b.importance);
161 }
162 let tmp_path = output_dir.join("spaced.bin.tmp");
163 fs::write(&tmp_path, &buf).map_err(|e| format!("write spaced.bin: {}", e))?;
164 fs::rename(&tmp_path, &path).map_err(|e| format!("rename spaced.bin: {}", e))
165 }
166
167 pub fn remap_indexes(&mut self, old_to_new: &[u32]) {
171 self.blocks.retain_mut(|b| {
172 if (b.block_idx as usize) < old_to_new.len() {
173 let new_idx = old_to_new[b.block_idx as usize];
174 if new_idx != u32::MAX {
175 b.block_idx = new_idx;
176 return true;
177 }
178 }
179 false
180 });
181 }
182
183 pub fn find(&self, block_idx: u32) -> Option<&SpacedBlock> {
185 self.blocks.iter().find(|b| b.block_idx == block_idx)
186 }
187
188 pub fn find_mut(&mut self, block_idx: u32) -> Option<&mut SpacedBlock> {
189 self.blocks.iter_mut().find(|b| b.block_idx == block_idx)
190 }
191
192 pub fn record_recall(&mut self, block_idx: u32, importance: u8, quality: u8) {
194 let now = now_ms();
195 if let Some(block) = self.find_mut(block_idx) {
196 block.record_recall(quality, now);
197 block.importance = importance;
198 } else {
199 let mut block = SpacedBlock {
201 block_idx,
202 recall_count: 0,
203 last_recall_ms: now,
204 interval_days: INITIAL_INTERVAL_DAYS,
205 ease_factor: DEFAULT_EASE,
206 importance,
207 };
208 block.record_recall(quality, now);
209 self.blocks.push(block);
210 }
211 }
212
213 pub fn spacing_boost(&self, block_idx: u32) -> f32 {
215 let now = now_ms();
216 self.find(block_idx)
217 .map(|b| b.spacing_boost(now))
218 .unwrap_or(0.0)
219 }
220
221 pub fn due_count(&self) -> usize {
223 let now = now_ms();
224 self.blocks.iter().filter(|b| b.is_due(now)).count()
225 }
226
227 pub fn mastered_count(&self) -> usize {
229 self.blocks
230 .iter()
231 .filter(|b| b.recall_count >= MASTERED_THRESHOLD)
232 .count()
233 }
234
235 pub fn stats(&self) -> SpacedStats {
237 let now = now_ms();
238 SpacedStats {
239 total_blocks: self.blocks.len(),
240 due: self.blocks.iter().filter(|b| b.is_due(now)).count(),
241 mastered: self
242 .blocks
243 .iter()
244 .filter(|b| b.recall_count >= MASTERED_THRESHOLD)
245 .count(),
246 fresh: self
247 .blocks
248 .iter()
249 .filter(|b| now.saturating_sub(b.last_recall_ms) < FRESH_WINDOW_MS)
250 .count(),
251 avg_ease: self.blocks.iter().map(|b| b.ease_factor).sum::<f32>()
252 / self.blocks.len().max(1) as f32,
253 avg_interval: self.blocks.iter().map(|b| b.interval_days).sum::<f32>()
254 / self.blocks.len().max(1) as f32,
255 }
256 }
257
258 pub fn due_blocks(&self) -> Vec<u32> {
260 let now = now_ms();
261 let mut due: Vec<u32> = self
262 .blocks
263 .iter()
264 .filter(|b| b.is_due(now))
265 .map(|b| b.block_idx)
266 .collect();
267 due.sort();
268 due
269 }
270}
271
272pub struct SpacedStats {
275 pub total_blocks: usize,
276 pub due: usize,
277 pub mastered: usize,
278 pub fresh: usize,
279 pub avg_ease: f32,
280 pub avg_interval: f32,
281}
282
283fn now_ms() -> u64 {
284 SystemTime::now()
285 .duration_since(UNIX_EPOCH)
286 .unwrap_or_default()
287 .as_millis() as u64
288}
289
290#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn test_empty() {
298 let sr = SpacedRepetition::load_or_init(Path::new("."));
299 assert_eq!(sr.stats().total_blocks, 0);
300 }
301
302 #[test]
303 fn test_sm2_interval_growth() {
304 let _sr = SpacedRepetition { blocks: vec![] };
305 let mut block = SpacedBlock {
306 block_idx: 0,
307 recall_count: 0,
308 last_recall_ms: 1000,
309 interval_days: 1.0,
310 ease_factor: 2.5,
311 importance: 5,
312 };
313 block.record_recall(4, 2000);
315 assert!(block.interval_days >= 1.0);
316 let after_first = block.interval_days;
317
318 block.record_recall(5, 3000);
320 assert!(block.interval_days > after_first);
321
322 block.record_recall(5, 4000);
324 assert!(block.interval_days > after_first);
325 }
326
327 #[test]
328 fn test_poor_quality_resets() {
329 let mut block = SpacedBlock {
330 block_idx: 0,
331 recall_count: 2,
332 last_recall_ms: 1000,
333 interval_days: 10.0,
334 ease_factor: 2.5,
335 importance: 5,
336 };
337 block.record_recall(1, 2000); assert!((block.interval_days - 1.0).abs() < 0.01);
339 assert!(block.ease_factor < 2.5);
340 }
341
342 #[test]
343 fn test_save_load_roundtrip() {
344 let dir = std::env::temp_dir();
345 let mut sr = SpacedRepetition::load_or_init(&dir);
346 sr.record_recall(0, 7, 4);
347 sr.record_recall(1, 5, 5);
348 sr.save(&dir).unwrap();
349
350 let loaded = SpacedRepetition::load_or_init(&dir);
351 assert_eq!(loaded.blocks.len(), 2);
352 assert_eq!(loaded.blocks[0].block_idx, 0);
353 assert_eq!(loaded.blocks[1].block_idx, 1);
354
355 let _ = std::fs::remove_file(dir.join("spaced.bin"));
356 }
357
358 #[test]
359 fn test_due_detection() {
360 let sr = SpacedRepetition {
361 blocks: vec![SpacedBlock {
362 block_idx: 0,
363 recall_count: 1,
364 last_recall_ms: 1000, interval_days: 0.001, ease_factor: 2.5,
367 importance: 5,
368 }],
369 };
370 assert!(sr.due_count() > 0);
371 assert!(sr.spacing_boost(0) > 0.0);
372 }
373
374 #[test]
375 fn test_stats() {
376 let mut sr = SpacedRepetition { blocks: vec![] };
377 sr.record_recall(0, 5, 5);
378 sr.record_recall(1, 3, 3);
379 let stats = sr.stats();
380 assert_eq!(stats.total_blocks, 2);
381 assert!(stats.avg_ease > 0.0);
382 }
383}