Skip to main content

microscope_memory/
fingerprint.rs

1//! Structural fingerprinting for Microscope Memory.
2//!
3//! Each block gets a structural fingerprint at index time:
4//! Shannon entropy + byte distribution hash. Blocks with similar
5//! fingerprints are linked in `links.bin` — creating "wormholes"
6//! between structurally similar but spatially distant blocks.
7//!
8//! Binary format: fingerprints.idx (FGP1), links.bin (LNK1)
9
10use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use crate::parallel::*;
15
16// ─── Constants ──────────────────────────────────────
17
18/// Similarity threshold for creating a structural link (0..1).
19const LINK_THRESHOLD: f32 = 0.85;
20/// Maximum links per block.
21const MAX_LINKS_PER_BLOCK: usize = 8;
22/// Number of byte histogram buckets (compressed from 256).
23const HIST_BUCKETS: usize = 16;
24/// Fingerprint binary size: 4 (entropy f32) + 16 (histogram) + 8 (hash u64) = 28 bytes
25const FINGERPRINT_BYTES: usize = 28;
26
27// ─── Types ──────────────────────────────────────────
28
29/// Structural fingerprint for a single block.
30#[derive(Clone, Debug)]
31pub struct BlockFingerprint {
32    /// Shannon entropy of the block content (0.0 = uniform, ~8.0 = max random).
33    pub entropy: f32,
34    /// Compressed byte distribution (16 buckets, normalized to 0..255).
35    pub histogram: [u8; HIST_BUCKETS],
36    /// FNV-1a hash of the histogram for fast comparison.
37    pub hash: u64,
38}
39
40/// A structural link between two blocks.
41#[derive(Clone, Debug)]
42pub struct StructuralLink {
43    pub block_a: u32,
44    pub block_b: u32,
45    pub similarity: f32,
46}
47
48/// Link table — mmap-friendly storage of structural links.
49pub struct LinkTable {
50    pub fingerprints: Vec<BlockFingerprint>,
51    pub links: Vec<StructuralLink>,
52}
53
54impl LinkTable {
55    /// Build fingerprints and links from block data.
56    pub fn build(block_texts: &[&str]) -> Self {
57        let fingerprints: Vec<BlockFingerprint> = block_texts
58            .iter()
59            .map(|text| compute_fingerprint(text.as_bytes()))
60            .collect();
61
62        let links = find_links(&fingerprints);
63
64        LinkTable {
65            fingerprints,
66            links,
67        }
68    }
69
70    /// Get structural links for a specific block.
71    pub fn links_for(&self, block_idx: u32) -> Vec<&StructuralLink> {
72        self.links
73            .iter()
74            .filter(|l| l.block_a == block_idx || l.block_b == block_idx)
75            .collect()
76    }
77
78    /// Get the other end of a link from a given block.
79    pub fn linked_blocks(&self, block_idx: u32) -> Vec<(u32, f32)> {
80        self.links
81            .iter()
82            .filter_map(|l| {
83                if l.block_a == block_idx {
84                    Some((l.block_b, l.similarity))
85                } else if l.block_b == block_idx {
86                    Some((l.block_a, l.similarity))
87                } else {
88                    None
89                }
90            })
91            .collect()
92    }
93
94    /// Find the most structurally similar blocks to a query text.
95    pub fn find_similar(&self, text: &str, k: usize) -> Vec<(u32, f32)> {
96        let query_fp = compute_fingerprint(text.as_bytes());
97        // Total order: similarity descending, then block index ascending. A
98        // stable sort by similarity alone produced exactly this order, so
99        // naming the tie-break explicitly keeps the result identical when the
100        // sort below becomes a partial selection.
101        let cmp = |a: &(u32, f32), b: &(u32, f32)| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0));
102        let mut results: Vec<(u32, f32)> = self
103            .fingerprints
104            .par_iter()
105            .enumerate()
106            .map(|(i, fp)| (i as u32, fingerprint_similarity(&query_fp, fp)))
107            .filter(|(_, sim)| *sim > 0.5)
108            .collect();
109
110        // Partial selection instead of sorting every match. The caller runs
111        // this on the top-3 results of every recall, and most fingerprints
112        // clear the 0.5 threshold, so a full sort of ~699k elements three times
113        // per query is what made an MCP recall take seconds. The top k is
114        // uniquely determined by the total order above, so selecting then
115        // sorting those k yields the same answer as sorting everything.
116        //
117        // The order of these three statements is the whole optimisation.
118        // `select_nth_unstable_by(k, cmp)` partitions in place: positions 0..k
119        // end up holding the k best elements but in *no* order, and position k
120        // holds the (k+1)-th. Truncating immediately after it keeps exactly
121        // those k; sorting first threw the partition away and re-sorted all
122        // ~699k, which is the cost this was written to avoid. `cmp` is a total
123        // order, so the k-element set is well defined even with ties, and
124        // `partial_selection_matches_full_sort` pins that against a full sort.
125        if results.len() > k {
126            results.select_nth_unstable_by(k, cmp);
127            results.truncate(k);
128        }
129        results.sort_by(cmp);
130        results
131    }
132
133    /// Statistics.
134    pub fn stats(&self) -> FingerprintStats {
135        let avg_entropy = if self.fingerprints.is_empty() {
136            0.0
137        } else {
138            self.fingerprints.iter().map(|f| f.entropy).sum::<f32>()
139                / self.fingerprints.len() as f32
140        };
141
142        // Count unique hash buckets
143        let mut hash_counts: HashMap<u64, usize> = HashMap::new();
144        for fp in &self.fingerprints {
145            *hash_counts.entry(fp.hash).or_insert(0) += 1;
146        }
147        let unique_hashes = hash_counts.len();
148        let largest_cluster = hash_counts.values().max().copied().unwrap_or(0);
149
150        FingerprintStats {
151            block_count: self.fingerprints.len(),
152            link_count: self.links.len(),
153            avg_entropy,
154            unique_hashes,
155            largest_cluster,
156        }
157    }
158
159    /// Save to disk.
160    pub fn save(&self, output_dir: &Path) -> Result<(), String> {
161        save_fingerprints(output_dir, &self.fingerprints)?;
162        save_links(output_dir, &self.links)?;
163        Ok(())
164    }
165
166    /// Load from disk.
167    pub fn load(output_dir: &Path) -> Option<Self> {
168        let fingerprints = load_fingerprints(output_dir)?;
169        let links = load_links(output_dir).unwrap_or_default();
170        Some(LinkTable {
171            fingerprints,
172            links,
173        })
174    }
175    /// Load the link table, reusing a previous instance when neither
176    /// `fingerprints.idx` nor `links.bin` has changed on disk.
177    ///
178    /// The MCP server rebuilt this on every recall: 65.8 MB of links parsed per
179    /// query. A long-lived process may hold it; the (length, mtime) stamp of
180    /// both files reloads it after a rebuild rewrites either one, so a server
181    /// that outlives a rebuild does not serve a stale table.
182    pub fn load_cached(output_dir: &Path) -> Option<std::sync::Arc<LinkTable>> {
183        fn stamp(path: &Path) -> (u64, u64) {
184            match fs::metadata(path) {
185                Ok(m) => {
186                    let mtime = m
187                        .modified()
188                        .ok()
189                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
190                        .map(|d| d.as_millis() as u64)
191                        .unwrap_or(0);
192                    (m.len(), mtime)
193                }
194                Err(_) => (0, 0),
195            }
196        }
197        type Cache = ((u64, u64, u64, u64), std::sync::Arc<LinkTable>);
198        static CACHE: std::sync::OnceLock<std::sync::Mutex<Option<Cache>>> =
199            std::sync::OnceLock::new();
200        // The LinkTable is built from `fingerprints.idx` (FGP1) and `links.bin`
201        // (LNK1). Not from `fingerprints.bin`, which is the Hebbian activation
202        // fingerprint log (FPR1) and is rewritten by every recall -- keying on
203        // it would miss the cache on every single call, which is what the first
204        // version of this function did.
205        let (fl, fm) = stamp(&output_dir.join("fingerprints.idx"));
206        let (ll, lm) = stamp(&output_dir.join("links.bin"));
207        let key = (fl, fm, ll, lm);
208        let mut guard = CACHE
209            .get_or_init(|| std::sync::Mutex::new(None))
210            .lock()
211            .unwrap_or_else(|e| e.into_inner());
212        if let Some((k, table)) = guard.as_ref() {
213            if *k == key {
214                return Some(std::sync::Arc::clone(table));
215            }
216        }
217        let table = std::sync::Arc::new(Self::load(output_dir)?);
218        *guard = Some((key, std::sync::Arc::clone(&table)));
219        Some(table)
220    }
221}
222
223pub struct FingerprintStats {
224    pub block_count: usize,
225    pub link_count: usize,
226    pub avg_entropy: f32,
227    pub unique_hashes: usize,
228    pub largest_cluster: usize,
229}
230
231// ─── Core algorithms ────────────────────────────────
232
233/// Compute the structural fingerprint of a block's content.
234pub fn compute_fingerprint(data: &[u8]) -> BlockFingerprint {
235    // Shannon entropy
236    let mut byte_counts = [0u32; 256];
237    for &b in data {
238        byte_counts[b as usize] += 1;
239    }
240    let len = data.len().max(1) as f32;
241
242    let entropy: f32 = byte_counts
243        .iter()
244        .filter(|&&c| c > 0)
245        .map(|&c| {
246            let p = c as f32 / len;
247            -p * p.log2()
248        })
249        .sum();
250
251    // Compressed histogram: 256 → 16 buckets
252    let mut histogram = [0u8; HIST_BUCKETS];
253    for (i, bucket) in histogram.iter_mut().enumerate() {
254        let start = i * 16;
255        let end = start + 16;
256        let sum: u32 = byte_counts[start..end].iter().sum();
257        // Normalize to 0..255
258        *bucket = ((sum as f32 / len) * 255.0).min(255.0) as u8;
259    }
260
261    // FNV-1a hash of histogram
262    let hash = fnv1a_hash(&histogram);
263
264    BlockFingerprint {
265        entropy,
266        histogram,
267        hash,
268    }
269}
270
271/// Compute similarity between two fingerprints (0.0..1.0).
272pub fn fingerprint_similarity(a: &BlockFingerprint, b: &BlockFingerprint) -> f32 {
273    // Fast path: if hashes differ wildly, skip detailed comparison
274    if a.hash != b.hash {
275        // Histogram cosine similarity
276        let mut dot = 0u32;
277        let mut norm_a = 0u32;
278        let mut norm_b = 0u32;
279        for i in 0..HIST_BUCKETS {
280            let va = a.histogram[i] as u32;
281            let vb = b.histogram[i] as u32;
282            dot += va * vb;
283            norm_a += va * va;
284            norm_b += vb * vb;
285        }
286
287        let denom = ((norm_a as f64).sqrt() * (norm_b as f64).sqrt()) as f32;
288        if denom < 1.0 {
289            return 0.0;
290        }
291        let cosine = dot as f32 / denom;
292
293        // Entropy similarity (penalize large entropy differences)
294        let entropy_diff = (a.entropy - b.entropy).abs();
295        let entropy_sim = 1.0 - (entropy_diff / 8.0).min(1.0);
296
297        cosine * 0.7 + entropy_sim * 0.3
298    } else {
299        // Same hash → very similar histograms, refine with entropy
300        let entropy_diff = (a.entropy - b.entropy).abs();
301        let entropy_sim = 1.0 - (entropy_diff / 8.0).min(1.0);
302        0.85 + entropy_sim * 0.15
303    }
304}
305
306/// Find structural links between fingerprints above threshold.
307fn find_links(fingerprints: &[BlockFingerprint]) -> Vec<StructuralLink> {
308    let mut links = Vec::new();
309    let n = fingerprints.len();
310
311    // Group by hash for O(n*k) instead of O(n²)
312    let mut hash_groups: HashMap<u64, Vec<usize>> = HashMap::new();
313    for (i, fp) in fingerprints.iter().enumerate() {
314        hash_groups.entry(fp.hash).or_default().push(i);
315    }
316
317    // Within each hash group, all pairs are candidates
318    for group in hash_groups.values() {
319        if group.len() < 2 {
320            continue;
321        }
322        for i in 0..group.len() {
323            let mut block_links = 0;
324            for j in (i + 1)..group.len() {
325                if block_links >= MAX_LINKS_PER_BLOCK {
326                    break;
327                }
328                let a = group[i];
329                let b = group[j];
330                let sim = fingerprint_similarity(&fingerprints[a], &fingerprints[b]);
331                if sim >= LINK_THRESHOLD {
332                    links.push(StructuralLink {
333                        block_a: a as u32,
334                        block_b: b as u32,
335                        similarity: sim,
336                    });
337                    block_links += 1;
338                }
339            }
340        }
341    }
342
343    // Also check near-miss hashes (different hash but potentially similar)
344    // Only for small block counts to avoid O(n²)
345    if n < 5000 {
346        for i in 0..n {
347            let mut block_links = links
348                .iter()
349                .filter(|l| l.block_a == i as u32 || l.block_b == i as u32)
350                .count();
351            if block_links >= MAX_LINKS_PER_BLOCK {
352                continue;
353            }
354
355            for j in (i + 1)..n {
356                if fingerprints[i].hash == fingerprints[j].hash {
357                    continue; // already checked
358                }
359                let sim = fingerprint_similarity(&fingerprints[i], &fingerprints[j]);
360                if sim >= LINK_THRESHOLD {
361                    links.push(StructuralLink {
362                        block_a: i as u32,
363                        block_b: j as u32,
364                        similarity: sim,
365                    });
366                    block_links += 1;
367                    if block_links >= MAX_LINKS_PER_BLOCK {
368                        break;
369                    }
370                }
371            }
372        }
373    }
374
375    links
376}
377
378pub(crate) fn fnv1a_hash(data: &[u8]) -> u64 {
379    let mut h: u64 = 0xcbf29ce484222325;
380    for &b in data {
381        h ^= b as u64;
382        h = h.wrapping_mul(0x100000001b3);
383    }
384    h
385}
386
387// ─── Binary I/O ─────────────────────────────────────
388
389fn save_fingerprints(output_dir: &Path, fps: &[BlockFingerprint]) -> Result<(), String> {
390    let path = output_dir.join("fingerprints.idx");
391    let mut buf = Vec::with_capacity(8 + fps.len() * FINGERPRINT_BYTES);
392    buf.extend_from_slice(b"FGP1");
393    buf.extend_from_slice(&(fps.len() as u32).to_le_bytes());
394    for fp in fps {
395        buf.extend_from_slice(&fp.entropy.to_le_bytes());
396        buf.extend_from_slice(&fp.histogram);
397        buf.extend_from_slice(&fp.hash.to_le_bytes());
398    }
399    let tmp_path = output_dir.join("fingerprints.idx.tmp");
400    fs::write(&tmp_path, &buf).map_err(|e| format!("write fingerprints.idx: {}", e))?;
401    fs::rename(&tmp_path, &path).map_err(|e| format!("rename fingerprints.idx: {}", e))
402}
403
404fn load_fingerprints(output_dir: &Path) -> Option<Vec<BlockFingerprint>> {
405    let path = output_dir.join("fingerprints.idx");
406    let data = fs::read(&path).ok()?;
407    if data.len() < 8 || &data[0..4] != b"FGP1" {
408        return None;
409    }
410    let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
411    let mut fps = Vec::with_capacity(count);
412    let mut pos = 8;
413    for _ in 0..count {
414        if pos + FINGERPRINT_BYTES > data.len() {
415            break;
416        }
417        let entropy = f32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
418        let mut histogram = [0u8; HIST_BUCKETS];
419        histogram.copy_from_slice(&data[pos + 4..pos + 4 + HIST_BUCKETS]);
420        let hash = u64::from_le_bytes(data[pos + 20..pos + 28].try_into().unwrap());
421        fps.push(BlockFingerprint {
422            entropy,
423            histogram,
424            hash,
425        });
426        pos += FINGERPRINT_BYTES;
427    }
428    Some(fps)
429}
430
431fn save_links(output_dir: &Path, links: &[StructuralLink]) -> Result<(), String> {
432    let path = output_dir.join("links.bin");
433    let mut buf = Vec::with_capacity(8 + links.len() * 12);
434    buf.extend_from_slice(b"LNK1");
435    buf.extend_from_slice(&(links.len() as u32).to_le_bytes());
436    for link in links {
437        buf.extend_from_slice(&link.block_a.to_le_bytes());
438        buf.extend_from_slice(&link.block_b.to_le_bytes());
439        buf.extend_from_slice(&link.similarity.to_le_bytes());
440    }
441    let tmp_path = output_dir.join("links.bin.tmp");
442    fs::write(&tmp_path, &buf).map_err(|e| format!("write links.bin: {}", e))?;
443    fs::rename(&tmp_path, &path).map_err(|e| format!("rename links.bin: {}", e))
444}
445
446fn load_links(output_dir: &Path) -> Option<Vec<StructuralLink>> {
447    let path = output_dir.join("links.bin");
448    let data = fs::read(&path).ok()?;
449    if data.len() < 8 || &data[0..4] != b"LNK1" {
450        return None;
451    }
452    let count = u32::from_le_bytes(data[4..8].try_into().unwrap()) as usize;
453    let mut links = Vec::with_capacity(count);
454    let mut pos = 8;
455    for _ in 0..count {
456        if pos + 12 > data.len() {
457            break;
458        }
459        let block_a = u32::from_le_bytes(data[pos..pos + 4].try_into().unwrap());
460        let block_b = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap());
461        let similarity = f32::from_le_bytes(data[pos + 8..pos + 12].try_into().unwrap());
462        links.push(StructuralLink {
463            block_a,
464            block_b,
465            similarity,
466        });
467        pos += 12;
468    }
469    Some(links)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    /// The partial selection in `find_similar` must return exactly what a full
477    /// sort of every candidate would, including when similarities tie.
478    ///
479    /// Ties are the interesting case and the reason the comparator carries an
480    /// explicit index tie-break: if it sorted by similarity alone, the k-element
481    /// set would not be well defined and this test could pass by luck. Here
482    /// many blocks are byte-identical, so their similarities are exactly equal
483    /// and the tie-break alone decides which of them lands in the top k.
484    #[test]
485    fn partial_selection_matches_full_sort() {
486        // 400 blocks, deliberately repetitive: 20 distinct texts repeated, so
487        // similarities tie exactly and the boundary of the top k is decided by
488        // the index tie-break rather than by the score.
489        let mut texts: Vec<String> = Vec::new();
490        for i in 0..20 {
491            for _ in 0..20 {
492                texts.push(format!(
493                    "block {i} mentions topic {} with a fairly long body so the \
494                     fingerprint histogram is not degenerate",
495                    i * 7919
496                ));
497            }
498        }
499        let owned: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
500        let table = LinkTable::build(&owned);
501
502        let query = owned[7];
503        for k in [1usize, 2, 3, 5, 19, 20, 21, 100, 399, 400] {
504            // Reference: the same candidate set and the same comparator, sorted
505            // in full. Recomputed here rather than reusing find_similar, because
506            // reusing it would test the implementation against itself.
507            let query_fp = compute_fingerprint(query.as_bytes());
508            let mut full: Vec<(u32, f32)> = table
509                .fingerprints
510                .iter()
511                .enumerate()
512                .map(|(i, fp)| (i as u32, fingerprint_similarity(&query_fp, fp)))
513                .filter(|(_, sim)| *sim > 0.5)
514                .collect();
515            full.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
516            full.truncate(k);
517
518            let got = table.find_similar(query, k);
519            assert_eq!(
520                got,
521                full,
522                "partial selection differs from a full sort at k={k} ({} candidates)",
523                full.len()
524            );
525        }
526    }
527
528    /// A tie in similarity must be broken by ascending block index, so the top k
529    /// is a single well-defined set rather than an arbitrary one of several.
530    #[test]
531    fn ties_break_by_ascending_index() {
532        // Every block is identical, so every similarity is identical and the
533        // order is entirely the tie-break.
534        let owned: Vec<&str> = vec!["exactly the same text in every block"; 50];
535        let table = LinkTable::build(&owned);
536        let hits = table.find_similar("exactly the same text in every block", 5);
537        assert_eq!(
538            hits.iter().map(|(i, _)| *i).collect::<Vec<_>>(),
539            vec![0, 1, 2, 3, 4],
540            "identical blocks must come back in index order"
541        );
542        assert!(
543            hits.windows(2).all(|w| w[0].1 == w[1].1),
544            "all similarities are equal here, so the ordering is the tie-break"
545        );
546    }
547
548    /// Times the partial selection against a full sort on a table large enough
549    /// for the difference to show, and prints both. Ignored by default because
550    /// it is a measurement, not an assertion -- but the fix it measures is a
551    /// performance fix, and the two equivalence tests above pass on the old
552    /// ordering too, because the old ordering was correct and merely slow. Run
553    /// it with: cargo test --release --lib -- --ignored --nocapture time_selection
554    #[test]
555    #[ignore]
556    fn time_selection() {
557        // Texts that share a byte histogram, so nearly all of them clear the
558        // 0.5 similarity floor and the sort is genuinely over the whole table.
559        // The first version of this test varied the subject number widely, which
560        // left 3 candidates -- too few for the partial path to run at all, so it
561        // measured nothing and the speedup it printed was noise.
562        let n = 200_000usize;
563        let template: String = "abcdefghijklmnopqrstuvwxyz ".repeat(24);
564        let texts: Vec<String> = (0..n).map(|i| format!("{template} ref {i}")).collect();
565        let owned: Vec<&str> = texts.iter().map(|s| s.as_str()).collect();
566        let table = LinkTable::build(&owned);
567        let query = owned[123];
568
569        let t0 = std::time::Instant::now();
570        let got = table.find_similar(query, 3);
571        let partial = t0.elapsed();
572
573        let t1 = std::time::Instant::now();
574        let query_fp = compute_fingerprint(query.as_bytes());
575        let mut full: Vec<(u32, f32)> = table
576            .fingerprints
577            .iter()
578            .enumerate()
579            .map(|(i, fp)| (i as u32, fingerprint_similarity(&query_fp, fp)))
580            .filter(|(_, sim)| *sim > 0.5)
581            .collect();
582        // Count before truncating: an earlier version of this test printed
583        // full.len() afterwards and so always reported "3 candidates", which
584        // reads as "the partial path never ran" and is why the number below
585        // needs to be this candidate count and not the returned length.
586        let candidates = full.len();
587        full.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
588        full.truncate(3);
589        let sorted = t1.elapsed();
590
591        println!("candidates over the 0.5 floor: {candidates}");
592        println!("partial selection : {partial:?}");
593        println!("full sort         : {sorted:?}");
594        println!(
595            "speedup           : {:.1}x",
596            sorted.as_secs_f64() / partial.as_secs_f64()
597        );
598        assert_eq!(got, full, "the two orderings must agree");
599    }
600
601    #[test]
602    fn test_entropy_uniform() {
603        let fp = compute_fingerprint(b"aaaaaaaaaa");
604        assert!(fp.entropy < 0.01); // single byte = 0 entropy
605    }
606
607    #[test]
608    fn test_entropy_varied() {
609        let data: Vec<u8> = (0..=255).collect();
610        let fp = compute_fingerprint(&data);
611        assert!(fp.entropy > 7.0); // max entropy ≈ 8.0
612    }
613
614    #[test]
615    fn test_fingerprint_deterministic() {
616        let a = compute_fingerprint(b"hello world, this is a test of the fingerprint system");
617        let b = compute_fingerprint(b"hello world, this is a test of the fingerprint system");
618        assert_eq!(a.hash, b.hash);
619        assert!((a.entropy - b.entropy).abs() < 0.001);
620        assert_eq!(a.histogram, b.histogram);
621    }
622
623    #[test]
624    fn test_similarity_identical() {
625        let fp = compute_fingerprint(b"hello world test");
626        let sim = fingerprint_similarity(&fp, &fp);
627        assert!(sim > 0.99);
628    }
629
630    #[test]
631    fn test_similarity_different() {
632        let a = compute_fingerprint(b"aaaaaaaaaaaaaaaa");
633        let data: Vec<u8> = (0..=255).collect();
634        let b = compute_fingerprint(&data);
635        let sim = fingerprint_similarity(&a, &b);
636        assert!(sim < 0.5); // very different
637    }
638
639    #[test]
640    fn test_similarity_similar_text() {
641        let a = compute_fingerprint(b"the quick brown fox jumps over the lazy dog");
642        let b = compute_fingerprint(b"the fast brown fox leaps over the tired dog");
643        let sim = fingerprint_similarity(&a, &b);
644        assert!(sim > 0.7); // similar letter distributions
645    }
646
647    #[test]
648    fn test_find_links() {
649        let texts = [
650            "hello world this is a test",
651            "hello world this is a test", // identical
652            "completely different binary data 12345!@#$%",
653            "hello world this is another test", // similar
654        ];
655        let fps: Vec<BlockFingerprint> = texts
656            .iter()
657            .map(|t| compute_fingerprint(t.as_bytes()))
658            .collect();
659        let links = find_links(&fps);
660
661        // At minimum, blocks 0 and 1 should be linked (identical)
662        assert!(links
663            .iter()
664            .any(|l| { (l.block_a == 0 && l.block_b == 1) || (l.block_a == 1 && l.block_b == 0) }));
665    }
666
667    #[test]
668    fn test_link_table_build() {
669        let texts = vec!["hello world", "hello world", "binary data xyz"];
670        let table = LinkTable::build(&texts);
671        assert_eq!(table.fingerprints.len(), 3);
672        // At least one link between the two identical texts
673        assert!(!table.links.is_empty());
674    }
675
676    #[test]
677    fn test_find_similar() {
678        let texts = vec![
679            "hello world testing one two three",
680            "hello world testing four five six",
681            "completely random binary noise !!!",
682        ];
683        let table = LinkTable::build(&texts);
684        let results = table.find_similar("hello world testing", 5);
685        assert!(!results.is_empty());
686    }
687
688    #[test]
689    fn test_save_load_roundtrip() {
690        let tmp = tempfile::tempdir().expect("create temp dir");
691        let dir = tmp.path();
692
693        let texts = vec!["hello world", "test data", "more content here"];
694        let table = LinkTable::build(&texts);
695        table.save(dir).expect("save");
696
697        let loaded = LinkTable::load(dir).expect("load");
698        assert_eq!(loaded.fingerprints.len(), 3);
699        assert!((loaded.fingerprints[0].entropy - table.fingerprints[0].entropy).abs() < 0.001);
700        assert_eq!(loaded.fingerprints[0].hash, table.fingerprints[0].hash);
701        assert_eq!(loaded.links.len(), table.links.len());
702    }
703
704    #[test]
705    fn test_linked_blocks() {
706        let texts = vec!["abc abc abc", "abc abc abc", "xyz xyz xyz"];
707        let table = LinkTable::build(&texts);
708        let linked = table.linked_blocks(0);
709        // Block 0 and 1 are identical, should be linked
710        assert!(linked.iter().any(|(idx, _)| *idx == 1));
711    }
712
713    #[test]
714    fn test_stats() {
715        let texts = vec!["hello", "world", "hello"];
716        let table = LinkTable::build(&texts);
717        let stats = table.stats();
718        assert_eq!(stats.block_count, 3);
719    }
720
721    #[test]
722    fn test_fnv1a_deterministic() {
723        assert_eq!(fnv1a_hash(b"hello"), fnv1a_hash(b"hello"));
724        assert_ne!(fnv1a_hash(b"hello"), fnv1a_hash(b"world"));
725    }
726}