const MAX_PREFIX_LENGTH: usize = 4;
const PREFIX_SCALE: f64 = 0.1;
pub fn jaro_similarity(s1: &str, s2: &str) -> f64 {
let s1_chars: Vec<char> = s1.chars().collect();
let s2_chars: Vec<char> = s2.chars().collect();
jaro_similarity_chars(&s1_chars, &s2_chars)
}
fn jaro_similarity_chars(s1_chars: &[char], s2_chars: &[char]) -> f64 {
let len1 = s1_chars.len();
let len2 = s2_chars.len();
if len1 == 0 && len2 == 0 {
return 1.0;
}
if len1 == 0 || len2 == 0 {
return 0.0;
}
let match_distance = (len1.max(len2) / 2).saturating_sub(1);
let mut s1_matches = vec![false; len1];
let mut s2_matches = vec![false; len2];
let mut matches = 0usize;
let mut transpositions = 0usize;
for i in 0..len1 {
let start = i.saturating_sub(match_distance);
let end = (i + match_distance + 1).min(len2);
for j in start..end {
if s2_matches[j] || s1_chars[i] != s2_chars[j] {
continue;
}
s1_matches[i] = true;
s2_matches[j] = true;
matches += 1;
break;
}
}
if matches == 0 {
return 0.0;
}
let mut k = 0;
for i in 0..len1 {
if !s1_matches[i] {
continue;
}
while !s2_matches[k] {
k += 1;
}
if s1_chars[i] != s2_chars[k] {
transpositions += 1;
}
k += 1;
}
let m = matches as f64;
let t = transpositions as f64 / 2.0;
((m / len1 as f64) + (m / len2 as f64) + ((m - t) / m)) / 3.0
}
pub fn jaro_winkler_similarity(s1: &str, s2: &str) -> f64 {
let s1_chars: Vec<char> = s1.chars().collect();
let s2_chars: Vec<char> = s2.chars().collect();
jaro_winkler_similarity_chars(&s1_chars, &s2_chars)
}
fn jaro_winkler_similarity_chars(s1_chars: &[char], s2_chars: &[char]) -> f64 {
let jaro = jaro_similarity_chars(s1_chars, s2_chars);
let prefix_len = s1_chars
.iter()
.zip(s2_chars.iter())
.take(MAX_PREFIX_LENGTH)
.take_while(|(a, b)| a == b)
.count();
jaro + (prefix_len as f64 * PREFIX_SCALE * (1.0 - jaro))
}
pub fn jaro_winkler_similarity_scaled(s1: &str, s2: &str, prefix_scale: f64) -> f64 {
assert!(
(0.0..=0.25).contains(&prefix_scale),
"prefix_scale must be in [0.0, 0.25] to ensure result in [0, 1], got {}",
prefix_scale
);
let s1_chars: Vec<char> = s1.chars().collect();
let s2_chars: Vec<char> = s2.chars().collect();
let jaro = jaro_similarity_chars(&s1_chars, &s2_chars);
let prefix_len = s1_chars
.iter()
.zip(s2_chars.iter())
.take(MAX_PREFIX_LENGTH)
.take_while(|(a, b)| a == b)
.count();
jaro + (prefix_len as f64 * prefix_scale * (1.0 - jaro))
}
#[inline]
pub fn is_similar(s1: &str, s2: &str, threshold: f64) -> bool {
jaro_winkler_similarity(s1, s2) >= threshold
}
pub fn similarity_to_distance_approx(similarity: f64, avg_len: f64) -> f64 {
(1.0 - similarity) * avg_len
}
pub fn distance_to_similarity_approx(distance: f64, avg_len: f64) -> f64 {
if avg_len <= 0.0 {
return 1.0;
}
(1.0 - distance / avg_len).max(0.0)
}
#[cfg(test)]
mod tests {
use super::*;
const EPSILON: f64 = 1e-6;
#[test]
fn test_jaro_empty_strings() {
assert!((jaro_similarity("", "") - 1.0).abs() < EPSILON);
assert!((jaro_similarity("a", "") - 0.0).abs() < EPSILON);
assert!((jaro_similarity("", "b") - 0.0).abs() < EPSILON);
}
#[test]
fn test_jaro_identical_strings() {
assert!((jaro_similarity("abc", "abc") - 1.0).abs() < EPSILON);
assert!((jaro_similarity("hello", "hello") - 1.0).abs() < EPSILON);
}
#[test]
fn test_jaro_classic_examples() {
let sim = jaro_similarity("MARTHA", "MARHTA");
assert!(sim > 0.94 && sim < 0.95, "MARTHA/MARHTA = {}", sim);
let sim = jaro_similarity("DWAYNE", "DUANE");
assert!(sim > 0.82 && sim < 0.84, "DWAYNE/DUANE = {}", sim);
let sim = jaro_similarity("DIXON", "DICKSONX");
assert!(sim > 0.76 && sim < 0.78, "DIXON/DICKSONX = {}", sim);
}
#[test]
fn test_jaro_symmetry() {
let pairs = [("hello", "world"), ("abc", "xyz"), ("test", "tset")];
for (a, b) in pairs {
let sim1 = jaro_similarity(a, b);
let sim2 = jaro_similarity(b, a);
assert!(
(sim1 - sim2).abs() < EPSILON,
"jaro({}, {}) = {} != {} = jaro({}, {})",
a,
b,
sim1,
sim2,
b,
a
);
}
}
#[test]
fn test_jaro_winkler_empty_strings() {
assert!((jaro_winkler_similarity("", "") - 1.0).abs() < EPSILON);
assert!((jaro_winkler_similarity("a", "") - 0.0).abs() < EPSILON);
}
#[test]
fn test_jaro_winkler_identical_strings() {
assert!((jaro_winkler_similarity("abc", "abc") - 1.0).abs() < EPSILON);
}
#[test]
fn test_jaro_winkler_prefix_bonus() {
let pairs = [
("MARTHA", "MARHTA"), ("hello", "helo"), ("test", "tset"), ];
for (a, b) in pairs {
let jaro = jaro_similarity(a, b);
let jw = jaro_winkler_similarity(a, b);
assert!(
jw >= jaro - EPSILON,
"JW({}, {}) = {} should be >= Jaro = {}",
a,
b,
jw,
jaro
);
}
}
#[test]
fn test_jaro_winkler_classic_examples() {
let sim = jaro_winkler_similarity("MARTHA", "MARHTA");
assert!(sim > 0.96 && sim < 0.97, "MARTHA/MARHTA JW = {}", sim);
}
#[test]
fn test_jaro_winkler_symmetry() {
let pairs = [("hello", "world"), ("abc", "xyz"), ("test", "tset")];
for (a, b) in pairs {
let sim1 = jaro_winkler_similarity(a, b);
let sim2 = jaro_winkler_similarity(b, a);
assert!(
(sim1 - sim2).abs() < EPSILON,
"jw({}, {}) = {} != {} = jw({}, {})",
a,
b,
sim1,
sim2,
b,
a
);
}
}
#[test]
fn test_jaro_winkler_scaled() {
let sim_default = jaro_winkler_similarity("hello", "helo");
let sim_scaled = jaro_winkler_similarity_scaled("hello", "helo", 0.2);
assert!(
sim_scaled > sim_default,
"scaled {} should be > default {}",
sim_scaled,
sim_default
);
}
#[test]
#[should_panic(expected = "prefix_scale must be in [0.0, 0.25]")]
fn test_jaro_winkler_scaled_invalid() {
jaro_winkler_similarity_scaled("a", "b", 0.3);
}
#[test]
fn test_similarity_to_distance() {
let dist = similarity_to_distance_approx(1.0, 5.0);
assert!((dist - 0.0).abs() < EPSILON);
let dist = similarity_to_distance_approx(0.0, 5.0);
assert!((dist - 5.0).abs() < EPSILON);
}
#[test]
fn test_distance_to_similarity() {
let sim = distance_to_similarity_approx(0.0, 5.0);
assert!((sim - 1.0).abs() < EPSILON);
let sim = distance_to_similarity_approx(5.0, 5.0);
assert!((sim - 0.0).abs() < EPSILON);
}
#[test]
fn test_unicode() {
let sim = jaro_winkler_similarity("café", "cafe");
assert!(sim > 0.8);
let sim = jaro_winkler_similarity("日本語", "日本語");
assert!((sim - 1.0).abs() < EPSILON);
}
}