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