use crate::config::NormalizerConfig;
use crate::error::Result;
pub(crate) fn jaro_winkler_chars(s1: &[char], s2: &[char]) -> f32 {
let len1 = s1.len();
let len2 = s2.len();
if len1 == 0 && len2 == 0 {
return 1.0;
}
if len1 == 0 || len2 == 0 {
return 0.0;
}
let window = (len1.max(len2) / 2).saturating_sub(1);
let mut s1_matched = vec![false; len1];
let mut s2_matched = vec![false; len2];
let mut match_count = 0usize;
for (i, &c1) in s1.iter().enumerate() {
let lo = i.saturating_sub(window);
let hi = (i + window + 1).min(len2);
for j in lo..hi {
if !s2_matched[j] && c1 == s2[j] {
s1_matched[i] = true;
s2_matched[j] = true;
match_count += 1;
break;
}
}
}
if match_count == 0 {
return 0.0;
}
let mut transpositions = 0usize;
let mut k = 0usize;
for (i, &c1) in s1.iter().enumerate() {
if s1_matched[i] {
while !s2_matched[k] {
k += 1;
}
if c1 != s2[k] {
transpositions += 1;
}
k += 1;
}
}
let m = match_count as f32;
let t = (transpositions / 2) as f32;
let jaro = (m / len1 as f32 + m / len2 as f32 + (m - t) / m) / 3.0;
let prefix = s1
.iter()
.zip(s2.iter())
.take(4)
.take_while(|(a, b)| a == b)
.count() as f32;
jaro + prefix * 0.1 * (1.0 - jaro)
}
pub(crate) fn score_strs(a: &str, b: &str) -> f32 {
let ca: Vec<char> = a.chars().collect();
let cb: Vec<char> = b.chars().collect();
jaro_winkler_chars(&ca, &cb)
}
pub fn similarity_score(a: &str, b: &str, config: &NormalizerConfig) -> Result<f32> {
let na = crate::normalize::normalize(a, config)?;
let nb = crate::normalize::normalize(b, config)?;
Ok(score_strs(&na, &nb))
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> NormalizerConfig {
NormalizerConfig::default()
}
#[test]
fn identical_strings_score_one() {
assert_eq!(similarity_score("斉藤", "斉藤", &cfg()).unwrap(), 1.0);
}
#[test]
fn variant_forms_score_one() {
assert_eq!(similarity_score("斎藤", "齋藤", &cfg()).unwrap(), 1.0);
}
#[test]
fn watanabe_variants_score_one() {
assert_eq!(similarity_score("渡辺", "渡邊", &cfg()).unwrap(), 1.0);
}
#[test]
fn different_names_score_low() {
let score = similarity_score("斎藤", "佐藤", &cfg()).unwrap();
assert!(score < 0.9, "expected < 0.9, got {score}");
}
#[test]
fn empty_vs_empty_score_one() {
assert_eq!(similarity_score("", "", &cfg()).unwrap(), 1.0);
}
#[test]
fn empty_vs_nonempty_score_zero() {
assert_eq!(similarity_score("", "斉", &cfg()).unwrap(), 0.0);
}
#[test]
fn completely_different_score_zero() {
let score = similarity_score("田中", "鈴木", &cfg()).unwrap();
assert_eq!(score, 0.0);
}
#[test]
fn score_in_range() {
let score = similarity_score("斎藤一郎", "斉藤二郎", &cfg()).unwrap();
assert!((0.0..=1.0).contains(&score), "score {score} out of range");
}
}