Skip to main content

microscope_memory/
embedding_index.rs

1//! Embedding index: mmap-backed pre-computed embedding vectors.
2//!
3//! Sparse format — only blocks that actually received an embedding are stored
4//! (depth <= max_depth, non-trivial text, non-zero vector). Previously every
5//! block held a full vector with a NaN sentinel for the ~99% un-embedded ones,
6//! which blew the file up to gigabytes for no benefit. Search now scans only
7//! the embedded subset.
8//!
9//! Layout:
10//!   [u32 embedded_count][u32 dim][u32 max_depth]
11//!   [u32 block_idx × embedded_count]      (ascending, for binary search)
12//!   [f32 × dim × embedded_count]
13//!
14//! `embedding(block_idx)` returns None for blocks without a stored vector.
15
16use std::fs;
17use std::path::{Path, PathBuf};
18use std::sync::{Arc, Mutex};
19use std::time::SystemTime;
20
21use crate::parallel::*;
22
23use crate::embeddings::{cosine_similarity_simd, EmbeddingProvider};
24
25/// Mmap-backed embedding index for fast semantic lookup.
26#[allow(dead_code)]
27pub struct EmbeddingIndex {
28    data: memmap2::Mmap,
29    embedded_count: usize,
30    dim: usize,
31    max_depth: u32,
32}
33
34/// Process-wide cache of the mapped embedding index, keyed on path, file length
35/// and modification time.
36///
37/// `recall` opened this per query: `EmbeddingIndex::open` mmaps `embeddings.bin`
38/// and the mapping is dropped when the enclosing block ends, so every recall
39/// mapped and unmapped the file. On the 967,587-block evaluation index that is a
40/// 20 MB mapping, and leaving the scope measured 2.6 ms -- 8% of a 30 ms query,
41/// spent entirely in teardown rather than in any named work.
42///
43/// Length and mtime detect a rebuild. That matters because the index is
44/// regenerated whenever the corpus is rebuilt, and a cache that outlived one
45/// would search vectors that no longer exist. Rebuilds write to a temp file and
46/// rename, so a changed index always changes the length, the mtime, or both.
47/// What the cache keys on and hands back: the path it was opened for, the
48/// length and mtime that say whether it is still current, and the open index.
49type CachedEmbeddingIndex = (PathBuf, u64, Option<SystemTime>, Arc<EmbeddingIndex>);
50
51static EMBED_INDEX_CACHE: Mutex<Option<CachedEmbeddingIndex>> = Mutex::new(None);
52
53/// [`EmbeddingIndex::open`] through the process-wide cache.
54///
55/// Returns a shared handle; the mapping stays alive for as long as any caller
56/// holds it, which is the point. A `None` result means the file is absent or
57/// malformed, exactly as with `open`.
58pub fn open_embedding_cached(path: &Path) -> Option<Arc<EmbeddingIndex>> {
59    let meta = fs::metadata(path).ok()?;
60    let key = (meta.len(), meta.modified().ok());
61
62    let mut guard = EMBED_INDEX_CACHE.lock().ok()?;
63    let hit = match guard.as_ref() {
64        Some((cached_path, len, mtime, cached))
65            if cached_path == path && *len == key.0 && *mtime == key.1 =>
66        {
67            Some(Arc::clone(cached))
68        }
69        _ => None,
70    };
71    if let Some(hit) = hit {
72        return Some(hit);
73    }
74    let parsed = Arc::new(EmbeddingIndex::open(path)?);
75    let handle = Arc::clone(&parsed);
76    *guard = Some((path.to_path_buf(), key.0, key.1, parsed));
77    Some(handle)
78}
79
80const HEADER_SIZE: usize = 12; // 3 × u32
81
82impl EmbeddingIndex {
83    /// Open an existing embeddings.bin file (sparse format).
84    pub fn open(path: &Path) -> Option<Self> {
85        if !path.exists() {
86            return None;
87        }
88        let file = fs::File::open(path).ok()?;
89        let data = unsafe { memmap2::Mmap::map(&file).ok()? };
90        if data.len() < HEADER_SIZE {
91            return None;
92        }
93
94        let embedded_count = u32::from_le_bytes(data[0..4].try_into().unwrap()) as usize;
95        let dim = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
96        let max_depth = u32::from_le_bytes(data[8..12].try_into().unwrap());
97
98        let expected = HEADER_SIZE
99            + embedded_count
100                .checked_mul(4)
101                .and_then(|v| v.checked_add(embedded_count.checked_mul(dim * 4)?))
102                .unwrap_or(usize::MAX);
103        if expected > data.len() {
104            return None;
105        }
106
107        Some(EmbeddingIndex {
108            data,
109            embedded_count,
110            dim,
111            max_depth,
112        })
113    }
114
115    /// Number of stored (embedded) blocks.
116    pub fn block_count(&self) -> usize {
117        self.embedded_count
118    }
119
120    /// Get the embedding for a block index (zero-copy mmap access).
121    pub fn embedding(&self, block_idx: usize) -> Option<&[f32]> {
122        let ids = self.block_ids();
123        let pos = ids.binary_search(&(block_idx as u32)).ok()?;
124        let offset = HEADER_SIZE + self.embedded_count * 4 + pos * self.dim * 4;
125        let ptr = self.data[offset..].as_ptr() as *const f32;
126        // Safety: the vectors region is a multiple of 4 bytes past a 4-aligned
127        // base, and the size was validated in open().
128        Some(unsafe { std::slice::from_raw_parts(ptr, self.dim) })
129    }
130
131    /// Embedding dimension.
132    pub fn dim(&self) -> usize {
133        self.dim
134    }
135
136    /// Max depth that was embedded.
137    #[allow(dead_code)]
138    pub fn max_depth(&self) -> u8 {
139        self.max_depth as u8
140    }
141
142    /// Search for top-k most similar blocks to query embedding.
143    /// Returns Vec<(similarity, block_index)> sorted descending.
144    pub fn search(&self, query_emb: &[f32], k: usize) -> Vec<(f32, usize)> {
145        search_with_floor(
146            query_emb,
147            k,
148            self.dim,
149            &self.data,
150            HEADER_SIZE,
151            self.embedded_count,
152            self.block_ids(),
153        )
154    }
155
156    /// Every stored block id, in ascending order.
157    ///
158    /// Exposed for diagnostics that reason about the whole embedded set (a depth
159    /// histogram, a per-block score) rather than a top-k slice of it.
160    pub fn all_block_ids(&self) -> &[u32] {
161        self.block_ids()
162    }
163
164    /// Cosine similarity of one stored block against a query embedding.
165    pub fn similarity_of(&self, block_idx: usize, query_emb: &[f32]) -> Option<f32> {
166        if query_emb.len() != self.dim {
167            return None;
168        }
169        let ids = self.block_ids();
170        let pos = ids.binary_search(&(block_idx as u32)).ok()?;
171        let offset = HEADER_SIZE + self.embedded_count * 4 + pos * self.dim * 4;
172        let ptr = self.data[offset..].as_ptr() as *const f32;
173        // Safety: same invariants as search(); pos < embedded_count.
174        let emb = unsafe { std::slice::from_raw_parts(ptr, self.dim) };
175        Some(cosine_similarity_simd(query_emb, emb))
176    }
177
178    /// Rank of a stored block within the *full* untruncated cosine ordering.
179    ///
180    /// Diagnostics only. This separates "the embedding is bad" from "the top-k
181    /// is crowded out": a block with a high score at a high rank is being
182    /// drowned by redundant neighbours, not poorly represented.
183    pub fn full_rank_of(&self, block_idx: usize, query_emb: &[f32]) -> Option<(usize, f32)> {
184        if query_emb.len() != self.dim {
185            return None;
186        }
187        let ids = self.block_ids();
188        let target = ids.binary_search(&(block_idx as u32)).ok()?;
189        let target_off = HEADER_SIZE + self.embedded_count * 4 + target * self.dim * 4;
190        let tptr = self.data[target_off..].as_ptr() as *const f32;
191        let target_emb = unsafe { std::slice::from_raw_parts(tptr, self.dim) };
192        let target_sim = cosine_similarity_simd(query_emb, target_emb);
193        let mut higher = 0usize;
194        for i in 0..self.embedded_count {
195            let offset = HEADER_SIZE + self.embedded_count * 4 + i * self.dim * 4;
196            let ptr = self.data[offset..].as_ptr() as *const f32;
197            // Safety: same invariants as search(); i < embedded_count.
198            let emb = unsafe { std::slice::from_raw_parts(ptr, self.dim) };
199            if cosine_similarity_simd(query_emb, emb) > target_sim {
200                higher += 1;
201            }
202        }
203        Some((higher, target_sim))
204    }
205
206    /// Block-id lookup array (ascending u32 indices).
207    fn block_ids(&self) -> &[u32] {
208        let start = HEADER_SIZE;
209        let end = start + self.embedded_count * 4;
210        let ptr = self.data[start..end].as_ptr() as *const u32;
211        // Safety: start is 4-aligned and the region size was validated.
212        unsafe { std::slice::from_raw_parts(ptr, self.embedded_count) }
213    }
214}
215
216/// Minimum text length, in characters, for a block to be embedded. Overridable
217/// with `MICROSCOPE_MIN_EMBED_CHARS`.
218///
219/// The default was 24, justified as "the measured floor" on the grounds that
220/// dropping to 17 re-introduced crowding and cost hit@10 (81.7% -> 78.3%).
221/// That measurement was taken before `71e938d`, and it is void: the build at
222/// the time was cutting the evaluation corpus into fixed 1024-byte pieces at
223/// arbitrary byte offsets, so the corpus it was tuned against was itself
224/// truncated. Re-measured on the fixed build the curve is flat from 20 down to
225/// 12 -- no fall at all:
226///
227///     minimum   R@1     R@5     R@10
228///     24        76.7%   91.7%   95.0%
229///     20        80.0%   95.0%   98.3%
230///     16        80.0%   95.0%   98.3%
231///     12        80.0%   95.0%   98.3%
232///
233/// 20 is the smallest value that captures the whole gain, so it is the default:
234/// it admits the short-but-complete memories ("The user is vegetarian." is 23
235/// characters and was being refused) without going further than the measurement
236/// supports. The crowding the old comment described is not visible at any of
237/// these values on a corpus of real sentences.
238///
239/// One thing this does not re-measure: the count of short blocks in the
240/// 944,808-block evaluation corpus, which is what the old default was tuned
241/// against and which is also suspect now. If this system is pointed at a corpus
242/// of genuinely degenerate fragments rather than sentences, raise it back.
243pub fn min_embed_chars() -> usize {
244    std::env::var("MICROSCOPE_MIN_EMBED_CHARS")
245        .ok()
246        .and_then(|v| v.parse::<usize>().ok())
247        .unwrap_or(20)
248}
249
250/// Why a text is not worth embedding. Deliberately about what the text *is*,
251/// never about how it scores: a cosine-threshold dedup would merge
252/// contradictory facts -- of 40,000 sampled high-cosine pairs, 1,666 differ in
253/// numbers and 245 differ in negation.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum EmbedVerdict {
256    Keep,
257    /// Below the character floor.
258    Short,
259    /// The reader's sentinel for bytes it could not decode.
260    Unencodable,
261    /// UTF-8 bytes shown as cp1252 characters.
262    Mojibake,
263}
264
265/// The embedding quality gate, shared by the index build and the store path so
266/// a freshly stored memory is admitted under exactly the policy that built the
267/// index -- a second, looser admission rule would re-introduce what this
268/// removes.
269pub fn quality_gate(text: &str, min_chars: usize) -> EmbedVerdict {
270    // `text()` returns these sentinels for bytes it could not decode, so the
271    // block carries no retrievable content at all.
272    if text == "<bin>" || text == "[out of bounds]" {
273        return EmbedVerdict::Unencodable;
274    }
275    let total = text.chars().count();
276    if total < min_chars {
277        return EmbedVerdict::Short;
278    }
279    // Mojibake: if more than a quarter of the characters sit in the
280    // U+0080..U+02FF band, this is not natural language.
281    let suspicious = text
282        .chars()
283        .filter(|c| ('\u{80}'..='\u{2ff}').contains(c))
284        .count();
285    if total > 0 && suspicious * 4 > total {
286        return EmbedVerdict::Mojibake;
287    }
288    EmbedVerdict::Keep
289}
290
291/// Sidecar holding vectors for memories that are still in the append log.
292///
293/// A stored memory is invisible to the semantic path until the next full
294/// rebuild: `embeddings.bin` is built from the consolidated index, and the
295/// append log is addressed by position (`1_000_000 + ai`). This closes that
296/// gap -- the store path embeds the text once and records the vector under its
297/// append index, and recall scores it next to the main index.
298///
299/// Layout:
300///   [4 bytes "AEM1"][u32 dim][u32 record_count]
301///   [u32 append_index][f32 x dim] x record_count
302///
303/// Two properties keep it from desynchronising into wrong answers. It is
304/// removed wherever `append.bin` is removed (rebuild, doctor repair), and a
305/// record whose append index no longer exists is simply never looked up, so an
306/// external edit of the log cannot resurrect a vector for a different memory.
307pub const APPEND_EMBEDDINGS_FILE: &str = "append_embeddings.bin";
308const APPEND_EMBEDDINGS_MAGIC: &[u8; 4] = b"AEM1";
309const APPEND_EMBEDDINGS_HEADER: usize = 12;
310
311pub struct AppendEmbeddings {
312    pub dim: usize,
313    /// (append index, vector), in write order.
314    pub entries: Vec<(u32, Vec<f32>)>,
315}
316
317impl AppendEmbeddings {
318    pub fn new(dim: usize) -> Self {
319        AppendEmbeddings {
320            dim,
321            entries: Vec::new(),
322        }
323    }
324
325    /// Open an existing sidecar, or None when it is absent, malformed, or was
326    /// written at a different vector width. A width mismatch is ignored rather
327    /// than coerced: scoring 384-dim vectors against a 768-dim query is not a
328    /// degraded answer, it is a wrong one.
329    pub fn open(path: &Path, dim: usize) -> Option<Self> {
330        let data = fs::read(path).ok()?;
331        if data.len() < APPEND_EMBEDDINGS_HEADER || &data[0..4] != APPEND_EMBEDDINGS_MAGIC {
332            return None;
333        }
334        let file_dim = u32::from_le_bytes(data[4..8].try_into().ok()?) as usize;
335        if dim == 0 || file_dim != dim {
336            return None;
337        }
338        let count = u32::from_le_bytes(data[8..12].try_into().ok()?) as usize;
339        let stride = 4 + dim * 4;
340        let mut entries = Vec::with_capacity(count.min(65_536));
341        for i in 0..count {
342            let off = APPEND_EMBEDDINGS_HEADER + i * stride;
343            if off + stride > data.len() {
344                // A crash can leave a partial tail; keep the valid prefix.
345                break;
346            }
347            let index = u32::from_le_bytes(data[off..off + 4].try_into().ok()?);
348            let mut v = Vec::with_capacity(dim);
349            for j in 0..dim {
350                let p = off + 4 + j * 4;
351                v.push(f32::from_le_bytes(data[p..p + 4].try_into().ok()?));
352            }
353            entries.push((index, v));
354        }
355        Some(AppendEmbeddings { dim, entries })
356    }
357
358    pub fn push(&mut self, append_index: u32, vector: Vec<f32>) {
359        self.entries.push((append_index, vector));
360    }
361}
362
363/// Process-wide cache of the parsed append sidecar, keyed on file length and
364/// modification time.
365///
366/// `open` re-reads the whole file and re-allocates a `Vec<f32>` per entry on
367/// every call, and `recall` called it on every query: 2.3 ms per recall on the
368/// evaluation index, paid even though the file cannot change between two
369/// queries in the same process. Length and mtime together are enough to detect
370/// a rewrite, because `save` writes a temp file and renames over the target, so
371/// a changed sidecar always lands as a new length, mtime, or both.
372///
373/// A cached miss is kept too, so a missing or wrongly-sized sidecar does not
374/// re-read on every query either. The only case this cannot help is a sidecar
375/// that is rewritten within the filesystem's mtime resolution *and* to the same
376/// length; that is a narrower window than the one this closes, and it was
377/// previously no safer, because the old code re-read within that window too but
378/// also paid 2.3 ms to do it.
379///
380/// The path is part of the key, not just the length and mtime: two sidecars in
381/// different output directories can be the same length and the same age, and
382/// keying without it would hand one index's vectors to the other.
383/// As above; the usize is the vector width the sidecar was opened at, which
384/// has to match the query or the search is comparing different dimensions.
385type CachedAppendIndex = (
386    PathBuf,
387    u64,
388    Option<SystemTime>,
389    usize,
390    Arc<AppendEmbeddings>,
391);
392
393static APPEND_CACHE: Mutex<Option<CachedAppendIndex>> = Mutex::new(None);
394
395/// [`AppendEmbeddings::open`] through the process-wide cache.
396///
397/// Returns a shared handle; callers must not mutate it. A `None` result means
398/// the sidecar is absent, malformed, or a different vector width, exactly as
399/// with `open`.
400pub fn open_append_cached(path: &Path, dim: usize) -> Option<Arc<AppendEmbeddings>> {
401    // A missing file is answered from a stat rather than a read attempt.
402    let meta = fs::metadata(path).ok()?;
403    let key = (meta.len(), meta.modified().ok());
404
405    let mut guard = APPEND_CACHE.lock().ok()?;
406    // Scoped so the immutable borrow of the slot ends before it is overwritten.
407    let hit = match guard.as_ref() {
408        Some((cached_path, len, mtime, cached_dim, cached))
409            if cached_path == path && *len == key.0 && *mtime == key.1 && *cached_dim == dim =>
410        {
411            Some(Arc::clone(cached))
412        }
413        _ => None,
414    };
415    if let Some(hit) = hit {
416        return Some(hit);
417    }
418    let parsed = Arc::new(AppendEmbeddings::open(path, dim)?);
419    let handle = Arc::clone(&parsed);
420    *guard = Some((path.to_path_buf(), key.0, key.1, dim, parsed));
421    Some(handle)
422}
423
424impl AppendEmbeddings {
425    /// Write atomically (temp file + rename), like the main index.
426    pub fn save(&self, path: &Path) -> Result<(), String> {
427        let stride = 4 + self.dim * 4;
428        let mut buf = Vec::with_capacity(APPEND_EMBEDDINGS_HEADER + self.entries.len() * stride);
429        buf.extend_from_slice(APPEND_EMBEDDINGS_MAGIC);
430        buf.extend_from_slice(&(self.dim as u32).to_le_bytes());
431        buf.extend_from_slice(&(self.entries.len() as u32).to_le_bytes());
432        for (index, v) in &self.entries {
433            if v.len() != self.dim {
434                return Err(format!(
435                    "append embedding width {} does not match header dim {}",
436                    v.len(),
437                    self.dim
438                ));
439            }
440            buf.extend_from_slice(&index.to_le_bytes());
441            for f in v {
442                buf.extend_from_slice(&f.to_le_bytes());
443            }
444        }
445        let tmp = path.with_extension("bin.tmp");
446        fs::write(&tmp, &buf).map_err(|e| format!("write {}: {}", APPEND_EMBEDDINGS_FILE, e))?;
447        fs::rename(&tmp, path).map_err(|e| format!("rename {}: {}", APPEND_EMBEDDINGS_FILE, e))?;
448        Ok(())
449    }
450
451    /// Top-k by cosine as (similarity, append index), with the same floor the
452    /// main index uses so the two candidate sources stay comparable.
453    pub fn search(&self, query_emb: &[f32], k: usize) -> Vec<(f32, u32)> {
454        if query_emb.len() != self.dim {
455            return vec![];
456        }
457        let mut out: Vec<(f32, u32)> = self
458            .entries
459            .iter()
460            .filter_map(|(i, v)| {
461                let sim = cosine_similarity_simd(query_emb, v);
462                (sim > similarity_floor()).then_some((sim, *i))
463            })
464            .collect();
465        out.sort_by(|a, b| b.0.total_cmp(&a.0));
466        out.truncate(k);
467        out
468    }
469}
470
471/// The cosine below which a stored vector is not offered as a candidate.
472///
473/// This was a literal `0.3` in two places. It is read from
474/// `MICROSCOPE_SIM_FLOOR` so the value can be measured instead of guessed.
475///
476/// Swept on the 60-fact benchmark, with the padding fix in place so the
477/// similarities mean something:
478///
479///     floor   R@1     R@5     R@10
480///     0.30    85.0%   96.7%   96.7%
481///     0.20    85.0%   98.3%   98.3%
482///     0.10    85.0%   98.3%   98.3%
483///     0.00    85.0%   98.3%   98.3%
484///
485/// 0.2 is the knee: everything at or below it is admitted, and nothing below
486/// 0.2 adds a case. The two it recovers score 0.098 and 0.225 against their own
487/// answers -- real similarities for questions phrased in words the memory does
488/// not use, not noise. A floor is a filter on candidates, not a rank, so this
489/// costs nothing in ordering.
490pub fn similarity_floor() -> f32 {
491    static FLOOR: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
492    *FLOOR.get_or_init(|| {
493        std::env::var("MICROSCOPE_SIM_FLOOR")
494            .ok()
495            .and_then(|v| v.trim().parse::<f32>().ok())
496            .filter(|v| v.is_finite())
497            .unwrap_or(0.2)
498    })
499}
500
501/// The shared scan behind `EmbeddingIndex::search`, with the floor passed in.
502///
503/// Split out so a test can measure a different floor without going through the
504/// process-wide environment read, which happens once per process.
505fn search_with_floor(
506    query_emb: &[f32],
507    k: usize,
508    dim: usize,
509    data: &[u8],
510    header: usize,
511    count: usize,
512    ids: &[u32],
513) -> Vec<(f32, usize)> {
514    if query_emb.len() != dim {
515        return vec![];
516    }
517    let floor = similarity_floor();
518    let mut results: Vec<(f32, usize)> = (0..count)
519        .into_par_iter()
520        .filter_map(|i| {
521            let offset = header + count * 4 + i * dim * 4;
522            let ptr = data[offset..].as_ptr() as *const f32;
523            // Safety: validated in open(); i < count.
524            let emb = unsafe { std::slice::from_raw_parts(ptr, dim) };
525            let sim = cosine_similarity_simd(query_emb, emb);
526            (sim > floor).then_some((sim, ids[i] as usize))
527        })
528        .collect();
529
530    results.sort_by(|a, b| b.0.total_cmp(&a.0));
531    results.truncate(k);
532    results
533}
534
535/// Build a sparse embedding index file from a provider and reader.
536/// Only blocks at depth 0..=max_depth with non-trivial text get embedded;
537/// failed or zero embeddings are omitted (search treats them as absent).
538pub fn build_embedding_index(
539    provider: &dyn EmbeddingProvider,
540    reader: &crate::MicroscopeReader,
541    max_depth: u8,
542    output_path: &Path,
543) -> Result<(), String> {
544    let dim = provider.dimension();
545    let total_blocks = reader.block_count;
546
547    // Pass 1: count blocks that qualify (depth <= max_depth, text worth embedding).
548    // The rule itself is `quality_gate`, shared with the store path; its
549    // rationale and the measurements behind it are documented there.
550    let min_chars = min_embed_chars();
551    let mut skipped_short = 0usize;
552    let mut skipped_unencodable = 0usize;
553    let mut skipped_mojibake = 0usize;
554    let mut qualifying = Vec::new();
555    for i in 0..total_blocks {
556        let h = reader.header(i);
557        if h.depth > max_depth {
558            continue;
559        }
560        match quality_gate(reader.text(i), min_chars) {
561            EmbedVerdict::Keep => qualifying.push(i),
562            EmbedVerdict::Short => skipped_short += 1,
563            EmbedVerdict::Unencodable => skipped_unencodable += 1,
564            EmbedVerdict::Mojibake => skipped_mojibake += 1,
565        }
566    }
567    if skipped_short + skipped_unencodable + skipped_mojibake > 0 {
568        println!(
569            "  Embedding quality gate: -{} short (<{} chars), -{} unencodable, -{} mojibake",
570            skipped_short, min_chars, skipped_unencodable, skipped_mojibake
571        );
572    }
573
574    println!(
575        "  Embedding up to {} blocks (D0-D{}, dim={})...",
576        qualifying.len(),
577        max_depth,
578        dim
579    );
580
581    let mut block_ids: Vec<u32> = Vec::with_capacity(qualifying.len());
582    let mut vectors: Vec<f32> = Vec::with_capacity(qualifying.len() * dim);
583    let mut failures = 0usize;
584    let mut first_error: Option<String> = None;
585
586    for (n, &i) in qualifying.iter().enumerate() {
587        let text = reader.text(i);
588        match provider.embed(text) {
589            Ok(emb) if emb.len() == dim && emb.iter().any(|&v| v != 0.0) => {
590                block_ids.push(i as u32);
591                vectors.extend_from_slice(&emb);
592            }
593            Ok(emb) => {
594                // Length mismatch or all-zero vector: record it instead of
595                // silently dropping, so a dimension bug cannot look like a
596                // successful build.
597                failures += 1;
598                if first_error.is_none() {
599                    first_error = Some(format!(
600                        "block {} produced {} dims, expected {} ({})",
601                        i,
602                        emb.len(),
603                        dim,
604                        if emb.iter().all(|&v| v == 0.0) {
605                            "all-zero vector"
606                        } else {
607                            "length mismatch"
608                        }
609                    ));
610                }
611            }
612            Err(e) => {
613                failures += 1;
614                if first_error.is_none() {
615                    first_error = Some(format!("block {}: {}", i, e));
616                }
617            }
618        }
619        if n.is_multiple_of(1000) {
620            eprint!("\r  Embedded {}/{}", n, qualifying.len());
621        }
622    }
623    eprintln!("\r  Embedded {}/{}", qualifying.len(), qualifying.len());
624
625    // A build that stored nothing is a failure, not a success.
626    if qualifying.is_empty() {
627        return Err("no blocks qualified for embedding".into());
628    }
629    if block_ids.is_empty() {
630        return Err(format!(
631            "all {} blocks failed to embed; first failure: {}",
632            qualifying.len(),
633            first_error.unwrap_or_else(|| "unknown".into())
634        ));
635    }
636    if failures > 0 {
637        eprintln!(
638            "  WARN: {} of {} blocks were skipped",
639            failures,
640            qualifying.len()
641        );
642    }
643
644    let mut buf = Vec::with_capacity(HEADER_SIZE + block_ids.len() * 4 + vectors.len() * 4);
645    buf.extend_from_slice(&(block_ids.len() as u32).to_le_bytes());
646    buf.extend_from_slice(&(dim as u32).to_le_bytes());
647    buf.extend_from_slice(&(max_depth as u32).to_le_bytes());
648    for &id in &block_ids {
649        buf.extend_from_slice(&id.to_le_bytes());
650    }
651    for &v in &vectors {
652        buf.extend_from_slice(&v.to_le_bytes());
653    }
654
655    let tmp_path = output_path.with_extension("bin.tmp");
656    fs::write(&tmp_path, &buf).map_err(|e| format!("write embeddings.bin: {}", e))?;
657    fs::rename(&tmp_path, output_path).map_err(|e| format!("rename embeddings.bin: {}", e))?;
658    println!(
659        "  embeddings.bin: {:.1} KB ({} stored vectors of {} blocks, dim {})",
660        buf.len() as f64 / 1024.0,
661        block_ids.len(),
662        total_blocks,
663        dim
664    );
665
666    Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use std::io::Write;
673
674    #[test]
675    fn quality_gate_admits_only_retrievable_text() {
676        // A real memory, well above the floor, no mojibake band.
677        assert_eq!(
678            quality_gate("The user has a pine nut allergy.", 24),
679            EmbedVerdict::Keep
680        );
681        // Accented natural language is not mojibake: one character in the band
682        // out of 37 must not disqualify the text.
683        assert_eq!(
684            quality_gate("The user drinks café lattes every morning.", 24),
685            EmbedVerdict::Keep
686        );
687        // Below the character floor: the fragments that used to crowd the index.
688        assert_eq!(quality_gate("LLM", 24), EmbedVerdict::Short);
689        assert_eq!(
690            quality_gate("The user is vegetarian.", 24),
691            EmbedVerdict::Short,
692            "23 characters is under the measured 24 floor"
693        );
694        // The reader's sentinels for undecodable bytes carry no content at all.
695        assert_eq!(quality_gate("<bin>", 24), EmbedVerdict::Unencodable);
696        assert_eq!(
697            quality_gate("[out of bounds]", 24),
698            EmbedVerdict::Unencodable
699        );
700        // Mojibake: long enough to clear the floor, every character in the band.
701        assert_eq!(
702            // mojibake-ok: deliberate fixture; this is the input the gate must reject
703            quality_gate("đź§đź§đź§đź§đź§đź§đź§đź§", 24),
704            EmbedVerdict::Mojibake
705        );
706        // The floor is a parameter, not a constant baked into the rule.
707        assert_eq!(
708            quality_gate("The user is vegetarian.", 20),
709            EmbedVerdict::Keep,
710            "MICROSCOPE_MIN_EMBED_CHARS=20 would admit a 23-character fact"
711        );
712    }
713
714    #[test]
715    fn min_embed_chars_default_is_the_measured_floor() {
716        if std::env::var_os("MICROSCOPE_MIN_EMBED_CHARS").is_none() {
717            // 20, not 24: see the doc comment. The 24 was justified by a
718            // measurement taken on a build that truncated the corpus it was
719            // tuned against, and on the fixed build the curve is flat from 20
720            // down to 12.
721            assert_eq!(min_embed_chars(), 20);
722        }
723    }
724
725    #[test]
726    fn append_embeddings_roundtrip_and_guards() {
727        let dir = std::env::temp_dir().join("mscope_append_emb_test");
728        let _ = fs::create_dir_all(&dir);
729        let path = dir.join(APPEND_EMBEDDINGS_FILE);
730
731        let mut side = AppendEmbeddings::new(4);
732        side.push(0, vec![1.0, 0.0, 0.0, 0.0]);
733        side.push(1, vec![0.0, 1.0, 0.0, 0.0]);
734        side.push(7, vec![0.0, 0.0, 0.0, 1.0]);
735        side.save(&path).expect("save");
736
737        let opened = AppendEmbeddings::open(&path, 4).expect("open");
738        assert_eq!(opened.dim, 4);
739        assert_eq!(opened.entries.len(), 3);
740        assert_eq!(
741            opened.entries[2].0, 7,
742            "append index survives the round trip"
743        );
744
745        // A query equal to the first stored vector ranks it first.
746        let hits = opened.search(&[1.0, 0.0, 0.0, 0.0], 10);
747        assert_eq!(hits[0].1, 0);
748        assert!(hits[0].0 > 0.99, "identical vectors must score ~1.0");
749        // The floor is asserted against the live default, not a copy of it: the
750        // value changed from 0.3 to 0.2 on measurement, and a test that hardcoded
751        // the old number would have passed either way while the assertion text
752        // quietly lied.
753        let floor = similarity_floor();
754        assert!(
755            hits.iter().all(|&(sim, _)| sim > floor),
756            "everything returned must clear the floor ({floor})"
757        );
758
759        // A width mismatch must be ignored, not coerced: scoring 4-dim vectors
760        // against an 8-dim query would be a wrong answer, not a degraded one.
761        assert!(AppendEmbeddings::open(&path, 8).is_none());
762        // A foreign file is not a sidecar.
763        let junk = dir.join("junk.bin");
764        fs::write(&junk, b"NOPE\x04\x00\x00\x00\x00\x00\x00\x00").unwrap();
765        assert!(AppendEmbeddings::open(&junk, 4).is_none());
766
767        // A crash mid-write leaves a partial tail; the valid prefix must load.
768        let mut data = fs::read(&path).unwrap();
769        data.extend_from_slice(&[0xAB, 0xCD, 0xEF]);
770        fs::write(&path, &data).unwrap();
771        let partial = AppendEmbeddings::open(&path, 4).expect("valid prefix survives");
772        assert_eq!(partial.entries.len(), 3);
773
774        // A record of the wrong width is refused at save time, not on read.
775        let mut bad = AppendEmbeddings::new(4);
776        bad.push(0, vec![1.0, 0.0]);
777        assert!(bad.save(&path).is_err());
778
779        // A width-mismatched query never scores.
780        assert!(partial.search(&[1.0, 0.0], 10).is_empty());
781
782        let _ = fs::remove_dir_all(&dir);
783    }
784
785    #[test]
786    fn append_cache_returns_the_same_content_as_a_fresh_open() {
787        // The cache must not be a second, subtly different parser. Every shape
788        // the plain open accepts has to come back identical through it.
789        let dir = std::env::temp_dir().join("mscope_append_cache_parity");
790        let _ = fs::remove_dir_all(&dir);
791        fs::create_dir_all(&dir).unwrap();
792        let path = dir.join(APPEND_EMBEDDINGS_FILE);
793
794        let mut side = AppendEmbeddings::new(4);
795        side.push(0, vec![1.0, 0.0, 0.0, 0.0]);
796        side.push(3, vec![0.0, 0.5, 0.0, 0.0]);
797        side.save(&path).unwrap();
798
799        let fresh = AppendEmbeddings::open(&path, 4).unwrap();
800        let cached = open_append_cached(&path, 4).unwrap();
801        assert_eq!(fresh.entries, cached.entries, "cache must match open()");
802        assert_eq!(cached.dim, 4);
803
804        // A second call is served from the cache and must agree with the first.
805        let again = open_append_cached(&path, 4).unwrap();
806        assert_eq!(again.entries, fresh.entries);
807
808        // Guard parity: the width refusal and the missing-file case both hold.
809        assert!(open_append_cached(&path, 8).is_none());
810        assert!(open_append_cached(&dir.join("absent.bin"), 4).is_none());
811
812        let _ = fs::remove_dir_all(&dir);
813    }
814
815    #[test]
816    fn append_cache_invalidates_when_the_file_changes() {
817        // This is the test that matters. A cache that never notices a rewrite
818        // would silently serve a stale vector for a memory that has since been
819        // replaced, and no other test in this file would catch it.
820        let dir = std::env::temp_dir().join("mscope_append_cache_inval");
821        let _ = fs::remove_dir_all(&dir);
822        fs::create_dir_all(&dir).unwrap();
823        let path = dir.join(APPEND_EMBEDDINGS_FILE);
824
825        let mut first = AppendEmbeddings::new(4);
826        first.push(11, vec![1.0, 0.0, 0.0, 0.0]);
827        first.save(&path).unwrap();
828        assert_eq!(open_append_cached(&path, 4).unwrap().entries[0].0, 11);
829
830        // Rewrite with a different, longer payload. `save` renames a temp file
831        // over the target, so both the length and the mtime change.
832        let mut second = AppendEmbeddings::new(4);
833        second.push(22, vec![0.0, 1.0, 0.0, 0.0]);
834        second.push(33, vec![0.0, 0.0, 1.0, 0.0]);
835        second.push(44, vec![0.0, 0.0, 0.0, 1.0]);
836        second.save(&path).unwrap();
837
838        let after = open_append_cached(&path, 4).unwrap();
839        assert_eq!(
840            after.entries.len(),
841            3,
842            "a rewrite must be observed, not cached"
843        );
844        assert_eq!(after.entries[0].0, 22);
845        let fresh = AppendEmbeddings::open(&path, 4).unwrap();
846        assert_eq!(after.entries, fresh.entries, "and must match a fresh open");
847
848        let _ = fs::remove_dir_all(&dir);
849    }
850
851    #[test]
852    fn embedding_index_cache_matches_open() {
853        // The cache must not become a second, subtly different reader.
854        let dir = std::env::temp_dir().join("mscope_embed_cache_parity");
855        let _ = fs::remove_dir_all(&dir);
856        fs::create_dir_all(&dir).unwrap();
857        let path = dir.join("embeddings.bin");
858
859        let n = build_index(&dir, 3);
860        let fresh = EmbeddingIndex::open(&path).expect("open");
861        let cached = open_embedding_cached(&path).expect("cached open");
862        assert_eq!(cached.block_count(), n);
863        assert_eq!(cached.dim(), fresh.dim());
864        // A second call is served from the cache and agrees with the first.
865        assert_eq!(open_embedding_cached(&path).unwrap().block_count(), n);
866        // A missing file is a miss, not a panic and not a stale hit.
867        assert!(open_embedding_cached(&dir.join("nope.bin")).is_none());
868
869        let _ = fs::remove_dir_all(&dir);
870    }
871
872    /// The index is regenerated whenever the corpus is, so a cache that
873    /// outlives a rebuild would search vectors that no longer exist.
874    ///
875    /// Unix only, and not because the logic differs. Windows refuses to
876    /// overwrite a file that has an open section mapped onto it (error 1224),
877    /// so a rebuild cannot land while this process holds the mapping at all.
878    /// That is a property of the cache worth knowing rather than a reason to
879    /// skip the check: the key is identical on both platforms, and on Unix the
880    /// rewrite reaches `open_embedding_cached` and has to be seen.
881    #[cfg(unix)]
882    #[test]
883    fn embedding_index_cache_notices_a_rebuild() {
884        let dir = std::env::temp_dir().join("mscope_embed_cache_rebuild");
885        let _ = fs::remove_dir_all(&dir);
886        fs::create_dir_all(&dir).unwrap();
887        let path = dir.join("embeddings.bin");
888
889        let first = build_index(&dir, 2);
890        assert_eq!(open_embedding_cached(&path).unwrap().block_count(), first);
891
892        let bigger = build_index(&dir, 5);
893        assert_ne!(bigger, first, "the rebuild must actually differ");
894        let after = open_embedding_cached(&path).expect("reopen after rebuild");
895        assert_eq!(
896            after.block_count(),
897            bigger,
898            "a rebuilt index must not be served from the stale mapping"
899        );
900
901        let _ = fs::remove_dir_all(&dir);
902    }
903
904    /// Writes a valid `embeddings.bin` holding `n` 4-dim vectors and returns the
905    /// block count, in the same sparse layout the index builder emits.
906    fn build_index(dir: &Path, n: u32) -> usize {
907        let dim = 4usize;
908        let mut buf = Vec::new();
909        buf.extend_from_slice(&n.to_le_bytes());
910        buf.extend_from_slice(&(dim as u32).to_le_bytes());
911        buf.extend_from_slice(&3u32.to_le_bytes());
912        for i in 0..n {
913            buf.extend_from_slice(&i.to_le_bytes());
914            let mut v = vec![0.0f32; dim];
915            v[(i as usize) % dim] = 1.0;
916            for f in v {
917                buf.extend_from_slice(&f.to_le_bytes());
918            }
919        }
920        fs::write(dir.join("embeddings.bin"), &buf).unwrap();
921        n as usize
922    }
923
924    #[test]
925    fn append_cache_keeps_two_paths_apart() {
926        // Two sidecars that are the same length and the same width must not be
927        // confused for one another. Keying on length and mtime alone would fail
928        // this whenever the two files land inside one mtime tick.
929        let dir = std::env::temp_dir().join("mscope_append_cache_paths");
930        let _ = fs::remove_dir_all(&dir);
931        fs::create_dir_all(&dir).unwrap();
932        let a = dir.join("a.bin");
933        let b = dir.join("b.bin");
934
935        let mut sa = AppendEmbeddings::new(4);
936        sa.push(101, vec![1.0, 0.0, 0.0, 0.0]);
937        sa.save(&a).unwrap();
938        let mut sb = AppendEmbeddings::new(4);
939        sb.push(202, vec![1.0, 0.0, 0.0, 0.0]);
940        sb.save(&b).unwrap();
941
942        // Identical length, identical width, possibly identical mtime.
943        let ca = open_append_cached(&a, 4).unwrap();
944        let cb = open_append_cached(&b, 4).unwrap();
945        assert_eq!(ca.entries[0].0, 101, "a must not be served b's vector");
946        assert_eq!(cb.entries[0].0, 202, "b must not be served a's vector");
947        // And re-reading a after b still gives a.
948        assert_eq!(open_append_cached(&a, 4).unwrap().entries[0].0, 101);
949
950        let _ = fs::remove_dir_all(&dir);
951    }
952
953    #[test]
954    fn test_embedding_index_sparse_roundtrip() {
955        let dir = std::env::temp_dir().join("mscope_emb_sparse_test");
956        let _ = fs::create_dir_all(&dir);
957        let path = dir.join("embeddings.bin");
958
959        // Sparse file: 3 stored vectors, dim=4, max_depth=2.
960        // Stored blocks: 0, 2, 5. Block 7 absent.
961        let mut buf = Vec::new();
962        buf.extend_from_slice(&3u32.to_le_bytes()); // embedded_count
963        buf.extend_from_slice(&4u32.to_le_bytes()); // dim
964        buf.extend_from_slice(&2u32.to_le_bytes()); // max_depth
965        for &id in &[0u32, 2, 5] {
966            buf.extend_from_slice(&id.to_le_bytes());
967        }
968        for &v in &[1.0f32, 0.0, 0.0, 0.0] {
969            buf.extend_from_slice(&v.to_le_bytes());
970        }
971        for &v in &[0.0f32, 1.0, 0.0, 0.0] {
972            buf.extend_from_slice(&v.to_le_bytes());
973        }
974        for &v in &[0.0f32, 0.0, 1.0, 0.0] {
975            buf.extend_from_slice(&v.to_le_bytes());
976        }
977
978        let mut f = fs::File::create(&path).unwrap();
979        f.write_all(&buf).unwrap();
980
981        let idx = EmbeddingIndex::open(&path).unwrap();
982        assert_eq!(idx.block_count(), 3);
983        assert_eq!(idx.dim(), 4);
984        assert_eq!(idx.max_depth(), 2);
985
986        assert_eq!(idx.embedding(0).unwrap(), &[1.0, 0.0, 0.0, 0.0]);
987        assert_eq!(idx.embedding(2).unwrap(), &[0.0, 1.0, 0.0, 0.0]);
988        assert_eq!(idx.embedding(5).unwrap(), &[0.0, 0.0, 1.0, 0.0]);
989        assert!(idx.embedding(1).is_none());
990        assert!(idx.embedding(7).is_none());
991
992        // Query [1,0,0,0] should return block 0 first and never block 7.
993        let results = idx.search(&[1.0, 0.0, 0.0, 0.0], 10);
994        assert_eq!(results[0].1, 0);
995        assert!(!results.iter().any(|&(_, i)| i == 7));
996        assert!(!results.iter().any(|&(_, i)| i == 1));
997
998        // Truncated file must fail open (never panic on short mmap).
999        let trunc = dir.join("truncated.bin");
1000        fs::write(&trunc, &buf[..buf.len() - 3]).unwrap();
1001        assert!(EmbeddingIndex::open(&trunc).is_none());
1002
1003        let _ = fs::remove_dir_all(&dir);
1004    }
1005
1006    #[test]
1007    fn test_empty_index_open_fails_gracefully() {
1008        let dir = std::env::temp_dir().join("mscope_emb_empty_test");
1009        let _ = fs::create_dir_all(&dir);
1010        let path = dir.join("embeddings.bin");
1011        fs::write(&path, [0u8; 4]).unwrap();
1012        assert!(EmbeddingIndex::open(&path).is_none());
1013        let _ = fs::remove_dir_all(&dir);
1014    }
1015}