use crate::error::{Error, Result};
pub const MAX_SIGNATURE_SIZE: usize = 1 << 16;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Config {
pub signature_size: usize,
pub num_bands: usize,
pub shingle_size: usize,
pub similarity_threshold: f64,
pub(crate) max_document_size: usize,
pub(crate) memory_limit_in_mb: usize,
pub(crate) seed: u64,
pub(crate) store_documents: bool,
}
impl Config {
pub fn new(
signature_size: usize,
num_bands: usize,
shingle_size: usize,
similarity_threshold: f64,
) -> Result<Self> {
if signature_size == 0 {
return Err(Error::InvalidConfig {
reason: "signature_size must be at least 1".to_string(),
fix: "use signature_size >= 1".to_string(),
});
}
if signature_size > MAX_SIGNATURE_SIZE {
return Err(Error::InvalidConfig {
reason: format!(
"signature_size ({signature_size}) exceeds maximum {MAX_SIGNATURE_SIZE}"
),
fix: format!(
"use signature_size <= {MAX_SIGNATURE_SIZE}; larger signatures add no accuracy and only exhaust memory"
),
});
}
if num_bands == 0 {
return Err(Error::InvalidConfig {
reason: "num_bands must be at least 1".to_string(),
fix: "use num_bands >= 1".to_string(),
});
}
if signature_size % num_bands != 0 {
return Err(Error::InvalidConfig {
reason: format!(
"signature_size ({signature_size}) must be divisible by num_bands ({num_bands})"
),
fix: "use signature_size = num_bands * rows_per_band".to_string(),
});
}
if shingle_size == 0 {
return Err(Error::InvalidConfig {
reason: "shingle_size must be at least 1".to_string(),
fix: "use shingle_size >= 1".to_string(),
});
}
if similarity_threshold <= 0.0 || similarity_threshold > 1.0 {
return Err(Error::InvalidConfig {
reason: format!(
"similarity_threshold ({similarity_threshold}) must be in (0.0, 1.0]"
),
fix: "use 0.0 < similarity_threshold <= 1.0".to_string(),
});
}
Ok(Self {
signature_size,
num_bands,
shingle_size,
similarity_threshold,
max_document_size: 10 * 1024 * 1024, memory_limit_in_mb: 4096, seed: 0x9e37_79b9_7f4a_7c15, store_documents: false,
})
}
#[must_use]
pub fn with_similarity_threshold(mut self, threshold: f64) -> Self {
self.similarity_threshold = threshold.clamp(0.01, 1.0);
self
}
#[must_use]
pub fn with_num_bands(mut self, num_bands: usize) -> Self {
if num_bands > 0 {
self.num_bands = nearest_divisor(self.signature_size, num_bands);
}
self
}
#[must_use]
pub fn with_shingle_size(mut self, shingle_size: usize) -> Self {
if shingle_size > 0 {
self.shingle_size = shingle_size;
}
self
}
#[must_use]
pub fn with_signature_size(mut self, signature_size: usize) -> Self {
if signature_size > 0 {
let signature_size = signature_size.min(MAX_SIGNATURE_SIZE);
let bands = self.num_bands.max(1);
self.signature_size = signature_size.div_ceil(bands) * bands;
}
self
}
#[must_use]
pub fn with_max_document_size(mut self, max_bytes: usize) -> Self {
self.max_document_size = max_bytes;
self
}
#[must_use]
pub fn with_memory_limit(mut self, memory_limit_in_mb: usize) -> Self {
self.memory_limit_in_mb = memory_limit_in_mb;
self
}
#[must_use]
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
#[must_use]
pub fn with_store_documents(mut self, store: bool) -> Self {
self.store_documents = store;
self
}
#[must_use]
pub const fn rows_per_band(&self) -> usize {
self.signature_size / self.num_bands
}
#[must_use]
pub fn estimated_memory_per_document(&self) -> usize {
let signature_bytes = self.signature_size * 4;
let index_overhead = self.num_bands * 16; signature_bytes + index_overhead + 64 }
#[must_use]
pub fn max_documents_in_memory(&self) -> usize {
let memory_bytes = self.memory_limit_in_mb.saturating_mul(1024 * 1024);
let per_doc = self.estimated_memory_per_document();
memory_bytes / per_doc.max(1)
}
}
impl Default for Config {
fn default() -> Self {
Self {
signature_size: 128,
num_bands: 16,
shingle_size: 5,
similarity_threshold: 0.9,
max_document_size: 10 * 1024 * 1024,
memory_limit_in_mb: 4096,
seed: 0x9e37_79b9_7f4a_7c15,
store_documents: false,
}
}
}
fn nearest_divisor(n: usize, target: usize) -> usize {
if n == 0 {
return 1;
}
let limit = n.min(MAX_BAND_SEARCH);
let mut best = 1_usize;
let mut best_dist = target.abs_diff(1);
let mut d = 1_usize;
while d <= limit {
if n % d == 0 {
let dist = target.abs_diff(d);
if dist < best_dist {
best_dist = dist;
best = d;
}
}
d += 1;
}
best
}
const MAX_BAND_SEARCH: usize = 1 << 16;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearest_divisor_snaps_to_closest_divisor() {
assert_eq!(nearest_divisor(128, 20), 16); assert_eq!(nearest_divisor(128, 30), 32); assert_eq!(nearest_divisor(128, 8), 8); assert_eq!(nearest_divisor(100, 7), 5); assert_eq!(nearest_divisor(0, 9), 1); }
#[test]
fn with_num_bands_snaps_indivisible_request_to_valid_divisor() {
let config = Config::default().with_num_bands(20);
assert_eq!(config.num_bands, 16);
assert_eq!(config.signature_size % config.num_bands, 0);
let config = Config::default().with_num_bands(30);
assert_eq!(config.num_bands, 32);
assert_eq!(config.signature_size % config.num_bands, 0);
}
#[test]
fn with_signature_size_rounds_up_to_multiple_of_bands() {
let config = Config::default().with_signature_size(100);
assert_eq!(config.num_bands, 16);
assert_eq!(config.signature_size, 112);
assert_eq!(config.signature_size % config.num_bands, 0);
let config = Config::default().with_signature_size(256);
assert_eq!(config.signature_size, 256);
}
#[test]
fn default_config_valid() {
let config = Config::default();
assert_eq!(config.signature_size, 128);
assert_eq!(config.num_bands, 16);
assert_eq!(config.rows_per_band(), 8);
}
#[test]
fn new_validates_signature_size() {
let result = Config::new(100, 16, 5, 0.9);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("divisible"));
}
#[test]
fn new_validates_threshold() {
let result = Config::new(128, 16, 5, 0.0);
assert!(result.is_err());
let result = Config::new(128, 16, 5, 1.5);
assert!(result.is_err());
}
#[test]
fn builder_pattern_works() {
let config = Config::default()
.with_similarity_threshold(0.85)
.with_num_bands(8)
.with_shingle_size(4);
assert!((config.similarity_threshold - 0.85).abs() < f64::EPSILON);
assert_eq!(config.num_bands, 8);
assert_eq!(config.shingle_size, 4);
}
#[test]
fn rows_per_band_calculation() {
let config = Config::new(256, 32, 5, 0.9).unwrap();
assert_eq!(config.rows_per_band(), 8);
}
#[test]
fn memory_estimation() {
let config = Config::default();
let per_doc = config.estimated_memory_per_document();
assert!(per_doc > 0);
let max_docs = config.max_documents_in_memory();
assert!(max_docs > 0);
}
#[test]
fn invalid_shingle_size_rejected() {
let result = Config::new(128, 16, 0, 0.9);
assert!(result.is_err());
}
#[test]
fn valid_config_accepts() {
let config = Config::new(128, 16, 5, 0.9).unwrap();
assert_eq!(config.signature_size, 128);
assert_eq!(config.num_bands, 16);
}
#[test]
fn new_rejects_oversized_signature_size() {
let result = Config::new(MAX_SIGNATURE_SIZE + 16, 16, 5, 0.9);
let err = result.expect_err("oversized signature_size must be rejected");
let msg = err.to_string();
assert!(msg.contains("exceeds maximum"), "error names the bound: {msg}");
assert!(msg.contains("Fix:"), "error carries a fix: {msg}");
assert!(Config::new(MAX_SIGNATURE_SIZE, 16, 5, 0.9).is_ok());
}
#[test]
fn with_signature_size_near_usize_max_cannot_overflow() {
let config = Config::default().with_signature_size(usize::MAX);
assert_eq!(config.signature_size, MAX_SIGNATURE_SIZE);
assert_eq!(config.signature_size % config.num_bands, 0);
}
}