Skip to main content

codehelion_core/
near_match.rs

1//! Structural-mode near-match candidate generation: `MinHash` + LSH.
2//!
3//! The exact-hash seed layer ([`crate::candidate`]) finds fragments whose
4//! structure is *identical* under the feature recipe — Type-1 and Type-2
5//! clones. Type-3 clones differ: statements inserted, deleted or reordered, so
6//! no single hash matches end to end, but most of the two units' structural
7//! fingerprints still coincide. This layer finds those units by set
8//! similarity.
9//!
10//! Each unit becomes a shingle set — the union of its statement-window and
11//! subtree feature hashes. A `MinHash` signature estimates the Jaccard
12//! similarity of any two sets from a fixed-length vector, and Locality-
13//! Sensitive Hashing bands those signatures so that only unit pairs likely to
14//! be similar are ever examined, sidestepping the quadratic all-pairs compare.
15//!
16//! LSH is probabilistic, so it is used only to *propose* pairs; every proposed
17//! pair must then clear two deterministic gates before it is emitted, which is
18//! also what keeps the output a pure function of the input:
19//!
20//! - a **length-ratio** pre-filter drops pairs whose unit sizes differ by more
21//!   than [`NearMatchConfig::max_length_ratio`] — a large and a small unit are
22//!   not a Type-3 pair however their shingles happened to band;
23//! - an **estimated-Jaccard** gate drops pairs whose signature similarity is
24//!   below [`NearMatchConfig::min_estimated_jaccard`], so a spurious band
25//!   collision between dissimilar units never survives.
26//!
27//! Candidate-explosion control matches the seed layer (AGENTS.md invariant
28//! 10): an LSH bucket larger than the posting cap is high-frequency structure
29//! and is dropped whole and counted, and a global pair budget bounds the
30//! distinct pairs examined. Buckets are processed smallest-first within each
31//! deterministic band; a pair colliding in multiple LSH bands is charged once,
32//! when it first enters the distinct candidate set. Once the ceiling fires,
33//! later buckets are not walked. Everything dropped is counted in
34//! [`NearMatchStats`].
35//!
36//! As in [`crate::candidate`], the budget is spent a bucket at a time: a bucket
37//! it cannot hold entirely is left alone rather than sampled, because a set of
38//! units compared to each other only in part is what grouping reads as a set
39//! that disagrees. The posting cap bounds the one bucket that must be
40//! materialised to identify its previously unseen pairs.
41//!
42//! This design deliberately subsumes the separate size-bucket and
43//! prefix-filtering prefilters: LSH banding partitions the search, and the
44//! length-ratio gate bounds size divergence, which together already bound the
45//! candidate set without a second size index. Signatures are held in one flat
46//! buffer and capped before indexing; each LSH band builds and discards its own
47//! posting map, so the index itself cannot grow with every band at once.
48
49use std::collections::{BTreeMap, BTreeSet};
50
51use crate::features::{FileFeatures, UnitFeatures, UnitRef};
52
53/// Default number of `MinHash` permutations per signature.
54pub const DEFAULT_NUM_HASHES: usize = 128;
55
56/// Default number of LSH bands; rows per band is `num_hashes / bands`.
57///
58/// Two rows per band puts the LSH S-curve crossover (~`(1/bands)^(1/rows)`)
59/// well below [`DEFAULT_MIN_ESTIMATED_JACCARD`], so LSH proposes every pair the
60/// acceptance gate would keep and the gate, not LSH, sets precision. The
61/// recall/candidate-count trade-off is calibrated against the corpus.
62pub const DEFAULT_BANDS: usize = 64;
63
64/// Default largest unit-size ratio a pair may span.
65pub const DEFAULT_MAX_LENGTH_RATIO: f64 = 3.0;
66
67/// Default smallest shingle-set size a unit needs to be signed. Below this a
68/// `MinHash` estimate is too noisy to trust.
69pub const DEFAULT_MIN_SHINGLES: usize = 4;
70
71/// Default smallest estimated Jaccard a pair must reach to be emitted. Type-3
72/// edits routinely land here.
73///
74/// Not calibrated against the corpus, and not for want of trying: every value
75/// from 0.1 to one no estimate can reach leaves every corpus this project has
76/// reporting exactly the same groups. Turning the stage off does too. What
77/// this gate is worth is therefore unmeasured rather than measured and small —
78/// the stage exists for gapped clones the exact seeds miss, and the largest
79/// case here is under half a million lines.
80pub const DEFAULT_MIN_ESTIMATED_JACCARD: f64 = 0.3;
81
82/// Default LSH-bucket cap; larger buckets are high-frequency and dropped.
83pub const DEFAULT_POSTING_CAP: usize = 256;
84
85/// Default global candidate-pair upper bound.
86pub const DEFAULT_PAIR_BUDGET: usize = 2_000_000;
87
88/// Default maximum number of units admitted to the near-match signature stage.
89pub const DEFAULT_MAX_SIGNED_UNITS: usize = 100_000;
90
91/// Default width of the diagnostic band directly below the candidate gate.
92///
93/// Near misses are deliberately a small inspection window, not a second
94/// candidate stream. They make it possible to see proposals that only just
95/// missed the estimate gate without retaining the unbounded set of every
96/// rejected LSH collision.
97pub const DEFAULT_NEAR_MISS_DELTA: f64 = 0.05;
98
99/// Default global cap on retained near-match diagnostics.
100pub const DEFAULT_NEAR_MISS_CAP: usize = 1_000;
101
102/// Tuning for near-match candidate generation. Defaults are provisional and
103/// calibrated against the corpus with the funnel measurement.
104#[derive(Debug, Clone, PartialEq)]
105pub struct NearMatchConfig {
106    /// Number of `MinHash` permutations per signature.
107    pub num_hashes: usize,
108    /// Number of LSH bands. Rows per band is `num_hashes / bands`; a higher
109    /// band count raises recall at the cost of more candidate pairs.
110    pub bands: usize,
111    /// Units with fewer distinct shingles than this are not signed.
112    pub min_shingles: usize,
113    /// Largest ratio of unit sizes (in nodes) a pair may span.
114    pub max_length_ratio: f64,
115    /// Smallest estimated Jaccard a pair must reach to be emitted.
116    pub min_estimated_jaccard: f64,
117    /// Longest LSH bucket that still enters pairing; longer ones are dropped
118    /// as high-frequency structure and counted.
119    pub posting_cap: usize,
120    /// Upper bound on distinct candidate pairs examined.
121    pub pair_budget: usize,
122    /// Largest number of units admitted to the signature stage. Later eligible
123    /// units are skipped deterministically and reported rather than causing
124    /// the flat signature buffer to grow without bound.
125    pub max_signed_units: usize,
126    /// Width of the diagnostic estimate band immediately below
127    /// [`Self::min_estimated_jaccard`].
128    pub near_miss_delta: f64,
129    /// Maximum below-threshold, size-compatible LSH proposals retained as
130    /// diagnostic near misses.
131    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    /// Rows per band, at least one, never more than the signature length.
153    fn rows(&self) -> usize {
154        (self.num_hashes / self.bands.max(1)).max(1)
155    }
156
157    /// Lowest estimate retained as a diagnostic near miss.
158    ///
159    /// A caller can ask for a wider band than the threshold itself, but it
160    /// still cannot retain estimates below zero. The upper endpoint remains
161    /// exclusive in [`is_near_miss`], so an estimate that clears the primary
162    /// candidate threshold can never be duplicated here.
163    fn near_miss_floor(&self) -> f64 {
164        (self.min_estimated_jaccard - self.near_miss_delta).max(0.0)
165    }
166
167    /// Whether a below-threshold estimate belongs to the diagnostic band.
168    fn is_near_miss(&self, estimate: f64) -> bool {
169        estimate >= self.near_miss_floor() && estimate < self.min_estimated_jaccard
170    }
171}
172
173/// A near-match candidate: two units whose structural shingle sets overlap
174/// enough to be a possible Type-3 clone. Canonical: `a < b`.
175#[derive(Debug, Clone, Copy, PartialEq)]
176pub struct NearMatchPair {
177    /// The lower unit.
178    pub a: UnitRef,
179    /// The higher unit.
180    pub b: UnitRef,
181    /// `MinHash`-estimated Jaccard similarity of the two shingle sets.
182    pub estimated_jaccard: f64,
183}
184
185/// One size-compatible LSH proposal whose estimate fell just below the
186/// primary candidate threshold.
187///
188/// This is diagnostic telemetry only. It is never lifted to verification,
189/// grouping, or a primary finding.
190#[derive(Debug, Clone, Copy, PartialEq)]
191pub struct NearMatchNearMiss {
192    /// The lower unit.
193    pub a: UnitRef,
194    /// The higher unit.
195    pub b: UnitRef,
196    /// `MinHash`-estimated Jaccard similarity of the two shingle sets.
197    pub estimated_jaccard: f64,
198}
199
200/// Counters describing what near-match generation saw and dropped.
201#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct NearMatchStats {
203    /// Units across all files.
204    pub units: usize,
205    /// Units signed (cleared `min_shingles`).
206    pub signed_units: usize,
207    /// Units skipped for having too few shingles.
208    pub skipped_small: usize,
209    /// Eligible units skipped after the signature-stage ceiling was reached.
210    pub signed_limit_dropped: usize,
211    /// LSH buckets with at least two members.
212    pub buckets: usize,
213    /// Buckets dropped for exceeding the posting cap.
214    pub stop_buckets: usize,
215    /// Bucket members dropped with them.
216    pub stop_bucket_members: usize,
217    /// Distinct pairs proposed by LSH before the deterministic gates.
218    pub proposed_pairs: usize,
219    /// Pairs dropped by the length-ratio gate.
220    pub filtered_by_size: usize,
221    /// Pairs dropped by the estimated-Jaccard gate.
222    pub filtered_by_jaccard: usize,
223    /// Size-compatible, below-threshold pairs inside the configured
224    /// diagnostic band before its retention cap.
225    pub near_miss_band_pairs: usize,
226    /// Diagnostic near misses retained under the global cap.
227    pub near_misses_retained: usize,
228    /// Diagnostic near misses not retained after the global cap was reached.
229    pub near_miss_cap_dropped: usize,
230    /// Candidate pairs emitted.
231    pub candidate_pairs: usize,
232    /// Whether the pair budget ran out before all buckets were paired.
233    pub budget_exhausted: bool,
234    /// Previously unseen pairs in the first eligible bucket the budget could
235    /// not admit. Later buckets are deliberately not materialised, so this is
236    /// a lower bound on the distinct candidate-pair work left.
237    pub budget_dropped: usize,
238}
239
240/// The near-match stage's output: candidate unit pairs plus funnel statistics.
241#[derive(Debug, Clone, PartialEq)]
242pub struct NearMatchSet {
243    /// Candidate pairs, deterministically ordered by `(a, b)`.
244    pub pairs: Vec<NearMatchPair>,
245    /// Bounded diagnostic proposals immediately below the estimate threshold,
246    /// deterministically ordered by `(a, b)`.
247    pub near_misses: Vec<NearMatchNearMiss>,
248    /// What the stage saw and dropped.
249    pub stats: NearMatchStats,
250}
251
252/// Generate near-match candidate unit pairs across `files`.
253///
254/// The result is a pure function of the input: the `MinHash` permutations are
255/// fixed, LSH bucketing is deterministic, and the emitted pairs are sorted, so
256/// file order only moves the `file` indices inside the unit references.
257#[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    // Keep unit references beside one flat signature buffer. A nested `Vec`
263    // per unit amplifies allocator metadata on large trees without helping the
264    // fixed-width MinHash representation.
265    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    // Apply the deterministic gates. `proposed` is already sorted, so output
294    // ordering is stable without re-sorting.
295    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
339/// Propose candidate index pairs (into `signed`) via LSH banding, applying the
340/// bucket cap and pair budget. Returns distinct `(a, b)` index pairs with
341/// `a < b`, sorted.
342fn 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        // This map exists for one band only. Releasing it before the next
355        // bounds posting-list memory independently of the number of bands.
356        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            // A physical pair commonly collides in many LSH bands. Charge the
378            // work that will actually reach verification — distinct pairs — not
379            // every occurrence. The posting cap bounds this materialisation.
380            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
402/// Borrow one fixed-width signature from the flat signature buffer.
403fn 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
408/// The union of a unit's window and subtree feature hashes, folded to `u64`
409/// shingles, sorted and deduplicated. The kind is mixed in so a window hash
410/// and a subtree hash with the same bytes stay distinct shingles.
411fn shingles_of(unit: &UnitFeatures) -> Vec<u64> {
412    const WINDOW_DOMAIN: u64 = 0x5749_4e44_4f57_0000; // "WINDOW"
413    const SUBTREE_DOMAIN: u64 = 0x5355_4254_5245_0000; // "SUBTRE"
414    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
426/// Fold a 16-byte feature hash to a `u64` shingle base. The two halves are
427/// mixed with distinct multipliers and a finalizer, so distinct hashes stay
428/// distinct even when their bytes are symmetric.
429fn 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
441/// The `MinHash` signature of a shingle set: the per-permutation minimum.
442fn 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
455/// Estimated Jaccard similarity: the fraction of signature positions that
456/// agree. Signatures always share a length here.
457fn 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
462/// Lossless `usize` ratio via `u32`, `0.0` when the denominator is zero.
463fn 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
473/// A deterministic table of `count` permutation seeds from a fixed constant,
474/// so signatures never depend on run-time randomness.
475fn permutation_seeds(count: usize) -> Vec<u64> {
476    let mut state = 0x1234_5678_9abc_def0u64;
477    (0..count).map(|_| splitmix64(&mut state)).collect()
478}
479
480/// `SplitMix64`: a deterministic seed generator.
481const 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
489/// One `MinHash` permutation of a shingle: a strong finalizer of `x ^ seed`.
490const 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
497/// A band's key: the band index folded together with its signature rows.
498fn 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    /// A unit whose shingle set is exactly the given window and subtree hash
517    /// seeds, with a chosen node count for the length-ratio gate.
518    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        // Sets share five of seven shingles: true Jaccard = 5/9.
604        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        // True Jaccard 5/9 ~= 0.556; a 128-hash estimate lands close.
614        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        // Even if LSH proposed nothing, the estimate gate would have caught it.
634        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            // No estimate can clear this threshold, so all three LSH
662            // proposals are diagnostic-only. This isolates storage policy
663            // from primary candidate selection.
664            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        // Identical shingles, but sizes 10 vs 40: ratio 4 exceeds the cap of 3.
691        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        // Four identical units, bucket cap 3: every band bucket holds all four
713        // and is stopped, so no pair survives.
714        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        // Identical signatures collide in every band, but the three physical
733        // pairs are still only three verification candidates. Charging the
734        // same three pairs once per band spuriously exhausts this budget.
735        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        // Three units band together, so the bucket is worth three pairs and
755        // the allowance is worth one. Taking one of the three would leave the
756        // units compared to each other in part, which grouping reads as a set
757        // that disagrees rather than as a set nobody finished comparing.
758        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        // Lists are visited smallest first. The two-unit bucket fits, then the
776        // three-unit bucket exceeds the remaining allowance. The latter and
777        // every larger bucket are left untouched. The one refused bucket is
778        // materialised only up to the posting cap to identify distinct work.
779        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        // The two-unit bucket costs one pair and is met first, so it survives
797        // the allowance the three-unit bucket cannot fit into.
798        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}