1use 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
22const EUREKA_MIN_SPATIAL_DIST: f32 = 0.05;
26const EUREKA_MIN_EMO_SIM: f32 = 0.3;
28const EUREKA_MIN_INSIGHT_SCORE: f32 = 0.5;
30
31#[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 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 }
57 }
58}
59
60pub struct EurekaLog {
62 pub events: Vec<EurekaEvent>,
63 pub next_id: u64,
64}
65
66impl EurekaLog {
67 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 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 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 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
193pub 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 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 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 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 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, 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
291pub 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 assert!((score - 1.68).abs() < 0.01);
363 }
364}