Skip to main content

sbom_tools/matching/
lsh.rs

1//! Locality-Sensitive Hashing (LSH) for approximate nearest neighbor search.
2//!
3//! This module provides `MinHash` LSH for efficient similarity search on large SBOMs
4//! (10,000+ components). It trades some accuracy for dramatic speed improvements
5//! by using hash-based approximate matching.
6//!
7//! # How it works
8//!
9//! 1. Each component name is converted to a set of character shingles (n-grams)
10//! 2. `MinHash` signatures are computed for each shingle set
11//! 3. Signatures are divided into bands and hashed into buckets
12//! 4. Components in the same bucket are candidate matches
13//!
14//! # Performance
15//!
16//! - Build time: O(n × k) where k = signature size
17//! - Query time: O(1) average for bucket lookup + O(m) for candidates
18//! - Space: O(n × k) for signatures
19
20use super::index::ComponentIndex;
21use crate::model::{CanonicalId, Component, NormalizedSbom};
22use std::collections::{HashMap, HashSet};
23use std::hash::{Hash, Hasher};
24
25/// Fixed seed for `MinHash` coefficient generation.
26///
27/// Coefficients must be identical across runs so that LSH candidate sets —
28/// and therefore diff results for large SBOMs — are reproducible. Never seed
29/// from input data or process-local randomness.
30const MINHASH_COEFF_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
31
32/// splitmix64 PRNG step (public-domain generator by Sebastiano Vigna).
33///
34/// Produces well-distributed 64-bit values from a sequential seed, which is
35/// all `MinHash` needs for its `h(x) = a*x + b` coefficient pairs.
36fn 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/// Configuration for LSH index.
45#[derive(Debug, Clone)]
46pub struct LshConfig {
47    /// Number of hash functions in the `MinHash` signature
48    pub num_hashes: usize,
49    /// Number of bands to divide the signature into
50    pub num_bands: usize,
51    /// Size of character shingles (n-grams)
52    pub shingle_size: usize,
53    /// Minimum Jaccard similarity threshold this config is tuned for
54    pub target_threshold: f64,
55    /// Include ecosystem as a token in shingles (improves grouping by ecosystem)
56    pub include_ecosystem_token: bool,
57    /// Include group/namespace as a token in shingles (useful for Maven, npm scopes)
58    pub include_group_token: bool,
59}
60
61impl LshConfig {
62    /// Create a config tuned for the given similarity threshold.
63    ///
64    /// The number of bands and rows are chosen to maximize the probability
65    /// of finding pairs with similarity >= threshold while minimizing false positives.
66    #[must_use]
67    pub fn for_threshold(threshold: f64) -> Self {
68        // The S-curve midpoint of banded MinHash is t ≈ (1/b)^(1/r) with
69        // b bands of r rows (b × r = 100 hashes here). Stricter thresholds
70        // want fewer bands with more rows (higher midpoint); permissive
71        // thresholds want many bands of few rows so low-similarity pairs
72        // still collide in some band. Each midpoint sits below its target
73        // threshold so target-similarity pairs are caught with high
74        // probability (recall-safe). The 0.8 row keeps its historical
75        // (25, 4) — looser than the 0.7 row's midpoint — because it is the
76        // production default and its candidate sets are pinned.
77        //
78        // The previous table had this inverted: t≥0.9 got (50, 2)
79        // (midpoint ~0.14 — floods with dissimilar candidates) and t≥0.5
80        // got (10, 10) (midpoint ~0.79 — missed ~99% of the pairs
81        // permissive() claims to target).
82        let (num_bands, rows_per_band) = if threshold >= 0.9 {
83            (10, 10) // midpoint ~0.79; P(catch a 0.9-similar pair) ≈ 0.99
84        } else if threshold >= 0.8 {
85            (25, 4) // midpoint ~0.45 (unchanged: the production default)
86        } else if threshold >= 0.7 {
87            (20, 5) // midpoint ~0.55
88        } else {
89            (50, 2) // midpoint ~0.14; P(catch a 0.5-similar pair) ≈ 1.0
90        };
91
92        Self {
93            num_hashes: num_bands * rows_per_band,
94            num_bands,
95            shingle_size: 3, // Trigrams work well for package names
96            target_threshold: threshold,
97            include_ecosystem_token: true, // Helps group by ecosystem
98            include_group_token: false,    // Optional, disabled by default
99        }
100    }
101
102    /// Default config for balanced matching (~0.8 threshold).
103    #[must_use]
104    pub fn default_balanced() -> Self {
105        Self::for_threshold(0.8)
106    }
107
108    /// Config for strict matching (~0.9 threshold).
109    #[must_use]
110    pub fn strict() -> Self {
111        Self::for_threshold(0.9)
112    }
113
114    /// Config for permissive matching (~0.5 threshold).
115    #[must_use]
116    pub fn permissive() -> Self {
117        Self::for_threshold(0.5)
118    }
119
120    /// Get rows per band (signature elements per band).
121    #[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/// `MinHash` signature for a component.
134#[derive(Debug, Clone)]
135pub struct MinHashSignature {
136    /// The hash values (one per hash function)
137    pub values: Vec<u64>,
138}
139
140impl MinHashSignature {
141    /// Compute the estimated Jaccard similarity between two signatures.
142    #[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
159/// LSH index for efficient approximate nearest neighbor search.
160pub struct LshIndex {
161    /// Configuration
162    config: LshConfig,
163    /// `MinHash` signatures for each component
164    signatures: HashMap<CanonicalId, MinHashSignature>,
165    /// Band buckets: `band_index` -> `bucket_hash` -> component IDs
166    buckets: Vec<HashMap<u64, Vec<CanonicalId>>>,
167    /// Hash coefficients for `MinHash` (a, b pairs for h(x) = (ax + b) mod p)
168    hash_coeffs: Vec<(u64, u64)>,
169    /// Large prime for hashing
170    prime: u64,
171}
172
173impl LshIndex {
174    /// Create a new LSH index with the given configuration.
175    #[must_use]
176    pub fn new(config: LshConfig) -> Self {
177        // Generate hash coefficients deterministically from a fixed seed
178        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; // Ensure odd (coprime with 2^64)
183
184            let b = splitmix64(&mut seed);
185
186            hash_coeffs.push((a, b));
187        }
188
189        // Initialize empty buckets for each band
190        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, // Large prime close to 2^64
200        }
201    }
202
203    /// Build an LSH index from an SBOM.
204    #[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    /// Insert a component into the index.
216    pub fn insert(&mut self, id: CanonicalId, component: &Component) {
217        // Compute shingles from the component (uses ecosystem-aware normalization)
218        let shingles = self.compute_shingles(component);
219
220        // Compute MinHash signature
221        let signature = self.compute_minhash(&shingles);
222
223        // Insert into band buckets
224        self.insert_into_buckets(&id, &signature);
225
226        // Store signature
227        self.signatures.insert(id, signature);
228    }
229
230    /// Find candidate matches for a component.
231    ///
232    /// Returns component IDs that are likely similar based on LSH buckets.
233    /// These candidates should be verified with exact similarity computation.
234    #[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    /// Find candidates using a pre-computed signature.
243    ///
244    /// Candidates are returned in deterministic band/bucket order so that
245    /// downstream truncation selects the same subset across runs.
246    #[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    /// Find candidates for a component from another index.
268    ///
269    /// Useful for diffing: build index from new SBOM, query with old SBOM components.
270    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    /// Get the `MinHash` signature for a component.
277    #[must_use]
278    pub fn get_signature(&self, id: &CanonicalId) -> Option<&MinHashSignature> {
279        self.signatures.get(id)
280    }
281
282    /// Estimate similarity between two components in the index.
283    #[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    /// Get statistics about the index.
291    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    /// Compute character shingles (n-grams) from a component.
327    ///
328    /// Uses ecosystem-aware normalization from `ComponentIndex` for consistent
329    /// shingling across `PyPI`, Cargo, npm, etc. Also adds optional ecosystem
330    /// and group tokens to improve candidate grouping.
331    fn compute_shingles(&self, component: &Component) -> HashSet<u64> {
332        // Get ecosystem for normalization
333        let ecosystem = component
334            .ecosystem
335            .as_ref()
336            .map(std::string::ToString::to_string);
337        let ecosystem_str = ecosystem.as_deref();
338
339        // Use ComponentIndex's normalization for consistency
340        let normalized = ComponentIndex::normalize_name(&component.name, ecosystem_str);
341        let chars: Vec<char> = normalized.chars().collect();
342
343        // Estimate capacity: roughly (len - shingle_size + 1) shingles + 2 tokens
344        let estimated_shingles = chars.len().saturating_sub(self.config.shingle_size) + 3;
345        let mut shingles = HashSet::with_capacity(estimated_shingles);
346
347        // Compute name shingles
348        if chars.len() < self.config.shingle_size {
349            // For very short names, use the whole name as a shingle
350            let mut hasher = std::collections::hash_map::DefaultHasher::new();
351            normalized.hash(&mut hasher);
352            shingles.insert(hasher.finish());
353        } else {
354            // Hash character windows directly without allocating intermediate strings
355            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        // Add ecosystem token (helps group components by ecosystem)
363        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        // Add group/namespace token (useful for Maven group IDs, npm scopes)
373        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    /// Compute `MinHash` signature from shingles.
386    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                // h_i(x) = (a*x + b) mod prime
392                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    /// Insert a signature into band buckets.
403    fn insert_into_buckets(&mut self, id: &CanonicalId, signature: &MinHashSignature) {
404        let rows_per_band = self.config.rows_per_band();
405
406        // Pre-compute all band hashes to avoid borrow conflicts
407        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    /// Hash a band of the signature.
420    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/// Statistics about an LSH index.
438#[derive(Debug, Clone)]
439pub struct LshIndexStats {
440    /// Total number of indexed components
441    pub total_components: usize,
442    /// Number of bands
443    pub num_bands: usize,
444    /// Total number of hash functions
445    pub num_hashes: usize,
446    /// Total number of non-empty buckets
447    pub total_buckets: usize,
448    /// Maximum components in a single bucket
449    pub max_bucket_size: usize,
450    /// Average components per bucket
451    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    /// The band/row table must be theory-consistent: the S-curve midpoint
487    /// (1/b)^(1/r) must not EXCEED the target threshold (recall safety) and
488    /// must increase with the threshold (stricter thresholds prune harder).
489    /// The old table was inverted — permissive() missed ~99% of the pairs it
490    /// targeted while strict() flooded.
491    #[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        // The production default (0.8 -> 25x4) is pinned: changing it changes
512        // candidate sets for every diff above the LSH gate.
513        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        // Query for similar to "lodash"
563        let query = make_component("lodash");
564        let candidates = index.find_candidates(&query);
565
566        // Should find lodash variants as candidates
567        // Note: LSH is probabilistic, so we check for likely outcomes
568        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        // Similar names should have higher estimated similarity
593        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}