1use crate::parallel::*;
4use colored::Colorize;
5use std::fs;
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8
9use crate::config::Config;
10use crate::types::{AppendEntry, BlockHeader, MemoryQueryOptions, ProjectId};
11use crate::{
12 auto_depth, content_coords_blended, layer_to_id, safe_truncate, BLOCK_DATA_SIZE,
13 DEPTH_ENTRY_SIZE, HEADER_SIZE, LAYER_NAMES, LEGACY_HEADER_SIZE, META_HEADER_SIZE,
14};
15
16#[cfg(windows)]
17use windows_sys::Win32::System::Memory::{
18 VirtualQuery, MEMORY_BASIC_INFORMATION, PAGE_GUARD, PAGE_NOACCESS,
19};
20
21#[inline]
39pub fn contains_ci(needle: &str, haystack: &str) -> bool {
40 if needle.is_empty() {
41 return true;
42 }
43 if needle.is_ascii() {
48 let n = needle.as_bytes();
49 let h = haystack.as_bytes();
50 if n.len() > h.len() {
51 return false;
52 }
53 let first = n[0];
54 let first_upper = first.to_ascii_uppercase();
55 let mut i = 0usize;
56 while i + n.len() <= h.len() {
57 let b = h[i];
58 if (b == first || b == first_upper)
59 && h[i..i + n.len()]
60 .iter()
61 .zip(n)
62 .all(|(&hb, &nb)| hb.to_ascii_lowercase() == nb)
63 {
64 return true;
65 }
66 i += 1;
67 }
68 return false;
69 }
70
71 let bytes = haystack.as_bytes();
73 let mut start = 0usize;
74 while start < bytes.len() {
75 if haystack.is_char_boundary(start) && ci_matches_at(needle, haystack, start) {
76 return true;
77 }
78 start += 1;
79 }
80 false
81}
82
83fn append_stamp(path: &Path) -> Option<(u64, std::time::SystemTime)> {
86 let md = fs::metadata(path).ok()?;
87 Some((md.len(), md.modified().ok()?))
88}
89
90#[inline]
93fn ci_matches_at(needle: &str, haystack: &str, start: usize) -> bool {
94 let mut ni = needle.chars();
95 let mut hi = haystack[start..].chars();
96 loop {
97 match (ni.next(), hi.next()) {
98 (None, _) => return true,
99 (Some(nc), Some(hc)) => {
100 if nc == hc {
101 continue;
102 }
103 if !nc.to_lowercase().eq(hc.to_lowercase()) {
106 return false;
107 }
108 }
109 _ => return false,
110 }
111 }
112}
113
114pub fn layer_color(id: u8) -> &'static str {
115 match id {
116 0 => "white",
117 1 => "blue",
118 2 => "cyan",
119 3 => "green",
120 4 => "red",
121 5 => "yellow",
122 6 => "magenta",
123 7 => "orange",
124 8 => "lime",
125 9 => "purple",
126 _ => "white",
127 }
128}
129
130#[cfg(target_arch = "x86_64")]
131use std::arch::x86_64::*;
132
133#[inline(always)]
134fn l2_dist_sq_simd(h: &BlockHeader, x: f32, y: f32, z: f32, qz: f32, zw: f32) -> f32 {
135 #[cfg(target_arch = "x86_64")]
136 unsafe {
137 let h_vals = _mm_loadu_ps(h as *const BlockHeader as *const f32);
138 let q_vals = _mm_set_ps(qz, z, y, x);
139 let diff = _mm_sub_ps(h_vals, q_vals);
140 let weights = _mm_set_ps(zw, 1.0, 1.0, 1.0);
141 let weighted_diff = _mm_mul_ps(diff, weights);
142 let sq = _mm_mul_ps(weighted_diff, weighted_diff);
143 let res = _mm_hadd_ps(sq, sq);
144 let res2 = _mm_hadd_ps(res, res);
145 let mut dist = 0.0f32;
146 _mm_store_ss(&mut dist, res2);
147 dist
148 }
149 #[cfg(not(target_arch = "x86_64"))]
150 {
151 let dx = h.x - x;
152 let dy = h.y - y;
153 let dz = h.z - z;
154 let dw = (h.zoom - qz) * zw;
155 dx * dx + dy * dy + dz * dz + dw * dw
156 }
157}
158
159pub enum DataStore {
161 Mmap(memmap2::Mmap),
163 #[cfg(feature = "compression")]
165 InMemory(Vec<u8>),
166}
167
168impl std::ops::Deref for DataStore {
169 type Target = [u8];
170 fn deref(&self) -> &[u8] {
171 match self {
172 DataStore::Mmap(m) => m,
173 #[cfg(feature = "compression")]
174 DataStore::InMemory(v) => v,
175 }
176 }
177}
178
179pub struct MicroscopeReader {
181 pub headers: memmap2::Mmap,
182 pub data: DataStore,
183 pub block_count: usize,
184 pub header_stride: usize,
185 pub depth_ranges: [(u32, u32); 9],
186 pub text_index: Option<crate::text_index::TextIndex>,
187 pub append_log: Vec<AppendEntry>,
196 append_log_stamp: Option<(u64, std::time::SystemTime)>,
197}
198
199impl MicroscopeReader {
200 pub fn open(config: &Config) -> Result<Self, String> {
201 Self::open_from_path(&config.paths.output_dir)
202 }
203
204 fn open_from_path(output_dir: &str) -> Result<Self, String> {
205 let output_dir = Path::new(output_dir);
206 let meta_path = output_dir.join("meta.bin");
207 let hdr_path = output_dir.join("microscope.bin");
208 let dat_path = output_dir.join("data.bin");
209
210 let meta = fs::read(&meta_path)
211 .map_err(|e| format!("open meta.bin — run 'build' first: {}", e))?;
212 if meta.len() < 12 {
213 return Err("meta.bin too small".to_string());
214 }
215 let magic = &meta[0..4];
216 let header_stride = if magic == b"MSC4" {
217 HEADER_SIZE
218 } else {
219 LEGACY_HEADER_SIZE
220 };
221 if magic != b"MSCM" && magic != b"MSC2" && magic != b"MSC3" && magic != b"MSC4" {
222 return Err("invalid magic: expected MSCM, MSC2, MSC3 or MSC4".to_string());
223 }
224 let block_count = u32::from_le_bytes(
225 meta[8..12]
226 .try_into()
227 .map_err(|_| "meta.bin: bad block_count bytes")?,
228 ) as usize;
229 let mut depth_ranges = [(0u32, 0u32); 9];
230 for (d, range) in depth_ranges.iter_mut().enumerate() {
231 let off = META_HEADER_SIZE + d * DEPTH_ENTRY_SIZE;
232 if off + 8 > meta.len() {
233 return Err(format!("meta.bin truncated at depth {}", d));
234 }
235 let start = u32::from_le_bytes(
236 meta[off..off + 4]
237 .try_into()
238 .map_err(|_| "meta.bin: bad depth range bytes")?,
239 );
240 let count = u32::from_le_bytes(
241 meta[off + 4..off + 8]
242 .try_into()
243 .map_err(|_| "meta.bin: bad depth range bytes")?,
244 );
245 *range = (start, count);
246 }
247
248 let hdr_file =
249 fs::File::open(&hdr_path).map_err(|e| format!("open microscope.bin: {}", e))?;
250 let headers =
252 unsafe { memmap2::Mmap::map(&hdr_file).map_err(|e| format!("mmap headers: {}", e))? };
253
254 let needed = block_count.checked_mul(header_stride).ok_or_else(|| {
261 format!(
262 "meta.bin claims {} blocks, which overflows the {} byte header",
263 block_count, header_stride
264 )
265 })?;
266 if headers.len() < needed {
267 return Err(format!(
268 "microscope.bin is {} bytes but meta.bin claims {} blocks of {} \
269 bytes each, which needs {}",
270 headers.len(),
271 block_count,
272 header_stride,
273 needed
274 ));
275 }
276
277 #[cfg(windows)]
279 if let Err(e) = Self::verify_mmap_protection(headers.as_ptr(), headers.len()) {
280 return Err(format!("Stability check failed (headers): {}", e));
281 }
282
283 #[cfg(feature = "compression")]
284 let data = {
285 let zst_path = output_dir.join("data.bin.zst");
286 if zst_path.exists()
287 && (!dat_path.exists()
288 || fs::metadata(&zst_path)
289 .and_then(|zm| {
290 fs::metadata(&dat_path).map(|dm| {
291 zm.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH)
292 > dm.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH)
293 })
294 })
295 .unwrap_or(false))
296 {
297 let compressed =
298 fs::read(&zst_path).map_err(|e| format!("read data.bin.zst: {}", e))?;
299 let decompressed = zstd::decode_all(std::io::Cursor::new(&compressed))
300 .map_err(|e| format!("zstd decompress: {}", e))?;
301 DataStore::InMemory(decompressed)
302 } else {
303 let dat_file =
304 fs::File::open(&dat_path).map_err(|e| format!("open data.bin: {}", e))?;
305 DataStore::Mmap(unsafe {
307 memmap2::Mmap::map(&dat_file).map_err(|e| format!("mmap data.bin: {}", e))?
308 })
309 }
310 };
311
312 #[cfg(not(feature = "compression"))]
313 let data = {
314 let dat_file =
315 fs::File::open(&dat_path).map_err(|e| format!("open data.bin: {}", e))?;
316 DataStore::Mmap(unsafe {
318 memmap2::Mmap::map(&dat_file).map_err(|e| format!("mmap data.bin: {}", e))?
319 })
320 };
321
322 let text_index = crate::text_index::TextIndex::open(&output_dir.join("text_index.bin"));
323
324 let append_path = output_dir.join("append.bin");
329 let append_log = read_append_log(&append_path);
330 let append_log_stamp = append_stamp(&append_path);
331
332 Ok(MicroscopeReader {
333 headers,
334 data,
335 block_count,
336 header_stride,
337 depth_ranges,
338 text_index,
339 append_log,
340 append_log_stamp,
341 })
342 }
343
344 #[cfg(windows)]
346 fn verify_mmap_protection(ptr: *const u8, _len: usize) -> Result<(), String> {
347 let mut info: MEMORY_BASIC_INFORMATION = unsafe { std::mem::zeroed() };
348 let mut _return_len: usize = 0;
349
350 let res = unsafe {
351 VirtualQuery(
352 ptr as *const _,
353 &mut info as *mut _ as *mut _,
354 std::mem::size_of::<MEMORY_BASIC_INFORMATION>(),
355 )
356 };
357 if res == 0 {
358 return Err("VirtualQuery failed".to_string());
359 }
360
361 if info.Protect == PAGE_NOACCESS || (info.Protect & PAGE_GUARD) != 0 {
362 return Err("Memory protection violation: Page is NOACCESS or GUARD".to_string());
363 }
364
365 Ok(())
366 }
367
368 #[inline(always)]
369 pub fn header(&self, i: usize) -> BlockHeader {
370 assert!(
371 i < self.block_count,
372 "block header index {i} out of bounds (block_count={})",
373 self.block_count
374 );
375 unsafe { self.header_unchecked(i) }
376 }
377
378 #[inline(always)]
384 pub unsafe fn header_unchecked(&self, i: usize) -> BlockHeader {
385 let off = i * self.header_stride;
386 let ptr = unsafe { self.headers.as_ptr().add(off) };
387 if self.header_stride == HEADER_SIZE {
388 unsafe { std::ptr::read_unaligned(ptr as *const BlockHeader) }
389 } else {
390 let mut bytes = [0u8; LEGACY_HEADER_SIZE];
392 unsafe {
393 std::ptr::copy_nonoverlapping(ptr, bytes.as_mut_ptr(), LEGACY_HEADER_SIZE);
394 }
395 let x = f32::from_le_bytes(bytes[0..4].try_into().unwrap());
396 let y = f32::from_le_bytes(bytes[4..8].try_into().unwrap());
397 let z = f32::from_le_bytes(bytes[8..12].try_into().unwrap());
398 let zoom = f32::from_le_bytes(bytes[12..16].try_into().unwrap());
399 let depth = bytes[16];
400 let layer_id = bytes[17];
401 let data_offset = u32::from_le_bytes(bytes[18..22].try_into().unwrap());
402 let data_len = u16::from_le_bytes(bytes[22..24].try_into().unwrap());
403 let parent_idx = u32::from_le_bytes(bytes[24..28].try_into().unwrap());
404 let child_count = u16::from_le_bytes(bytes[28..30].try_into().unwrap());
405 let crc16 = [bytes[30], bytes[31]];
406 BlockHeader {
407 x,
408 y,
409 z,
410 zoom,
411 depth,
412 layer_id,
413 data_offset,
414 data_len,
415 parent_idx,
416 child_count,
417 crc16,
418 project_id: ProjectId::GLOBAL,
419 importance: 0,
420 flags: 0,
421 }
422 }
423 }
424
425 #[inline(always)]
426 pub fn text(&self, i: usize) -> &str {
427 let h = self.header(i);
428 let start = h.data_offset as usize;
429 let end = start + h.data_len as usize;
430
431 if end > self.data.len() || start >= end {
433 return "[out of bounds]";
434 }
435
436 let raw = &self.data[start..end];
437
438 std::str::from_utf8(raw).unwrap_or("<bin>")
440 }
441
442 pub fn look(
444 &self,
445 config: &Config,
446 x: f32,
447 y: f32,
448 z: f32,
449 zoom: u8,
450 k: usize,
451 ) -> Vec<(f32, usize, bool)> {
452 let (start, count) = self.depth_ranges[zoom as usize];
453 let (start, count) = (start as usize, count as usize);
454
455 let mut results: Vec<(f32, usize, bool)> = Vec::with_capacity(count + 10);
456 if count > 0 {
457 for i in start..(start + count) {
458 let h = self.header(i);
459 let dx = h.x - x;
460 let dy = h.y - y;
461 let dz = h.z - z;
462 results.push((dx * dx + dy * dy + dz * dz, i, true));
463 }
464 }
465
466 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
467 let appended = read_append_log(&append_path);
468 for (ai, entry) in appended.iter().enumerate() {
469 if entry.depth != zoom {
470 continue;
471 }
472 let dx = entry.x - x;
473 let dy = entry.y - y;
474 let dz = entry.z - z;
475 results.push((dx * dx + dy * dy + dz * dz, ai + 1_000_000, false));
476 }
477
478 let k = k.min(results.len());
479 if k == 0 {
480 return vec![];
481 }
482 results.select_nth_unstable_by(k - 1, |a, b| a.0.total_cmp(&b.0));
483 results.truncate(k);
484 results.sort_by(|a, b| a.0.total_cmp(&b.0));
485 results
486 }
487
488 #[allow(clippy::too_many_arguments)]
490 pub fn look_soft(
491 &self,
492 config: &Config,
493 x: f32,
494 y: f32,
495 z: f32,
496 zoom: u8,
497 k: usize,
498 zw: f32,
499 ) -> Vec<(f32, usize, bool)> {
500 let qz = zoom as f32 / 8.0;
501 let mut results: Vec<(f32, usize, bool)> = (0..self.block_count)
502 .into_par_iter()
503 .map(|i| {
504 let h = unsafe { self.header_unchecked(i) };
505 (l2_dist_sq_simd(&h, x, y, z, qz, zw), i, true)
506 })
507 .collect();
508
509 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
510 let appended = read_append_log(&append_path);
511 for (ai, entry) in appended.iter().enumerate() {
512 let dx = entry.x - x;
513 let dy = entry.y - y;
514 let dz = entry.z - z;
515 let entry_zoom = entry.depth as f32 / 8.0;
516 let dw = (entry_zoom - qz) * zw;
517 results.push((dx * dx + dy * dy + dz * dz + dw * dw, ai + 1_000_000, false));
518 }
519
520 let k = k.min(results.len());
521 if k == 0 {
522 return vec![];
523 }
524 results.select_nth_unstable_by(k - 1, |a, b| a.0.total_cmp(&b.0));
525 results.truncate(k);
526 results.sort_by(|a, b| a.0.total_cmp(&b.0));
527 results
528 }
529
530 #[allow(clippy::too_many_arguments)]
533 pub fn radial_search(
534 &self,
535 config: &Config,
536 x: f32,
537 y: f32,
538 z: f32,
539 depth: u8,
540 radius: f32,
541 k: usize,
542 ) -> ResultSet {
543 let radius_sq = radius * radius;
544 let (start, count) = self.depth_ranges[depth as usize];
545 let (start, count) = (start as usize, count as usize);
546
547 let mut candidates: Vec<(f32, usize, bool)> = if count > 0 {
549 (start..(start + count))
550 .into_par_iter()
551 .filter_map(|i| {
552 let h = self.header(i);
553 let qz = depth as f32 / 8.0;
554 let dist_sq = l2_dist_sq_simd(&h, x, y, z, qz, 0.0); if dist_sq <= radius_sq {
556 Some((dist_sq, i, true))
557 } else {
558 None
559 }
560 })
561 .collect()
562 } else {
563 Vec::new()
564 };
565
566 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
568 let appended = read_append_log(&append_path);
569 for (ai, entry) in appended.iter().enumerate() {
570 if entry.depth != depth {
571 continue;
572 }
573 let dx = entry.x - x;
574 let dy = entry.y - y;
575 let dz = entry.z - z;
576 let dist_sq = dx * dx + dy * dy + dz * dz;
577 if dist_sq <= radius_sq {
578 candidates.push((dist_sq, ai + 1_000_000, false));
579 }
580 }
581
582 candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
583
584 let primary = candidates
586 .first()
587 .map(|&(dist, idx, is_main)| RadialResult {
588 block_idx: idx,
589 dist_sq: dist,
590 weight: 1.0,
591 is_main,
592 });
593
594 let neighbors: Vec<RadialResult> = candidates
595 .iter()
596 .skip(1)
597 .take(k.saturating_sub(1))
598 .map(|&(dist_sq, idx, is_main)| {
599 let weight = if dist_sq > 0.0001 {
601 (radius_sq - dist_sq) / radius_sq
602 } else {
603 1.0
604 };
605 RadialResult {
606 block_idx: idx,
607 dist_sq,
608 weight,
609 is_main,
610 }
611 })
612 .collect();
613
614 let total_within_radius = candidates.len();
615
616 ResultSet {
617 primary,
618 neighbors,
619 center: (x, y, z),
620 depth,
621 radius,
622 total_within_radius,
623 }
624 }
625
626 fn candidate_blocks(&self, query_lower: &str) -> Vec<usize> {
643 if let Some(idx) = &self.text_index {
644 if idx.block_count() == self.block_count {
647 if let Some(cands) = idx.candidates_substring(query_lower) {
648 let mut out: Vec<usize> = cands.into_iter().map(|i| i as usize).collect();
649 out.sort_unstable();
650 return out;
651 }
652 }
653 }
654 (0..self.block_count).collect()
655 }
656
657 pub fn find_text(&self, query: &str, k: usize) -> Vec<(u8, usize)> {
666 let q = query.to_lowercase();
667 let candidates = self.candidate_blocks(&q);
668 let mut results: Vec<(u8, usize)> = candidates
669 .into_par_iter()
670 .filter_map(|i| {
671 if self.text(i).to_lowercase().contains(&q) {
672 Some((unsafe { self.header_unchecked(i) }.depth, i))
673 } else {
674 None
675 }
676 })
677 .collect();
678
679 results.sort_by_key(|&(d, _)| d);
680 results.truncate(k);
681 results
682 }
683
684 pub fn find_text_ranked(&self, query: &str, k: usize) -> Vec<(u8, usize, f32)> {
693 let q = query.to_lowercase();
694 let q_terms: Vec<&str> = q.split_whitespace().filter(|t| t.len() > 1).collect();
695 let candidates = self.candidate_blocks(&q);
696 let mut scored: Vec<(u8, usize, f32)> = candidates
697 .into_par_iter()
698 .filter_map(|i| {
699 let h = unsafe { self.header_unchecked(i) };
700 let text = self.text(i);
701 let low = text.to_lowercase();
702 if !low.contains(&q) {
703 return None;
704 }
705 Some((
706 h.depth,
707 i,
708 relevance_score(&q, &q_terms, &low, h.importance),
709 ))
710 })
711 .collect();
712
713 if scored.is_empty() {
714 return Vec::new();
715 }
716 scored.sort_by(|a, b| {
722 b.2.partial_cmp(&a.2)
723 .unwrap_or(std::cmp::Ordering::Equal)
724 .then_with(|| a.0.cmp(&b.0))
725 });
726 scored.truncate(k);
727 scored
728 }
729
730 pub fn refresh_append_log(&mut self, config: &Config) {
737 let path = Path::new(&config.paths.output_dir).join("append.bin");
738 self.append_log = read_append_log(&path);
739 self.append_log_stamp = append_stamp(&path);
740 }
741
742 fn append_log_is_stale(&self, output_dir: &Path) -> bool {
747 let path = output_dir.join("append.bin");
748 match (append_stamp(&path), self.append_log_stamp) {
749 (Some(now), Some(cached)) => {
750 now.0 != cached.0
751 || now
752 .1
753 .duration_since(cached.1)
754 .map(|d| !d.is_zero())
755 .unwrap_or(true)
756 }
757 (Some(_), None) | (None, Some(_)) => true,
759 (None, None) => false,
760 }
761 }
762
763 pub fn find_text_all_ranked(
771 &mut self,
772 _config: &Config,
773 query: &str,
774 k: usize,
775 ) -> Vec<(u8, usize, bool, f32)> {
776 let q = query.to_lowercase();
780 let q_terms: Vec<&str> = q.split_whitespace().filter(|t| t.len() > 1).collect();
781
782 if self.append_log_is_stale(Path::new(&_config.paths.output_dir)) {
785 let path = Path::new(&_config.paths.output_dir).join("append.bin");
786 self.append_log = read_append_log(&path);
787 self.append_log_stamp = append_stamp(&path);
788 }
789
790 let mut main: Vec<(u8, usize, f32)> = self.find_text_ranked(query, k).into_iter().collect();
791
792 for (idx, entry) in self.append_log.iter().enumerate() {
793 let low = entry.text.to_lowercase();
794 if low.contains(&q) {
795 let s = relevance_score(&q, &q_terms, &low, entry.importance);
796 main.push((entry.depth, idx + 1_000_000, s));
797 }
798 }
799
800 if main.is_empty() {
801 return Vec::new();
802 }
803 main.sort_by(|a, b| {
806 b.2.partial_cmp(&a.2)
807 .unwrap_or(std::cmp::Ordering::Equal)
808 .then_with(|| a.0.cmp(&b.0))
809 });
810 main.truncate(k);
811 main.into_iter()
812 .map(|(d, i, s)| (d, i, i < 1_000_000, s))
813 .collect()
814 }
815
816 pub fn find_text_all(&self, config: &Config, query: &str, k: usize) -> Vec<(u8, usize, bool)> {
819 let q = query.to_lowercase();
820 let mut results: Vec<(u8, usize, bool)> = self
821 .find_text(query, k)
822 .into_iter()
823 .map(|(depth, idx)| (depth, idx, true))
824 .collect();
825
826 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
827 let appended = read_append_log(&append_path);
828 results.extend(appended.iter().enumerate().filter_map(|(idx, entry)| {
829 entry
830 .text
831 .to_lowercase()
832 .contains(&q)
833 .then_some((entry.depth, idx + 1_000_000, false))
834 }));
835
836 results.sort_by_key(|&(depth, _, is_main)| (depth, is_main));
837 results.truncate(k);
838 results
839 }
840
841 pub fn print_result_with_limit(&self, i: usize, dist: f32, preview_limit: Option<usize>) {
845 let h = self.header(i);
846 let text = self.text(i);
847 let layer = LAYER_NAMES.get(h.layer_id as usize).unwrap_or(&"?");
848 let full: String = text.chars().filter(|&c| c != '\n').collect();
849 let preview = match preview_limit {
850 Some(n) if n > 0 && full.chars().count() > n => {
851 format!("{}…", full.chars().take(n).collect::<String>())
852 }
853 _ => full,
854 };
855 println!(
856 " {} {} {} {}",
857 format!("D{}", h.depth).cyan(),
858 format!("L2={:.5}", dist).yellow(),
859 format!("[{}/{}]", layer, layer_color(h.layer_id)).green(),
860 preview
861 );
862 }
863
864 pub fn print_result(&self, i: usize, dist: f32) {
865 self.print_result_with_limit(i, dist, None)
866 }
867}
868
869#[inline(always)]
872fn entry_visible(entry: &AppendEntry, active: ProjectId, include_global: bool) -> bool {
873 entry.project_id == active || (include_global && entry.project_id.is_global())
874}
875
876impl MicroscopeReader {
877 #[allow(clippy::too_many_arguments)]
879 pub fn look_with_options(
880 &self,
881 config: &Config,
882 x: f32,
883 y: f32,
884 z: f32,
885 zoom: u8,
886 k: usize,
887 opts: Option<&MemoryQueryOptions>,
888 ) -> Vec<(f32, usize, bool)> {
889 let opts = opts.cloned().unwrap_or_default();
890 let (start, count) = self.depth_ranges[zoom as usize];
891 let (start, count) = (start as usize, count as usize);
892
893 let mut results: Vec<(f32, usize, bool)> = Vec::with_capacity(count + 10);
894 if count > 0 {
895 for i in start..(start + count) {
896 let h = self.header(i);
897 if !h.is_visible_to(opts.active_project, opts.include_global)
898 || h.importance < opts.min_importance
899 {
900 continue;
901 }
902 let dx = h.x - x;
903 let dy = h.y - y;
904 let dz = h.z - z;
905 results.push((dx * dx + dy * dy + dz * dz, i, true));
906 }
907 }
908
909 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
910 let appended = read_append_log(&append_path);
911 for (ai, entry) in appended.iter().enumerate() {
912 if entry.depth != zoom {
913 continue;
914 }
915 if !entry_visible(entry, opts.active_project, opts.include_global)
916 || entry.importance < opts.min_importance
917 {
918 continue;
919 }
920 let dx = entry.x - x;
921 let dy = entry.y - y;
922 let dz = entry.z - z;
923 results.push((dx * dx + dy * dy + dz * dz, ai + 1_000_000, false));
924 }
925
926 let k = k.min(results.len());
927 if k == 0 {
928 return vec![];
929 }
930 results.select_nth_unstable_by(k - 1, |a, b| a.0.total_cmp(&b.0));
931 results.truncate(k);
932 results.sort_by(|a, b| a.0.total_cmp(&b.0));
933 results
934 }
935
936 #[allow(clippy::too_many_arguments)]
938 pub fn look_soft_with_options(
939 &self,
940 config: &Config,
941 x: f32,
942 y: f32,
943 z: f32,
944 zoom: u8,
945 k: usize,
946 zw: f32,
947 opts: Option<&MemoryQueryOptions>,
948 ) -> Vec<(f32, usize, bool)> {
949 let opts = opts.cloned().unwrap_or_default();
950 let qz = zoom as f32 / 8.0;
951 let mut results: Vec<(f32, usize, bool)> = (0..self.block_count)
952 .into_par_iter()
953 .filter_map(|i| {
954 let h = unsafe { self.header_unchecked(i) };
955 if !h.is_visible_to(opts.active_project, opts.include_global)
956 || h.importance < opts.min_importance
957 {
958 return None;
959 }
960 Some((l2_dist_sq_simd(&h, x, y, z, qz, zw), i, true))
961 })
962 .collect();
963
964 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
965 let appended = read_append_log(&append_path);
966 for (ai, entry) in appended.iter().enumerate() {
967 if !entry_visible(entry, opts.active_project, opts.include_global)
968 || entry.importance < opts.min_importance
969 {
970 continue;
971 }
972 let dx = entry.x - x;
973 let dy = entry.y - y;
974 let dz = entry.z - z;
975 let entry_zoom = entry.depth as f32 / 8.0;
976 let dw = (entry_zoom - qz) * zw;
977 results.push((dx * dx + dy * dy + dz * dz + dw * dw, ai + 1_000_000, false));
978 }
979
980 let k = k.min(results.len());
981 if k == 0 {
982 return vec![];
983 }
984 results.select_nth_unstable_by(k - 1, |a, b| a.0.total_cmp(&b.0));
985 results.truncate(k);
986 results.sort_by(|a, b| a.0.total_cmp(&b.0));
987 results
988 }
989
990 #[allow(clippy::too_many_arguments)]
992 pub fn radial_search_with_options(
993 &self,
994 config: &Config,
995 x: f32,
996 y: f32,
997 z: f32,
998 depth: u8,
999 radius: f32,
1000 k: usize,
1001 opts: Option<&MemoryQueryOptions>,
1002 ) -> ResultSet {
1003 let opts = opts.cloned().unwrap_or_default();
1004 let radius_sq = radius * radius;
1005 let (start, count) = self.depth_ranges[depth as usize];
1006 let (start, count) = (start as usize, count as usize);
1007
1008 let mut candidates: Vec<(f32, usize, bool)> = if count > 0 {
1009 (start..(start + count))
1010 .into_par_iter()
1011 .filter_map(|i| {
1012 let h = self.header(i);
1013 if !h.is_visible_to(opts.active_project, opts.include_global)
1014 || h.importance < opts.min_importance
1015 {
1016 return None;
1017 }
1018 let qz = depth as f32 / 8.0;
1019 let dist_sq = l2_dist_sq_simd(&h, x, y, z, qz, 0.0); if dist_sq <= radius_sq {
1021 Some((dist_sq, i, true))
1022 } else {
1023 None
1024 }
1025 })
1026 .collect()
1027 } else {
1028 Vec::new()
1029 };
1030
1031 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
1032 let appended = read_append_log(&append_path);
1033 for (ai, entry) in appended.iter().enumerate() {
1034 if entry.depth != depth {
1035 continue;
1036 }
1037 if !entry_visible(entry, opts.active_project, opts.include_global)
1038 || entry.importance < opts.min_importance
1039 {
1040 continue;
1041 }
1042 let dx = entry.x - x;
1043 let dy = entry.y - y;
1044 let dz = entry.z - z;
1045 let dist_sq = dx * dx + dy * dy + dz * dz;
1046 if dist_sq <= radius_sq {
1047 candidates.push((dist_sq, ai + 1_000_000, false));
1048 }
1049 }
1050
1051 candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
1052
1053 let primary = candidates
1054 .first()
1055 .map(|&(dist, idx, is_main)| RadialResult {
1056 block_idx: idx,
1057 dist_sq: dist,
1058 weight: 1.0,
1059 is_main,
1060 });
1061
1062 let neighbors: Vec<RadialResult> = candidates
1063 .iter()
1064 .skip(1)
1065 .take(k.saturating_sub(1))
1066 .map(|&(dist_sq, idx, is_main)| {
1067 let weight = if dist_sq > 0.0001 {
1068 (radius_sq - dist_sq) / radius_sq
1069 } else {
1070 1.0
1071 };
1072 RadialResult {
1073 block_idx: idx,
1074 dist_sq,
1075 weight,
1076 is_main,
1077 }
1078 })
1079 .collect();
1080
1081 let total_within_radius = candidates.len();
1082
1083 ResultSet {
1084 primary,
1085 neighbors,
1086 center: (x, y, z),
1087 depth,
1088 radius,
1089 total_within_radius,
1090 }
1091 }
1092
1093 pub fn find_text_with_options(
1095 &self,
1096 query: &str,
1097 k: usize,
1098 opts: Option<&MemoryQueryOptions>,
1099 ) -> Vec<(u8, usize)> {
1100 let opts = opts.cloned().unwrap_or_default();
1101 let q = query.to_lowercase();
1102 let mut results: Vec<(u8, usize)> = (0..self.block_count)
1103 .into_par_iter()
1104 .filter_map(|i| {
1105 let h = unsafe { self.header_unchecked(i) };
1106 if !h.is_visible_to(opts.active_project, opts.include_global)
1107 || h.importance < opts.min_importance
1108 {
1109 return None;
1110 }
1111 if self.text(i).to_lowercase().contains(&q) {
1112 Some((h.depth, i))
1113 } else {
1114 None
1115 }
1116 })
1117 .collect();
1118
1119 results.sort_by_key(|&(d, _)| d);
1120 results.truncate(k);
1121 results
1122 }
1123
1124 pub fn find_text_all_with_options(
1126 &self,
1127 config: &Config,
1128 query: &str,
1129 k: usize,
1130 opts: Option<&MemoryQueryOptions>,
1131 ) -> Vec<(u8, usize, bool)> {
1132 let opts = opts.cloned().unwrap_or_default();
1133 let q = query.to_lowercase();
1134 let mut results: Vec<(u8, usize, bool)> = self
1135 .find_text_with_options(query, k, Some(&opts))
1136 .into_iter()
1137 .map(|(depth, idx)| (depth, idx, true))
1138 .collect();
1139
1140 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
1141 let appended = read_append_log(&append_path);
1142 results.extend(appended.iter().enumerate().filter_map(|(idx, entry)| {
1143 if !entry_visible(entry, opts.active_project, opts.include_global)
1144 || entry.importance < opts.min_importance
1145 {
1146 return None;
1147 }
1148 entry
1149 .text
1150 .to_lowercase()
1151 .contains(&q)
1152 .then_some((entry.depth, idx + 1_000_000, false))
1153 }));
1154
1155 results.sort_by_key(|&(depth, _, is_main)| (depth, is_main));
1156 results.truncate(k);
1157 results
1158 }
1159}
1160
1161#[allow(dead_code)]
1162pub fn read_append_log(path: &Path) -> Vec<AppendEntry> {
1163 if !path.exists() {
1164 return vec![];
1165 }
1166 let data = fs::read(path).unwrap_or_default();
1167 if data.is_empty() {
1168 return vec![];
1169 }
1170
1171 let mut entries = Vec::new();
1172 let mut pos = 0;
1173
1174 let (is_v2, is_v3) = if data.len() >= 4 {
1175 (&data[0..4] == b"APv2", &data[0..4] == b"APv3")
1176 } else {
1177 (false, false)
1178 };
1179 if is_v2 || is_v3 {
1180 pos = 4;
1181 }
1182
1183 let header_size = if is_v3 {
1186 35
1187 } else if is_v2 {
1188 19
1189 } else {
1190 18
1191 };
1192
1193 while pos + header_size <= data.len() {
1194 let len = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap()) as usize;
1195 let lid = data[pos + 4];
1196 let imp = data[pos + 5];
1197 let depth = if is_v2 || is_v3 { data[pos + 6] } else { 4u8 };
1198
1199 let project_id = if is_v3 {
1200 let mut pid = [0u8; 16];
1201 pid.copy_from_slice(&data[pos + 7..pos + 23]);
1202 ProjectId(pid)
1203 } else {
1204 ProjectId::GLOBAL
1205 };
1206
1207 let coords_start = if is_v3 {
1208 pos + 23
1209 } else if is_v2 {
1210 pos + 7
1211 } else {
1212 pos + 6
1213 };
1214
1215 let x = f32::from_le_bytes(data[coords_start..coords_start + 4].try_into().unwrap());
1216 let y = f32::from_le_bytes(data[coords_start + 4..coords_start + 8].try_into().unwrap());
1217 let z = f32::from_le_bytes(
1218 data[coords_start + 8..coords_start + 12]
1219 .try_into()
1220 .unwrap(),
1221 );
1222 pos += header_size;
1223 if pos + len > data.len() {
1224 break;
1225 }
1226 let text = String::from_utf8_lossy(&data[pos..pos + len]).to_string();
1227 pos += len;
1228 entries.push(AppendEntry {
1229 text,
1230 layer_id: lid,
1231 importance: imp,
1232 depth,
1233 x,
1234 y,
1235 z,
1236 emotion: [0.0f32; 21],
1237 project_id,
1238 });
1239 }
1240 entries
1241}
1242
1243pub fn print_append_result(appended: &[AppendEntry], idx: usize, dist: f32) {
1245 let ai = idx - 1_000_000;
1246 if ai < appended.len() {
1247 let e = &appended[ai];
1248 let layer = LAYER_NAMES.get(e.layer_id as usize).unwrap_or(&"?");
1249 println!(
1250 " {} {} {} {}",
1251 format!("D{}", e.depth).cyan(),
1252 format!("L2={:.5}", dist).yellow(),
1253 format!("[{}/new]", layer).green(),
1254 safe_truncate(&e.text, 70)
1255 );
1256 }
1257}
1258
1259#[derive(Debug, Clone)]
1263pub struct RadialResult {
1264 pub block_idx: usize,
1265 pub dist_sq: f32,
1266 pub weight: f32, pub is_main: bool,
1268}
1269
1270#[derive(Debug)]
1272pub struct ResultSet {
1273 pub primary: Option<RadialResult>,
1274 pub neighbors: Vec<RadialResult>,
1275 pub center: (f32, f32, f32),
1276 pub depth: u8,
1277 pub radius: f32,
1278 pub total_within_radius: usize,
1279}
1280
1281impl ResultSet {
1282 pub fn all(&self) -> Vec<&RadialResult> {
1284 let mut v = Vec::with_capacity(1 + self.neighbors.len());
1285 if let Some(ref p) = self.primary {
1286 v.push(p);
1287 }
1288 v.extend(self.neighbors.iter());
1289 v
1290 }
1291
1292 pub fn block_indices(&self) -> Vec<(u32, f32)> {
1294 self.all()
1295 .iter()
1296 .map(|r| (r.block_idx as u32, r.weight))
1297 .collect()
1298 }
1299}
1300
1301const LOCK_STALE_SECS: u64 = 300;
1305
1306const LOCK_ACQUIRE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
1308
1309pub(crate) struct FileLock {
1310 path: PathBuf,
1311 token: String,
1312}
1313
1314impl FileLock {
1315 pub(crate) fn acquire(config: &Config) -> Result<Self, String> {
1316 let lock_path = Path::new(&config.paths.output_dir).join("microscope.lock");
1317 Self::acquire_inner(&lock_path, LOCK_ACQUIRE_TIMEOUT)
1318 }
1319
1320 fn acquire_inner(lock_path: &Path, timeout: std::time::Duration) -> Result<Self, String> {
1321 let deadline = std::time::Instant::now() + timeout;
1322 loop {
1323 match fs::OpenOptions::new()
1324 .write(true)
1325 .create_new(true)
1326 .open(lock_path)
1327 {
1328 Ok(mut file) => {
1329 let token = lock_token();
1330 if let Err(e) = file.write_all(token.as_bytes()).and_then(|()| file.flush()) {
1331 let _ = fs::remove_file(lock_path);
1333 return Err(format!("lock acquire: write owner token: {}", e));
1334 }
1335 return Ok(FileLock {
1336 path: lock_path.to_path_buf(),
1337 token,
1338 });
1339 }
1340 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
1341 if lock_is_stale(lock_path) {
1342 let _ = fs::remove_file(lock_path);
1345 continue;
1346 }
1347 if std::time::Instant::now() >= deadline {
1348 let mtime = fs::metadata(lock_path)
1349 .and_then(|m| m.modified())
1350 .map(|t| format!("{:?}", t))
1351 .unwrap_or_else(|_| "unknown".to_string());
1352 let content = fs::read_to_string(lock_path)
1353 .unwrap_or_else(|_| "<unreadable>".to_string());
1354 return Err(format!(
1355 "store lock held by {} since {}; remove {} if stale",
1356 content,
1357 mtime,
1358 lock_path.display()
1359 ));
1360 }
1361 std::thread::sleep(std::time::Duration::from_millis(25));
1362 }
1363 Err(e) => return Err(format!("lock acquire: {}", e)),
1364 }
1365 }
1366 }
1367}
1368
1369fn lock_token() -> String {
1372 let unix_ms = std::time::SystemTime::now()
1373 .duration_since(std::time::UNIX_EPOCH)
1374 .map(|d| d.as_millis())
1375 .unwrap_or(0);
1376 format!("{}:{}", std::process::id(), unix_ms)
1377}
1378
1379#[cfg(windows)]
1381fn lock_owner_pid(content: &str) -> Option<u32> {
1382 content.split(':').next()?.trim().parse().ok()
1383}
1384
1385fn lock_is_stale(path: &Path) -> bool {
1387 if let Ok(meta) = fs::metadata(path) {
1390 if let Ok(modified) = meta.modified() {
1391 if let Ok(age) = modified.elapsed() {
1392 if age.as_secs() > LOCK_STALE_SECS {
1393 return true;
1394 }
1395 }
1396 }
1397 }
1398 #[cfg(windows)]
1403 {
1404 if let Ok(content) = fs::read_to_string(path) {
1405 if let Some(pid) = lock_owner_pid(&content) {
1406 return !pid_is_alive(pid);
1407 }
1408 }
1409 }
1410 false
1411}
1412
1413#[cfg(windows)]
1416fn pid_is_alive(pid: u32) -> bool {
1417 use windows_sys::Win32::Foundation::CloseHandle;
1418 use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
1419 unsafe {
1420 let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
1421 if handle == 0 {
1422 return false;
1423 }
1424 CloseHandle(handle);
1425 true
1426 }
1427}
1428
1429impl Drop for FileLock {
1430 fn drop(&mut self) {
1431 match fs::read_to_string(&self.path) {
1435 Ok(content) if content == self.token => {
1436 let _ = fs::remove_file(&self.path);
1437 }
1438 _ => {}
1439 }
1440 }
1441}
1442
1443pub fn parse_imp_marker(entry: &str) -> (&str, u8) {
1449 let text = entry.trim_start();
1450 if let Some(rest) = text.strip_prefix("(imp=") {
1451 if let Some(end) = rest.find(')') {
1452 if let Ok(imp) = rest[..end].parse::<u8>() {
1453 return (rest[end + 1..].trim_start(), imp);
1454 }
1455 }
1456 }
1457 (entry, 5)
1458}
1459
1460fn persist_to_layer_file(
1461 config: &Config,
1462 text: &str,
1463 layer: &str,
1464 importance: u8,
1465) -> Result<(), String> {
1466 let layers_dir = Path::new(&config.paths.layers_dir);
1467 let file_path = layers_dir.join(format!("{}.txt", layer));
1468 let mut content = String::new();
1469 if file_path.exists() {
1470 content = fs::read_to_string(&file_path).map_err(|e| format!("read layer file: {}", e))?;
1471 }
1472 let stamped: String;
1473 let entry_text: &str = if layer == "session" {
1474 let ts = std::time::SystemTime::now()
1475 .duration_since(std::time::UNIX_EPOCH)
1476 .unwrap_or_default()
1477 .as_secs();
1478 let datetime = chrono_stamp(ts);
1479 stamped = format!("(imp={}) [{}] {}", importance, datetime, text);
1480 &stamped
1481 } else {
1482 stamped = format!("(imp={}) {}", importance, text);
1483 &stamped
1484 };
1485 let mut entries: Vec<&str> = content
1486 .split("\n\n")
1487 .filter(|s| !s.trim().is_empty())
1488 .collect();
1489 entries.push(entry_text);
1490 let retention = config.index.layer_retention_entries;
1491 if retention > 0 && entries.len() > retention {
1492 let start = entries.len() - retention;
1493 entries.drain(..start);
1494 }
1495 let result = entries.join("\n\n");
1496 let tmp_path = file_path.with_extension("txt.tmp");
1497 fs::write(&tmp_path, &result).map_err(|e| format!("write layer file: {}", e))?;
1498 fs::rename(&tmp_path, &file_path).map_err(|e| format!("rename layer file: {}", e))?;
1499 Ok(())
1500}
1501
1502fn chrono_stamp(epoch_secs: u64) -> String {
1503 let total_days = epoch_secs / 86400;
1504 let mut y = 1970u64;
1505 let mut remaining = total_days;
1506 loop {
1507 let diy = if is_leap(y) { 366 } else { 365 };
1508 if remaining < diy {
1509 break;
1510 }
1511 remaining -= diy;
1512 y += 1;
1513 }
1514 let leap = is_leap(y);
1515 let mdays = [
1516 31,
1517 if leap { 29 } else { 28 },
1518 31,
1519 30,
1520 31,
1521 30,
1522 31,
1523 31,
1524 30,
1525 31,
1526 30,
1527 31,
1528 ];
1529 let mut mo = 1u64;
1530 for &md in &mdays {
1531 if remaining < md as u64 {
1532 break;
1533 }
1534 remaining -= md as u64;
1535 mo += 1;
1536 }
1537 let day = remaining + 1;
1538 let secs_in_day = epoch_secs % 86400;
1539 let h = secs_in_day / 3600;
1540 let m = (secs_in_day % 3600) / 60;
1541 format!("{:04}-{:02}-{:02} {:02}:{:02}", y, mo, day, h, m)
1542}
1543
1544fn is_leap(y: u64) -> bool {
1545 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
1546}
1547
1548#[derive(Clone, Copy)]
1549enum AppendFormat {
1550 V2,
1551 V3,
1552}
1553
1554fn detect_append_format(path: &Path) -> AppendFormat {
1555 match fs::read(path) {
1556 Ok(data) if data.len() >= 4 => {
1557 if &data[0..4] == b"APv3" {
1558 AppendFormat::V3
1559 } else if &data[0..4] == b"APv2" {
1560 AppendFormat::V2
1561 } else {
1562 AppendFormat::V3
1563 }
1564 }
1565 _ => AppendFormat::V3,
1566 }
1567}
1568
1569#[allow(clippy::too_many_arguments)]
1570fn write_append_entry(
1571 f: &mut fs::File,
1572 lid: u8,
1573 importance: u8,
1574 depth: u8,
1575 project_id: ProjectId,
1576 x: f32,
1577 y: f32,
1578 z: f32,
1579 text_bytes: &[u8],
1580 format: AppendFormat,
1581) -> Result<(), String> {
1582 let write = |f: &mut fs::File, data: &[u8]| -> Result<(), String> {
1583 f.write_all(data)
1584 .map_err(|e| format!("write append log: {}", e))
1585 };
1586 write(f, &(text_bytes.len() as u32).to_le_bytes())?;
1587 write(f, &[lid])?;
1588 write(f, &[importance])?;
1589 write(f, &[depth])?;
1590 if matches!(format, AppendFormat::V3) {
1591 write(f, &project_id.0)?;
1592 }
1593 write(f, &x.to_le_bytes())?;
1594 write(f, &y.to_le_bytes())?;
1595 write(f, &z.to_le_bytes())?;
1596 write(f, text_bytes)?;
1597 Ok(())
1598}
1599
1600pub fn store_memory(
1601 config: &Config,
1602 text: &str,
1603 layer: &str,
1604 importance: u8,
1605) -> Result<(), String> {
1606 store_memory_with_status(config, text, layer, importance, None, None)
1607}
1608
1609pub fn store_memory_with_emotion(
1611 config: &Config,
1612 text: &str,
1613 layer: &str,
1614 importance: u8,
1615 emotion: Option<[f32; 21]>,
1616) -> Result<(), String> {
1617 store_memory_with_status(config, text, layer, importance, None, emotion)
1618}
1619
1620pub fn store_memory_temporary(
1623 config: &Config,
1624 text: &str,
1625 layer: &str,
1626 importance: u8,
1627) -> Result<(), String> {
1628 let _lock = FileLock::acquire(config)?;
1629 let (x, y, z) = content_coords_blended(text, layer, config.search.semantic_weight);
1630 let lid = layer_to_id(layer);
1631 let depth = auto_depth(text);
1632
1633 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
1634 let needs_magic = !append_path.exists()
1635 || fs::metadata(&append_path)
1636 .map(|m| m.len() == 0)
1637 .unwrap_or(true);
1638
1639 let mut file = fs::OpenOptions::new()
1640 .create(true)
1641 .append(true)
1642 .open(&append_path)
1643 .map_err(|e| format!("open append log: {}", e))?;
1644
1645 let format = if needs_magic {
1646 AppendFormat::V3
1647 } else {
1648 detect_append_format(&append_path)
1649 };
1650
1651 if needs_magic {
1652 let magic = match format {
1653 AppendFormat::V2 => b"APv2",
1654 AppendFormat::V3 => b"APv3",
1655 };
1656 file.write_all(magic)
1657 .map_err(|e| format!("write append log: {}", e))?;
1658 }
1659
1660 let text_bytes = text.as_bytes();
1661 let len = text_bytes.len().min(BLOCK_DATA_SIZE);
1662
1663 write_append_entry(
1664 &mut file,
1665 lid,
1666 importance,
1667 depth,
1668 config.project_id,
1669 x,
1670 y,
1671 z,
1672 &text_bytes[..len],
1673 format,
1674 )?;
1675
1676 let output_dir = Path::new(&config.paths.output_dir);
1678 let entry = crate::timeline::TimelineEntry {
1679 ts_ms: crate::timeline::now_epoch_ms(),
1680 layer_id: lid,
1681 importance,
1682 depth,
1683 status: crate::timeline::STATUS_NORMAL,
1684 text: text.to_string(),
1685 };
1686 if let Err(e) = crate::timeline::append_entry(&output_dir.join("timeline.bin"), &entry) {
1687 eprintln!(" {} append timeline: {}", "WARN".yellow(), e);
1688 }
1689
1690 Ok(())
1691}
1692
1693fn embed_appended_entry_with(config: &Config, text: &str, precomputed: Option<Vec<f32>>) {
1712 use crate::embedding_index::{
1713 min_embed_chars, quality_gate, AppendEmbeddings, EmbedVerdict, APPEND_EMBEDDINGS_FILE,
1714 };
1715
1716 let feature_missing = match config.embedding.provider.as_str() {
1719 "candle" => !cfg!(feature = "embeddings"),
1720 "onnx" => !cfg!(feature = "onnx"),
1721 "python" => !cfg!(feature = "python"),
1722 _ => false,
1723 };
1724 if feature_missing {
1725 return;
1726 }
1727
1728 let dim = config.embedding.dim;
1729 if dim == 0 || quality_gate(text, min_embed_chars()) != EmbedVerdict::Keep {
1730 return;
1731 }
1732
1733 let output_dir = Path::new(&config.paths.output_dir);
1734 let append_index = read_append_log(&output_dir.join("append.bin"))
1737 .len()
1738 .saturating_sub(1);
1739
1740 let embedded = match precomputed {
1747 Some(v) => Ok(v),
1748 None => crate::embeddings::with_cached_provider(&config.embedding, dim, |p| p.embed(text)),
1749 };
1750 match embedded {
1751 Ok(v) if v.len() == dim => {
1752 let path = output_dir.join(APPEND_EMBEDDINGS_FILE);
1753 let mut sidecar =
1754 AppendEmbeddings::open(&path, dim).unwrap_or_else(|| AppendEmbeddings::new(dim));
1755 sidecar.entries.retain(|(i, _)| *i as usize != append_index);
1758 sidecar.push(append_index as u32, v);
1759 if let Err(e) = sidecar.save(&path) {
1760 eprintln!(" {} append embedding sidecar: {}", "WARN".yellow(), e);
1761 }
1762 }
1763 Ok(_) => eprintln!(
1764 " {} append embedding width differs from dim {}; stored without a vector",
1765 "WARN".yellow(),
1766 dim
1767 ),
1768 Err(e) => eprintln!(
1769 " {} append embedding: {}; stored without a vector",
1770 "WARN".yellow(),
1771 e
1772 ),
1773 }
1774}
1775
1776pub fn store_memory_with_status(
1777 config: &Config,
1778 text: &str,
1779 layer: &str,
1780 importance: u8,
1781 status: Option<&str>,
1782 emotion: Option<[f32; 21]>,
1783) -> Result<(), String> {
1784 store_memory_with_status_inner(config, text, layer, importance, status, emotion, None)
1785}
1786
1787pub fn store_memory_with_embedding(
1794 config: &Config,
1795 text: &str,
1796 layer: &str,
1797 importance: u8,
1798 embedding: Option<Vec<f32>>,
1799) -> Result<(), String> {
1800 store_memory_with_status_inner(config, text, layer, importance, None, None, embedding)
1801}
1802
1803fn store_memory_with_status_inner(
1804 config: &Config,
1805 text: &str,
1806 layer: &str,
1807 importance: u8,
1808 status: Option<&str>,
1809 emotion: Option<[f32; 21]>,
1810 precomputed: Option<Vec<f32>>,
1811) -> Result<(), String> {
1812 let _lock = FileLock::acquire(config)?;
1813 let t0 = std::time::Instant::now();
1814 let (x, y, z) = content_coords_blended(text, layer, config.search.semantic_weight);
1815 let lid = layer_to_id(layer);
1816 let depth = auto_depth(text);
1817
1818 let append_path = Path::new(&config.paths.output_dir).join("append.bin");
1819
1820 let needs_magic = !append_path.exists()
1821 || fs::metadata(&append_path)
1822 .map(|m| m.len() == 0)
1823 .unwrap_or(true);
1824
1825 let mut file = fs::OpenOptions::new()
1826 .create(true)
1827 .append(true)
1828 .open(&append_path)
1829 .map_err(|e| format!("open append log: {}", e))?;
1830
1831 let format = if needs_magic {
1832 AppendFormat::V3
1833 } else {
1834 detect_append_format(&append_path)
1835 };
1836
1837 if needs_magic {
1838 let magic = match format {
1839 AppendFormat::V2 => b"APv2",
1840 AppendFormat::V3 => b"APv3",
1841 };
1842 file.write_all(magic)
1843 .map_err(|e| format!("write append log: {}", e))?;
1844 }
1845
1846 let text_bytes = text.as_bytes();
1847 let len = text_bytes.len().min(BLOCK_DATA_SIZE);
1848
1849 write_append_entry(
1850 &mut file,
1851 lid,
1852 importance,
1853 depth,
1854 config.project_id,
1855 x,
1856 y,
1857 z,
1858 &text_bytes[..len],
1859 format,
1860 )?;
1861 file.flush()
1862 .map_err(|e| format!("flush append log: {}", e))?;
1863
1864 embed_appended_entry_with(config, text, precomputed);
1868
1869 if let Err(e) = persist_to_layer_file(config, text, layer, importance) {
1870 eprintln!(" {} persist to layer file: {}", "WARN".yellow(), e);
1871 }
1872
1873 let output_dir = Path::new(&config.paths.output_dir);
1875 let timeline_status = match status.unwrap_or("normal") {
1876 "open" => crate::timeline::STATUS_OPEN,
1877 "resolved" => crate::timeline::STATUS_RESOLVED,
1878 "archived" => crate::timeline::STATUS_ARCHIVED,
1879 _ => crate::timeline::STATUS_NORMAL,
1880 };
1881 let entry = crate::timeline::TimelineEntry {
1882 ts_ms: crate::timeline::now_epoch_ms(),
1883 layer_id: lid,
1884 importance,
1885 depth,
1886 status: timeline_status,
1887 text: text.to_string(),
1888 };
1889 if let Err(e) = crate::timeline::append_entry(&output_dir.join("timeline.bin"), &entry) {
1890 eprintln!(" {} append timeline: {}", "WARN".yellow(), e);
1891 }
1892
1893 if status == Some("open") {
1895 match crate::open_loops::append_open(output_dir, text, importance) {
1896 Ok(loop_id) => {
1897 println!(" {} loop_id={}", "LOOP".cyan().bold(), loop_id);
1898 }
1899 Err(e) => {
1900 eprintln!(" {} open loop: {}", "WARN".yellow(), e);
1901 }
1902 }
1903 }
1904
1905 if let Some(emo) = emotion {
1910 if let Err(e) = append_emotion_log(output_dir, text, &emo) {
1911 eprintln!(" {} append emotion log: {}", "WARN".yellow(), e);
1912 }
1913 }
1914
1915 drop(file);
1918 drop(_lock);
1919
1920 match crate::build::maybe_auto_rebuild(config) {
1921 Ok(Some(count)) => println!(
1922 " {} {} append entries consolidated",
1923 "AUTO-REBUILD".cyan().bold(),
1924 count
1925 ),
1926 Ok(None) => {}
1927 Err(e) => eprintln!(" {} auto-rebuild: {}", "WARN".yellow(), e),
1928 }
1929
1930 let elapsed = t0.elapsed();
1931 println!(
1932 " {} D{} [{}/{}] ({:.3},{:.3},{:.3}) {}",
1933 "STORED".green().bold(),
1934 depth,
1935 layer,
1936 layer_color(lid),
1937 x,
1938 y,
1939 z,
1940 safe_truncate(text, 60)
1941 );
1942 if timeline_status != crate::timeline::STATUS_NORMAL {
1943 let label = match timeline_status {
1944 crate::timeline::STATUS_OPEN => "open",
1945 crate::timeline::STATUS_RESOLVED => "resolved",
1946 crate::timeline::STATUS_ARCHIVED => "archived",
1947 _ => "?",
1948 };
1949 println!(" {} status={}", "TIMELINE".cyan().bold(), label);
1950 }
1951 println!(" {} ns", elapsed.as_nanos());
1952 Ok(())
1953}
1954
1955pub const EMOTION_VECTOR_SIZE: usize = 21;
1957
1958pub const EMOTION_DIMS: &[&str] = &[
1960 "joy",
1961 "sadness",
1962 "anger",
1963 "fear",
1964 "surprise",
1965 "disgust",
1966 "trust",
1967 "anticipation",
1968 "love",
1969 "gratitude",
1970 "curiosity",
1971 "confusion",
1972 "pride",
1973 "shame",
1974 "anxiety",
1975 "calm",
1976 "excitement",
1977 "boredom",
1978 "hope",
1979 "regret",
1980 "empathy",
1981];
1982
1983pub fn emotional_similarity(a: &[f32; 21], b: &[f32; 21]) -> f32 {
1985 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
1986 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
1987 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
1988 if na < 1e-10 || nb < 1e-10 {
1989 return 0.0;
1990 }
1991 (dot / (na * nb)).clamp(0.0, 1.0)
1992}
1993
1994type EmotionLookup = Box<dyn Fn(usize) -> Option<[f32; 21]>>;
1995
1996pub fn load_emotion_lookup(output_dir: &Path) -> Option<EmotionLookup> {
1999 let path = output_dir.join("emotions.bin");
2000 if !path.exists() {
2001 return None;
2002 }
2003 let data = fs::read(&path).ok()?;
2004 let entry_size = 21 * 4; let count = data.len() / entry_size;
2006 Some(Box::new(move |idx: usize| {
2007 if idx >= count {
2008 return None;
2009 }
2010 let off = idx * entry_size;
2011 if off + entry_size > data.len() {
2012 return None;
2013 }
2014 let mut emo = [0.0f32; 21];
2015 for (i, e) in emo.iter_mut().enumerate() {
2016 let bytes: [u8; 4] = data[off + i * 4..off + i * 4 + 4].try_into().ok()?;
2017 *e = f32::from_le_bytes(bytes);
2018 }
2019 Some(emo)
2020 }))
2021}
2022
2023pub fn write_emotions_batch(path: &Path, updates: &[(usize, [f32; 21])]) -> Result<(), String> {
2031 if updates.is_empty() {
2032 return Ok(());
2033 }
2034 let entry_size = 21 * 4;
2035 let max_idx = updates.iter().map(|(i, _)| *i).max().unwrap_or(0);
2036 let needed = (max_idx + 1) * entry_size;
2037 let mut data = if path.exists() {
2038 fs::read(path).map_err(|e| format!("read emotions.bin: {}", e))?
2039 } else {
2040 Vec::new()
2041 };
2042 if data.len() < needed {
2043 data.resize(needed, 0u8);
2044 }
2045 for &(idx, emo) in updates {
2046 let off = idx * entry_size;
2047 if off + entry_size > data.len() {
2048 return Err(format!("emotion index {} out of range", idx));
2049 }
2050 for (i, v) in emo.iter().enumerate() {
2051 data[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes());
2052 }
2053 }
2054 let tmp_path = path.with_extension("bin.tmp");
2055 fs::write(&tmp_path, &data).map_err(|e| format!("write emotions.bin: {}", e))?;
2056 fs::rename(&tmp_path, path).map_err(|e| format!("rename emotions.bin: {}", e))
2057}
2058
2059pub fn write_emotion(path: &Path, block_idx: usize, emotion: &[f32; 21]) -> Result<(), String> {
2062 let entry_size = 21 * 4;
2063 let needed = (block_idx + 1) * entry_size;
2064 let mut data = if path.exists() {
2065 fs::read(path).map_err(|e| format!("read emotions.bin: {}", e))?
2066 } else {
2067 Vec::new()
2068 };
2069 if data.len() < needed {
2070 data.resize(needed, 0u8);
2071 }
2072 let off = block_idx * entry_size;
2073 for i in 0..21 {
2074 data[off + i * 4..off + i * 4 + 4].copy_from_slice(&emotion[i].to_le_bytes());
2075 }
2076 let tmp_path = path.with_extension("bin.tmp");
2078 fs::write(&tmp_path, &data).map_err(|e| format!("write emotions.bin: {}", e))?;
2079 fs::rename(&tmp_path, path).map_err(|e| format!("rename emotions.bin: {}", e))?;
2080 Ok(())
2081}
2082
2083pub fn append_emotion_log(
2086 output_dir: &Path,
2087 text: &str,
2088 emotion: &[f32; 21],
2089) -> Result<(), String> {
2090 let path = output_dir.join("emotion_log.bin");
2091 let mut file = fs::OpenOptions::new()
2092 .create(true)
2093 .append(true)
2094 .open(&path)
2095 .map_err(|e| format!("open emotion_log.bin: {}", e))?;
2096 let ts = std::time::SystemTime::now()
2097 .duration_since(std::time::UNIX_EPOCH)
2098 .unwrap_or_default()
2099 .as_millis() as u64;
2100 file.write_all(&ts.to_le_bytes())
2101 .map_err(|e| format!("write emotion_log ts: {}", e))?;
2102 for &v in emotion {
2103 file.write_all(&v.to_le_bytes())
2104 .map_err(|e| format!("write emotion_log value: {}", e))?;
2105 }
2106 let text_bytes = text.as_bytes();
2107 let len = text_bytes.len().min(4096) as u32;
2108 file.write_all(&len.to_le_bytes())
2109 .map_err(|e| format!("write emotion_log text_len: {}", e))?;
2110 file.write_all(&text_bytes[..len as usize])
2111 .map_err(|e| format!("write emotion_log text: {}", e))?;
2112 Ok(())
2113}
2114
2115pub fn build_emotions_from_log(output_dir: &Path, reader: &MicroscopeReader) -> Result<(), String> {
2118 let log_path = output_dir.join("emotion_log.bin");
2119 if !log_path.exists() {
2120 return Ok(());
2121 }
2122 let data = fs::read(&log_path).map_err(|e| format!("read emotion_log.bin: {}", e))?;
2123 let entry_size = 8 + 21 * 4 + 4; let mut emotions = vec![[0.0f32; 21]; reader.block_count];
2125 let mut i = 0;
2126 while i + entry_size <= data.len() {
2127 let ts_bytes: [u8; 8] = data[i..i + 8].try_into().unwrap();
2128 let _ts = u64::from_le_bytes(ts_bytes);
2129 i += 8;
2130 let mut emo = [0.0f32; 21];
2131 for e in emo.iter_mut() {
2132 let bytes: [u8; 4] = data[i..i + 4].try_into().unwrap();
2133 *e = f32::from_le_bytes(bytes);
2134 i += 4;
2135 }
2136 let text_len = u32::from_le_bytes(data[i..i + 4].try_into().unwrap()) as usize;
2137 i += 4;
2138 let text_end = (i + text_len).min(data.len());
2139 let _text = String::from_utf8_lossy(&data[i..text_end]).to_string();
2140 i = text_end;
2141
2142 let (tx, ty, tz) = crate::content_coords(&_text, "emotional");
2144 let mut best_dist = f32::MAX;
2145 let mut best_idx = 0;
2146 for bi in 0..reader.block_count {
2147 let h = reader.header(bi);
2148 let dx = h.x - tx;
2149 let dy = h.y - ty;
2150 let dz = h.z - tz;
2151 let d = dx * dx + dy * dy + dz * dz;
2152 if d < best_dist {
2153 best_dist = d;
2154 best_idx = bi;
2155 }
2156 }
2157 if best_dist < 0.1 {
2158 emotions[best_idx] = emo;
2159 }
2160 }
2161 let emo_path = output_dir.join("emotions.bin");
2163 let mut out = Vec::with_capacity(emotions.len() * 21 * 4);
2164 for emo in &emotions {
2165 for &v in emo {
2166 out.extend_from_slice(&v.to_le_bytes());
2167 }
2168 }
2169 let emo_tmp = output_dir.join("emotions.bin.tmp");
2170 fs::write(&emo_tmp, &out).map_err(|e| format!("write emotions.bin: {}", e))?;
2171 fs::rename(&emo_tmp, &emo_path).map_err(|e| format!("rename emotions.bin: {}", e))?;
2172 Ok(())
2173}
2174
2175pub fn format_emotion(emotion: &[f32; 21]) -> String {
2177 let mut parts: Vec<String> = Vec::new();
2178 for (i, label) in EMOTION_DIMS.iter().enumerate() {
2179 if i < emotion.len() && emotion[i] > 0.1 {
2180 parts.push(format!("{}={:.2}", label, emotion[i]));
2181 }
2182 }
2183 if parts.is_empty() {
2184 "neutral".to_string()
2185 } else {
2186 parts.join(", ")
2187 }
2188}
2189
2190#[cfg(test)]
2191mod tests {
2192 use super::*;
2193 use std::time::Duration;
2194
2195 #[test]
2196 fn lock_token_contains_pid_and_timestamp() {
2197 let token = lock_token();
2198 let mut parts = token.splitn(2, ':');
2199 let pid = parts.next().unwrap().parse::<u32>().unwrap();
2200 assert_eq!(pid, std::process::id());
2201 let ms = parts.next().unwrap().parse::<u128>().unwrap();
2202 assert!(ms > 0);
2203 }
2204
2205 #[test]
2206 fn acquire_releases_on_temp_dir() {
2207 let dir = tempfile::tempdir().unwrap();
2208 let path = dir.path().join("microscope.lock");
2209 {
2210 let lock = FileLock::acquire_inner(&path, Duration::from_secs(2)).unwrap();
2211 assert!(path.exists());
2212 assert_eq!(fs::read_to_string(&path).unwrap(), lock.token);
2213 }
2214 assert!(!path.exists(), "drop must remove our own lock");
2215 }
2216
2217 #[test]
2218 fn live_lock_errors_in_bounded_time() {
2219 let dir = tempfile::tempdir().unwrap();
2220 let path = dir.path().join("microscope.lock");
2221 fs::write(&path, lock_token()).unwrap();
2222 let t0 = std::time::Instant::now();
2223 match FileLock::acquire_inner(&path, Duration::from_millis(300)) {
2224 Ok(_) => panic!("a fresh live lock must not be stolen"),
2225 Err(err) => {
2226 assert!(
2227 t0.elapsed() < Duration::from_secs(5),
2228 "acquire must give up within the timeout"
2229 );
2230 assert!(
2231 err.contains("microscope.lock"),
2232 "error should mention the lock path: {}",
2233 err
2234 );
2235 }
2236 }
2237 }
2238
2239 #[cfg(windows)]
2240 #[test]
2241 fn dead_owner_pid_is_broken() {
2242 let dir = tempfile::tempdir().unwrap();
2243 let path = dir.path().join("microscope.lock");
2244 fs::write(&path, "4294967295:0").unwrap();
2245 let lock = FileLock::acquire_inner(&path, Duration::from_secs(2)).unwrap();
2246 assert_eq!(fs::read_to_string(&path).unwrap(), lock.token);
2247 }
2248
2249 #[test]
2250 fn parse_imp_marker_extracts_importance_and_strips_marker() {
2251 assert_eq!(
2252 parse_imp_marker("(imp=7) döntés rögzítve"),
2253 ("döntés rögzítve", 7)
2254 );
2255 assert_eq!(
2256 parse_imp_marker("(imp=8) [2026-08-02 10:00] identitás"),
2257 ("[2026-08-02 10:00] identitás", 8)
2258 );
2259 assert_eq!(
2260 parse_imp_marker("régi bejegyzés marker nélkül"),
2261 ("régi bejegyzés marker nélkül", 5)
2262 );
2263 assert_eq!(
2264 parse_imp_marker("(imp=12) túl magas érték"),
2265 ("túl magas érték", 12)
2266 );
2267 }
2268
2269 #[test]
2270 fn header_reads_valid_index_and_panics_out_of_bounds() {
2271 let dir = tempfile::tempdir().unwrap();
2272 let hdr_file = dir.path().join("microscope.bin");
2273 fs::write(&hdr_file, vec![0u8; HEADER_SIZE]).unwrap();
2274 let dat_file = dir.path().join("data.bin");
2275 fs::write(&dat_file, vec![0u8; 1]).unwrap();
2276 let map = unsafe { memmap2::Mmap::map(&std::fs::File::open(&hdr_file).unwrap()) }.unwrap();
2277 let dmap = unsafe { memmap2::Mmap::map(&std::fs::File::open(&dat_file).unwrap()) }.unwrap();
2278 let reader = MicroscopeReader {
2279 headers: map,
2280 data: DataStore::Mmap(dmap),
2281 block_count: 1,
2282 header_stride: HEADER_SIZE,
2283 depth_ranges: [(0, 0); 9],
2284 text_index: None,
2285 append_log: Vec::new(),
2286 append_log_stamp: None,
2287 };
2288
2289 let h = reader.header(0);
2291 assert_eq!(h.depth, 0);
2292
2293 let result = std::panic::catch_unwind(|| {
2295 let _ = reader.header(1);
2296 });
2297 assert!(result.is_err(), "out-of-bounds header read must panic");
2298 }
2299}
2300
2301pub fn relevance_score(query: &str, query_terms: &[&str], text_lower: &str, importance: u8) -> f32 {
2312 if text_lower.is_empty() {
2313 return 0.0;
2314 }
2315
2316 let phrase = if text_lower.contains(query) {
2317 1.0
2318 } else if query_terms.is_empty() {
2319 0.5
2320 } else {
2321 let hits = query_terms
2322 .iter()
2323 .filter(|t| text_lower.contains(**t))
2324 .count();
2325 hits as f32 / query_terms.len() as f32 * 0.7
2326 };
2327
2328 let imp = (importance.min(10) as f32) / 10.0 * 0.15;
2329 let len = text_lower.chars().count() as f32;
2330 let spec = (query.chars().count() as f32 / len.max(1.0)).min(1.0);
2331
2332 phrase * 0.65 + imp + spec * 0.20
2333}
2334
2335#[cfg(test)]
2336mod relevance_tests {
2337 use super::relevance_score;
2338
2339 fn score(query: &str, text: &str, importance: u8) -> f32 {
2340 let q = query.to_lowercase();
2341 let terms: Vec<&str> = q.split_whitespace().filter(|t| t.len() > 1).collect();
2342 relevance_score(&q, &terms, &text.to_lowercase(), importance)
2343 }
2344
2345 #[test]
2346 fn exact_phrase_beats_incidental_mention() {
2347 let exact = score("di oallergia", "A felhasznalonak di oallergiaja van", 5);
2348 let noisy = score(
2349 "di oallergia",
2350 "Ez egy hosszu, teljesen mas tartalmu blokk, amelyik veletlenul szot emlit arról, hogy a felhasznalo tegnap elment egy orvoshoz, es kozben sok mas dolog is tortent.",
2351 5,
2352 );
2353 assert!(exact > noisy, "exact {exact} should beat noisy {noisy}");
2354 }
2355
2356 #[test]
2357 fn importance_breaks_ties() {
2358 let important = score("allergy", "dioallergia", 9);
2359 let trivial = score("allergy", "dioallergia", 1);
2360 assert!(important > trivial);
2361 }
2362
2363 #[test]
2364 fn short_block_outranks_long_block_with_same_term() {
2365 let short = score("allergy", "dioallergia", 5);
2366 let long = score(
2367 "allergy",
2368 "dioallergia, tovabba sok mas irrelevans informacio, ami hosszabbava teszi ezt a blokkot es elnyomja a relevanciat",
2369 5,
2370 );
2371 assert!(short > long, "short {short} should beat long {long}");
2372 }
2373
2374 #[test]
2375 fn full_phrase_beats_partial_term_match() {
2376 let full = score("pine nut allergy", "The user has a pine nut allergy.", 5);
2377 let partial = score("pine nut allergy", "The user mentioned pine once.", 5);
2378 assert!(full > partial);
2379 }
2380}