Skip to main content

microscope_memory/
build.rs

1//! Extracts text from RAW TEXT layer files, constructs a 9-depth block hierarchy
2//! (identity → layers → clusters → items → sentences → tokens → syllables → chars → bytes),
3//! and writes the binary output files (microscope.bin, data.bin, meta.bin, merkle.bin, embeddings.bin).
4
5use crate::config::Config;
6use crate::reader::{read_append_log, FileLock, MicroscopeReader};
7use crate::types::BlockHeader;
8use crate::{
9    content_coords_blended, crc16_ccitt, hex_str, layer_to_id, merkle, safe_truncate, to_block,
10    BLOCK_DATA_SIZE, DEPTH_ENTRY_SIZE, HEADER_SIZE, META_HEADER_SIZE,
11};
12
13use crate::parallel::*;
14use colored::Colorize;
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17use std::fs;
18use std::io::{BufWriter, Seek, Write};
19use std::path::Path;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct RebuildOutcome {
23    pub rebuilt: bool,
24    pub pending_entries: usize,
25}
26
27/// Rebuild the main index while holding the same process lock used by stores.
28/// The append log is removed only after a successful build.
29///
30/// `heavy` controls regenerating the expensive deterministic sidecars
31/// (embedding vectors + structural fingerprint links). The auto-rebuild path
32/// passes `false`: it consolidates the append log fast and drops stale
33/// positional sidecars instead of re-serving them after a content shift.
34pub fn rebuild_pending(
35    config: &Config,
36    force_when_empty: bool,
37    heavy: bool,
38) -> Result<RebuildOutcome, String> {
39    let _lock = FileLock::acquire(config)?;
40    let output_dir = Path::new(&config.paths.output_dir);
41    let append_path = output_dir.join("append.bin");
42    let pending_entries = read_append_log(&append_path).len();
43
44    if pending_entries == 0 && !force_when_empty {
45        return Ok(RebuildOutcome {
46            rebuilt: false,
47            pending_entries: 0,
48        });
49    }
50
51    let previous_blocks = read_meta_block_count(output_dir);
52
53    build(config, true, heavy)?;
54
55    let rebuilt_blocks = read_meta_block_count(output_dir);
56    if pending_entries > 0 && previous_blocks > 0 && rebuilt_blocks < previous_blocks {
57        return Err(format!(
58            "refusing rebuild shrink: {} -> {} blocks",
59            previous_blocks, rebuilt_blocks
60        ));
61    }
62    if append_path.exists() {
63        fs::remove_file(&append_path).map_err(|e| format!("clear append log: {}", e))?;
64        // The append-log vectors are keyed by position in that log, so they die
65        // with it: the entries are now main blocks and were embedded by the
66        // build above.
67        let sidecar = Path::new(&config.paths.output_dir)
68            .join(crate::embedding_index::APPEND_EMBEDDINGS_FILE);
69        let _ = fs::remove_file(sidecar);
70    }
71
72    Ok(RebuildOutcome {
73        rebuilt: true,
74        pending_entries,
75    })
76}
77
78/// Consolidate the append log automatically when the configured threshold is
79/// reached, or immediately when no main index exists yet.
80pub fn maybe_auto_rebuild(config: &Config) -> Result<Option<usize>, String> {
81    if !config.index.auto_rebuild {
82        return Ok(None);
83    }
84
85    let output_dir = Path::new(&config.paths.output_dir);
86    let append_path = output_dir.join("append.bin");
87    let pending_entries = read_append_log(&append_path).len();
88    if pending_entries == 0 {
89        return Ok(None);
90    }
91
92    let threshold = config.index.auto_rebuild_entries.max(1);
93    let index_missing = !output_dir.join("meta.bin").exists();
94    if !index_missing && pending_entries < threshold {
95        return Ok(None);
96    }
97
98    let outcome = rebuild_pending(config, false, false)?;
99    Ok(outcome.rebuilt.then_some(outcome.pending_entries))
100}
101
102fn read_meta_block_count(output_dir: &Path) -> usize {
103    let meta = match fs::read(output_dir.join("meta.bin")) {
104        Ok(meta) => meta,
105        Err(_) => return 0,
106    };
107    if meta.len() < 12 {
108        return 0;
109    }
110    u32::from_le_bytes(meta[8..12].try_into().unwrap()) as usize
111}
112
113// ─── Internal block for building ─────────────────────
114struct RawBlock {
115    data: Vec<u8>,
116    depth: u8,
117    x: f32,
118    y: f32,
119    z: f32,
120    layer_id: u8,
121    parent_idx: u32,
122    child_count: u16,
123    importance: u8,
124}
125
126// ─── Extract text values from RAW files ───────────────────
127// Zero JSON dependency. Standard UTF-8 text files.
128/// Split one layer entry that is longer than BLOCK_DATA_SIZE into pieces that
129/// each fit, without losing any bytes.
130///
131/// `to_block` truncates at BLOCK_DATA_SIZE, so a 3,000-character abstract used
132/// to be stored as 1,021 characters plus "...". Everything past that point was
133/// unreachable: not retrievable, not embedded, and not in the printed text the
134/// evaluation scores. Cutting on sentence boundaries and, failing that, on
135/// whitespace, means every character survives and each piece still starts and
136/// ends at a natural boundary.
137///
138/// The last piece may be short. That is fine and intended: merging it into the
139/// previous one is exactly the truncation this replaces.
140fn split_oversized_entry(line: &str) -> Vec<String> {
141    let limit = BLOCK_DATA_SIZE;
142    let mut pieces: Vec<String> = Vec::new();
143    let mut current = String::new();
144
145    for sentence in split_sentences(line) {
146        let sentence = sentence.trim();
147        if sentence.is_empty() {
148            continue;
149        }
150        if sentence.len() > limit {
151            // One sentence longer than the whole block. Flush what we have and
152            // hard-split this one on whitespace.
153            if !current.is_empty() {
154                pieces.push(std::mem::take(&mut current));
155            }
156            for word in sentence.split_whitespace() {
157                if !current.is_empty() && current.len() + word.len() + 1 > limit {
158                    pieces.push(std::mem::take(&mut current));
159                }
160                if !current.is_empty() {
161                    current.push(' ');
162                }
163                current.push_str(word);
164            }
165            continue;
166        }
167        if !current.is_empty() && current.len() + sentence.len() + 2 > limit {
168            pieces.push(std::mem::take(&mut current));
169        }
170        if !current.is_empty() {
171            current.push(' ');
172        }
173        current.push_str(sentence);
174    }
175    if !current.is_empty() {
176        pieces.push(current);
177    }
178    if pieces.is_empty() {
179        pieces.push(line.to_string());
180    }
181    pieces
182}
183
184// Files are read and split into blocks by default.
185
186fn extract_texts_from_file(path: &Path) -> Vec<(String, u8)> {
187    let mut texts: Vec<(String, u8)> = Vec::new();
188    let raw = match fs::read_to_string(path) {
189        Ok(s) => s,
190        Err(_) => return texts,
191    };
192
193    // Split by double newline or chunking
194    for chunk in raw.split("\n\n") {
195        let trimmed = chunk.trim();
196        if trimmed.len() > 3 {
197            let (text, importance) = crate::reader::parse_imp_marker(trimmed);
198            texts.push((text.to_string(), importance));
199        }
200    }
201
202    // Fallback if no doubles: one entry per line.
203    //
204    // The original bug this replaces: the whole file was cut at an arbitrary
205    // byte, so the 60-fact evaluation corpus became a single 1,024-byte block
206    // ending "...ends with .hu.\r\nThe user does not hav", and the fragment
207    // holding the answer lost the tail the evaluation looks for.
208    //
209    // The second attempt fixed that by cutting on line boundaries, but it
210    // *packed* consecutive lines into one block until the limit was reached.
211    // That was invisible at BLOCK_DATA_SIZE = 1024, where a typical entry
212    // overflowed the limit on its own, so one line became one block. Raising the
213    // limit to 16 KiB exposed it immediately: the same code turned 5,183 SciFact
214    // abstracts into 504 blocks of roughly ten documents each, and the
215    // evaluation would have measured retrieval over merged documents.
216    //
217    // A line in a layer file is an entry. Entries are not merged.
218    if texts.len() < 2 {
219        texts.clear();
220        for line in raw.lines() {
221            let line = line.trim();
222            if line.is_empty() {
223                continue;
224            }
225            if line.len() > BLOCK_DATA_SIZE {
226                // One entry larger than a whole block. `to_block` would cut it,
227                // losing the tail, so split it on sentence boundaries instead.
228                // On BEIR SciFact this used to hit 4,300 of 5,183 abstracts, and
229                // the resulting disagreement with the reference embeddings is
230                // what the earlier "long stored blocks" investigation chased.
231                for piece in split_oversized_entry(line) {
232                    let (text, importance) = crate::reader::parse_imp_marker(&piece);
233                    if text.trim().len() > 3 {
234                        texts.push((text.to_string(), importance));
235                    }
236                }
237            } else {
238                let (text, importance) = crate::reader::parse_imp_marker(line);
239                if text.trim().len() > 3 {
240                    texts.push((text.to_string(), importance));
241                }
242            }
243        }
244    }
245
246    texts
247}
248
249// ─── Split text into sentences ───────────────────────
250fn split_sentences(text: &str) -> Vec<String> {
251    let mut sentences = Vec::new();
252    let mut current = String::new();
253    let chars: Vec<char> = text.chars().collect();
254    for (i, &ch) in chars.iter().enumerate() {
255        current.push(ch);
256        if !matches!(ch, '.' | '!' | '?' | '\n') || current.trim().len() <= 10 {
257            continue;
258        }
259        // A period inside a token is not a sentence end. "ends with .hu." and
260        // "3.5" and "e.g." were being cut there, and the fragment that survived
261        // lost the very token a search would look for: the 60-fact corpus
262        // stored "The user's email address ends with ." with ".hu" split off
263        // into its own block. A terminator only counts when what follows is
264        // the end of the text, whitespace, or a capital -- which keeps
265        // sentence-initial capitals working without breaking abbreviations.
266        let next = chars.get(i + 1).copied();
267        let ends_here = match next {
268            None => true,
269            Some(c) => c.is_whitespace() || c.is_uppercase(),
270        };
271        if ends_here {
272            sentences.push(current.trim().to_string());
273            current = String::new();
274        }
275    }
276    if current.trim().len() > 5 {
277        sentences.push(current.trim().to_string());
278    }
279    sentences
280}
281
282// ─── Compute deterministic SHA-256 hash of all layer source files ────
283pub fn compute_layers_hash(config: &Config) -> [u8; 32] {
284    let layers_dir = Path::new(&config.paths.layers_dir);
285    let layer_files = &config.memory_layers.layers;
286    let mut sorted_names: Vec<&String> = layer_files.iter().collect();
287    sorted_names.sort();
288    let mut hasher = Sha256::new();
289    for name in &sorted_names {
290        let path = layers_dir.join(format!("{}.txt", name));
291        if let Ok(contents) = fs::read(&path) {
292            hasher.update(&contents);
293        }
294    }
295    let result = hasher.finalize();
296    let mut hash = [0u8; 32];
297    hash.copy_from_slice(&result);
298    hash
299}
300
301// ─── BUILD: layers/ → binary ─────────────────────────
302/// Rebuild the binary index from the raw layer files.
303///
304/// `heavy` regenerates the expensive deterministic sidecars (embeddings +
305/// fingerprint links). Auto-rebuild passes `false` and drops stale copies so
306/// positional vectors/links are never served with wrong block indices.
307pub fn build(config: &Config, force: bool, heavy: bool) -> Result<(), String> {
308    let layers_hash = compute_layers_hash(config);
309
310    // Incremental build check — skip if layers unchanged
311    if !force {
312        let output_dir = Path::new(&config.paths.output_dir);
313        let meta_path = output_dir.join("meta.bin");
314        if let Ok(meta) = fs::read(&meta_path) {
315            if meta.len() >= 152 && (&meta[0..4] == b"MSC3" || &meta[0..4] == b"MSC4") {
316                let stored_hash = &meta[120..152];
317                if stored_hash == &layers_hash[..] {
318                    println!("{}", "Layers unchanged — skipping rebuild".green().bold());
319                    return Ok(());
320                }
321            }
322        }
323    }
324
325    println!(
326        "{}",
327        "Building microscope from raw layers (zero JSON)..."
328            .cyan()
329            .bold()
330    );
331
332    let layers_dir = Path::new(&config.paths.layers_dir);
333    let output_dir = Path::new(&config.paths.output_dir);
334
335    // Load evidence ledger for flags stamping (epistemic class).
336    let ledger = crate::epistemic::EvidenceLedger::load_or_init(output_dir);
337
338    // Capture the pre-rebuild index for content-based state remapping. Rebuild
339    // re-derives block positions from layer files, so positional sidecars
340    // (Hebbian, spaced repetition, archetypes, patterns, eureka) must be
341    // remapped by matching old block content to the new layout — not by the
342    // depth sort order, which is a no-op because blocks are already
343    // depth-ordered.
344    let old_snapshot = capture_old_snapshot(config);
345    let layers_changed = read_stored_layers_hash(output_dir)
346        .map(|stored| stored != layers_hash)
347        .unwrap_or(true);
348
349    if !output_dir.exists() {
350        fs::create_dir_all(output_dir).map_err(|e| format!("create output dir: {}", e))?;
351    }
352
353    let layer_files = &config.memory_layers.layers;
354
355    // Collect all raw texts per layer
356    let mut layer_texts: Vec<(String, Vec<(String, u8)>)> = Vec::new();
357    for name in layer_files {
358        let path = layers_dir.join(format!("{}.txt", name));
359        let texts = extract_texts_from_file(&path);
360        println!("  {} {}: {} items", ">".green(), name, texts.len());
361        layer_texts.push((name.clone(), texts));
362    }
363
364    let mut blocks: Vec<RawBlock> = Vec::new();
365
366    // ═══ DEPTH 0: Identity ═══
367    let identity = "Microscope Memory: 9-depth hierarchical cognitive engine. Binary mmap, sub-microsecond spatial search, Hebbian learning, Merkle integrity.";
368    blocks.push(RawBlock {
369        data: to_block(identity),
370        depth: 0,
371        x: 0.25,
372        y: 0.25,
373        z: 0.25,
374        layer_id: 0,
375        parent_idx: u32::MAX,
376        child_count: layer_files.len() as u16,
377        importance: 8,
378    });
379
380    // ═══ DEPTH 1: Layer summaries ═══
381    let sw = config.search.semantic_weight;
382    let depth1_start = blocks.len();
383    for (name, texts) in &layer_texts {
384        let preview: Vec<String> = texts
385            .iter()
386            .take(3)
387            .map(|(t, _)| safe_truncate(t, 40))
388            .collect();
389        let summary = format!("[{}] {} elem. {}", name, texts.len(), preview.join(" | "));
390        let (x, y, z) = content_coords_blended(name, name, sw);
391        blocks.push(RawBlock {
392            data: to_block(&summary),
393            depth: 1,
394            x,
395            y,
396            z,
397            layer_id: layer_to_id(name),
398            parent_idx: 0,
399            child_count: texts.len().div_ceil(5) as u16, // cluster count
400            importance: 5,
401        });
402    }
403
404    // ═══ DEPTH 2: Clusters (5 items each) ═══
405    let _depth2_start = blocks.len();
406    let mut depth2_layer_offsets: Vec<(usize, usize)> = Vec::new(); // (start_in_blocks, count)
407    for (li, (name, texts)) in layer_texts.iter().enumerate() {
408        let cluster_start = blocks.len();
409        for ci in (0..texts.len()).step_by(5) {
410            let chunk: Vec<String> = texts[ci..texts.len().min(ci + 5)]
411                .iter()
412                .map(|(t, _)| safe_truncate(t, 40))
413                .collect();
414            let summary = format!("[{} #{}] {}", name, ci / 5, chunk.join(" | "));
415            let (x, y, z) = content_coords_blended(&summary, name, sw);
416            blocks.push(RawBlock {
417                data: to_block(&summary),
418                depth: 2,
419                x,
420                y,
421                z,
422                layer_id: layer_to_id(name),
423                parent_idx: (depth1_start + li) as u32,
424                child_count: chunk.len() as u16,
425                importance: 5,
426            });
427        }
428        depth2_layer_offsets.push((cluster_start, blocks.len() - cluster_start));
429    }
430
431    // ═══ DEPTH 3: Individual items ═══
432    let depth3_start = blocks.len();
433    let mut depth3_positions: Vec<(f32, f32, f32)> = Vec::new();
434    for (li, (name, texts)) in layer_texts.iter().enumerate() {
435        for (ti, (text, item_importance)) in texts.iter().enumerate() {
436            let (x, y, z) = content_coords_blended(text, name, sw);
437            let cluster_idx = ti / 5;
438            let (d2_start, d2_count) = depth2_layer_offsets[li];
439            let parent = if cluster_idx < d2_count {
440                (d2_start + cluster_idx) as u32
441            } else {
442                u32::MAX
443            };
444
445            blocks.push(RawBlock {
446                data: to_block(text),
447                depth: 3,
448                x,
449                y,
450                z,
451                layer_id: layer_to_id(name),
452                parent_idx: parent,
453                child_count: 0, // will update
454                importance: *item_importance,
455            });
456            depth3_positions.push((x, y, z));
457        }
458    }
459
460    // ═══ DEPTH 4: Sentences ═══
461    let _depth4_start = blocks.len();
462    let mut depth4_parents: Vec<usize> = Vec::new();
463
464    let d4_results: Vec<Vec<RawBlock>> = (depth3_start..(depth3_start + depth3_positions.len()))
465        .into_par_iter()
466        .map(|d3i| {
467            let text = std::str::from_utf8(&blocks[d3i].data).unwrap_or("");
468            let sentences = split_sentences(text);
469            let mut local_blocks = Vec::new();
470            for sent in &sentences {
471                if sent.len() < 10 {
472                    continue;
473                }
474                let (px, py, pz) = depth3_positions[d3i - depth3_start];
475                let h = sent
476                    .as_bytes()
477                    .iter()
478                    .fold(0u64, |a, &b| a.wrapping_mul(31).wrapping_add(b as u64));
479                let ox = ((h & 0xFF) as f32 - 128.0) / 25500.0;
480                let oy = (((h >> 8) & 0xFF) as f32 - 128.0) / 25500.0;
481                let oz = (((h >> 16) & 0xFF) as f32 - 128.0) / 25500.0;
482
483                local_blocks.push(RawBlock {
484                    data: to_block(sent),
485                    depth: 4,
486                    x: px + ox,
487                    y: py + oy,
488                    z: pz + oz,
489                    layer_id: blocks[d3i].layer_id,
490                    parent_idx: d3i as u32,
491                    child_count: 0,
492                    importance: 5,
493                });
494            }
495            local_blocks
496        })
497        .collect();
498
499    for (i, local) in d4_results.into_iter().enumerate() {
500        let d3i = depth3_start + i;
501        blocks[d3i].child_count = local.len() as u16;
502        for b in local {
503            blocks.push(b);
504            depth4_parents.push(blocks.len() - 1);
505        }
506    }
507
508    // ═══ DEPTH 5: Tokens (words) ═══
509    let mut depth5_parents: Vec<usize> = Vec::new();
510    let depth4_parents_clone = depth4_parents.clone();
511    let d5_results: Vec<Vec<RawBlock>> = depth4_parents
512        .into_par_iter()
513        .map(|d4i| {
514            let text_owned = String::from_utf8_lossy(&blocks[d4i].data).to_string();
515            let px = blocks[d4i].x;
516            let py = blocks[d4i].y;
517            let pz = blocks[d4i].z;
518            let lid = blocks[d4i].layer_id;
519
520            let tokens: Vec<String> = text_owned
521                .split_whitespace()
522                .take(8)
523                .map(|s| s.to_string())
524                .collect();
525            let mut local_blocks = Vec::new();
526            for tok in &tokens {
527                if tok.len() < 2 {
528                    continue;
529                }
530                let h = tok
531                    .as_bytes()
532                    .iter()
533                    .fold(0u64, |a, &b| a.wrapping_mul(31).wrapping_add(b as u64));
534                let ox = ((h & 0xFF) as f32 - 128.0) / 255000.0;
535                let oy = (((h >> 8) & 0xFF) as f32 - 128.0) / 255000.0;
536                let oz = (((h >> 16) & 0xFF) as f32 - 128.0) / 255000.0;
537
538                local_blocks.push(RawBlock {
539                    data: to_block(tok),
540                    depth: 5,
541                    x: px + ox,
542                    y: py + oy,
543                    z: pz + oz,
544                    layer_id: lid,
545                    parent_idx: d4i as u32,
546                    child_count: 0,
547                    importance: 5,
548                });
549            }
550            local_blocks
551        })
552        .collect();
553
554    for (i, local) in d5_results.into_iter().enumerate() {
555        let d4i = depth4_parents_clone[i];
556        blocks[d4i].child_count = local.len() as u16;
557        for b in local {
558            blocks.push(b);
559            depth5_parents.push(blocks.len() - 1);
560        }
561    }
562
563    // ═══ DEPTH 6: Syllables / morphemes (sub-word) ═══
564    let mut depth6_parents: Vec<usize> = Vec::new();
565    let d6_results: Vec<Vec<RawBlock>> = depth5_parents
566        .clone()
567        .into_par_iter()
568        .map(|d5i| {
569            let text_owned = String::from_utf8_lossy(&blocks[d5i].data).to_string();
570            let px = blocks[d5i].x;
571            let py = blocks[d5i].y;
572            let pz = blocks[d5i].z;
573            let lid = blocks[d5i].layer_id;
574
575            let chars: Vec<char> = text_owned.chars().collect();
576            if chars.len() < 3 {
577                return vec![];
578            }
579            let chunk_size = 3.max(chars.len() / 3).min(5);
580            let mut local_blocks = Vec::new();
581            for chunk in chars.chunks(chunk_size) {
582                let syl: String = chunk.iter().collect();
583                if syl.trim().is_empty() {
584                    continue;
585                }
586                let h = syl
587                    .as_bytes()
588                    .iter()
589                    .fold(0u64, |a, &b| a.wrapping_mul(37).wrapping_add(b as u64));
590                let ox = ((h & 0xFF) as f32 - 128.0) / 2550000.0;
591                let oy = (((h >> 8) & 0xFF) as f32 - 128.0) / 2550000.0;
592                let oz = (((h >> 16) & 0xFF) as f32 - 128.0) / 2550000.0;
593
594                local_blocks.push(RawBlock {
595                    data: to_block(&syl),
596                    depth: 6,
597                    x: px + ox,
598                    y: py + oy,
599                    z: pz + oz,
600                    layer_id: lid,
601                    parent_idx: d5i as u32,
602                    child_count: 0,
603                    importance: 5,
604                });
605            }
606            local_blocks
607        })
608        .collect();
609
610    for (i, local) in d6_results.into_iter().enumerate() {
611        let d5i = depth5_parents[i];
612        blocks[d5i].child_count = local.len() as u16;
613        for b in local {
614            blocks.push(b);
615            depth6_parents.push(blocks.len() - 1);
616        }
617    }
618
619    // ═══ DEPTH 7: Characters ═══
620    let mut depth7_parents: Vec<usize> = Vec::new();
621    let d7_results: Vec<Vec<RawBlock>> = depth6_parents
622        .clone()
623        .into_par_iter()
624        .map(|d6i| {
625            let text_owned = String::from_utf8_lossy(&blocks[d6i].data).to_string();
626            let px = blocks[d6i].x;
627            let py = blocks[d6i].y;
628            let pz = blocks[d6i].z;
629            let lid = blocks[d6i].layer_id;
630
631            let mut local_blocks = Vec::new();
632            for ch in text_owned.chars() {
633                if ch.is_whitespace() {
634                    continue;
635                }
636                let h = (ch as u64).wrapping_mul(0x517cc1b727220a95);
637                let ox = ((h & 0xFF) as f32 - 128.0) / 25500000.0;
638                let oy = (((h >> 8) & 0xFF) as f32 - 128.0) / 25500000.0;
639                let oz = (((h >> 16) & 0xFF) as f32 - 128.0) / 25500000.0;
640
641                let ch_str = ch.to_string();
642                local_blocks.push(RawBlock {
643                    data: to_block(&ch_str),
644                    depth: 7,
645                    x: px + ox,
646                    y: py + oy,
647                    z: pz + oz,
648                    layer_id: lid,
649                    parent_idx: d6i as u32,
650                    child_count: 0,
651                    importance: 5,
652                });
653            }
654            local_blocks
655        })
656        .collect();
657
658    for (i, local) in d7_results.into_iter().enumerate() {
659        let d6i = depth6_parents[i];
660        blocks[d6i].child_count = local.len() as u16;
661        for b in local {
662            blocks.push(b);
663            depth7_parents.push(blocks.len() - 1);
664        }
665    }
666
667    // ═══ DEPTH 8: Raw bytes — the atomic level. Below this, data corrupts. ═══
668    let d8_results: Vec<Vec<RawBlock>> = depth7_parents
669        .clone()
670        .into_par_iter()
671        .map(|d7i| {
672            let text_owned = String::from_utf8_lossy(&blocks[d7i].data).to_string();
673            let px = blocks[d7i].x;
674            let py = blocks[d7i].y;
675            let pz = blocks[d7i].z;
676            let lid = blocks[d7i].layer_id;
677
678            let bytes = text_owned.as_bytes();
679            let mut local_blocks = Vec::new();
680            for &byte in bytes {
681                let hex = format!("0x{:02X}", byte);
682                let h = (byte as u64).wrapping_mul(0x9E3779B97F4A7C15);
683                let ox = ((h & 0xFF) as f32 - 128.0) / 255000000.0;
684                let oy = (((h >> 8) & 0xFF) as f32 - 128.0) / 255000000.0;
685                let oz = (((h >> 16) & 0xFF) as f32 - 128.0) / 255000000.0;
686
687                local_blocks.push(RawBlock {
688                    data: to_block(&hex),
689                    depth: 8,
690                    x: px + ox,
691                    y: py + oy,
692                    z: pz + oz,
693                    layer_id: lid,
694                    parent_idx: d7i as u32,
695                    child_count: 0, // LEAF. Below = corruption.
696                    importance: 5,
697                });
698            }
699            local_blocks
700        })
701        .collect();
702
703    for (i, local) in d8_results.into_iter().enumerate() {
704        let d7i = depth7_parents[i];
705        blocks[d7i].child_count = local.len() as u16;
706        for b in local {
707            blocks.push(b);
708        }
709    }
710
711    let n = blocks.len();
712    println!("\n  {} blocks total", n);
713
714    // Sort by depth (a stable no-op in practice — blocks are built in depth
715    // order, so this leaves the layout untouched).
716    let mut indices: Vec<usize> = (0..n).collect();
717    indices.sort_by_key(|&i| blocks[i].depth);
718
719    // Remap parent indices after the (no-op) sort — only used for header wiring.
720    let mut sort_old_to_new = vec![0u32; n];
721    for (new_i, &old_i) in indices.iter().enumerate() {
722        sort_old_to_new[old_i] = new_i as u32;
723    }
724
725    // Content-based mapping from the PREVIOUS index layout to the new one.
726    // Sidecar states reference blocks by position; positions shift whenever
727    // layer entries are truncated (retention) or edited, so we match old block
728    // content (depth + layer + bytes) to the freshly built layout. Blocks that
729    // no longer exist map to u32::MAX and are dropped by the remap routines.
730    let content_old_to_new = compute_content_remap(&old_snapshot, &blocks);
731
732    // Write binary files
733    let output_dir = Path::new(&config.paths.output_dir);
734    fs::create_dir_all(output_dir).ok();
735
736    let hdr_path = output_dir.join("microscope.bin");
737    let dat_path = output_dir.join("data.bin");
738    let meta_path = output_dir.join("meta.bin");
739
740    let mut hdr_file = BufWriter::new(
741        fs::File::create(&hdr_path).map_err(|e| format!("create microscope.bin: {}", e))?,
742    );
743    let mut dat_file =
744        BufWriter::new(fs::File::create(&dat_path).map_err(|e| format!("create data.bin: {}", e))?);
745
746    let mut depth_ranges: Vec<(u32, u32)> = vec![(0, 0); 9];
747    let mut cur_depth: u8 = 0;
748    let mut range_start: u32 = 0;
749
750    for (new_i, &old_i) in indices.iter().enumerate() {
751        let b = &blocks[old_i];
752        let offset = dat_file
753            .stream_position()
754            .map_err(|e| format!("data.bin stream_position: {}", e))? as u32;
755        let len = b.data.len().min(BLOCK_DATA_SIZE) as u16;
756        dat_file
757            .write_all(&b.data[..len as usize])
758            .map_err(|e| format!("write data.bin: {}", e))?;
759
760        let parent = if b.parent_idx == u32::MAX {
761            u32::MAX
762        } else {
763            sort_old_to_new[b.parent_idx as usize]
764        };
765
766        let crc = crc16_ccitt(&b.data[..len as usize]);
767        let hdr = BlockHeader {
768            x: b.x,
769            y: b.y,
770            z: b.z,
771            zoom: b.depth as f32 / 8.0,
772            depth: b.depth,
773            layer_id: b.layer_id,
774            data_offset: offset,
775            data_len: len,
776            parent_idx: parent,
777            child_count: b.child_count,
778            crc16: crc.to_le_bytes(),
779            project_id: config.project_id,
780            importance: b.importance,
781            flags: {
782                // Stamp epistemic class from evidence ledger into flags bits 0-2.
783                let text_str = std::str::from_utf8(&b.data[..len as usize])
784                    .unwrap_or("")
785                    .trim_end_matches('\0')
786                    .trim();
787                let ch = crate::epistemic::content_hash(text_str);
788                match ledger.records.get(&ch) {
789                    Some(rec) => rec.class as u8 & 0x07,
790                    None => 0,
791                }
792            },
793        };
794
795        let bytes: &[u8] = unsafe {
796            std::slice::from_raw_parts(&hdr as *const BlockHeader as *const u8, HEADER_SIZE)
797        };
798        hdr_file
799            .write_all(bytes)
800            .map_err(|e| format!("write microscope.bin: {}", e))?;
801
802        // Track depth ranges
803        if b.depth != cur_depth {
804            depth_ranges[cur_depth as usize] = (range_start, new_i as u32 - range_start);
805            range_start = new_i as u32;
806            cur_depth = b.depth;
807        }
808    }
809    depth_ranges[cur_depth as usize] = (range_start, n as u32 - range_start);
810    hdr_file
811        .flush()
812        .map_err(|e| format!("flush microscope.bin: {}", e))?;
813    dat_file
814        .flush()
815        .map_err(|e| format!("flush data.bin: {}", e))?;
816
817    // ═══ Optional zstd compression of data.bin ═══
818    #[cfg(feature = "compression")]
819    if config.performance.compression {
820        let raw_data =
821            fs::read(&dat_path).map_err(|e| format!("read data.bin for compression: {}", e))?;
822        let raw_size = raw_data.len();
823        let compressed = zstd::encode_all(std::io::Cursor::new(&raw_data), 3)
824            .map_err(|e| format!("zstd compress: {}", e))?;
825        let comp_size = compressed.len();
826        let zst_path = output_dir.join("data.bin.zst");
827        fs::write(&zst_path, &compressed).map_err(|e| format!("write data.bin.zst: {}", e))?;
828        let ratio = if comp_size > 0 {
829            raw_size as f64 / comp_size as f64
830        } else {
831            0.0
832        };
833        println!(
834            "  {}: {} → {} bytes ({:.1}x ratio)",
835            "zstd".green(),
836            raw_size,
837            comp_size,
838            ratio,
839        );
840    }
841
842    // ═══ Merkle tree: SHA-256 over all block data ═══
843    let merkle_path = output_dir.join("merkle.bin");
844    // Re-read data.bin to get all block data slices for Merkle leaves
845    hdr_file
846        .flush()
847        .map_err(|e| format!("flush microscope.bin: {}", e))?;
848    dat_file
849        .flush()
850        .map_err(|e| format!("flush data.bin: {}", e))?;
851
852    let dat_bytes = fs::read(&dat_path).map_err(|e| format!("read data.bin for merkle: {}", e))?;
853    let hdr_bytes =
854        fs::read(&hdr_path).map_err(|e| format!("read microscope.bin for merkle: {}", e))?;
855    let mut leaf_slices: Vec<&[u8]> = Vec::with_capacity(n);
856    for i in 0..n {
857        let hdr_off = i * HEADER_SIZE;
858        let data_offset =
859            u32::from_le_bytes(hdr_bytes[hdr_off + 18..hdr_off + 22].try_into().unwrap()) as usize;
860        let data_len =
861            u16::from_le_bytes(hdr_bytes[hdr_off + 22..hdr_off + 24].try_into().unwrap()) as usize;
862        if data_offset + data_len <= dat_bytes.len() {
863            leaf_slices.push(&dat_bytes[data_offset..data_offset + data_len]);
864        } else {
865            leaf_slices.push(&[]);
866        }
867    }
868
869    let merkle_tree = merkle::MerkleTree::build(&leaf_slices);
870    let merkle_tmp = merkle_path.with_extension("bin.tmp");
871    fs::write(&merkle_tmp, merkle_tree.to_bytes())
872        .map_err(|e| format!("write merkle.bin: {}", e))?;
873    fs::rename(&merkle_tmp, &merkle_path).map_err(|e| format!("rename merkle.bin: {}", e))?;
874    println!(
875        "  {}: {} leaves, root={}",
876        "merkle".green(),
877        merkle_tree.leaf_count,
878        hex_str(&merkle_tree.root)
879    );
880
881    // meta.bin — MSC4 format with merkle root + layers hash
882    let mut meta_buf = Vec::with_capacity(META_HEADER_SIZE + 9 * DEPTH_ENTRY_SIZE + 32 + 32);
883    meta_buf.extend_from_slice(b"MSC4"); // magic v4
884    meta_buf.extend_from_slice(&4u32.to_le_bytes()); // version
885    meta_buf.extend_from_slice(&(n as u32).to_le_bytes()); // block_count
886    meta_buf.extend_from_slice(&9u32.to_le_bytes()); // depth_count
887    for &(start, count) in &depth_ranges {
888        meta_buf.extend_from_slice(&start.to_le_bytes());
889        meta_buf.extend_from_slice(&count.to_le_bytes());
890    }
891    meta_buf.extend_from_slice(&merkle_tree.root); // 32 bytes merkle root
892    meta_buf.extend_from_slice(&layers_hash); // 32 bytes layers content hash
893    let meta_tmp = meta_path.with_extension("bin.tmp");
894    fs::write(&meta_tmp, &meta_buf).map_err(|e| format!("write meta.bin: {}", e))?;
895    fs::rename(&meta_tmp, meta_path).map_err(|e| format!("rename meta.bin: {}", e))?;
896
897    // Report
898    let hdr_size = n * HEADER_SIZE;
899    let dat_size = dat_file.stream_position().unwrap_or(0) as usize; // Get final data size
900    let meta_size = meta_buf.len();
901    println!(
902        "\n  {}: {} bytes ({:.1} KB)",
903        "headers".green(),
904        hdr_size,
905        hdr_size as f64 / 1024.0
906    );
907    println!(
908        "  {}:    {} bytes ({:.1} KB)",
909        "data".green(),
910        dat_size,
911        dat_size as f64 / 1024.0
912    );
913    println!("  {}:    {} bytes", "meta".green(), meta_size);
914    println!(
915        "  {}:   {:.1} KB",
916        "TOTAL".yellow().bold(),
917        (hdr_size + dat_size + meta_size) as f64 / 1024.0
918    );
919
920    let fits = if hdr_size < 32768 {
921        "L1d (32KB)"
922    } else if hdr_size < 262144 {
923        "L2 (256KB)"
924    } else {
925        "L3"
926    };
927    println!("  cache:   {}", fits.green().bold());
928
929    for (d, &(_start, count)) in depth_ranges.iter().enumerate() {
930        println!("  Depth {}: {:>5} blocks", d, count);
931    }
932
933    // ═══ Embedding index + structural fingerprinting (heavy sidecars) ═══
934    // Regenerated only on explicit full builds. The light (auto) rebuild path
935    // drops stale copies instead of serving positional vectors/links that no
936    // longer match the rebuilt block layout.
937    if heavy {
938        // ═══ Embedding index (mock provider, or candle if enabled) ═══
939        if config.embedding.provider != "none" {
940            println!("\n  Building embedding index...");
941            let emb_path = output_dir.join("embeddings.bin");
942            let reader = MicroscopeReader::open(config)?;
943            let max_depth = config.embedding.max_depth;
944
945            let provider: Box<dyn crate::embeddings::EmbeddingProvider> =
946                crate::embeddings::provider_from_config(&config.embedding, config.embedding.dim);
947
948            match crate::embedding_index::build_embedding_index(
949                &*provider, &reader, max_depth, &emb_path,
950            ) {
951                Ok(()) => println!("  {} embeddings.bin built", "OK".green()),
952                Err(e) => eprintln!("  {} embedding build: {}", "ERR".red(), e),
953            }
954        }
955
956        // ═══ Inverted text index (fast find_text / recall prefilter) ═══
957        let text_index_result = (|| -> Result<(), String> {
958            let reader = MicroscopeReader::open(config)?;
959            crate::text_index::build_text_index(
960                (0..reader.block_count).map(|i| reader.text(i)),
961                reader.block_count,
962                &output_dir.join("text_index.bin"),
963            )
964        })();
965        match text_index_result {
966            Ok(()) => println!("  {} text_index.bin built", "OK".green()),
967            Err(e) => eprintln!("  {} text index build: {}", "ERR".red(), e),
968        }
969
970        // ═══ Hebbian delta integration ═══
971        let hebb_path = output_dir.join("activations.bin");
972        if hebb_path.exists() {
973            let hebb = crate::hebbian::HebbianState::load_or_init(output_dir, n);
974            let drifted = hebb
975                .activations
976                .iter()
977                .filter(|r| {
978                    r.drift_x.abs() > 0.001 || r.drift_y.abs() > 0.001 || r.drift_z.abs() > 0.001
979                })
980                .count();
981
982            if drifted > 0 {
983                apply_hebbian_deltas(output_dir, &hebb, n)?;
984                println!(
985                    "  {} Hebbian deltas applied to {} blocks",
986                    "HEBBIAN".magenta(),
987                    drifted
988                );
989            }
990        }
991
992        // ═══ Structural fingerprinting ═══
993        {
994            let reader = MicroscopeReader::open(config)?;
995            let texts: Vec<&str> = (0..reader.block_count).map(|i| reader.text(i)).collect();
996            let table = crate::fingerprint::LinkTable::build(&texts);
997            table.save(output_dir)?;
998            let stats = table.stats();
999            println!(
1000                "  {} {} links across {} blocks",
1001                "FINGERPRINT".cyan(),
1002                stats.link_count,
1003                stats.block_count
1004            );
1005        }
1006    } else if layers_changed {
1007        // Light rebuild with content change: stale *positional* sidecars must
1008        // not be served — drop them and rebuild on the next explicit `build`.
1009        //
1010        // `text_index.bin` is in this list on purpose: its postings are block
1011        // ids, and a rebuild reassigns those, so the file left over from the
1012        // previous layout would answer queries with the wrong blocks. The index
1013        // is cheap to regenerate (a heavy build takes milliseconds), but this
1014        // path deliberately does not spend them; `tests/integration.rs::
1015        // test_light_rebuild_drops_stale_text_index` pins the behaviour.
1016        //
1017        // Consequence for the recall speedup: after a light rebuild the reader
1018        // falls back to a full scan until the next heavy build, or until the
1019        // index is regenerated by hand (the `index_text` example).
1020        for name in [
1021            "embeddings.bin",
1022            "links.bin",
1023            "fingerprints.idx",
1024            "text_index.bin",
1025        ] {
1026            let p = output_dir.join(name);
1027            if p.exists() {
1028                let _ = fs::remove_file(&p);
1029            }
1030        }
1031        println!(
1032            "  {} heavy sidecars skipped (embeddings, fingerprint); stale copies removed",
1033            "LIGHT".cyan()
1034        );
1035    } else {
1036        println!(
1037            "  {} layers unchanged — heavy sidecars kept",
1038            "LIGHT".cyan()
1039        );
1040    }
1041
1042    // ═══ Emotion log → emotions.bin ═══
1043    {
1044        let reader = MicroscopeReader::open(config)?;
1045        crate::reader::build_emotions_from_log(output_dir, &reader)?;
1046    }
1047
1048    // ═══ Remap cognitive state after the layout change ═══
1049    // Rebuild re-derives block positions from layer files; every positional
1050    // sidecar must be remapped through the content-based mapping so learned
1051    // state keeps pointing at the right blocks after a retention shift or an
1052    // edit. (MM-001 only covered Hebbian/PredictiveCache/Mirror and used the
1053    // depth-sort mapping, which is the identity — the fix here matches content.)
1054    {
1055        let mut hebb = crate::hebbian::HebbianState::load_or_init(output_dir, n);
1056        hebb.remap_indexes(&content_old_to_new);
1057        // The remap sizes the vector to the highest surviving block; pad back
1058        // to the full new block count so every block has a record again.
1059        if hebb.activations.len() < n {
1060            hebb.activations
1061                .resize(n, crate::hebbian::ActivationRecord::default());
1062        }
1063        hebb.save(output_dir)
1064            .map_err(|e| format!("save hebbian after remap: {e}"))?;
1065        println!("  {} Hebbian state remapped (content)", "REMAP".magenta());
1066    }
1067    {
1068        let mut pc = crate::predictive_cache::PredictiveCache::load_or_init(output_dir);
1069        pc.remap_indexes(&content_old_to_new);
1070        pc.save(output_dir)
1071            .map_err(|e| format!("save predictive_cache after remap: {e}"))?;
1072        println!("  {} PredictiveCache remapped", "REMAP".magenta());
1073    }
1074    {
1075        let mut mirror = crate::mirror::MirrorState::load_or_init(output_dir);
1076        mirror.remap_indexes(&content_old_to_new);
1077        mirror
1078            .save(output_dir)
1079            .map_err(|e| format!("save mirror after remap: {e}"))?;
1080        println!("  {} MirrorState remapped", "REMAP".magenta());
1081    }
1082    {
1083        let mut spaced = crate::spaced_repetition::SpacedRepetition::load_or_init(output_dir);
1084        spaced.remap_indexes(&content_old_to_new);
1085        spaced
1086            .save(output_dir)
1087            .map_err(|e| format!("save spaced after remap: {e}"))?;
1088        println!("  {} SpacedRepetition remapped", "REMAP".magenta());
1089    }
1090    {
1091        let mut archetypes = crate::archetype::ArchetypeState::load_or_init(output_dir);
1092        archetypes.remap_indexes(&content_old_to_new);
1093        archetypes
1094            .save(output_dir)
1095            .map_err(|e| format!("save archetypes after remap: {e}"))?;
1096        println!("  {} Archetypes remapped", "REMAP".magenta());
1097    }
1098    {
1099        let mut tg = crate::thought_graph::ThoughtGraphState::load_or_init(output_dir);
1100        tg.remap_indexes(&content_old_to_new);
1101        tg.save(output_dir)
1102            .map_err(|e| format!("save thought_graph after remap: {e}"))?;
1103        println!("  {} ThoughtPatterns remapped", "REMAP".magenta());
1104    }
1105    {
1106        let mut eureka = crate::eureka::EurekaLog::load_or_init(output_dir);
1107        eureka.remap_indexes(&content_old_to_new);
1108        eureka
1109            .save(output_dir)
1110            .map_err(|e| format!("save eureka after remap: {e}"))?;
1111        println!("  {} EurekaLog remapped", "REMAP".magenta());
1112    }
1113
1114    // ═══ Text index: regenerate when missing (light path dropped it) ═══
1115    // The layout is final here (all remaps above are done), so a fresh index
1116    // is never stale. The heavy path already built it, so this is a no-op
1117    // there; the light path dropped it as a stale positional sidecar, and
1118    // this restores the indexed recall path instead of leaving every query
1119    // on a full scan until the next heavy build. Content-derived and cheap
1120    // (milliseconds for tens of thousands of blocks), unlike the embedding
1121    // index which stays dropped by policy.
1122    {
1123        let tix_path = output_dir.join("text_index.bin");
1124        if !tix_path.exists() {
1125            let tix_result = (|| -> Result<(), String> {
1126                let reader = MicroscopeReader::open(config)?;
1127                crate::text_index::build_text_index(
1128                    (0..reader.block_count).map(|i| reader.text(i)),
1129                    reader.block_count,
1130                    &tix_path,
1131                )
1132            })();
1133            match tix_result {
1134                Ok(()) => println!("  {} text_index.bin regenerated", "OK".green()),
1135                Err(e) => eprintln!("  {} text index regenerate: {}", "ERR".red(), e),
1136            }
1137        }
1138    }
1139
1140    println!("\n{}", "ZERO JSON. Pure binary. Done.".green().bold());
1141    Ok(())
1142}
1143
1144/// Post-process: apply Hebbian drift deltas to microscope.bin header coordinates.
1145fn apply_hebbian_deltas(
1146    output_dir: &Path,
1147    hebb: &crate::hebbian::HebbianState,
1148    block_count: usize,
1149) -> Result<(), String> {
1150    let hdr_path = output_dir.join("microscope.bin");
1151    let mut data = fs::read(&hdr_path).map_err(|e| format!("read microscope.bin: {}", e))?;
1152
1153    for i in 0..block_count.min(hebb.activations.len()) {
1154        let rec = &hebb.activations[i];
1155        if rec.drift_x.abs() < 0.001 && rec.drift_y.abs() < 0.001 && rec.drift_z.abs() < 0.001 {
1156            continue;
1157        }
1158
1159        let off = i * HEADER_SIZE;
1160        if off + 12 > data.len() {
1161            break;
1162        }
1163
1164        // Read current x, y, z (first 12 bytes of header, 3×f32 LE)
1165        let x = f32::from_le_bytes(data[off..off + 4].try_into().unwrap());
1166        let y = f32::from_le_bytes(data[off + 4..off + 8].try_into().unwrap());
1167        let z = f32::from_le_bytes(data[off + 8..off + 12].try_into().unwrap());
1168
1169        // Apply drift
1170        let new_x = x + rec.drift_x;
1171        let new_y = y + rec.drift_y;
1172        let new_z = z + rec.drift_z;
1173
1174        data[off..off + 4].copy_from_slice(&new_x.to_le_bytes());
1175        data[off + 4..off + 8].copy_from_slice(&new_y.to_le_bytes());
1176        data[off + 8..off + 12].copy_from_slice(&new_z.to_le_bytes());
1177    }
1178
1179    let hdr_tmp = hdr_path.with_extension("bin.tmp");
1180    fs::write(&hdr_tmp, &data).map_err(|e| format!("write microscope.bin: {}", e))?;
1181    fs::rename(&hdr_tmp, &hdr_path).map_err(|e| format!("rename microscope.bin: {}", e))?;
1182
1183    // Clear drift values after integration (they're now baked in)
1184    let mut hebb_clone = crate::hebbian::HebbianState {
1185        activations: hebb.activations.clone(),
1186        coactivations: hebb.coactivations.clone(),
1187        fingerprints: hebb.fingerprints.clone(),
1188    };
1189    for rec in &mut hebb_clone.activations {
1190        rec.drift_x = 0.0;
1191        rec.drift_y = 0.0;
1192        rec.drift_z = 0.0;
1193    }
1194    hebb_clone
1195        .save(output_dir)
1196        .map_err(|e| format!("save cleared Hebbian: {}", e))
1197}
1198
1199/// Snapshot of one block from the pre-rebuild index, used to remap positional
1200/// sidecar state by content after a rebuild changes block positions.
1201struct OldBlockSnapshot {
1202    depth: u8,
1203    layer_id: u8,
1204    text: Vec<u8>,
1205}
1206
1207/// Read every block of the current index into memory before it is overwritten.
1208fn capture_old_snapshot(config: &Config) -> Option<Vec<OldBlockSnapshot>> {
1209    let reader = MicroscopeReader::open(config).ok()?;
1210    let snapshots: Vec<OldBlockSnapshot> = (0..reader.block_count)
1211        .map(|i| {
1212            let h = reader.header(i);
1213            OldBlockSnapshot {
1214                depth: h.depth,
1215                layer_id: h.layer_id,
1216                text: reader.text(i).as_bytes().to_vec(),
1217            }
1218        })
1219        .collect();
1220    Some(snapshots)
1221}
1222
1223/// Read the layers content hash stored in the pre-rebuild meta.bin (MSC4).
1224fn read_stored_layers_hash(output_dir: &Path) -> Option<[u8; 32]> {
1225    let meta = fs::read(output_dir.join("meta.bin")).ok()?;
1226    if meta.len() < 152 || &meta[0..4] != b"MSC4" {
1227        return None;
1228    }
1229    let mut hash = [0u8; 32];
1230    hash.copy_from_slice(&meta[120..152]);
1231    Some(hash)
1232}
1233
1234/// Build a positional remap from the OLD index layout to the NEW one by
1235/// matching block content (depth + layer + bytes). Old blocks that have no
1236/// counterpart in the new layout map to `u32::MAX` and are dropped by the
1237/// sidecar remap routines. Duplicate content maps in order so repeated entries
1238/// keep their relative positions.
1239fn compute_content_remap(old: &Option<Vec<OldBlockSnapshot>>, new_blocks: &[RawBlock]) -> Vec<u32> {
1240    let old = match old {
1241        Some(v) => v,
1242        None => return Vec::new(),
1243    };
1244    if old.is_empty() {
1245        return Vec::new();
1246    }
1247
1248    // key = (depth, layer_id, data bytes) → new layout positions (ascending).
1249    let mut index: HashMap<(u8, u8, Vec<u8>), Vec<u32>> = HashMap::new();
1250    for (j, b) in new_blocks.iter().enumerate() {
1251        index
1252            .entry((b.depth, b.layer_id, b.data.clone()))
1253            .or_default()
1254            .push(j as u32);
1255    }
1256
1257    let mut cursor: HashMap<(u8, u8, Vec<u8>), usize> = HashMap::new();
1258    let mut map = vec![u32::MAX; old.len()];
1259    for (i, s) in old.iter().enumerate() {
1260        let key = (s.depth, s.layer_id, s.text.clone());
1261        if let Some(list) = index.get(&key) {
1262            let c = cursor.entry(key).or_insert(0);
1263            if *c < list.len() {
1264                map[i] = list[*c];
1265                *c += 1;
1266            }
1267        }
1268    }
1269    map
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    fn snap(depth: u8, layer_id: u8, text: &str) -> OldBlockSnapshot {
1277        OldBlockSnapshot {
1278            depth,
1279            layer_id,
1280            text: text.as_bytes().to_vec(),
1281        }
1282    }
1283
1284    fn block(depth: u8, layer_id: u8, text: &str) -> RawBlock {
1285        RawBlock {
1286            data: to_block(text),
1287            depth,
1288            x: 0.0,
1289            y: 0.0,
1290            z: 0.0,
1291            layer_id,
1292            parent_idx: u32::MAX,
1293            child_count: 0,
1294            importance: 5,
1295        }
1296    }
1297
1298    /// Retention shift: the first layer entry is truncated before rebuild, so
1299    /// every surviving block moves one position earlier. The content remap must
1300    /// follow the surviving blocks to their new positions.
1301    #[test]
1302    fn content_remap_follows_retention_shift() {
1303        let old = Some(vec![
1304            snap(3, 1, "Alpha first entry"),
1305            snap(3, 1, "Beta second entry"),
1306            snap(3, 1, "Gamma third entry"),
1307        ]);
1308        // New layout: Alpha is gone (retention), Beta and Gamma shift down.
1309        let new_blocks = vec![
1310            block(0, 0, "identity"),
1311            block(3, 1, "Beta second entry"),
1312            block(3, 1, "Gamma third entry"),
1313            block(4, 1, "Beta second entry"),
1314        ];
1315        let map = compute_content_remap(&old, &new_blocks);
1316        // Alpha has no counterpart → u32::MAX.
1317        assert_eq!(map[0], u32::MAX);
1318        // Beta (old #1) → first Beta in the new layout (#1). Note the D4
1319        // sentence "Beta second entry" also matches; ordered matching assigns
1320        // the first (D3) occurrence, which is what recall returns.
1321        assert_eq!(map[1], 1);
1322        assert_eq!(map[2], 2);
1323    }
1324
1325    /// Rebuild with unchanged content: every old block maps to itself.
1326    #[test]
1327    fn content_remap_is_identity_for_unchanged_content() {
1328        let old = Some(vec![
1329            snap(3, 1, "Alpha first entry"),
1330            snap(3, 1, "Beta second entry"),
1331        ]);
1332        let new_blocks = vec![
1333            block(0, 0, "identity"),
1334            block(3, 1, "Alpha first entry"),
1335            block(3, 1, "Beta second entry"),
1336        ];
1337        let map = compute_content_remap(&old, &new_blocks);
1338        assert_eq!(map, vec![1, 2]);
1339    }
1340
1341    /// No old index (first build): empty mapping is safe.
1342    #[test]
1343    fn content_remap_empty_without_old_index() {
1344        let map = compute_content_remap(&None, &[block(3, 1, "x")]);
1345        assert!(map.is_empty());
1346    }
1347
1348    /// An entry longer than one block must be split, not truncated.
1349    ///
1350    /// This is the SciFact regression. 4,300 of 5,183 abstracts exceed
1351    /// BLOCK_DATA_SIZE, and `to_block` used to cut them at 1,021 characters,
1352    /// so the tail of most of the corpus was never stored. Asserting on the
1353    /// reassembled word set is what makes the test meaningful: a truncation
1354    /// passes a "does it fit in a block" check, it fails this one.
1355    #[test]
1356    fn oversized_entry_is_split_without_losing_bytes() {
1357        let sentence = "The quick brown fox jumps over the lazy dog. ";
1358        // Sized past BLOCK_DATA_SIZE (16 KiB), not to a fixed byte count: this
1359        // constant was raised from 1024 and the test has to follow it, or it
1360        // silently stops exercising the split path it exists to cover.
1361        let entry = sentence.repeat(400);
1362        assert!(entry.len() > BLOCK_DATA_SIZE);
1363
1364        let pieces = split_oversized_entry(entry.trim());
1365        assert!(pieces.len() > 1, "expected the entry to be split");
1366        for p in &pieces {
1367            assert!(
1368                p.len() <= BLOCK_DATA_SIZE,
1369                "piece of {} bytes exceeds the block size",
1370                p.len()
1371            );
1372        }
1373
1374        let rejoined = pieces.join(" ");
1375        let original_words: Vec<&str> = entry.split_whitespace().collect();
1376        let rejoined_words: Vec<&str> = rejoined.split_whitespace().collect();
1377        assert_eq!(
1378            original_words, rejoined_words,
1379            "splitting changed the content of the entry"
1380        );
1381    }
1382
1383    /// The split must also survive a single sentence longer than a block.
1384    #[test]
1385    fn oversized_single_sentence_is_split_on_whitespace() {
1386        let entry = "word ".repeat(4000); // no sentence terminator at all
1387        assert!(entry.len() > BLOCK_DATA_SIZE);
1388        let pieces = split_oversized_entry(entry.trim());
1389        assert!(pieces.len() > 1);
1390        for p in &pieces {
1391            assert!(p.len() <= BLOCK_DATA_SIZE);
1392        }
1393        assert_eq!(pieces.join(" "), entry.trim());
1394    }
1395
1396    /// Ordinary short entries are untouched: no needless fragmentation.
1397    #[test]
1398    fn short_entry_is_returned_whole() {
1399        let entry = "A short fact that fits in a block. Another sentence.";
1400        let pieces = split_oversized_entry(entry);
1401        assert_eq!(pieces, vec![entry.to_string()]);
1402    }
1403
1404    /// A layer file with one entry per line must yield one block per line.
1405    ///
1406    /// This is the regression behind 504 blocks for 5,183 abstracts. The reader
1407    /// used to pack consecutive lines together until BLOCK_DATA_SIZE was
1408    /// reached, which was invisible while the limit was 1,024 and a typical
1409    /// abstract overflowed it alone. At 16 KiB the same code merged about ten
1410    /// documents per block, and the benchmark would have scored retrieval over
1411    /// merged documents while reporting a plausible-looking R@k.
1412    ///
1413    /// The entries here are sized well below the limit, so any packing
1414    /// whatsoever fails the assertion.
1415    #[test]
1416    fn one_line_per_entry_is_not_packed_into_shared_blocks() {
1417        let n = 40usize;
1418        let mut file = String::new();
1419        for i in 0..n {
1420            file.push_str(&format!("document number {} with some body text\n", i));
1421        }
1422        let tmp = tempfile::tempdir().expect("tempdir");
1423        let path = tmp.path().join("long_term.txt");
1424        fs::write(&path, &file).unwrap();
1425
1426        let texts = extract_texts_from_file(&path);
1427        assert_eq!(
1428            texts.len(),
1429            n,
1430            "one line must be one block; {} lines produced {} blocks",
1431            n,
1432            texts.len()
1433        );
1434        for (i, (text, _)) in texts.iter().enumerate() {
1435            assert!(
1436                text.contains(&format!("document number {}", i)),
1437                "block {} does not hold its own entry: {:?}",
1438                i,
1439                text
1440            );
1441        }
1442    }
1443}