Skip to main content

microscope_memory/
eureka.rs

1//! Eureka/Insight — váratlan összefüggések detektálása.
2//!
3//! Amikor a recall olyan blokkot hoz fel, ami térben távol van a query-től, de
4//! emocionálisan vagy tematikusan erősen releváns, az egy "eureka pillanat" —
5//! potenciálisan új, nem triviális összefüggés a memóriában.
6//!
7//! Binary format: EUR1
8//!   magic: "EUR1" (4 bytes)
9//!   count: u32 (4 bytes)
10//!   events[count]:
11//!     id: u64 | ts: u64 | qlen: u16 | query[] | tlen: u16 | text[]
12//!     surprise: f32 | curiosity: f32 | spatial_dist: f32 | emotional_sim: f32
13//!     layer_id: u8 | block_index: u32
14
15use std::fs;
16use std::path::Path;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use crate::reader::{emotional_similarity, load_emotion_lookup, EMOTION_DIMS};
20use crate::{safe_truncate, MicroscopeReader};
21
22// ─── Constants ──────────────────────────────────────
23
24/// Minimum spatial distance (squared) to qualify as "distant" → potential eureka.
25const EUREKA_MIN_SPATIAL_DIST: f32 = 0.05;
26/// Minimum emotional similarity to qualify as eureka.
27const EUREKA_MIN_EMO_SIM: f32 = 0.3;
28/// Insight score threshold: surprise * curiosity * emo_sim / spatial_dist.
29const EUREKA_MIN_INSIGHT_SCORE: f32 = 0.5;
30
31// ─── Types ──────────────────────────────────────────
32
33/// A single eureka/insight event.
34#[derive(Clone, Debug)]
35pub struct EurekaEvent {
36    pub id: u64,
37    pub timestamp_ms: u64,
38    pub query: String,
39    pub text: String,
40    pub surprise_score: f32,
41    pub curiosity_score: f32,
42    pub spatial_dist: f32,
43    pub emotional_sim: f32,
44    pub layer_id: u8,
45    pub block_index: u32,
46}
47
48impl EurekaEvent {
49    /// Compute the overall insight score.
50    pub fn insight_score(&self) -> f32 {
51        let base = self.surprise_score * self.curiosity_score * self.emotional_sim;
52        if self.spatial_dist > 0.01 {
53            base / self.spatial_dist
54        } else {
55            base * 10.0 // very close spatial → still interesting if emotion-high
56        }
57    }
58}
59
60/// Persistent eureka log.
61pub struct EurekaLog {
62    pub events: Vec<EurekaEvent>,
63    pub next_id: u64,
64}
65
66impl EurekaLog {
67    /// Load from EUR1 file or return empty.
68    pub fn load_or_init(output_dir: &Path) -> Self {
69        let path = output_dir.join("eureka.bin");
70        if let Ok(data) = fs::read(&path) {
71            if data.len() >= 8 && &data[0..4] == b"EUR1" {
72                let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
73                let mut events = Vec::with_capacity(count);
74                let mut off = 8;
75                let mut max_id = 0u64;
76                for _ in 0..count {
77                    if off + 16 > data.len() {
78                        break;
79                    }
80                    let id = u64::from_le_bytes(data[off..off + 8].try_into().unwrap());
81                    let ts = u64::from_le_bytes(data[off + 8..off + 16].try_into().unwrap());
82                    off += 16;
83                    if off + 2 > data.len() {
84                        break;
85                    }
86                    let qlen = u16::from_le_bytes(data[off..off + 2].try_into().unwrap()) as usize;
87                    off += 2;
88                    if off + qlen > data.len() {
89                        break;
90                    }
91                    let query = String::from_utf8_lossy(&data[off..off + qlen]).to_string();
92                    off += qlen;
93                    if off + 2 > data.len() {
94                        break;
95                    }
96                    let tlen = u16::from_le_bytes(data[off..off + 2].try_into().unwrap()) as usize;
97                    off += 2;
98                    let text = if off + tlen <= data.len() {
99                        String::from_utf8_lossy(&data[off..off + tlen]).to_string()
100                    } else {
101                        String::new()
102                    };
103                    off += tlen;
104                    if off + 20 > data.len() {
105                        break;
106                    }
107                    let surprise = f32::from_le_bytes(data[off..off + 4].try_into().unwrap());
108                    let curiosity = f32::from_le_bytes(data[off + 4..off + 8].try_into().unwrap());
109                    let sd = f32::from_le_bytes(data[off + 8..off + 12].try_into().unwrap());
110                    let es = f32::from_le_bytes(data[off + 12..off + 16].try_into().unwrap());
111                    let lid = data[off + 16];
112                    let bi = u32::from_le_bytes(data[off + 17..off + 21].try_into().unwrap());
113                    off += 21;
114                    if id > max_id {
115                        max_id = id;
116                    }
117                    events.push(EurekaEvent {
118                        id,
119                        timestamp_ms: ts,
120                        query,
121                        text,
122                        surprise_score: surprise,
123                        curiosity_score: curiosity,
124                        spatial_dist: sd,
125                        emotional_sim: es,
126                        layer_id: lid,
127                        block_index: bi,
128                    });
129                }
130                return EurekaLog {
131                    events,
132                    next_id: max_id + 1,
133                };
134            }
135        }
136        EurekaLog {
137            events: Vec::new(),
138            next_id: 1,
139        }
140    }
141
142    /// Save to EUR1 file.
143    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
144        let path = output_dir.join("eureka.bin");
145        let mut buf = Vec::with_capacity(8 + self.events.len() * 128);
146        buf.extend_from_slice(b"EUR1");
147        buf.extend_from_slice(&(self.events.len() as u32).to_le_bytes());
148        for ev in &self.events {
149            buf.extend_from_slice(&ev.id.to_le_bytes());
150            buf.extend_from_slice(&ev.timestamp_ms.to_le_bytes());
151            let qbytes = ev.query.as_bytes();
152            buf.extend_from_slice(&(qbytes.len() as u16).to_le_bytes());
153            buf.extend_from_slice(qbytes);
154            let tbytes = ev.text.as_bytes();
155            buf.extend_from_slice(&(tbytes.len() as u16).to_le_bytes());
156            buf.extend_from_slice(tbytes);
157            buf.extend_from_slice(&ev.surprise_score.to_le_bytes());
158            buf.extend_from_slice(&ev.curiosity_score.to_le_bytes());
159            buf.extend_from_slice(&ev.spatial_dist.to_le_bytes());
160            buf.extend_from_slice(&ev.emotional_sim.to_le_bytes());
161            buf.push(ev.layer_id);
162            buf.extend_from_slice(&ev.block_index.to_le_bytes());
163        }
164        let tmp_path = output_dir.join("eureka.bin.tmp");
165        fs::write(&tmp_path, &buf).map_err(|e| format!("write eureka.bin: {}", e))?;
166        fs::rename(&tmp_path, &path).map_err(|e| format!("rename eureka.bin: {}", e))
167    }
168
169    /// Remap event block indices after a rebuild changed the block layout.
170    /// `old_to_new[old_idx] = new_idx`; events whose block no longer exists
171    /// are dropped.
172    pub fn remap_indexes(&mut self, old_to_new: &[u32]) {
173        self.events.retain_mut(|ev| {
174            if (ev.block_index as usize) < old_to_new.len() {
175                let new_idx = old_to_new[ev.block_index as usize];
176                if new_idx != u32::MAX {
177                    ev.block_index = new_idx;
178                    return true;
179                }
180            }
181            false
182        });
183    }
184
185    /// Add a new event and save.
186    pub fn record(&mut self, output_dir: &Path, event: EurekaEvent) -> Result<(), String> {
187        self.events.push(event);
188        self.next_id += 1;
189        self.save(output_dir)
190    }
191}
192
193// ─── Detection ──────────────────────────────────────
194
195/// Detect eureka moments from recall results.
196/// For each result that is spatially distant from the query but emotionally
197/// similar, compute an insight score. Events above threshold get recorded
198/// and stored as an insight memory.
199pub fn detect_eureka(
200    config: &crate::config::Config,
201    reader: &MicroscopeReader,
202    query: &str,
203    emotion: Option<&[f32; 21]>,
204    results: &[(f32, usize, bool)],
205) -> Vec<EurekaEvent> {
206    let output_dir = Path::new(&config.paths.output_dir);
207    // The emotion lookup is 58.7 MB on the 699k-block eval index and is only
208    // ever read in the `(Some(qe), Some(lookup))` arm below. `recall` calls
209    // this with `emotion: None`, so loading it unconditionally cost 17.5 ms per
210    // recall to build a value nothing reads. Load it when there is a query
211    // emotion to compare against, and not otherwise.
212    let emotion_lookup = if emotion.is_some() {
213        load_emotion_lookup(output_dir)
214    } else {
215        None
216    };
217    let (qx, qy, qz) =
218        crate::content_coords_blended(query, "long_term", config.search.semantic_weight);
219    let mut eureka_events = Vec::new();
220
221    // Extract surprise and curiosity from the query emotion
222    let surprise_idx = EMOTION_DIMS
223        .iter()
224        .position(|&d| d == "surprise")
225        .unwrap_or(4);
226    let curiosity_idx = EMOTION_DIMS
227        .iter()
228        .position(|&d| d == "curiosity")
229        .unwrap_or(10);
230    let query_surprise = emotion.map(|e| e[surprise_idx]).unwrap_or(0.0);
231    let query_curiosity = emotion.map(|e| e[curiosity_idx]).unwrap_or(0.0);
232
233    for &(_score, idx, is_main) in results {
234        if !is_main {
235            continue;
236        }
237        if idx >= reader.block_count {
238            continue;
239        }
240
241        let hdr = reader.header(idx);
242        let dx = hdr.x - qx;
243        let dy = hdr.y - qy;
244        let dz = hdr.z - qz;
245        let spatial_dist = dx * dx + dy * dy + dz * dz;
246
247        if spatial_dist < EUREKA_MIN_SPATIAL_DIST {
248            continue;
249        }
250
251        // Emotional similarity between query emotion and block emotion
252        let emotional_sim = match (emotion, emotion_lookup.as_ref()) {
253            (Some(qe), Some(lookup)) => lookup(idx)
254                .map(|be| emotional_similarity(qe, &be))
255                .unwrap_or(0.0),
256            _ => 0.0,
257        };
258
259        if emotional_sim < EUREKA_MIN_EMO_SIM {
260            continue;
261        }
262
263        // Use query surprise/curiosity, or default to emotional_sim * 0.5
264        let surprise = query_surprise.max(emotional_sim * 0.3);
265        let curiosity = query_curiosity.max(emotional_sim * 0.4);
266
267        let event = EurekaEvent {
268            id: 0, // assigned by EurekaLog::record
269            timestamp_ms: SystemTime::now()
270                .duration_since(UNIX_EPOCH)
271                .unwrap_or_default()
272                .as_millis() as u64,
273            query: safe_truncate(query, 80),
274            text: safe_truncate(reader.text(idx), 120),
275            surprise_score: surprise,
276            curiosity_score: curiosity,
277            spatial_dist: spatial_dist.sqrt(),
278            emotional_sim,
279            layer_id: hdr.layer_id,
280            block_index: idx as u32,
281        };
282
283        if event.insight_score() >= EUREKA_MIN_INSIGHT_SCORE {
284            eureka_events.push(event);
285        }
286    }
287
288    eureka_events
289}
290
291// ─── CLI display ────────────────────────────────────
292
293/// Format a single eureka event for CLI output.
294pub fn format_eureka(event: &EurekaEvent) -> String {
295    format!(
296        "  [#{}] 🔍 {} | surprise={:.2} curiosity={:.2} dist={:.3} emo_sim={:.2} | score={:.1}\n         Q: {}\n         R: {}",
297        event.id,
298        event.timestamp_ms,
299        event.surprise_score,
300        event.curiosity_score,
301        event.spatial_dist,
302        event.emotional_sim,
303        event.insight_score(),
304        safe_truncate(&event.query, 60),
305        safe_truncate(&event.text, 60),
306    )
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn test_empty_log() {
315        let log = EurekaLog::load_or_init(Path::new("."));
316        assert!(log.events.is_empty());
317        assert_eq!(log.next_id, 1);
318    }
319
320    #[test]
321    fn test_save_load_roundtrip() {
322        let dir = std::env::temp_dir();
323        let mut log = EurekaLog::load_or_init(&dir);
324        let ev = EurekaEvent {
325            id: 1,
326            timestamp_ms: 1000,
327            query: "test query".into(),
328            text: "test result".into(),
329            surprise_score: 0.8,
330            curiosity_score: 0.7,
331            spatial_dist: 0.3,
332            emotional_sim: 0.6,
333            layer_id: 3,
334            block_index: 42,
335        };
336        log.record(&dir, ev).unwrap();
337
338        let loaded = EurekaLog::load_or_init(&dir);
339        assert_eq!(loaded.events.len(), 1);
340        assert_eq!(loaded.events[0].query, "test query");
341        assert!((loaded.events[0].surprise_score - 0.8).abs() < 0.01);
342
343        let _ = std::fs::remove_file(dir.join("eureka.bin"));
344    }
345
346    #[test]
347    fn test_insight_score_formula() {
348        let ev = EurekaEvent {
349            id: 1,
350            timestamp_ms: 0,
351            query: "q".into(),
352            text: "r".into(),
353            surprise_score: 0.8,
354            curiosity_score: 0.7,
355            spatial_dist: 0.2,
356            emotional_sim: 0.6,
357            layer_id: 0,
358            block_index: 0,
359        };
360        let score = ev.insight_score();
361        // 0.8 * 0.7 * 0.6 / 0.2 = 1.68
362        assert!((score - 1.68).abs() < 0.01);
363    }
364}