1use super::index::ComponentIndex;
21use crate::model::{CanonicalId, Component, NormalizedSbom};
22use std::collections::{HashMap, HashSet};
23use std::hash::{Hash, Hasher};
24
25const MINHASH_COEFF_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
31
32fn splitmix64(state: &mut u64) -> u64 {
37 *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
38 let mut z = *state;
39 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
40 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
41 z ^ (z >> 31)
42}
43
44#[derive(Debug, Clone)]
46pub struct LshConfig {
47 pub num_hashes: usize,
49 pub num_bands: usize,
51 pub shingle_size: usize,
53 pub target_threshold: f64,
55 pub include_ecosystem_token: bool,
57 pub include_group_token: bool,
59}
60
61impl LshConfig {
62 #[must_use]
67 pub fn for_threshold(threshold: f64) -> Self {
68 let (num_bands, rows_per_band) = if threshold >= 0.9 {
83 (10, 10) } else if threshold >= 0.8 {
85 (25, 4) } else if threshold >= 0.7 {
87 (20, 5) } else {
89 (50, 2) };
91
92 Self {
93 num_hashes: num_bands * rows_per_band,
94 num_bands,
95 shingle_size: 3, target_threshold: threshold,
97 include_ecosystem_token: true, include_group_token: false, }
100 }
101
102 #[must_use]
104 pub fn default_balanced() -> Self {
105 Self::for_threshold(0.8)
106 }
107
108 #[must_use]
110 pub fn strict() -> Self {
111 Self::for_threshold(0.9)
112 }
113
114 #[must_use]
116 pub fn permissive() -> Self {
117 Self::for_threshold(0.5)
118 }
119
120 #[must_use]
122 pub const fn rows_per_band(&self) -> usize {
123 self.num_hashes / self.num_bands
124 }
125}
126
127impl Default for LshConfig {
128 fn default() -> Self {
129 Self::default_balanced()
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct MinHashSignature {
136 pub values: Vec<u64>,
138}
139
140impl MinHashSignature {
141 #[must_use]
143 pub fn estimated_similarity(&self, other: &Self) -> f64 {
144 if self.values.len() != other.values.len() {
145 return 0.0;
146 }
147
148 let matching = self
149 .values
150 .iter()
151 .zip(other.values.iter())
152 .filter(|(a, b)| a == b)
153 .count();
154
155 matching as f64 / self.values.len() as f64
156 }
157}
158
159pub struct LshIndex {
161 config: LshConfig,
163 signatures: HashMap<CanonicalId, MinHashSignature>,
165 buckets: Vec<HashMap<u64, Vec<CanonicalId>>>,
167 hash_coeffs: Vec<(u64, u64)>,
169 prime: u64,
171}
172
173impl LshIndex {
174 #[must_use]
176 pub fn new(config: LshConfig) -> Self {
177 let mut hash_coeffs = Vec::with_capacity(config.num_hashes);
179 let mut seed = MINHASH_COEFF_SEED;
180
181 for _ in 0..config.num_hashes {
182 let a = splitmix64(&mut seed) | 1; let b = splitmix64(&mut seed);
185
186 hash_coeffs.push((a, b));
187 }
188
189 let buckets = (0..config.num_bands)
191 .map(|_| HashMap::with_capacity(64))
192 .collect();
193
194 Self {
195 config,
196 signatures: HashMap::with_capacity(256),
197 buckets,
198 hash_coeffs,
199 prime: 0xFFFF_FFFF_FFFF_FFC5, }
201 }
202
203 #[must_use]
205 pub fn build(sbom: &NormalizedSbom, config: LshConfig) -> Self {
206 let mut index = Self::new(config);
207
208 for (id, comp) in &sbom.components {
209 index.insert(id.clone(), comp);
210 }
211
212 index
213 }
214
215 pub fn insert(&mut self, id: CanonicalId, component: &Component) {
217 let shingles = self.compute_shingles(component);
219
220 let signature = self.compute_minhash(&shingles);
222
223 self.insert_into_buckets(&id, &signature);
225
226 self.signatures.insert(id, signature);
228 }
229
230 #[must_use]
235 pub fn find_candidates(&self, component: &Component) -> Vec<CanonicalId> {
236 let shingles = self.compute_shingles(component);
237 let signature = self.compute_minhash(&shingles);
238
239 self.find_candidates_by_signature(&signature)
240 }
241
242 #[must_use]
247 pub fn find_candidates_by_signature(&self, signature: &MinHashSignature) -> Vec<CanonicalId> {
248 let mut candidates = Vec::new();
249 let mut seen = HashSet::new();
250 let rows_per_band = self.config.rows_per_band();
251
252 for (band_idx, bucket_map) in self.buckets.iter().enumerate() {
253 let band_hash = self.hash_band(signature, band_idx, rows_per_band);
254
255 if let Some(ids) = bucket_map.get(&band_hash) {
256 for id in ids {
257 if seen.insert(id.clone()) {
258 candidates.push(id.clone());
259 }
260 }
261 }
262 }
263
264 candidates
265 }
266
267 pub fn find_candidates_for_id(&self, id: &CanonicalId) -> Vec<CanonicalId> {
271 self.signatures.get(id).map_or_else(Vec::new, |signature| {
272 self.find_candidates_by_signature(signature)
273 })
274 }
275
276 #[must_use]
278 pub fn get_signature(&self, id: &CanonicalId) -> Option<&MinHashSignature> {
279 self.signatures.get(id)
280 }
281
282 #[must_use]
284 pub fn estimate_similarity(&self, id_a: &CanonicalId, id_b: &CanonicalId) -> Option<f64> {
285 let sig_a = self.signatures.get(id_a)?;
286 let sig_b = self.signatures.get(id_b)?;
287 Some(sig_a.estimated_similarity(sig_b))
288 }
289
290 pub fn stats(&self) -> LshIndexStats {
292 let total_components = self.signatures.len();
293 let total_buckets: usize = self
294 .buckets
295 .iter()
296 .map(std::collections::HashMap::len)
297 .sum();
298 let max_bucket_size = self
299 .buckets
300 .iter()
301 .flat_map(|b| b.values())
302 .map(std::vec::Vec::len)
303 .max()
304 .unwrap_or(0);
305 let avg_bucket_size = if total_buckets > 0 {
306 self.buckets
307 .iter()
308 .flat_map(|b| b.values())
309 .map(std::vec::Vec::len)
310 .sum::<usize>() as f64
311 / total_buckets as f64
312 } else {
313 0.0
314 };
315
316 LshIndexStats {
317 total_components,
318 num_bands: self.config.num_bands,
319 num_hashes: self.config.num_hashes,
320 total_buckets,
321 max_bucket_size,
322 avg_bucket_size,
323 }
324 }
325
326 fn compute_shingles(&self, component: &Component) -> HashSet<u64> {
332 let ecosystem = component
334 .ecosystem
335 .as_ref()
336 .map(std::string::ToString::to_string);
337 let ecosystem_str = ecosystem.as_deref();
338
339 let normalized = ComponentIndex::normalize_name(&component.name, ecosystem_str);
341 let chars: Vec<char> = normalized.chars().collect();
342
343 let estimated_shingles = chars.len().saturating_sub(self.config.shingle_size) + 3;
345 let mut shingles = HashSet::with_capacity(estimated_shingles);
346
347 if chars.len() < self.config.shingle_size {
349 let mut hasher = std::collections::hash_map::DefaultHasher::new();
351 normalized.hash(&mut hasher);
352 shingles.insert(hasher.finish());
353 } else {
354 for window in chars.windows(self.config.shingle_size) {
356 let mut hasher = std::collections::hash_map::DefaultHasher::new();
357 window.hash(&mut hasher);
358 shingles.insert(hasher.finish());
359 }
360 }
361
362 if self.config.include_ecosystem_token
364 && let Some(ref eco) = ecosystem
365 {
366 let mut hasher = std::collections::hash_map::DefaultHasher::new();
367 "__eco:".hash(&mut hasher);
368 eco.to_lowercase().hash(&mut hasher);
369 shingles.insert(hasher.finish());
370 }
371
372 if self.config.include_group_token
374 && let Some(ref group) = component.group
375 {
376 let mut hasher = std::collections::hash_map::DefaultHasher::new();
377 "__grp:".hash(&mut hasher);
378 group.to_lowercase().hash(&mut hasher);
379 shingles.insert(hasher.finish());
380 }
381
382 shingles
383 }
384
385 fn compute_minhash(&self, shingles: &HashSet<u64>) -> MinHashSignature {
387 let mut min_hashes = vec![u64::MAX; self.config.num_hashes];
388
389 for &shingle in shingles {
390 for (i, &(a, b)) in self.hash_coeffs.iter().enumerate() {
391 let hash = a.wrapping_mul(shingle).wrapping_add(b) % self.prime;
393 if hash < min_hashes[i] {
394 min_hashes[i] = hash;
395 }
396 }
397 }
398
399 MinHashSignature { values: min_hashes }
400 }
401
402 fn insert_into_buckets(&mut self, id: &CanonicalId, signature: &MinHashSignature) {
404 let rows_per_band = self.config.rows_per_band();
405
406 let band_hashes: Vec<u64> = (0..self.config.num_bands)
408 .map(|band_idx| self.hash_band(signature, band_idx, rows_per_band))
409 .collect();
410
411 for (band_idx, bucket_map) in self.buckets.iter_mut().enumerate() {
412 bucket_map
413 .entry(band_hashes[band_idx])
414 .or_default()
415 .push(id.clone());
416 }
417 }
418
419 fn hash_band(
421 &self,
422 signature: &MinHashSignature,
423 band_idx: usize,
424 rows_per_band: usize,
425 ) -> u64 {
426 let start = band_idx * rows_per_band;
427 let end = (start + rows_per_band).min(signature.values.len());
428
429 let mut hasher = std::collections::hash_map::DefaultHasher::new();
430 for &value in &signature.values[start..end] {
431 value.hash(&mut hasher);
432 }
433 hasher.finish()
434 }
435}
436
437#[derive(Debug, Clone)]
439pub struct LshIndexStats {
440 pub total_components: usize,
442 pub num_bands: usize,
444 pub num_hashes: usize,
446 pub total_buckets: usize,
448 pub max_bucket_size: usize,
450 pub avg_bucket_size: f64,
452}
453
454impl std::fmt::Display for LshIndexStats {
455 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 write!(
457 f,
458 "LSH Index: {} components, {} bands × {} hashes, {} buckets (max: {}, avg: {:.1})",
459 self.total_components,
460 self.num_bands,
461 self.num_hashes / self.num_bands,
462 self.total_buckets,
463 self.max_bucket_size,
464 self.avg_bucket_size
465 )
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::model::DocumentMetadata;
473
474 fn make_component(name: &str) -> Component {
475 Component::new(name.to_string(), format!("id-{}", name))
476 }
477
478 #[test]
479 fn test_lsh_config_for_threshold() {
480 let config = LshConfig::for_threshold(0.8);
481 assert_eq!(config.num_hashes, 100);
482 assert!(config.num_bands > 0);
483 assert_eq!(config.num_hashes, config.num_bands * config.rows_per_band());
484 }
485
486 #[test]
492 fn test_for_threshold_bands_are_theory_consistent() {
493 let midpoint = |t: f64| {
494 let c = LshConfig::for_threshold(t);
495 (1.0 / c.num_bands as f64).powf(1.0 / c.rows_per_band() as f64)
496 };
497
498 for t in [0.5, 0.7, 0.8, 0.9] {
499 assert!(
500 midpoint(t) < t,
501 "midpoint {:.3} must sit below target {t} for recall",
502 midpoint(t)
503 );
504 }
505 assert!(
506 midpoint(0.9) > midpoint(0.5),
507 "stricter thresholds must prune harder: {:.3} vs {:.3}",
508 midpoint(0.9),
509 midpoint(0.5)
510 );
511 let default_config = LshConfig::default();
514 assert_eq!(
515 (default_config.num_bands, default_config.rows_per_band()),
516 (25, 4)
517 );
518 }
519
520 #[test]
521 fn test_minhash_signature_similarity() {
522 let sig_a = MinHashSignature {
523 values: vec![1, 2, 3, 4, 5],
524 };
525 let sig_b = MinHashSignature {
526 values: vec![1, 2, 3, 4, 5],
527 };
528 assert_eq!(sig_a.estimated_similarity(&sig_b), 1.0);
529
530 let sig_c = MinHashSignature {
531 values: vec![1, 2, 3, 6, 7],
532 };
533 assert!((sig_a.estimated_similarity(&sig_c) - 0.6).abs() < 0.01);
534 }
535
536 #[test]
537 fn test_lsh_index_build() {
538 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
539 sbom.add_component(make_component("lodash"));
540 sbom.add_component(make_component("lodash-es"));
541 sbom.add_component(make_component("underscore"));
542 sbom.add_component(make_component("react"));
543
544 let index = LshIndex::build(&sbom, LshConfig::default_balanced());
545 let stats = index.stats();
546
547 assert_eq!(stats.total_components, 4);
548 assert!(stats.total_buckets > 0);
549 }
550
551 #[test]
552 fn test_lsh_finds_similar_names() {
553 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
554 sbom.add_component(make_component("lodash"));
555 sbom.add_component(make_component("lodash-es"));
556 sbom.add_component(make_component("lodash-fp"));
557 sbom.add_component(make_component("react"));
558 sbom.add_component(make_component("angular"));
559
560 let index = LshIndex::build(&sbom, LshConfig::for_threshold(0.5));
561
562 let query = make_component("lodash");
564 let candidates = index.find_candidates(&query);
565
566 assert!(
569 !candidates.is_empty(),
570 "Should find at least some candidates"
571 );
572 }
573
574 #[test]
575 fn test_lsh_signature_estimation() {
576 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
577
578 let comp1 = make_component("lodash");
579 let comp2 = make_component("lodash-es");
580 let comp3 = make_component("completely-different-name");
581
582 let id1 = comp1.canonical_id.clone();
583 let id2 = comp2.canonical_id.clone();
584 let id3 = comp3.canonical_id.clone();
585
586 sbom.add_component(comp1);
587 sbom.add_component(comp2);
588 sbom.add_component(comp3);
589
590 let index = LshIndex::build(&sbom, LshConfig::default_balanced());
591
592 let sim_12 = index.estimate_similarity(&id1, &id2).unwrap();
594 let sim_13 = index.estimate_similarity(&id1, &id3).unwrap();
595
596 assert!(
597 sim_12 > sim_13,
598 "lodash vs lodash-es ({:.2}) should be more similar than lodash vs completely-different ({:.2})",
599 sim_12,
600 sim_13
601 );
602 }
603
604 #[test]
605 fn test_lsh_deterministic_across_instances() {
606 let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
607 for name in ["lodash", "lodash-es", "underscore", "react", "angular"] {
608 sbom.add_component(make_component(name));
609 }
610
611 let index_a = LshIndex::build(&sbom, LshConfig::default_balanced());
612 let index_b = LshIndex::build(&sbom, LshConfig::default_balanced());
613
614 for id in sbom.components.keys() {
615 assert_eq!(
616 index_a.get_signature(id).unwrap().values,
617 index_b.get_signature(id).unwrap().values,
618 "signatures must be identical across index instances"
619 );
620 }
621
622 let query = make_component("lodash");
623 assert_eq!(
624 index_a.find_candidates(&query),
625 index_b.find_candidates(&query),
626 "candidate lists must be identical (same content and order)"
627 );
628 }
629
630 #[test]
631 fn test_lsh_index_stats() {
632 let config = LshConfig::for_threshold(0.8);
633 let index = LshIndex::new(config);
634
635 let stats = index.stats();
636 assert_eq!(stats.total_components, 0);
637 assert_eq!(stats.num_bands, 25);
638 assert_eq!(stats.num_hashes, 100);
639 }
640}