1#![allow(
2 clippy::module_name_repetitions,
3 clippy::must_use_candidate,
4 clippy::missing_errors_doc,
5)]
6#![warn(missing_docs)]
7#![warn(clippy::pedantic)]
8#![forbid(unsafe_code)]
9#![warn(missing_docs, clippy::pedantic)]
44
45#![cfg_attr(
46 not(test),
47 deny(
48 clippy::unwrap_used,
49 clippy::expect_used,
50 clippy::todo,
51 clippy::unimplemented,
52 clippy::panic
53 )
54)]
55#![allow(
56 clippy::cast_possible_truncation,
57 clippy::cast_possible_wrap,
58 clippy::module_name_repetitions,
59 clippy::needless_pass_by_value,
60 clippy::must_use_candidate,
61 clippy::return_self_not_must_use,
62 clippy::unnecessary_literal_bound,
63 clippy::doc_markdown,
64 clippy::cast_precision_loss
65)]
66
67mod cluster;
68mod config;
69mod error;
70mod lsh;
71mod minhash;
72pub mod shingle;
73mod fast_hash;
74mod transform;
75
76pub use config::Config;
77pub use error::{Error, Result};
78pub use cluster::DuplicateCluster;
79pub use lsh::LshIndex;
80pub use minhash::{exact_jaccard_similarity, expected_error, MinHashSignature, MinHasher};
81pub use shingle::ShingleIterator;
82pub use fast_hash::{hash_bytes, FastHasher};
83pub use transform::{DedupTransformer, StatefulDedupTransform};
84
85pub mod tenshift {
87 pub use tenshift_core::sample::Sample;
88 pub use tenshift_core::transform::{Transform, TransformResult};
89}
90
91pub const DEFAULT_SIGNATURE_SIZE: usize = 128;
93
94pub const DEFAULT_SHINGLE_SIZE: usize = 5;
96
97pub const DEFAULT_NUM_BANDS: usize = 16;
99
100pub const DEFAULT_SIMILARITY_THRESHOLD: f64 = 0.9;
102
103#[must_use]
107pub const fn compute_rows_per_band(signature_size: usize, num_bands: usize) -> Option<usize> {
108 if num_bands == 0 || signature_size % num_bands != 0 {
109 return None;
110 }
111 Some(signature_size / num_bands)
112}
113
114#[must_use]
119pub fn candidate_probability(similarity: f64, num_bands: usize, rows_per_band: usize) -> f64 {
120 if similarity <= 0.0 {
121 return 0.0;
122 }
123 if similarity >= 1.0 {
124 return 1.0;
125 }
126 let band_match_prob = similarity.powf(rows_per_band as f64);
129 1.0 - (1.0 - band_match_prob).powf(num_bands as f64)
130}
131
132#[must_use]
137pub fn optimize_lsh_params(
138 signature_size: usize,
139 target_threshold: f64,
140) -> (usize, usize) {
141 if signature_size == 0 {
153 return (1, 0);
154 }
155 const MAX_CANDIDATE_BANDS: usize = 1024;
162 let search_limit = signature_size.min(MAX_CANDIDATE_BANDS);
163 let mut best: Option<(f64, usize, usize)> = None;
164
165 for num_bands in 1..=search_limit {
166 if signature_size % num_bands != 0 {
167 continue;
168 }
169 let rows_per_band = signature_size / num_bands;
170
171 let p_at_threshold = candidate_probability(target_threshold, num_bands, rows_per_band);
173 let p_below = candidate_probability(target_threshold * 0.9, num_bands, rows_per_band);
174 let p_above =
175 candidate_probability((target_threshold * 1.1).min(1.0), num_bands, rows_per_band);
176
177 let score = p_above - p_below - (p_at_threshold - 0.5).abs() * 0.5;
179
180 if best.map_or(true, |(best_score, _, _)| score > best_score) {
181 best = Some((score, num_bands, rows_per_band));
182 }
183 }
184
185 let (_, bands, rows) = best.unwrap_or((0.0, 1, signature_size));
187 (bands, rows)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn test_compute_rows_per_band() {
196 assert_eq!(compute_rows_per_band(128, 16), Some(8));
197 assert_eq!(compute_rows_per_band(128, 8), Some(16));
198 assert_eq!(compute_rows_per_band(128, 0), None);
199 assert_eq!(compute_rows_per_band(128, 3), None);
200 }
201
202 #[test]
203 fn test_candidate_probability_bounds() {
204 assert_eq!(candidate_probability(0.0, 16, 8), 0.0);
205 assert_eq!(candidate_probability(1.0, 16, 8), 1.0);
206 }
207
208 #[test]
209 fn test_candidate_probability_increases_with_similarity() {
210 let p1 = candidate_probability(0.5, 16, 8);
211 let p2 = candidate_probability(0.8, 16, 8);
212 let p3 = candidate_probability(0.95, 16, 8);
213
214 assert!(p1 < p2, "probability should increase with similarity");
215 assert!(p2 < p3, "probability should increase with similarity");
216 }
217
218 #[test]
219 fn test_optimize_lsh_params_produces_valid_params() {
220 let (bands, rows) = optimize_lsh_params(128, 0.9);
221 assert!(bands > 0);
222 assert!(rows > 0);
223 assert_eq!(bands * rows, 128);
224 }
225
226 #[test]
227 fn test_optimize_lsh_params_valid_when_indivisible_by_hardcoded_bands() {
228 for size in [1_usize, 2, 3, 5, 6, 7, 10, 25, 30, 50, 100, 127, 200] {
234 let (bands, rows) = optimize_lsh_params(size, 0.85);
235 assert!(bands >= 1, "size {size}: bands must be >= 1, got {bands}");
236 assert!(rows >= 1, "size {size}: rows must be >= 1, got {rows}");
237 assert_eq!(
238 bands * rows,
239 size,
240 "size {size}: returned ({bands}, {rows}) must tile the signature exactly"
241 );
242 }
243 }
244}
245
246#[cfg(doctest)]
248#[doc = include_str!("../README.md")]
249struct ReadmeExamples;