1use std::collections::{BTreeMap, BTreeSet};
50
51use crate::features::{FileFeatures, UnitFeatures, UnitRef};
52
53pub const DEFAULT_NUM_HASHES: usize = 128;
55
56pub const DEFAULT_BANDS: usize = 64;
63
64pub const DEFAULT_MAX_LENGTH_RATIO: f64 = 3.0;
66
67pub const DEFAULT_MIN_SHINGLES: usize = 4;
70
71pub const DEFAULT_MIN_ESTIMATED_JACCARD: f64 = 0.3;
81
82pub const DEFAULT_POSTING_CAP: usize = 256;
84
85pub const DEFAULT_PAIR_BUDGET: usize = 2_000_000;
87
88pub const DEFAULT_MAX_SIGNED_UNITS: usize = 100_000;
90
91pub const DEFAULT_NEAR_MISS_DELTA: f64 = 0.05;
98
99pub const DEFAULT_NEAR_MISS_CAP: usize = 1_000;
101
102#[derive(Debug, Clone, PartialEq)]
105pub struct NearMatchConfig {
106 pub num_hashes: usize,
108 pub bands: usize,
111 pub min_shingles: usize,
113 pub max_length_ratio: f64,
115 pub min_estimated_jaccard: f64,
117 pub posting_cap: usize,
120 pub pair_budget: usize,
122 pub max_signed_units: usize,
126 pub near_miss_delta: f64,
129 pub near_miss_cap: usize,
132}
133
134impl Default for NearMatchConfig {
135 fn default() -> Self {
136 Self {
137 num_hashes: DEFAULT_NUM_HASHES,
138 bands: DEFAULT_BANDS,
139 min_shingles: DEFAULT_MIN_SHINGLES,
140 max_length_ratio: DEFAULT_MAX_LENGTH_RATIO,
141 min_estimated_jaccard: DEFAULT_MIN_ESTIMATED_JACCARD,
142 posting_cap: DEFAULT_POSTING_CAP,
143 pair_budget: DEFAULT_PAIR_BUDGET,
144 max_signed_units: DEFAULT_MAX_SIGNED_UNITS,
145 near_miss_delta: DEFAULT_NEAR_MISS_DELTA,
146 near_miss_cap: DEFAULT_NEAR_MISS_CAP,
147 }
148 }
149}
150
151impl NearMatchConfig {
152 fn rows(&self) -> usize {
154 (self.num_hashes / self.bands.max(1)).max(1)
155 }
156
157 fn near_miss_floor(&self) -> f64 {
164 (self.min_estimated_jaccard - self.near_miss_delta).max(0.0)
165 }
166
167 fn is_near_miss(&self, estimate: f64) -> bool {
169 estimate >= self.near_miss_floor() && estimate < self.min_estimated_jaccard
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct NearMatchPair {
177 pub a: UnitRef,
179 pub b: UnitRef,
181 pub estimated_jaccard: f64,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct NearMatchNearMiss {
192 pub a: UnitRef,
194 pub b: UnitRef,
196 pub estimated_jaccard: f64,
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct NearMatchStats {
203 pub units: usize,
205 pub signed_units: usize,
207 pub skipped_small: usize,
209 pub signed_limit_dropped: usize,
211 pub buckets: usize,
213 pub stop_buckets: usize,
215 pub stop_bucket_members: usize,
217 pub proposed_pairs: usize,
219 pub filtered_by_size: usize,
221 pub filtered_by_jaccard: usize,
223 pub near_miss_band_pairs: usize,
226 pub near_misses_retained: usize,
228 pub near_miss_cap_dropped: usize,
230 pub candidate_pairs: usize,
232 pub budget_exhausted: bool,
234 pub budget_dropped: usize,
238}
239
240#[derive(Debug, Clone, PartialEq)]
242pub struct NearMatchSet {
243 pub pairs: Vec<NearMatchPair>,
245 pub near_misses: Vec<NearMatchNearMiss>,
248 pub stats: NearMatchStats,
250}
251
252#[must_use]
258pub fn generate(files: &[FileFeatures], config: &NearMatchConfig) -> NearMatchSet {
259 let seeds = permutation_seeds(config.num_hashes);
260 let mut stats = NearMatchStats::default();
261
262 let mut signed = Vec::new();
266 let mut signatures = Vec::new();
267 for (file, features) in files.iter().enumerate() {
268 stats.units += features.units.len();
269 for (unit, unit_features) in features.units.iter().enumerate() {
270 let shingles = shingles_of(unit_features);
271 if shingles.len() < config.min_shingles {
272 stats.skipped_small += 1;
273 continue;
274 }
275 if signed.len() >= config.max_signed_units {
276 stats.signed_limit_dropped += 1;
277 continue;
278 }
279 let unit_ref = UnitRef {
280 file,
281 unit,
282 node_count: unit_features.vector.node_count,
283 };
284 signed.push(unit_ref);
285 signatures.extend(signature(&shingles, &seeds));
286 }
287 }
288 stats.signed_units = signed.len();
289
290 let proposed = propose_pairs(&signed, &signatures, config, &mut stats);
291 stats.proposed_pairs = proposed.len();
292
293 let mut pairs = Vec::new();
296 let mut near_misses = Vec::new();
297 for (ai, bi) in proposed {
298 let ref_a = signed[ai];
299 let ref_b = signed[bi];
300 if !ref_a.within_length_ratio(ref_b, config.max_length_ratio) {
301 stats.filtered_by_size += 1;
302 continue;
303 }
304 let estimated = estimated_jaccard(
305 signature_at(&signatures, ai, config.num_hashes),
306 signature_at(&signatures, bi, config.num_hashes),
307 );
308 if estimated < config.min_estimated_jaccard {
309 stats.filtered_by_jaccard += 1;
310 if config.is_near_miss(estimated) {
311 stats.near_miss_band_pairs += 1;
312 if near_misses.len() < config.near_miss_cap {
313 near_misses.push(NearMatchNearMiss {
314 a: ref_a,
315 b: ref_b,
316 estimated_jaccard: estimated,
317 });
318 } else {
319 stats.near_miss_cap_dropped += 1;
320 }
321 }
322 continue;
323 }
324 pairs.push(NearMatchPair {
325 a: ref_a,
326 b: ref_b,
327 estimated_jaccard: estimated,
328 });
329 }
330 stats.candidate_pairs = pairs.len();
331 stats.near_misses_retained = near_misses.len();
332 NearMatchSet {
333 pairs,
334 near_misses,
335 stats,
336 }
337}
338
339fn propose_pairs(
343 signed: &[UnitRef],
344 signatures: &[u64],
345 config: &NearMatchConfig,
346 stats: &mut NearMatchStats,
347) -> Vec<(usize, usize)> {
348 let rows = config.rows();
349 let bands = config.num_hashes / rows;
350
351 let mut seen: BTreeSet<(usize, usize)> = BTreeSet::new();
352 let mut remaining = config.pair_budget;
353 for band in 0..bands {
354 let mut buckets: BTreeMap<u64, Vec<usize>> = BTreeMap::new();
357 for index in 0..signed.len() {
358 let signature = signature_at(signatures, index, config.num_hashes);
359 let start = band * rows;
360 let key = band_key(band, &signature[start..start + rows]);
361 buckets.entry(key).or_default().push(index);
362 }
363 let mut lists: Vec<Vec<usize>> = buckets
364 .into_values()
365 .filter(|members| members.len() >= 2)
366 .collect();
367 lists.sort();
368 lists.sort_by_key(Vec::len);
369
370 for members in lists {
371 stats.buckets += 1;
372 if members.len() > config.posting_cap {
373 stats.stop_buckets += 1;
374 stats.stop_bucket_members += members.len();
375 continue;
376 }
377 let mut unseen = Vec::new();
381 for (offset, &a) in members.iter().enumerate() {
382 for &b in &members[offset + 1..] {
383 let pair = if a <= b { (a, b) } else { (b, a) };
384 if !seen.contains(&pair) {
385 unseen.push(pair);
386 }
387 }
388 }
389 if unseen.len() > remaining {
390 stats.budget_exhausted = true;
391 stats.budget_dropped = unseen.len();
392 return seen.into_iter().collect();
393 }
394 remaining -= unseen.len();
395 seen.extend(unseen);
396 }
397 }
398
399 seen.into_iter().collect()
400}
401
402fn signature_at(signatures: &[u64], index: usize, width: usize) -> &[u64] {
404 let start = index.saturating_mul(width);
405 &signatures[start..start.saturating_add(width)]
406}
407
408fn shingles_of(unit: &UnitFeatures) -> Vec<u64> {
412 const WINDOW_DOMAIN: u64 = 0x5749_4e44_4f57_0000; const SUBTREE_DOMAIN: u64 = 0x5355_4254_5245_0000; let mut shingles: Vec<u64> = Vec::with_capacity(unit.windows.len() + unit.subtrees.len());
415 for window in &unit.windows {
416 shingles.push(fold_hash(window.hash.as_bytes()) ^ WINDOW_DOMAIN);
417 }
418 for subtree in &unit.subtrees {
419 shingles.push(fold_hash(subtree.hash.as_bytes()) ^ SUBTREE_DOMAIN);
420 }
421 shingles.sort_unstable();
422 shingles.dedup();
423 shingles
424}
425
426fn fold_hash(bytes: &[u8; 16]) -> u64 {
430 let mut lo = [0u8; 8];
431 let mut hi = [0u8; 8];
432 lo.copy_from_slice(&bytes[..8]);
433 hi.copy_from_slice(&bytes[8..]);
434 let a = u64::from_le_bytes(lo);
435 let b = u64::from_le_bytes(hi);
436 let mut z = a.wrapping_mul(0xff51_afd7_ed55_8ccd) ^ b.wrapping_mul(0xc4ce_b9fe_1a85_ec53);
437 z = (z ^ (z >> 33)).wrapping_mul(0xff51_afd7_ed55_8ccd);
438 z ^ (z >> 29)
439}
440
441fn signature(shingles: &[u64], seeds: &[u64]) -> Vec<u64> {
443 seeds
444 .iter()
445 .map(|&seed| {
446 shingles
447 .iter()
448 .map(|&shingle| permute(shingle, seed))
449 .min()
450 .unwrap_or(u64::MAX)
451 })
452 .collect()
453}
454
455fn estimated_jaccard(a: &[u64], b: &[u64]) -> f64 {
458 let equal = a.iter().zip(b).filter(|(x, y)| x == y).count();
459 frac(equal, a.len())
460}
461
462fn frac(numer: usize, denom: usize) -> f64 {
464 let n = u32::try_from(numer).unwrap_or(u32::MAX);
465 let d = u32::try_from(denom).unwrap_or(u32::MAX);
466 if d == 0 {
467 0.0
468 } else {
469 f64::from(n) / f64::from(d)
470 }
471}
472
473fn permutation_seeds(count: usize) -> Vec<u64> {
476 let mut state = 0x1234_5678_9abc_def0u64;
477 (0..count).map(|_| splitmix64(&mut state)).collect()
478}
479
480const fn splitmix64(state: &mut u64) -> u64 {
482 *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15);
483 let mut z = *state;
484 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
485 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
486 z ^ (z >> 31)
487}
488
489const fn permute(x: u64, seed: u64) -> u64 {
491 let mut z = x ^ seed;
492 z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
493 z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
494 z ^ (z >> 31)
495}
496
497fn band_key(band: usize, rows: &[u64]) -> u64 {
499 let mut z = 0xcbf2_9ce4_8422_2325u64 ^ (band as u64).wrapping_mul(0x1_0000_01b3);
500 for &row in rows {
501 z = (z ^ row).wrapping_mul(0x0000_0100_0000_01b3);
502 }
503 z ^ (z >> 32)
504}
505
506#[cfg(test)]
507#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
508mod tests {
509 use super::*;
510 use crate::features::{
511 ApiCallFeature, CfgFeature, CharacteristicVector, FeatureHash, SubtreeFeature,
512 UnitFeatures, WindowFeature,
513 };
514 use crate::ir::ByteRange;
515
516 fn unit(windows: &[u8], subtrees: &[u8], node_count: u32) -> UnitFeatures {
519 let windows = windows
520 .iter()
521 .map(|&seed| WindowFeature {
522 hash: FeatureHash::from_bytes([seed; 16]),
523 length: 4,
524 range: ByteRange { start: 0, end: 8 },
525 block: 0,
526 offset: 0,
527 })
528 .collect();
529 let subtrees = subtrees
530 .iter()
531 .map(|&seed| SubtreeFeature {
532 hash: FeatureHash::from_bytes([seed; 16]),
533 node_count: 6,
534 range: ByteRange { start: 0, end: 8 },
535 })
536 .collect();
537 let vector = CharacteristicVector {
538 node_count,
539 ..CharacteristicVector::default()
540 };
541 UnitFeatures {
542 name: None,
543 shape_tag: 1,
544 range: ByteRange { start: 0, end: 100 },
545 windows,
546 subtrees,
547 vector,
548 cfg: CfgFeature {
549 hash: FeatureHash::from_bytes([0; 16]),
550 skeleton_hash: FeatureHash::from_bytes([0; 16]),
551 op_count: 0,
552 skeleton_ops: 0,
553 max_loop_depth: 0,
554 branch_count: 0,
555 },
556 api: ApiCallFeature {
557 names: Vec::new(),
558 sequence_hash: FeatureHash::from_bytes([0; 16]),
559 multiset_hash: FeatureHash::from_bytes([0; 16]),
560 },
561 }
562 }
563
564 fn file(units: Vec<UnitFeatures>) -> FileFeatures {
565 FileFeatures { units }
566 }
567
568 #[test]
569 fn identical_units_are_a_candidate_with_full_similarity() {
570 let files = vec![
571 file(vec![unit(&[1, 2, 3, 4], &[5, 6], 20)]),
572 file(vec![unit(&[1, 2, 3, 4], &[5, 6], 20)]),
573 ];
574 let set = generate(&files, &NearMatchConfig::default());
575 assert_eq!(set.pairs.len(), 1);
576 assert!((set.pairs[0].estimated_jaccard - 1.0).abs() < f64::EPSILON);
577 assert_eq!(set.stats.signed_units, 2);
578 assert!(!set.stats.budget_exhausted);
579 }
580
581 #[test]
582 fn signature_stage_stops_at_its_explicit_unit_ceiling() {
583 let files = vec![file(vec![
584 unit(&[1, 2, 3, 4], &[5, 6], 20),
585 unit(&[1, 2, 3, 4], &[5, 6], 20),
586 unit(&[1, 2, 3, 4], &[5, 6], 20),
587 ])];
588 let set = generate(
589 &files,
590 &NearMatchConfig {
591 max_signed_units: 2,
592 ..NearMatchConfig::default()
593 },
594 );
595
596 assert_eq!(set.stats.signed_units, 2);
597 assert_eq!(set.stats.signed_limit_dropped, 1);
598 assert_eq!(set.pairs.len(), 1);
599 }
600
601 #[test]
602 fn a_high_overlap_pair_is_proposed_and_its_estimate_is_accurate() {
603 let a = unit(&[1, 2, 3, 4, 5], &[6, 7], 20);
605 let b = unit(&[1, 2, 3, 4, 5], &[8, 9], 20);
606 let files = vec![file(vec![a, b])];
607 let config = NearMatchConfig {
608 min_estimated_jaccard: 0.3,
609 ..NearMatchConfig::default()
610 };
611 let set = generate(&files, &config);
612 assert_eq!(set.pairs.len(), 1, "a high-overlap pair must surface");
613 let true_jaccard = 5.0 / 9.0;
615 assert!(
616 (set.pairs[0].estimated_jaccard - true_jaccard).abs() < 0.15,
617 "estimate {} too far from {true_jaccard}",
618 set.pairs[0].estimated_jaccard
619 );
620 }
621
622 #[test]
623 fn disjoint_units_are_rejected_by_the_jaccard_gate() {
624 let files = vec![file(vec![
625 unit(&[1, 2, 3, 4], &[5, 6], 20),
626 unit(&[10, 11, 12, 13], &[14, 15], 20),
627 ])];
628 let set = generate(&files, &NearMatchConfig::default());
629 assert!(
630 set.pairs.is_empty(),
631 "disjoint units must not be candidates"
632 );
633 assert_eq!(set.stats.candidate_pairs, 0);
635 }
636
637 #[test]
638 fn near_miss_band_includes_its_lower_bound_but_not_the_candidate_threshold() {
639 let config = NearMatchConfig {
640 min_estimated_jaccard: 0.75,
641 near_miss_delta: 0.25,
642 ..NearMatchConfig::default()
643 };
644 assert!(config.is_near_miss(0.5));
645 assert!(config.is_near_miss(0.749_999));
646 assert!(!config.is_near_miss(0.499_999));
647 assert!(
648 !config.is_near_miss(0.75),
649 "an estimate that reaches the candidate threshold is never a near miss"
650 );
651 }
652
653 #[test]
654 fn near_miss_storage_is_capped_deterministically_without_changing_candidates() {
655 let files = vec![file(vec![
656 unit(&[1, 2, 3, 4], &[5, 6], 20),
657 unit(&[1, 2, 3, 4], &[5, 6], 20),
658 unit(&[1, 2, 3, 4], &[5, 6], 20),
659 ])];
660 let uncapped = NearMatchConfig {
661 min_estimated_jaccard: 1.1,
665 near_miss_delta: 1.1,
666 near_miss_cap: usize::MAX,
667 ..NearMatchConfig::default()
668 };
669 let full = generate(&files, &uncapped);
670 let capped = NearMatchConfig {
671 near_miss_cap: 2,
672 ..uncapped
673 };
674 let first = generate(&files, &capped);
675 let second = generate(&files, &capped);
676
677 assert!(full.pairs.is_empty());
678 assert_eq!(first.pairs, full.pairs);
679 assert_eq!(first.stats.candidate_pairs, full.stats.candidate_pairs);
680 assert_eq!(full.near_misses.len(), 3);
681 assert_eq!(first.near_misses.len(), 2);
682 assert_eq!(first.stats.near_miss_band_pairs, 3);
683 assert_eq!(first.stats.near_misses_retained, 2);
684 assert_eq!(first.stats.near_miss_cap_dropped, 1);
685 assert_eq!(first, second);
686 }
687
688 #[test]
689 fn the_length_ratio_gate_drops_size_mismatched_pairs() {
690 let files = vec![file(vec![
692 unit(&[1, 2, 3, 4], &[5, 6], 10),
693 unit(&[1, 2, 3, 4], &[5, 6], 40),
694 ])];
695 let set = generate(&files, &NearMatchConfig::default());
696 assert!(set.pairs.is_empty());
697 assert_eq!(set.stats.filtered_by_size, 1);
698 assert_eq!(set.stats.filtered_by_jaccard, 0);
699 }
700
701 #[test]
702 fn a_unit_with_too_few_shingles_is_not_signed() {
703 let files = vec![file(vec![unit(&[1, 2], &[], 20), unit(&[1, 2], &[], 20)])];
704 let set = generate(&files, &NearMatchConfig::default());
705 assert_eq!(set.stats.signed_units, 0);
706 assert_eq!(set.stats.skipped_small, 2);
707 assert!(set.pairs.is_empty());
708 }
709
710 #[test]
711 fn a_high_frequency_bucket_is_dropped_and_counted() {
712 let files = vec![file(vec![
715 unit(&[1, 2, 3, 4], &[5, 6], 20),
716 unit(&[1, 2, 3, 4], &[5, 6], 20),
717 unit(&[1, 2, 3, 4], &[5, 6], 20),
718 unit(&[1, 2, 3, 4], &[5, 6], 20),
719 ])];
720 let config = NearMatchConfig {
721 posting_cap: 3,
722 ..NearMatchConfig::default()
723 };
724 let set = generate(&files, &config);
725 assert!(set.pairs.is_empty());
726 assert!(set.stats.stop_buckets > 0);
727 assert_eq!(set.stats.candidate_pairs, 0);
728 }
729
730 #[test]
731 fn pair_budget_charges_each_distinct_pair_once_across_lsh_bands() {
732 let files = vec![file(vec![
736 unit(&[1, 2, 3, 4], &[5, 6], 20),
737 unit(&[1, 2, 3, 4], &[5, 6], 20),
738 unit(&[1, 2, 3, 4], &[5, 6], 20),
739 ])];
740 let set = generate(
741 &files,
742 &NearMatchConfig {
743 pair_budget: 3,
744 ..NearMatchConfig::default()
745 },
746 );
747
748 assert_eq!(set.stats.proposed_pairs, 3);
749 assert!(!set.stats.budget_exhausted);
750 }
751
752 #[test]
753 fn the_pair_budget_refuses_a_bucket_it_cannot_hold_whole() {
754 let files = vec![file(vec![
759 unit(&[1, 2, 3, 4], &[5, 6], 20),
760 unit(&[1, 2, 3, 4], &[5, 6], 20),
761 unit(&[1, 2, 3, 4], &[5, 6], 20),
762 ])];
763 let config = NearMatchConfig {
764 pair_budget: 1,
765 ..NearMatchConfig::default()
766 };
767 let set = generate(&files, &config);
768 assert_eq!(set.stats.proposed_pairs, 0);
769 assert!(set.stats.budget_exhausted);
770 assert_eq!(set.stats.budget_dropped, 3);
771 }
772
773 #[test]
774 fn a_refused_bucket_stops_before_quadratic_deduplication() {
775 let units = vec![
780 unit(&[1, 2, 3, 4], &[5, 6], 20),
781 unit(&[1, 2, 3, 4], &[5, 6], 20),
782 unit(&[1, 2, 3, 4], &[5, 6], 20),
783 unit(&[40, 41, 42, 43], &[44, 45], 20),
784 unit(&[40, 41, 42, 43], &[44, 45], 20),
785 ];
786 let files = vec![file(units)];
787 let full = generate(&files, &NearMatchConfig::default());
788 let squeezed = generate(
789 &files,
790 &NearMatchConfig {
791 pair_budget: 1,
792 ..NearMatchConfig::default()
793 },
794 );
795 assert!(squeezed.stats.budget_exhausted);
796 assert_eq!(squeezed.stats.proposed_pairs, 1);
799 assert!(
800 squeezed.stats.buckets < full.stats.buckets,
801 "the ceiling stops before walking buckets it cannot examine"
802 );
803 }
804
805 #[test]
806 fn generation_is_deterministic() {
807 let files = vec![
808 file(vec![unit(&[1, 2, 3, 4], &[5, 6], 20)]),
809 file(vec![unit(&[1, 2, 3, 5], &[5, 6], 22)]),
810 ];
811 let a = generate(&files, &NearMatchConfig::default());
812 let b = generate(&files, &NearMatchConfig::default());
813 assert_eq!(a, b);
814 }
815}