Skip to main content

dedup/
config.rs

1//! Configuration for the deduplication engine.
2
3use crate::error::{Error, Result};
4
5/// Configuration for the deduplication engine.
6///
7/// This struct controls all parameters for MinHash + LSH deduplication.
8/// Use the builder pattern to customize:
9///
10/// ```rust
11/// use dedup::Config;
12///
13/// let config = Config::default()
14///     .with_similarity_threshold(0.85)
15///     .with_num_bands(14)
16///     .with_shingle_size(4);
17/// ```
18/// Upper bound on [`Config::signature_size`]. MinHash accuracy gains flatten
19/// out by a few hundred hash functions (typical configurations use 64-256),
20/// so 65536 is far beyond any useful value. The cap exists to fail closed:
21/// the signature is stored per document and the hash coefficients are two
22/// `u64` vectors of this length, so an unbounded size turns a hostile or
23/// mistaken configuration into gigabytes of allocation.
24pub const MAX_SIGNATURE_SIZE: usize = 1 << 16;
25
26/// Configuration for MinHash and LSH deduplication parameters.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct Config {
29    /// Number of hash functions (signature size).
30    pub signature_size: usize,
31    /// Number of LSH bands.
32    pub num_bands: usize,
33    /// Size of shingles (k-grams).
34    pub shingle_size: usize,
35    /// Similarity threshold for duplicates (0.0-1.0).
36    pub similarity_threshold: f64,
37    /// Maximum document size in bytes.
38    pub(crate) max_document_size: usize,
39    /// Memory limit in MB for the LSH index.
40    pub(crate) memory_limit_in_mb: usize,
41    /// Seed for hash function randomization.
42    pub(crate) seed: u64,
43    /// Whether to store document content in memory.
44    pub(crate) store_documents: bool,
45}
46
47impl Config {
48    /// Create a new configuration with custom parameters.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if parameters are invalid:
53    /// - `signature_size` must be divisible by `num_bands`
54    /// - `similarity_threshold` must be in (0.0, 1.0]
55    /// - `shingle_size` must be at least 1
56    pub fn new(
57        signature_size: usize,
58        num_bands: usize,
59        shingle_size: usize,
60        similarity_threshold: f64,
61    ) -> Result<Self> {
62        if signature_size == 0 {
63            return Err(Error::InvalidConfig {
64                reason: "signature_size must be at least 1".to_string(),
65                fix: "use signature_size >= 1".to_string(),
66            });
67        }
68        if signature_size > MAX_SIGNATURE_SIZE {
69            return Err(Error::InvalidConfig {
70                reason: format!(
71                    "signature_size ({signature_size}) exceeds maximum {MAX_SIGNATURE_SIZE}"
72                ),
73                fix: format!(
74                    "use signature_size <= {MAX_SIGNATURE_SIZE}; larger signatures add no accuracy and only exhaust memory"
75                ),
76            });
77        }
78        if num_bands == 0 {
79            return Err(Error::InvalidConfig {
80                reason: "num_bands must be at least 1".to_string(),
81                fix: "use num_bands >= 1".to_string(),
82            });
83        }
84        if signature_size % num_bands != 0 {
85            return Err(Error::InvalidConfig {
86                reason: format!(
87                    "signature_size ({signature_size}) must be divisible by num_bands ({num_bands})"
88                ),
89                fix: "use signature_size = num_bands * rows_per_band".to_string(),
90            });
91        }
92        if shingle_size == 0 {
93            return Err(Error::InvalidConfig {
94                reason: "shingle_size must be at least 1".to_string(),
95                fix: "use shingle_size >= 1".to_string(),
96            });
97        }
98        if similarity_threshold <= 0.0 || similarity_threshold > 1.0 {
99            return Err(Error::InvalidConfig {
100                reason: format!(
101                    "similarity_threshold ({similarity_threshold}) must be in (0.0, 1.0]"
102                ),
103                fix: "use 0.0 < similarity_threshold <= 1.0".to_string(),
104            });
105        }
106
107        Ok(Self {
108            signature_size,
109            num_bands,
110            shingle_size,
111            similarity_threshold,
112            max_document_size: 10 * 1024 * 1024, // 10 MB default
113            memory_limit_in_mb: 4096,            // 4 GB default
114            seed: 0x9e37_79b9_7f4a_7c15,         // Random seed
115            store_documents: false,
116        })
117    }
118
119    /// Set the similarity threshold.
120    #[must_use]
121    pub fn with_similarity_threshold(mut self, threshold: f64) -> Self {
122        self.similarity_threshold = threshold.clamp(0.01, 1.0);
123        self
124    }
125
126    /// Set the number of LSH bands.
127    ///
128    /// The bands must tile the signature exactly (`signature_size % num_bands
129    /// == 0`). Rather than silently dropping a requested count that does not
130    /// divide the current `signature_size` (the old behavior, which left the
131    /// caller believing their value took effect), this snaps to the divisor of
132    /// `signature_size` nearest the request, so the invariant always holds and
133    /// the change always takes effect. A request of `0` (no valid band count)
134    /// leaves the current value unchanged. Use [`Config::new`] for a fallible,
135    /// error-returning construction instead.
136    #[must_use]
137    pub fn with_num_bands(mut self, num_bands: usize) -> Self {
138        if num_bands > 0 {
139            self.num_bands = nearest_divisor(self.signature_size, num_bands);
140        }
141        self
142    }
143
144    /// Set the shingle size (k-gram length).
145    #[must_use]
146    pub fn with_shingle_size(mut self, shingle_size: usize) -> Self {
147        if shingle_size > 0 {
148            self.shingle_size = shingle_size;
149        }
150        self
151    }
152
153    /// Set the signature size (number of hash functions).
154    ///
155    /// The signature must be an exact multiple of `num_bands`. Rather than
156    /// silently dropping a requested size that is not (the old behavior), this
157    /// rounds UP to the next multiple of the current `num_bands`, so the bands
158    /// always tile the signature and the change always takes effect. A request
159    /// of `0` leaves the current value unchanged. Use [`Config::new`] for a
160    /// fallible, error-returning construction instead.
161    #[must_use]
162    pub fn with_signature_size(mut self, signature_size: usize) -> Self {
163        if signature_size > 0 {
164            // Clamp before snapping: `div_ceil(bands) * bands` overflows usize
165            // for requests near usize::MAX (panic in debug, silent wrap to a
166            // tiny size in release), and an uncapped size lets the hasher
167            // allocate gigabytes of coefficients. The cap is far beyond any
168            // accuracy-useful signature, so real configurations are unchanged.
169            let signature_size = signature_size.min(MAX_SIGNATURE_SIZE);
170            let bands = self.num_bands.max(1);
171            self.signature_size = signature_size.div_ceil(bands) * bands;
172        }
173        self
174    }
175
176    /// Set the maximum document size in bytes.
177    #[must_use]
178    pub fn with_max_document_size(mut self, max_bytes: usize) -> Self {
179        self.max_document_size = max_bytes;
180        self
181    }
182
183    /// Set the memory limit in MB.
184    #[must_use]
185    pub fn with_memory_limit(mut self, memory_limit_in_mb: usize) -> Self {
186        self.memory_limit_in_mb = memory_limit_in_mb;
187        self
188    }
189
190    /// Set the random seed for reproducibility.
191    #[must_use]
192    pub fn with_seed(mut self, seed: u64) -> Self {
193        self.seed = seed;
194        self
195    }
196
197    /// Enable or disable storing document content.
198    #[must_use]
199    pub fn with_store_documents(mut self, store: bool) -> Self {
200        self.store_documents = store;
201        self
202    }
203
204    /// Calculate rows per band.
205    #[must_use]
206    pub const fn rows_per_band(&self) -> usize {
207        self.signature_size / self.num_bands
208    }
209
210    /// Calculate the estimated memory usage per document in bytes.
211    #[must_use]
212    pub fn estimated_memory_per_document(&self) -> usize {
213        // Signature: signature_size * 4 bytes (u32)
214        // LSH index overhead: num_bands * pointer overhead
215        let signature_bytes = self.signature_size * 4;
216        let index_overhead = self.num_bands * 16; // Approximate
217        signature_bytes + index_overhead + 64 // Base overhead per document
218    }
219
220    /// Calculate the maximum number of documents that fit in memory.
221    #[must_use]
222    pub fn max_documents_in_memory(&self) -> usize {
223        let memory_bytes = self.memory_limit_in_mb.saturating_mul(1024 * 1024);
224        let per_doc = self.estimated_memory_per_document();
225        memory_bytes / per_doc.max(1)
226    }
227}
228
229impl Default for Config {
230    fn default() -> Self {
231        // These parameters give good results for text deduplication
232        // Signature size 128, 16 bands = 8 rows per band
233        // Threshold ≈ (1/16)^(1/8) ≈ 0.83
234        Self {
235            signature_size: 128,
236            num_bands: 16,
237            shingle_size: 5,
238            similarity_threshold: 0.9,
239            max_document_size: 10 * 1024 * 1024,
240            memory_limit_in_mb: 4096,
241            seed: 0x9e37_79b9_7f4a_7c15,
242            store_documents: false,
243        }
244    }
245}
246
247/// The divisor of `n` closest to `target` (ties resolve to the smaller
248/// divisor). Used by [`Config::with_num_bands`] to snap a requested band count
249/// onto a value that tiles the signature. `n >= 1` always has the divisor `1`,
250/// so the result is always a valid divisor; returns `1` for `n == 0`.
251///
252/// The candidate scan is capped at [`MAX_BAND_SEARCH`] so a hostile
253/// `signature_size` near `usize::MAX` cannot make this loop O(n) and hang;
254/// realistic band counts are far below the cap, and `1` is always in range.
255fn nearest_divisor(n: usize, target: usize) -> usize {
256    if n == 0 {
257        return 1;
258    }
259    let limit = n.min(MAX_BAND_SEARCH);
260    let mut best = 1_usize;
261    let mut best_dist = target.abs_diff(1);
262    let mut d = 1_usize;
263    while d <= limit {
264        if n % d == 0 {
265            let dist = target.abs_diff(d);
266            if dist < best_dist {
267                best_dist = dist;
268                best = d;
269            }
270        }
271        d += 1;
272    }
273    best
274}
275
276/// Upper bound on the band-count divisor search in [`nearest_divisor`]. Well
277/// above any realistic LSH band count, so it never changes a real result, but
278/// keeps the scan O(1) for a hostile `signature_size`.
279const MAX_BAND_SEARCH: usize = 1 << 16;
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn nearest_divisor_snaps_to_closest_divisor() {
287        // Divisors of 128: 1,2,4,8,16,32,64,128.
288        assert_eq!(nearest_divisor(128, 20), 16); // 16 (d4) closer than 32 (d12)
289        assert_eq!(nearest_divisor(128, 30), 32); // 32 (d2) closer than 16 (d14)
290        assert_eq!(nearest_divisor(128, 8), 8); // exact divisor unchanged
291        assert_eq!(nearest_divisor(100, 7), 5); // divisors 1,2,4,5,10,...: 5 nearest to 7
292        assert_eq!(nearest_divisor(0, 9), 1); // degenerate
293    }
294
295    #[test]
296    fn with_num_bands_snaps_indivisible_request_to_valid_divisor() {
297        // Old behavior: 20 does not divide 128, so it was silently ignored and
298        // num_bands stayed 16 with the CALLER believing 20 took effect. Now it
299        // snaps to the nearest divisor and always tiles the signature.
300        let config = Config::default().with_num_bands(20);
301        assert_eq!(config.num_bands, 16);
302        assert_eq!(config.signature_size % config.num_bands, 0);
303
304        // A request that snaps to a different value than the default.
305        let config = Config::default().with_num_bands(30);
306        assert_eq!(config.num_bands, 32);
307        assert_eq!(config.signature_size % config.num_bands, 0);
308    }
309
310    #[test]
311    fn with_signature_size_rounds_up_to_multiple_of_bands() {
312        // num_bands defaults to 16; 100 is not a multiple, so the old code
313        // silently kept 128. Now it rounds up to the next multiple (112).
314        let config = Config::default().with_signature_size(100);
315        assert_eq!(config.num_bands, 16);
316        assert_eq!(config.signature_size, 112);
317        assert_eq!(config.signature_size % config.num_bands, 0);
318
319        // An exact multiple is preserved.
320        let config = Config::default().with_signature_size(256);
321        assert_eq!(config.signature_size, 256);
322    }
323
324    #[test]
325    fn default_config_valid() {
326        let config = Config::default();
327        assert_eq!(config.signature_size, 128);
328        assert_eq!(config.num_bands, 16);
329        assert_eq!(config.rows_per_band(), 8);
330    }
331
332    #[test]
333    fn new_validates_signature_size() {
334        let result = Config::new(100, 16, 5, 0.9);
335        assert!(result.is_err());
336        assert!(result.unwrap_err().to_string().contains("divisible"));
337    }
338
339    #[test]
340    fn new_validates_threshold() {
341        let result = Config::new(128, 16, 5, 0.0);
342        assert!(result.is_err());
343        let result = Config::new(128, 16, 5, 1.5);
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn builder_pattern_works() {
349        let config = Config::default()
350            .with_similarity_threshold(0.85)
351            .with_num_bands(8)
352            .with_shingle_size(4);
353        
354        assert!((config.similarity_threshold - 0.85).abs() < f64::EPSILON);
355        assert_eq!(config.num_bands, 8);
356        assert_eq!(config.shingle_size, 4);
357    }
358
359    #[test]
360    fn rows_per_band_calculation() {
361        let config = Config::new(256, 32, 5, 0.9).unwrap();
362        assert_eq!(config.rows_per_band(), 8);
363    }
364
365    #[test]
366    fn memory_estimation() {
367        let config = Config::default();
368        let per_doc = config.estimated_memory_per_document();
369        assert!(per_doc > 0);
370        
371        let max_docs = config.max_documents_in_memory();
372        assert!(max_docs > 0);
373    }
374
375    #[test]
376    fn invalid_shingle_size_rejected() {
377        let result = Config::new(128, 16, 0, 0.9);
378        assert!(result.is_err());
379    }
380
381    #[test]
382    fn valid_config_accepts() {
383        let config = Config::new(128, 16, 5, 0.9).unwrap();
384        assert_eq!(config.signature_size, 128);
385        assert_eq!(config.num_bands, 16);
386    }
387
388    /// Regression: `Config::new` accepted any `signature_size`, and the value
389    /// flowed straight into per-document signature vectors and the hasher's
390    /// coefficient vectors. `Config::new(usize::MAX, usize::MAX, ..)` passed
391    /// validation and then died on a capacity-overflow panic (or abort) deep
392    /// in `LshIndex::new` / `FastHasher::new`. Oversized sizes must now be
393    /// rejected up front with an actionable error.
394    #[test]
395    fn new_rejects_oversized_signature_size() {
396        let result = Config::new(MAX_SIGNATURE_SIZE + 16, 16, 5, 0.9);
397        let err = result.expect_err("oversized signature_size must be rejected");
398        let msg = err.to_string();
399        assert!(msg.contains("exceeds maximum"), "error names the bound: {msg}");
400        assert!(msg.contains("Fix:"), "error carries a fix: {msg}");
401
402        // The boundary itself stays valid.
403        assert!(Config::new(MAX_SIGNATURE_SIZE, 16, 5, 0.9).is_ok());
404    }
405
406    /// Regression: `with_signature_size(usize::MAX)` computed
407    /// `div_ceil(bands) * bands`, which overflows usize next to the maximum
408    /// (panic in debug builds, silent wrap to a tiny size in release). The
409    /// request is now clamped to `MAX_SIGNATURE_SIZE` before snapping, so the
410    /// builder can neither panic nor silently shrink the signature.
411    #[test]
412    fn with_signature_size_near_usize_max_cannot_overflow() {
413        let config = Config::default().with_signature_size(usize::MAX);
414        assert_eq!(config.signature_size, MAX_SIGNATURE_SIZE);
415        assert_eq!(config.signature_size % config.num_bands, 0);
416    }
417}