Skip to main content

microscope_memory/
reader.rs

1//! MicroscopeReader — high-performance memory-mapped reader for the binary index.
2
3use 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/// Allocation-free, case-insensitive substring test.
22///
23/// `needle` MUST already be lowercased (via `str::to_lowercase`).
24///
25/// **Measured, and rejected for the text-search hot path** — see
26/// `tests/contains_ci.rs::ab_contains_ci_vs_tolowercase`. On a 23,000-block
27/// corpus this costs ~4.9 µs per block against ~0.9 µs for the idiomatic
28/// `hay.to_lowercase().contains(q)`. The standard search is a SIMD two-way
29/// scan and beats a hand-rolled byte loop by roughly 5×, so the per-block
30/// temporary it saves costs far more than the temporary itself. The text
31/// search therefore still uses `to_lowercase()` + `contains()`; this function
32/// is kept as a tested utility for callers that genuinely cannot allocate
33/// (a `no_std` target, or a caller already holding a lowercased needle that
34/// must avoid a second temporary).
35///
36/// Correctness is that of `haystack.to_lowercase().contains(needle)`, checked
37/// exhaustively over every substring of a Hungarian fixture by the test suite.
38#[inline]
39pub fn contains_ci(needle: &str, haystack: &str) -> bool {
40    if needle.is_empty() {
41        return true;
42    }
43    // ASCII fast path. Valid UTF-8 never carries an ASCII byte inside a
44    // multi-byte sequence (continuation bytes are 0x80..=0xBF), so an ASCII
45    // byte match can only ever begin on a char boundary. That makes a plain
46    // byte-wise comparison exact — no decoding, no allocation.
47    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    // Non-ASCII needle: char-wise, still allocation-free.
72    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
83/// (byte length, modified time) of the append log, used by the reader's cache
84/// validation. `None` when the file does not exist or cannot be stat'd.
85fn 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/// Does `needle` match `haystack` starting exactly at byte offset `start`?
91/// Both sides are compared with `char::to_lowercase()` semantics.
92#[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                // `ToLowercase` is an iterator; `.eq()` compares them element-wise
104                // without allocating a String.
105                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
159/// Backing store for block data — either memory-mapped or decompressed in-memory.
160pub enum DataStore {
161    /// Normal mmap path (uncompressed data.bin)
162    Mmap(memmap2::Mmap),
163    /// Decompressed data held in memory (from data.bin.zst)
164    #[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
179/// High-performance memory-mapped reader for the Microscope index.
180pub 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    /// Hot append log, loaded at open. Cached because the combined ranked
188    /// search reads it on every query and it only changes on append.
189    ///
190    /// `append_log_stamp` is the (len, mtime) of the file the cache was built
191    /// from. The ranked search re-validates it against the file before use:
192    /// one `metadata()` call (~1 µs) instead of a full re-read (~230 µs), so a
193    /// reader held across stores still sees new appends. See
194    /// `find_text_all_ranked`.
195    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        // Safety: microscope.bin is read-only and will remain valid for the lifetime of MicroscopeReader
251        let headers =
252            unsafe { memmap2::Mmap::map(&hdr_file).map_err(|e| format!("mmap headers: {}", e))? };
253
254        // meta.bin states how many headers there should be; the mapping is how
255        // many there actually are. Nothing else connects the two, and the
256        // precondition on `header_unchecked` is only checked against the claimed
257        // count, so a header file truncated after the build would be read past
258        // the end of the mapping. Refuse here, where the error can name the
259        // cause, rather than there, where it is undefined behaviour.
260        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        // Red Audit: Stability check for headers mmap
278        #[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                // Safety: data.bin is read-only and will remain valid for the lifetime of MicroscopeReader
306                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            // Safety: data.bin is read-only and will remain valid for the lifetime of MicroscopeReader
317            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        // The hot append log, read once here instead of once per query.
325        // `find_text_all_ranked` used to `fs::read` this file on every call,
326        // which made the combined search pay a full file read (and parse) per
327        // query even though it changes only when a block is appended.
328        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    /// Verifies that the mmap'ed memory is indeed readable and not guarded.
345    #[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    /// Read the header for block `i` without a bounds check.
379    ///
380    /// # Safety
381    /// `i` must be strictly less than `self.block_count`. Violating this reads
382    /// outside the mmap region, which is undefined behavior.
383    #[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            // Legacy MSC3 header: 32 bytes, no project/importance/flags fields.
391            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        // Red Audit: Basic bounds and null-check sanitization
432        if end > self.data.len() || start >= end {
433            return "[out of bounds]";
434        }
435
436        let raw = &self.data[start..end];
437
438        // Anti-Analysis: Ensure no suspicious control characters
439        std::str::from_utf8(raw).unwrap_or("<bin>")
440    }
441
442    /// The MICROSCOPE: exact depth + spatial L2 search.
443    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    /// 4D soft zoom search with SIMD.
489    #[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    /// Radial search: find all blocks within `radius` of (x, y, z) at a specific depth.
531    /// Returns a ResultSet with the closest match as primary and neighbors distance-weighted.
532    #[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        // SIMD-accelerated radial scan within depth band
548        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); // no zoom weight for radial
555                    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        // Include append log entries at the same depth
567        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        // Build ResultSet
585        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                // Weight: inverse distance, normalized so closest neighbor = 1.0
600                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    /// Blocks to examine for `query_lower`, in ascending index order.
627    ///
628    /// When the inverted text index is present and was built over the same
629    /// block count, only its candidate blocks are scanned; otherwise every
630    /// block is.
631    ///
632    /// The index is a *prefilter*: it returns a superset of the blocks whose
633    /// text contains `query_lower`, so the caller still runs the exact
634    /// `to_lowercase().contains()` predicate. That keeps the result set
635    /// identical to a full scan — this is what makes the shortcut safe rather
636    /// than approximate.
637    ///
638    /// Ascending order is deliberate: `find_text` sorts its hits by depth with
639    /// a *stable* sort, so equal-depth ties break on scan order. Scanning the
640    /// candidates in ascending index order reproduces the exact ordering the
641    /// full scan produced.
642    fn candidate_blocks(&self, query_lower: &str) -> Vec<usize> {
643        if let Some(idx) = &self.text_index {
644            // A stale index (built over a different block count) must not be
645            // used, or it would silently drop blocks added since.
646            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    /// Text search
658    ///
659    /// The idiom here is `to_lowercase()` + `contains()` on purpose. The
660    /// allocation-free `contains_ci` alternative was implemented and measured
661    /// at ~5× slower (see its doc comment and `tests/contains_ci.rs`); the
662    /// standard search is SIMD two-way and the per-block temporary is not the
663    /// bottleneck it looks like. The win for this function comes from the
664    /// inverted index (see `candidate_blocks`), not from the predicate.
665    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    /// Text search ranked by relevance rather than by depth.
685    ///
686    /// The previous behaviour sorted by `depth` and truncated, so a shallow
687    /// noise fragment always outranked the exact fact the user asked for. This
688    /// scores each match on (a) how precisely it matches the query, (b) the
689    /// block's recorded importance, and (c) specificity (a short block that is
690    /// mostly the query is a stronger hit than a long block that merely
691    /// contains it), then drops near-tied noise via a relative score floor.
692    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        // Rank by score, then apply the k limit. There is deliberately NO
717        // relative score floor here: an earlier version dropped everything
718        // below 45% of the best hit, which also removed legitimate partial
719        // matches -- the exact situation a relevance ranking is meant to fix.
720        // Recall must not shrink; only the order may change.
721        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    /// Re-read the hot append log from disk.
731    ///
732    /// The log is cached at open because the ranked search reads it on every
733    /// query. Explicit refresh is available for callers that store and search
734    /// through the same reader and cannot tolerate the automatic validation
735    /// heuristic below (see `find_text_all_ranked`).
736    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    /// Cheap staleness check for the cached append log: `metadata()` is one
743    /// syscall, a re-read plus parse is ~230 µs. Both the byte length and the
744    /// modification time are compared, so a rewritten (not merely extended)
745    /// file is caught too.
746    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            // File appeared or vanished since the cache was built.
758            (Some(_), None) | (None, Some(_)) => true,
759            (None, None) => false,
760        }
761    }
762
763    /// Ranked text search across the main index and the hot append log.
764    ///
765    /// The append log is cached on the reader rather than re-read per query.
766    /// The cache validates itself against `append.bin`'s (len, mtime) on every
767    /// call — one `metadata()` syscall instead of a ~230 µs re-read — so a
768    /// reader held across stores still sees entries appended since it was
769    /// opened, and the result is always the same as a fresh reader's.
770    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        // `config` is part of the public signature (call sites pass the live
777        // config); the append path comes from it, so keep the argument even
778        // though the cache now owns the read.
779        let q = query.to_lowercase();
780        let q_terms: Vec<&str> = q.split_whitespace().filter(|t| t.len() > 1).collect();
781
782        // Refresh only when the file moved. A reader opened before the first
783        // store pays exactly one extra read for its whole life.
784        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        // Sort only. No relative score floor: dropping low-scoring matches
804        // shrinks recall, which is the opposite of what ranking should do.
805        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    /// Text search across both the immutable main index and the hot append log.
817    /// Append entries use virtual indices starting at 1_000_000.
818    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    /// Print one result. The full block text is shown: a 256-byte block fits on
842    /// one line, and truncating at 70 characters could hide the very fact the
843    /// user searched for. `preview_limit` can still cap it for narrow terminals.
844    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// ─── APPEND LOG ──────────────────────────────────────
870
871#[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    /// Project-scoped variant of `look`.
878    #[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    /// Project-scoped variant of `look_soft`.
937    #[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    /// Project-scoped variant of `radial_search`.
991    #[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); // no zoom weight for radial
1020                    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    /// Project-scoped variant of `find_text`.
1094    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    /// Project-scoped variant of `find_text_all`.
1125    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    // v2: len(4)+lid(1)+imp(1)+depth(1)+coords(12) = 19
1184    // v3: len(4)+lid(1)+imp(1)+depth(1)+project_id(16)+coords(12) = 35
1185    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
1243/// Display a single append-log result entry.
1244pub 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// ─── RADIAL SEARCH TYPES ─────────────────────────────
1260
1261/// A single result from radial search.
1262#[derive(Debug, Clone)]
1263pub struct RadialResult {
1264    pub block_idx: usize,
1265    pub dist_sq: f32,
1266    pub weight: f32, // 1.0 = primary, decays with distance for neighbors
1267    pub is_main: bool,
1268}
1269
1270/// ResultSet from radial search: primary hit + distance-weighted neighbors.
1271#[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    /// All results (primary + neighbors) as a flat list.
1283    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    /// Block indices of all results (for Hebbian co-activation).
1293    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
1301/// A lock file is considered stale when untouched for this many seconds,
1302/// regardless of content (covers lock files from older versions that carry no
1303/// owner token at all).
1304const LOCK_STALE_SECS: u64 = 300;
1305
1306/// Total time we are willing to wait for a live lock before giving up.
1307const 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                        // Never leave a partially-owned lock behind.
1332                        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                        // Another process may race us to remove and recreate the
1343                        // file; just retry create_new after the removal.
1344                        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
1369/// Ownership token: "<pid>:<unix milliseconds>". pid:unix_ms is unique enough
1370/// for the lifetime of a lock file; no random suffix is needed.
1371fn 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/// First field (before ':') of a lock token, when present and numeric.
1380#[cfg(windows)]
1381fn lock_owner_pid(content: &str) -> Option<u32> {
1382    content.split(':').next()?.trim().parse().ok()
1383}
1384
1385/// Returns true when the existing lock file can safely be broken.
1386fn lock_is_stale(path: &Path) -> bool {
1387    // Primary check: file age. A lock untouched for LOCK_STALE_SECS is stale
1388    // even if it has no readable owner token.
1389    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    // Secondary check (Windows only): the recorded owner PID is no longer
1399    // alive. On non-Windows platforms we deliberately rely on the mtime check
1400    // alone: std has no portable process-existence probe and adding libc just
1401    // for this would be a new dependency.
1402    #[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/// Windows-only process liveness probe using the already-available
1414/// windows-sys dependency (OpenProcess with the limited query right).
1415#[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        // Only delete the lock if it still carries OUR token. If another
1432        // process re-acquired the lock after we released it, removing the file
1433        // would break their mutual exclusion, so leave it alone.
1434        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
1443/// Parse a leading `(imp=N)` marker from a layer entry.
1444///
1445/// Layer entries carry their importance as a leading `(imp=N)` marker. Legacy
1446/// entries without the marker default to importance 5. Returns the entry text
1447/// without the marker and the parsed importance.
1448pub 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
1609/// Variant with emotion vector.
1610pub 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
1620/// Store memory to append log and timeline only (NOT to layer files).
1621/// Used for temporary/internal thoughts that should not persist through rebuilds.
1622pub 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    // Timeline log (always)
1677    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
1693/// Variant of `store_memory` that also writes to the timeline log and,
1694/// optionally, marks the entry as an open loop (status="open").
1695/// Embed one freshly stored memory and record it in the append-embeddings
1696/// sidecar under its append index, so the semantic path can reach it before the
1697/// next rebuild. Best effort by design: the memory is already durably in the
1698/// append log, and a provider that will not load or a write failure must never
1699/// turn a successful store into an error.
1700///
1701/// Admission uses the same `quality_gate` as the index build, so a fresh memory
1702/// cannot slip in under a looser rule than the one that built the index -- that
1703/// is how the 17..23 character crowding would come straight back.
1704/// Embed the stored text and record it in the append-embeddings sidecar.
1705///
1706/// `precomputed` short-circuits the inference for a caller that already
1707/// embedded the text -- the MCP recall embeds its nine associative links up
1708/// front, in parallel. The gate below still decides admission, so a
1709/// precomputed vector can never smuggle a fragment past the same policy the
1710/// build applies.
1711fn 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    // A provider whose cargo feature is not compiled in would fail on every
1717    // store; skip quietly rather than warn once per memory.
1718    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    // The entry just written is the last one. The log is append-only and the
1735    // caller still holds the file lock, so this position is stable.
1736    let append_index = read_append_log(&output_dir.join("append.bin"))
1737        .len()
1738        .saturating_sub(1);
1739
1740    // Cached provider: one MCP recall writes up to a dozen associative links
1741    // (the top-3 x top-5 keyword pairs in mcp.rs), and each write embedded its
1742    // text with a freshly constructed provider -- a MiniLM construction per
1743    // stored link, which is most of the 4.4 s an MCP call was taking. The
1744    // cache is keyed by (provider, model, dimension), so the behaviour is the
1745    // same and the model is built once per process.
1746    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            // One vector per append index: if a repaired log puts a different
1756            // memory at a position, the newer vector replaces the stale one.
1757            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
1787/// Store a memory whose embedding the caller has already computed.
1788///
1789/// The vector must be the one this store would have produced; it is only a
1790/// shortcut for callers that embed several texts at once. Passing a vector for
1791/// text that the quality gate rejects stores the memory without one, exactly
1792/// as an inline inference would.
1793pub 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    // Append-log vector: embed the stored text once so the semantic path can
1865    // reach it before the next rebuild (see `embed_appended_entry_with`). Done
1866    // before the layer-file write so a slow provider cannot delay persistence.
1867    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    // ─── Timeline log (always) ────────────────────────────
1874    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    // ─── Open loops (only when status=open) ────────────────
1894    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    // ─── Emotion log (when provided) ─────────────────────────
1906    // Previously the emotion vector was accepted but never written anywhere,
1907    // silently dropping all 21D emotion data. Now we persist it to emotion_log.bin
1908    // (rebuilt into emotions.bin during `build_emotions_from_log`).
1909    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    // Release the writer and process lock before a possible rebuild. The
1916    // rebuild helper acquires the same lock and rechecks the threshold.
1917    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
1955// ¦¦¦ Emotion constants ¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦¦
1956pub const EMOTION_VECTOR_SIZE: usize = 21;
1957
1958/// Emotion dimension labels for the 21D emotion vector.
1959pub 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
1983/// Cosine similarity between two 21D emotion vectors.
1984pub 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
1996/// Load emotions.bin and return a lookup closure.
1997/// emotions.bin format: flat array of [f32; 21] per block index.
1998pub 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; // 21 f32 values, 4 bytes each
2005    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
2023/// Write many block emotions with a single read-modify-write.
2024///
2025/// `write_emotion` does the whole file per call: read 58.7 MB on the eval
2026/// index, patch 84 bytes, write 58.7 MB, rename. A recall that blends ten
2027/// blocks therefore moved about 1.2 GB, which measured as 4.2 s. The file is
2028/// read once, every update is applied in memory, and the result is written once
2029/// -- same bytes, same atomic temp+rename guarantee, a tenth of the I/O.
2030pub 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
2059/// Write a single block's emotion vector to emotions.bin.
2060/// The file is grown to fit the block index if needed.
2061pub 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    // Atomic write: temp file + rename to prevent corruption on crash
2077    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
2083/// Append an emotion vector to the emotion log (emotion_log.bin).
2084/// Format: [u64 timestamp_ms] [f32; 21 emotion] [u32 text_len] [bytes text]
2085pub 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
2115/// Build emotions.bin from the emotion log and main index.
2116/// Reads emotion_log.bin and maps each entry to the closest main-index block.
2117pub 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; // ts + emotion + text_len
2124    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        // Find closest block by content coords
2143        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    // Write emotions.bin
2162    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
2175/// Format an emotion vector as a human-readable string.
2176pub 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        // Valid index reads the (dummy, zeroed) header without issue.
2290        let h = reader.header(0);
2291        assert_eq!(h.depth, 0);
2292
2293        // Out-of-bounds index must panic even in release builds.
2294        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
2301/// Score how well a block answers a text query.
2302///
2303/// The old ranking used depth alone, so a shallow fragment of noise always
2304/// came before the exact fact the user asked for. This combines three signals:
2305///
2306/// * **Phrase precision** — the whole query appearing verbatim is the strongest
2307///   signal; every query term appearing separately is weaker.
2308/// * **Importance** — the block's recorded importance (0-10), lightly weighted.
2309/// * **Specificity** — a short block that is mostly the query beats a long block
2310///   that merely mentions it somewhere.
2311pub 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}