use super::jaro_winkler::jaro_winkler_similarity;
use super::ngram::NgramIndex;
const DEFAULT_NGRAM_SIZE: usize = 2;
const DEFAULT_JARO_THRESHOLD: f64 = 0.7;
#[derive(Debug, Clone)]
pub struct HybridMatcher {
ngram_index: NgramIndex,
jaro_threshold: f64,
skip_jaro: bool,
}
impl HybridMatcher {
pub fn new<I>(terms: I) -> Self
where
I: IntoIterator<Item = String>,
{
Self::with_config(terms, DEFAULT_NGRAM_SIZE, DEFAULT_JARO_THRESHOLD)
}
pub fn with_config<I>(terms: I, ngram_size: usize, jaro_threshold: f64) -> Self
where
I: IntoIterator<Item = String>,
{
assert!(ngram_size > 0, "N-gram size must be at least 1");
assert!(
(0.0..=1.0).contains(&jaro_threshold),
"Jaro threshold must be in [0.0, 1.0], got {}",
jaro_threshold
);
let ngram_index = NgramIndex::from_iter(ngram_size, terms);
Self {
ngram_index,
jaro_threshold,
skip_jaro: false,
}
}
pub fn ngram_only<I>(terms: I, ngram_size: usize) -> Self
where
I: IntoIterator<Item = String>,
{
let ngram_index = NgramIndex::from_iter(ngram_size, terms);
Self {
ngram_index,
jaro_threshold: 0.0,
skip_jaro: true,
}
}
#[inline]
pub fn len(&self) -> usize {
self.ngram_index.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.ngram_index.is_empty()
}
#[inline]
pub fn ngram_size(&self) -> usize {
self.ngram_index.n()
}
#[inline]
pub fn jaro_threshold(&self) -> f64 {
self.jaro_threshold
}
pub fn set_jaro_threshold(&mut self, threshold: f64) {
assert!(
(0.0..=1.0).contains(&threshold),
"Jaro threshold must be in [0.0, 1.0], got {}",
threshold
);
self.jaro_threshold = threshold;
}
pub fn insert(&mut self, term: &str) {
self.ngram_index.insert(term);
}
pub fn remove(&mut self, term: &str) -> bool {
self.ngram_index.remove(term)
}
pub fn filter_candidates(&self, query: &str, max_distance: usize) -> Vec<&str> {
let ngram_candidates = self.ngram_index.find_candidates(query, max_distance);
if self.skip_jaro || self.jaro_threshold <= 0.0 {
return ngram_candidates;
}
let adaptive_threshold = self.compute_adaptive_threshold(query, max_distance);
ngram_candidates
.into_iter()
.filter(|term| jaro_winkler_similarity(query, term) >= adaptive_threshold)
.collect()
}
pub fn filter_candidates_with_scores(
&self,
query: &str,
max_distance: usize,
) -> Vec<(&str, f64)> {
let ngram_candidates = self.ngram_index.find_candidates(query, max_distance);
if self.skip_jaro {
return ngram_candidates.into_iter().map(|t| (t, 1.0)).collect();
}
let adaptive_threshold = self.compute_adaptive_threshold(query, max_distance);
let mut results: Vec<_> = ngram_candidates
.into_iter()
.map(|term| (term, jaro_winkler_similarity(query, term)))
.filter(|&(_, score)| score >= adaptive_threshold)
.collect();
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
results
}
fn compute_adaptive_threshold(&self, query: &str, max_distance: usize) -> f64 {
let query_len = query.chars().count();
if query_len == 0 {
return 0.0;
}
let distance_factor = 1.0 - (max_distance as f64 / query_len as f64).min(1.0);
(self.jaro_threshold * distance_factor).max(0.0).min(1.0)
}
pub fn stats(&self) -> (usize, usize) {
(self.ngram_index.ngram_count(), self.ngram_index.len())
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.ngram_index.iter()
}
}
impl Default for HybridMatcher {
fn default() -> Self {
Self {
ngram_index: NgramIndex::default(),
jaro_threshold: DEFAULT_JARO_THRESHOLD,
skip_jaro: false,
}
}
}
#[derive(Debug, Clone)]
pub struct HybridMatcherBuilder {
ngram_size: usize,
jaro_threshold: f64,
skip_jaro: bool,
}
impl HybridMatcherBuilder {
pub fn new() -> Self {
Self {
ngram_size: DEFAULT_NGRAM_SIZE,
jaro_threshold: DEFAULT_JARO_THRESHOLD,
skip_jaro: false,
}
}
pub fn ngram_size(mut self, size: usize) -> Self {
self.ngram_size = size;
self
}
pub fn jaro_threshold(mut self, threshold: f64) -> Self {
self.jaro_threshold = threshold;
self
}
pub fn ngram_only(mut self) -> Self {
self.skip_jaro = true;
self
}
pub fn build<I>(self, terms: I) -> HybridMatcher
where
I: IntoIterator<Item = String>,
{
let ngram_index = NgramIndex::from_iter(self.ngram_size, terms);
HybridMatcher {
ngram_index,
jaro_threshold: self.jaro_threshold,
skip_jaro: self.skip_jaro,
}
}
}
impl Default for HybridMatcherBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_terms() -> Vec<String> {
vec![
"apple",
"application",
"apply",
"appeal",
"banana",
"cherry",
"hello",
"help",
"world",
"helm",
]
.into_iter()
.map(String::from)
.collect()
}
#[test]
fn test_new() {
let matcher = HybridMatcher::new(test_terms());
assert_eq!(matcher.len(), 10);
assert!(!matcher.is_empty());
assert_eq!(matcher.ngram_size(), 2);
}
#[test]
fn test_with_config() {
let matcher = HybridMatcher::with_config(test_terms(), 3, 0.8);
assert_eq!(matcher.ngram_size(), 3);
assert!((matcher.jaro_threshold() - 0.8).abs() < 1e-10);
}
#[test]
fn test_filter_candidates() {
let matcher = HybridMatcher::new(test_terms());
let candidates = matcher.filter_candidates("aple", 1);
assert!(
candidates.contains(&"apple"),
"Expected 'apple' in candidates: {:?}",
candidates
);
}
#[test]
fn test_filter_with_scores() {
let matcher = HybridMatcher::new(test_terms());
let results = matcher.filter_candidates_with_scores("apple", 1);
let apple_result = results.iter().find(|(t, _)| *t == "apple");
assert!(apple_result.is_some());
let (_, score) = apple_result.expect("expected Some apple_result in test");
assert!(
*score > 0.99,
"Exact match should have score ~1.0, got {}",
score
);
}
#[test]
fn test_insert_remove() {
let mut matcher = HybridMatcher::new(test_terms());
assert_eq!(matcher.len(), 10);
matcher.insert("newterm");
assert_eq!(matcher.len(), 11);
assert!(matcher.remove("newterm"));
assert!(!matcher.remove("newterm"));
}
#[test]
fn test_ngram_only() {
let matcher = HybridMatcher::ngram_only(test_terms(), 2);
let candidates = matcher.filter_candidates("apple", 1);
assert!(!candidates.is_empty());
}
#[test]
fn test_builder() {
let matcher = HybridMatcherBuilder::new()
.ngram_size(3)
.jaro_threshold(0.75)
.build(test_terms());
assert_eq!(matcher.ngram_size(), 3);
assert!((matcher.jaro_threshold() - 0.75).abs() < 1e-10);
}
#[test]
fn test_builder_ngram_only() {
let matcher = HybridMatcherBuilder::new().ngram_only().build(test_terms());
let candidates = matcher.filter_candidates("apple", 1);
assert!(!candidates.is_empty());
}
#[test]
fn test_set_threshold() {
let mut matcher = HybridMatcher::new(test_terms());
matcher.set_jaro_threshold(0.9);
assert!((matcher.jaro_threshold() - 0.9).abs() < 1e-10);
}
#[test]
#[should_panic(expected = "Jaro threshold must be in [0.0, 1.0]")]
fn test_invalid_threshold_panics() {
let mut matcher = HybridMatcher::new(test_terms());
matcher.set_jaro_threshold(1.5);
}
#[test]
fn test_stats() {
let matcher = HybridMatcher::new(test_terms());
let (ngram_count, term_count) = matcher.stats();
assert_eq!(term_count, 10);
assert!(ngram_count > 0);
}
#[test]
fn test_iter() {
let matcher = HybridMatcher::new(test_terms());
let terms: Vec<_> = matcher.iter().collect();
assert_eq!(terms.len(), 10);
assert!(terms.contains(&"apple"));
assert!(terms.contains(&"banana"));
}
#[test]
fn test_empty_query() {
let matcher = HybridMatcher::new(test_terms());
let candidates = matcher.filter_candidates("", 2);
let _ = candidates;
}
#[test]
fn test_adaptive_threshold() {
let matcher = HybridMatcher::new(test_terms());
let candidates_lenient = matcher.filter_candidates("app", 3);
let candidates_strict = matcher.filter_candidates("app", 1);
assert!(
candidates_lenient.len() >= candidates_strict.len(),
"Lenient ({}) should have >= strict ({})",
candidates_lenient.len(),
candidates_strict.len()
);
}
}