1use std::collections::HashMap;
11use std::fs;
12use std::path::Path;
13
14use crate::parallel::*;
15
16const LINK_THRESHOLD: f32 = 0.85;
20const MAX_LINKS_PER_BLOCK: usize = 8;
22const HIST_BUCKETS: usize = 16;
24const FINGERPRINT_BYTES: usize = 28;
26
27#[derive(Clone, Debug)]
31pub struct BlockFingerprint {
32 pub entropy: f32,
34 pub histogram: [u8; HIST_BUCKETS],
36 pub hash: u64,
38}
39
40#[derive(Clone, Debug)]
42pub struct StructuralLink {
43 pub block_a: u32,
44 pub block_b: u32,
45 pub similarity: f32,
46}
47
48pub struct LinkTable {
50 pub fingerprints: Vec<BlockFingerprint>,
51 pub links: Vec<StructuralLink>,
52}
53
54impl LinkTable {
55 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 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 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 pub fn find_similar(&self, text: &str, k: usize) -> Vec<(u32, f32)> {
96 let query_fp = compute_fingerprint(text.as_bytes());
97 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 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 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 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 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 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 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 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
231pub fn compute_fingerprint(data: &[u8]) -> BlockFingerprint {
235 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 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 *bucket = ((sum as f32 / len) * 255.0).min(255.0) as u8;
259 }
260
261 let hash = fnv1a_hash(&histogram);
263
264 BlockFingerprint {
265 entropy,
266 histogram,
267 hash,
268 }
269}
270
271pub fn fingerprint_similarity(a: &BlockFingerprint, b: &BlockFingerprint) -> f32 {
273 if a.hash != b.hash {
275 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 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 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
306fn find_links(fingerprints: &[BlockFingerprint]) -> Vec<StructuralLink> {
308 let mut links = Vec::new();
309 let n = fingerprints.len();
310
311 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 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 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; }
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
387fn 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 #[test]
485 fn partial_selection_matches_full_sort() {
486 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 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 #[test]
531 fn ties_break_by_ascending_index() {
532 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 #[test]
555 #[ignore]
556 fn time_selection() {
557 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 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); }
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); }
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); }
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); }
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", "completely different binary data 12345!@#$%",
653 "hello world this is another test", ];
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 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 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 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}