Skip to main content

microscope_memory/
spaced_repetition.rs

1//! Spaced Repetition — Ebbinghaus felejtési görbe management.
2//!
3//! SM-2 alapú: minden recall után nő az intervallum, a gyakran felidézett
4//! blokkok egyre könnyebben előjönnek. Túléli a rebuild-et (block_idx alapú).
5//!
6//! Binary format: SPR1
7//!   [S,P,R,1] magic (4 bytes)
8//!   count: u32 (4 bytes)
9//!   records[]:
10//!     block_idx: u32 | recall_count: u16 | last_recall_ms: u64
11//!     interval_days: f32 | ease_factor: f32 | importance: u8
12//!     = 23 bytes/record
13
14use std::fs;
15use std::path::Path;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18// ─── Constants ──────────────────────────────────────
19
20/// SM-2 kezdő intervallum (nap).
21const INITIAL_INTERVAL_DAYS: f32 = 1.0;
22/// Minimum ease factor (nehéz → gyorsabban felejt).
23const MIN_EASE: f32 = 1.3;
24/// Maximum ease factor (könnyű → lassan felejt).
25const MAX_EASE: f32 = 5.0;
26/// Default ease factor (SM-2 standard).
27const DEFAULT_EASE: f32 = 2.5;
28/// Ha ennyiszer felidézték, "megtanultnak" tekintjük.
29const MASTERED_THRESHOLD: u16 = 15;
30/// Meddig számít "még frissnek" (ms). 7 nap.
31const FRESH_WINDOW_MS: u64 = 7 * 86_400_000;
32/// Boost mértéke: mennyit vonjunk le a distance-ből due blokknál.
33const SPACING_BOOST_FACTOR: f32 = 0.05;
34
35// ─── Types ──────────────────────────────────────────
36
37/// Egy blokk spaced repetition állapota.
38#[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    /// SM-2 szerint esedékes?
50    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    /// Mennyivel késett (nap). Negatív = még nem esedékes.
56    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    /// SM-2 minősítés alapján új ease_factor + interval.
62    /// quality: 0 (legrosszabb) – 5 (legjobb)
63    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            // recall failed → reset
70            self.interval_days = 1.0;
71            self.ease_factor = (self.ease_factor - 0.2).max(MIN_EASE);
72        } else {
73            // SM-2: update ease factor
74            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                // max 5 year
84            }
85        }
86    }
87
88    /// Spacing boost érték a recall scoring-hoz.
89    /// 0.0 ha nem esedékes, pozitív ha due vagy túlkésett.
90    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        // Alap boost + extra minél régebb óta esedékes
96        let base = SPACING_BOOST_FACTOR;
97        let extra = (overdue * 0.01).min(SPACING_BOOST_FACTOR);
98        base + extra
99    }
100}
101
102/// Spaced repetition állapot — az összes tracked blokk.
103pub struct SpacedRepetition {
104    pub blocks: Vec<SpacedBlock>,
105}
106
107impl SpacedRepetition {
108    /// Betöltés SPR1 file-ból, vagy üres init.
109    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    /// Mentés SPR1 formátumba.
148    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    /// Remap block indices after a rebuild changed the block layout.
168    /// `old_to_new[old_idx] = new_idx`; entries that no longer exist
169    /// (`u32::MAX`) are dropped.
170    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    /// Keress egy blokkot block_idx alapján.
184    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    /// Hozz létre vagy frissíts egy blokkot a recall után.
193    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            // Új blokk
200            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    /// Spacing boost adott blokkhoz a recall scoring-hoz.
214    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    /// Hány due blokk van?
222    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    /// Hány "mastered" blokk (recall_count >= 15)?
228    pub fn mastered_count(&self) -> usize {
229        self.blocks
230            .iter()
231            .filter(|b| b.recall_count >= MASTERED_THRESHOLD)
232            .count()
233    }
234
235    /// Statisztikák.
236    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    /// Listázd a due blokkokat block_idx szerint.
259    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
272// ─── Statisztikák ───────────────────────────────────
273
274pub 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// ─── Tesztek ────────────────────────────────────────
291
292#[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        // 1st recall (quality=4)
314        block.record_recall(4, 2000);
315        assert!(block.interval_days >= 1.0);
316        let after_first = block.interval_days;
317
318        // 2nd recall (quality=5)
319        block.record_recall(5, 3000);
320        assert!(block.interval_days > after_first);
321
322        // 3rd recall — SM-2 multiplies by ease_factor
323        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); // poor quality → reset
338        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, // régi
365                interval_days: 0.001, // nagyon rövid intervallum → due
366                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}