Skip to main content

microscope_memory/
working_memory.rs

1//! Working Memory — korlátos puffer 7±2 item, gyors decay (30mp), primacy/recency boost, auto-konszolidáció.
2//!
3//! Binary format: WKM1 (Working Memory v1)
4//!   [W,K,M,1] magic (4 bytes)
5//!   capacity: u8 | decay_ms: u32 | count: u8
6//!   items[]: text_len:u16 text timestamp_ms:u64 last_access_ms:u64
7//!            importance:f32 decay_rate:f32 serial_pos:u8
8//!            memory_type:u8 access_count:u32 layer_len:u8 layer
9
10use std::fs;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14// ─── Constants ─────────────────────────────────────────
15const DEFAULT_CAPACITY: usize = 7; // Miller-törvény: 7±2
16const DECAY_MS: u64 = 30_000; // 30 mp alatt felejt
17const CONSOLIDATE_ACCESS_THRESHOLD: u32 = 3; // 3 hozzáférés után konszolidáció
18const PRIMACY_BOOST: f32 = 0.15; // első elem boost
19const RECENCY_BOOST: f32 = 0.10; // utolsó elem boost
20                                 // ─── Types ─────────────────────────────────────────────
21#[repr(u8)]
22#[derive(Clone, Copy, Debug, PartialEq)]
23pub enum MemoryType {
24    Episodic = 0,
25    Semantic = 1,
26    Implicit = 2,
27    Explicit = 3,
28}
29
30/// Egy item a working memory-ban.
31#[derive(Clone, Debug)]
32pub struct WorkingMemoryItem {
33    pub text: String,
34    pub timestamp_ms: u64,
35    pub last_access_ms: u64,
36    pub importance: f32,
37    pub decay_rate: f32,
38    pub serial_pos: u8,
39    pub memory_type: MemoryType,
40    pub access_count: u32,
41    pub layer: String,
42}
43
44/// Working Memory állapot — korlátos puffer.
45pub struct WorkingMemory {
46    pub items: Vec<WorkingMemoryItem>,
47    pub capacity: usize,
48    pub decay_ms: u64,
49}
50
51/// Statisztikák.
52pub struct WorkingMemoryStats {
53    pub item_count: usize,
54    pub capacity: usize,
55    pub hot_items: usize, // access_count >= 2
56    pub decay_ms: u64,
57    pub consolidation_candidates: usize, // access_count >= CONSOLIDATE_ACCESS_THRESHOLD
58}
59impl WorkingMemory {
60    /// Betöltés WKM1 file-ból, vagy üres init.
61    pub fn load_or_init(output_dir: &Path) -> Self {
62        let path = output_dir.join("working_memory.bin");
63        if let Ok(data) = fs::read(&path) {
64            if data.len() >= 6 && &data[0..4] == b"WKM1" {
65                let capacity = data[4] as usize;
66                let decay_ms = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as u64;
67                let count = data[9] as usize;
68                let mut items = Vec::with_capacity(count);
69                let mut off = 10;
70                for _ in 0..count {
71                    if off + 2 > data.len() {
72                        break;
73                    }
74                    let text_len = u16::from_le_bytes([data[off], data[off + 1]]) as usize;
75                    off += 2;
76                    if off + text_len > data.len() {
77                        break;
78                    }
79                    let text = String::from_utf8_lossy(&data[off..off + text_len]).to_string();
80                    off += text_len;
81                    if off + 37 > data.len() {
82                        break;
83                    }
84                    let ts = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
85                    let la = u64::from_le_bytes(data[off + 8..off + 16].try_into().unwrap());
86                    let imp = f32::from_le_bytes(data[off + 16..off + 20].try_into().unwrap());
87                    let dr = f32::from_le_bytes(data[off + 20..off + 24].try_into().unwrap());
88                    let sp = data[off + 24];
89                    let mt = match data[off + 25] {
90                        0 => MemoryType::Episodic,
91                        1 => MemoryType::Semantic,
92                        2 => MemoryType::Implicit,
93                        3 => MemoryType::Explicit,
94                        _ => MemoryType::Episodic,
95                    };
96                    let ac = u32::from_le_bytes(data[off + 26..off + 30].try_into().unwrap());
97                    let ll = data[off + 30] as usize;
98                    off += 31;
99                    let layer = if off + ll <= data.len() {
100                        String::from_utf8_lossy(&data[off..off + ll]).to_string()
101                    } else {
102                        String::new()
103                    };
104                    off += ll;
105                    items.push(WorkingMemoryItem {
106                        text,
107                        timestamp_ms: ts,
108                        last_access_ms: la,
109                        importance: imp,
110                        decay_rate: dr,
111                        serial_pos: sp,
112                        memory_type: mt,
113                        access_count: ac,
114                        layer,
115                    });
116                }
117                return WorkingMemory {
118                    items,
119                    capacity,
120                    decay_ms,
121                };
122            }
123        }
124        WorkingMemory {
125            items: Vec::new(),
126            capacity: DEFAULT_CAPACITY,
127            decay_ms: DECAY_MS,
128        }
129    }
130
131    /// Mentés WKM1 binary formátumba.
132    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
133        let path = output_dir.join("working_memory.bin");
134        let mut buf = Vec::with_capacity(64 + self.items.len() * 128);
135        buf.extend_from_slice(b"WKM1");
136        buf.push(self.capacity as u8);
137        buf.extend_from_slice(&(self.decay_ms as u32).to_le_bytes());
138        buf.push(self.items.len() as u8);
139        for item in &self.items {
140            let text_bytes = item.text.as_bytes();
141            let text_len = text_bytes.len().min(u16::MAX as usize);
142            buf.extend_from_slice(&(text_len as u16).to_le_bytes());
143            buf.extend_from_slice(&text_bytes[..text_len]);
144            buf.extend_from_slice(&item.timestamp_ms.to_le_bytes());
145            buf.extend_from_slice(&item.last_access_ms.to_le_bytes());
146            buf.extend_from_slice(&item.importance.to_le_bytes());
147            buf.extend_from_slice(&item.decay_rate.to_le_bytes());
148            buf.push(item.serial_pos);
149            buf.push(item.memory_type as u8);
150            buf.extend_from_slice(&item.access_count.to_le_bytes());
151            let layer_bytes = item.layer.as_bytes();
152            let ll = layer_bytes.len().min(255);
153            buf.push(ll as u8);
154            buf.extend_from_slice(&layer_bytes[..ll]);
155        }
156        let tmp_path = output_dir.join("working_memory.bin.tmp");
157        fs::write(&tmp_path, &buf).map_err(|e| format!("write working_memory.bin: {}", e))?;
158        fs::rename(&tmp_path, &path).map_err(|e| format!("rename working_memory.bin: {}", e))
159    }
160    /// Push one item. Evict lowest-score item if full.
161    pub fn push(&mut self, text: &str, importance: f32, layer: &str, mem_type: MemoryType) {
162        let now = now_ms();
163        // Evict if at capacity
164        if self.items.len() >= self.capacity {
165            let mut worst = 0;
166            let mut worst_score = f32::MAX;
167            for (i, item) in self.items.iter().enumerate() {
168                let recency =
169                    ((now - item.last_access_ms) as f64 / self.decay_ms as f64).max(0.0) as f32;
170                let age_weight = if recency > 1.0 {
171                    0.1
172                } else {
173                    1.0 - recency * 0.9
174                };
175                let score = item.importance * age_weight;
176                if score < worst_score {
177                    worst_score = score;
178                    worst = i;
179                }
180            }
181            self.items.remove(worst);
182        }
183        self.items.push(WorkingMemoryItem {
184            text: text.to_string(),
185            timestamp_ms: now,
186            last_access_ms: now,
187            importance,
188            decay_rate: self.decay_ms as f32 / DEFAULT_CAPACITY as f32,
189            serial_pos: self.items.len() as u8,
190            memory_type: mem_type,
191            access_count: 0,
192            layer: layer.to_string(),
193        });
194    }
195
196    /// Decay: csökkenti az importance-t, evict < 0.1.
197    pub fn decay(&mut self) {
198        let now = now_ms();
199        self.items.retain(|item| {
200            let elapsed = (now - item.last_access_ms) as f32;
201            let age_ratio = elapsed / self.decay_ms as f32;
202            age_ratio < 1.0 || item.access_count >= CONSOLIDATE_ACCESS_THRESHOLD
203        });
204    }
205
206    /// Access an item by index: bumps last_access, increments count.
207    /// Applies primacy boost (serial_pos == 0) and recency boost (last item).
208    pub fn access(&mut self, idx: usize) -> Option<&WorkingMemoryItem> {
209        if idx >= self.items.len() {
210            return None;
211        }
212        let recency_boost = idx == self.items.len() - 1;
213        let item = &mut self.items[idx];
214        item.last_access_ms = now_ms();
215        item.access_count += 1;
216        if item.serial_pos == 0 {
217            item.importance += PRIMACY_BOOST;
218        }
219        if recency_boost {
220            item.importance += RECENCY_BOOST;
221        }
222        // Cannot return &self.items[idx] while &mut self.items[idx] is alive
223        None
224    }
225
226    /// Find item by text substring (linear scan).
227    pub fn find(&self, query: &str) -> Option<usize> {
228        let lower = query.to_lowercase();
229        self.items
230            .iter()
231            .position(|item| item.text.to_lowercase().contains(&lower))
232    }
233
234    /// Consolidate high-access items: returns the items that qualify.
235    /// Caller is responsible for storing them via store_memory() or other means.
236    pub fn consolidate(&mut self) -> Vec<WorkingMemoryItem> {
237        let mut consolidated = Vec::new();
238        let mut i = 0;
239        while i < self.items.len() {
240            if self.items[i].access_count >= CONSOLIDATE_ACCESS_THRESHOLD {
241                consolidated.push(self.items.remove(i));
242            } else {
243                i += 1;
244            }
245        }
246        consolidated
247    }
248
249    /// Compute working memory stats.
250    pub fn stats(&self) -> WorkingMemoryStats {
251        let hot = self.items.iter().filter(|i| i.access_count >= 2).count();
252        let cons_candidates = self
253            .items
254            .iter()
255            .filter(|i| i.access_count >= CONSOLIDATE_ACCESS_THRESHOLD)
256            .count();
257        WorkingMemoryStats {
258            item_count: self.items.len(),
259            capacity: self.capacity,
260            hot_items: hot,
261            decay_ms: self.decay_ms,
262            consolidation_candidates: cons_candidates,
263        }
264    }
265
266    /// Clear all items.
267    pub fn clear(&mut self) {
268        self.items.clear();
269    }
270}
271
272/// Current timestamp in milliseconds since UNIX epoch.
273fn now_ms() -> u64 {
274    SystemTime::now()
275        .duration_since(UNIX_EPOCH)
276        .unwrap_or_default()
277        .as_millis() as u64
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use std::path::Path;
284
285    #[test]
286    fn test_default_state() {
287        let wm = WorkingMemory::load_or_init(Path::new("."));
288        assert!(wm.items.is_empty());
289        assert_eq!(wm.capacity, DEFAULT_CAPACITY);
290        assert_eq!(wm.decay_ms, DECAY_MS);
291    }
292
293    #[test]
294    fn test_push_and_count() {
295        let mut wm = WorkingMemory::load_or_init(Path::new("."));
296        wm.push("first item", 5.0, "short_term", MemoryType::Episodic);
297        wm.push("second item", 3.0, "long_term", MemoryType::Semantic);
298        assert_eq!(wm.items.len(), 2);
299    }
300
301    #[test]
302    fn test_eviction_when_full() {
303        let mut wm = WorkingMemory {
304            items: vec![],
305            capacity: 3,
306            decay_ms: 30000,
307        };
308        wm.push("a", 1.0, "st", MemoryType::Episodic);
309        wm.push("b", 1.0, "st", MemoryType::Episodic);
310        wm.push("c", 1.0, "st", MemoryType::Episodic);
311        wm.push("d", 1.0, "st", MemoryType::Episodic);
312        assert_eq!(wm.items.len(), 3);
313    }
314
315    #[test]
316    fn test_access_bumps_count() {
317        let mut wm = WorkingMemory {
318            items: vec![],
319            capacity: 7,
320            decay_ms: 30000,
321        };
322        wm.push("test", 5.0, "st", MemoryType::Episodic);
323        wm.access(0);
324        assert_eq!(wm.items[0].access_count, 1);
325    }
326
327    #[test]
328    fn test_decay_removes_old() {
329        let mut wm = WorkingMemory {
330            items: vec![],
331            capacity: 7,
332            decay_ms: 1,
333        }; // 1ms decay
334        wm.push("old", 5.0, "st", MemoryType::Episodic);
335        std::thread::sleep(std::time::Duration::from_millis(5));
336        wm.decay();
337        assert!(wm.items.is_empty());
338    }
339
340    #[test]
341    fn test_stats() {
342        let mut wm = WorkingMemory {
343            items: vec![],
344            capacity: 5,
345            decay_ms: 30000,
346        };
347        wm.push("a", 5.0, "st", MemoryType::Episodic);
348        wm.push("b", 3.0, "lt", MemoryType::Semantic);
349        let s = wm.stats();
350        assert_eq!(s.item_count, 2);
351        assert_eq!(s.capacity, 5);
352    }
353
354    #[test]
355    fn test_save_load_roundtrip() {
356        let dir = std::env::temp_dir().join("microscope_wm_test");
357        let _ = std::fs::create_dir_all(&dir);
358        let mut wm = WorkingMemory::load_or_init(&dir);
359        wm.push("hello", 7.0, "short_term", MemoryType::Episodic);
360        wm.save(&dir).unwrap();
361
362        let loaded = WorkingMemory::load_or_init(&dir);
363        assert_eq!(loaded.items.len(), 1);
364        assert_eq!(loaded.items[0].text, "hello");
365        assert!((loaded.items[0].importance - 7.0).abs() < 0.01);
366
367        let _ = std::fs::remove_dir_all(&dir);
368    }
369
370    #[test]
371    fn test_clear() {
372        let mut wm = WorkingMemory {
373            items: vec![],
374            capacity: 7,
375            decay_ms: 30000,
376        };
377        wm.push("x", 5.0, "st", MemoryType::Episodic);
378        wm.clear();
379        assert!(wm.items.is_empty());
380    }
381}