use crate::ngram;
use std::collections::{BTreeSet, HashMap};
#[derive(Debug, Clone, Copy)]
pub struct BlockingConfig {
pub permutations: usize,
pub band_size: usize,
pub min_entropy: f64,
}
impl Default for BlockingConfig {
fn default() -> Self {
Self {
permutations: 128,
band_size: 4,
min_entropy: 0.5,
}
}
}
pub struct LshIndex {
bands: HashMap<u64, Vec<usize>>,
perms: usize,
band_size: usize,
}
impl LshIndex {
pub fn build(shingle_sets: &[BTreeSet<String>], config: &BlockingConfig) -> Self {
let mut bands: HashMap<u64, Vec<usize>> = HashMap::new();
for (i, sh) in shingle_sets.iter().enumerate() {
if !entropy_gate(sh, config.min_entropy) {
continue;
}
let sig = ngram::minhash(sh, config.permutations);
for band in ngram::lsh_bands(&sig, config.band_size) {
bands.entry(band).or_default().push(i);
}
}
Self {
bands,
perms: config.permutations,
band_size: config.band_size,
}
}
pub fn candidates(&self, query: &BTreeSet<String>, min_entropy: f64) -> Vec<usize> {
if !entropy_gate(query, min_entropy) {
return Vec::new();
}
let sig = ngram::minhash(query, self.perms);
let mut seen: BTreeSet<usize> = BTreeSet::new();
for band in ngram::lsh_bands(&sig, self.band_size) {
if let Some(idxs) = self.bands.get(&band) {
seen.extend(idxs.iter().copied());
}
}
seen.into_iter().collect()
}
pub fn insert(&mut self, shingles: &BTreeSet<String>, position: usize) {
let config = BlockingConfig::default();
if !entropy_gate(shingles, config.min_entropy) {
return;
}
let sig = ngram::minhash(shingles, self.perms);
for band in ngram::lsh_bands(&sig, self.band_size) {
self.bands.entry(band).or_default().push(position);
}
}
}
pub fn entropy_gate(shingles: &BTreeSet<String>, min_entropy: f64) -> bool {
ngram::shingle_entropy(shingles) >= min_entropy
}
#[cfg(test)]
mod tests {
use super::*;
fn shingles(s: &str) -> BTreeSet<String> {
ngram::shingles(s, 3)
}
#[test]
fn similar_surfaces_collide_on_a_band() {
let config = BlockingConfig::default();
let surfaces = [
"alice smith",
"bob jones",
"carol baker",
"dave miller",
"eve davis",
"frank wilson",
"grace lee",
"alicia smith", ];
let sets: Vec<BTreeSet<String>> = surfaces.iter().map(|s| shingles(s)).collect();
let index = LshIndex::build(&sets, &config);
let candidates = index.candidates(&shingles("alise smith"), config.min_entropy);
assert!(
candidates.contains(&0),
"alice smith should be a candidate: {candidates:?}"
);
assert!(
candidates.contains(&7),
"alicia smith should be a candidate: {candidates:?}"
);
assert!(candidates.len() < sets.len());
}
#[test]
fn entropy_gate_rejects_repetitive_surfaces() {
let config = BlockingConfig::default();
assert!(!entropy_gate(&shingles("aaaaa"), config.min_entropy));
assert!(!entropy_gate(&shingles("ㅋㅋㅋㅋㅋ"), config.min_entropy));
assert!(entropy_gate(&shingles("alice smith"), config.min_entropy));
}
#[test]
fn empty_query_yields_no_candidates() {
let config = BlockingConfig::default();
let sets = vec![shingles("alice smith"), shingles("bob jones")];
let index = LshIndex::build(&sets, &config);
assert!(
index
.candidates(&BTreeSet::new(), config.min_entropy)
.is_empty()
);
}
}