use super::features::{get_features, PhoneticFeature};
use rustc_hash::FxHashSet;
const VOICING_DISTANCE: f64 = 0.1;
const PLACE_ORDER: &[PhoneticFeature] = &[
PhoneticFeature::Bilabial,
PhoneticFeature::Labiodental,
PhoneticFeature::Dental,
PhoneticFeature::Alveolar,
PhoneticFeature::PostAlveolar,
PhoneticFeature::Retroflex,
PhoneticFeature::Palatal,
PhoneticFeature::Velar,
PhoneticFeature::Uvular,
PhoneticFeature::Pharyngeal,
PhoneticFeature::Epiglottal,
PhoneticFeature::Glottal,
];
const PLACE_STEP_DISTANCE: f64 = 0.15;
const MANNER_DISTANCES: &[(PhoneticFeature, PhoneticFeature, f64)] = &[
(PhoneticFeature::Stop, PhoneticFeature::Affricate, 0.2),
(PhoneticFeature::Stop, PhoneticFeature::Fricative, 0.3),
(PhoneticFeature::Stop, PhoneticFeature::Nasal, 0.3),
(PhoneticFeature::Stop, PhoneticFeature::Approximant, 0.4),
(PhoneticFeature::Fricative, PhoneticFeature::Affricate, 0.2),
(
PhoneticFeature::Fricative,
PhoneticFeature::Approximant,
0.3,
),
(PhoneticFeature::Nasal, PhoneticFeature::Approximant, 0.3),
(PhoneticFeature::Approximant, PhoneticFeature::Lateral, 0.1),
(PhoneticFeature::Lateral, PhoneticFeature::Rhotic, 0.2),
(PhoneticFeature::Tap, PhoneticFeature::Trill, 0.1),
(PhoneticFeature::Tap, PhoneticFeature::Rhotic, 0.1),
(PhoneticFeature::Trill, PhoneticFeature::Rhotic, 0.1),
(PhoneticFeature::Tap, PhoneticFeature::Approximant, 0.2),
(PhoneticFeature::Trill, PhoneticFeature::Approximant, 0.2),
];
const DEFAULT_MANNER_DISTANCE: f64 = 0.5;
const VOWEL_HEIGHT_STEP: f64 = 0.15; const VOWEL_BACKNESS_STEP: f64 = 0.15; const VOWEL_ROUNDING_DIFF: f64 = 0.1;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FeatureDistanceWeights {
pub voicing: f64,
pub place_step: f64,
pub manner_default: f64,
pub manner_table_scale: f64,
pub vowel_height_step: f64,
pub vowel_backness_step: f64,
pub vowel_rounding: f64,
}
impl FeatureDistanceWeights {
pub const fn standard() -> Self {
Self {
voicing: VOICING_DISTANCE,
place_step: PLACE_STEP_DISTANCE,
manner_default: DEFAULT_MANNER_DISTANCE,
manner_table_scale: 1.0,
vowel_height_step: VOWEL_HEIGHT_STEP,
vowel_backness_step: VOWEL_BACKNESS_STEP,
vowel_rounding: VOWEL_ROUNDING_DIFF,
}
}
}
impl Default for FeatureDistanceWeights {
fn default() -> Self {
Self::standard()
}
}
pub fn articulatory_distance(c1: char, c2: char) -> f64 {
articulatory_distance_weighted(c1, c2, &FeatureDistanceWeights::default())
}
pub fn articulatory_distance_weighted(c1: char, c2: char, weights: &FeatureDistanceWeights) -> f64 {
if c1 == c2 {
return 0.0;
}
let f1 = get_features(c1);
let f2 = get_features(c2);
if f1.is_empty() || f2.is_empty() {
return 1.0;
}
feature_set_distance_weighted(&f1, &f2, weights)
}
pub fn feature_set_distance(
f1: &FxHashSet<PhoneticFeature>,
f2: &FxHashSet<PhoneticFeature>,
) -> f64 {
feature_set_distance_weighted(f1, f2, &FeatureDistanceWeights::default())
}
pub fn feature_set_distance_weighted(
f1: &FxHashSet<PhoneticFeature>,
f2: &FxHashSet<PhoneticFeature>,
weights: &FeatureDistanceWeights,
) -> f64 {
let mut distance = 0.0;
let v1_vowel = f1.contains(&PhoneticFeature::Vowel);
let v2_vowel = f2.contains(&PhoneticFeature::Vowel);
if v1_vowel != v2_vowel {
return 1.0;
}
if v1_vowel {
distance += vowel_distance(f1, f2, weights);
} else {
let v1_voiced = f1.contains(&PhoneticFeature::Voiced);
let v2_voiced = f2.contains(&PhoneticFeature::Voiced);
if v1_voiced != v2_voiced {
distance += weights.voicing;
}
distance += place_distance(f1, f2, weights);
distance += manner_distance(f1, f2, weights);
}
distance.min(1.0) }
fn place_distance(
f1: &FxHashSet<PhoneticFeature>,
f2: &FxHashSet<PhoneticFeature>,
weights: &FeatureDistanceWeights,
) -> f64 {
let pos1 = find_place_position(f1);
let pos2 = find_place_position(f2);
match (pos1, pos2) {
(Some(p1), Some(p2)) => {
let diff = (p1 as i32 - p2 as i32).unsigned_abs() as f64;
diff * weights.place_step
}
_ => weights.manner_default,
}
}
fn find_place_position(features: &FxHashSet<PhoneticFeature>) -> Option<usize> {
for (i, place) in PLACE_ORDER.iter().enumerate() {
if features.contains(place) {
return Some(i);
}
}
None
}
fn manner_distance(
f1: &FxHashSet<PhoneticFeature>,
f2: &FxHashSet<PhoneticFeature>,
weights: &FeatureDistanceWeights,
) -> f64 {
let manner1 = find_manner(f1);
let manner2 = find_manner(f2);
match (manner1, manner2) {
(Some(m1), Some(m2)) => {
if m1 == m2 {
return 0.0;
}
for &(a, b, dist) in MANNER_DISTANCES {
if (a == m1 && b == m2) || (a == m2 && b == m1) {
return dist * weights.manner_table_scale;
}
}
weights.manner_default
}
_ => weights.manner_default,
}
}
fn find_manner(features: &FxHashSet<PhoneticFeature>) -> Option<PhoneticFeature> {
const MANNER_FEATURES: &[PhoneticFeature] = &[
PhoneticFeature::Stop,
PhoneticFeature::Fricative,
PhoneticFeature::Affricate,
PhoneticFeature::Nasal,
PhoneticFeature::Approximant,
PhoneticFeature::Lateral,
PhoneticFeature::Rhotic,
PhoneticFeature::Tap,
PhoneticFeature::Trill,
];
for manner in MANNER_FEATURES {
if features.contains(manner) {
return Some(*manner);
}
}
None
}
fn vowel_distance(
f1: &FxHashSet<PhoneticFeature>,
f2: &FxHashSet<PhoneticFeature>,
weights: &FeatureDistanceWeights,
) -> f64 {
let mut distance = 0.0;
let h1 = vowel_height(f1);
let h2 = vowel_height(f2);
distance += (h1 - h2).abs() as f64 * weights.vowel_height_step;
let b1 = vowel_backness(f1);
let b2 = vowel_backness(f2);
distance += (b1 - b2).abs() as f64 * weights.vowel_backness_step;
let r1 = f1.contains(&PhoneticFeature::Rounded);
let r2 = f2.contains(&PhoneticFeature::Rounded);
if r1 != r2 {
distance += weights.vowel_rounding;
}
distance
}
fn vowel_height(features: &FxHashSet<PhoneticFeature>) -> i32 {
if features.contains(&PhoneticFeature::High) {
2
} else if features.contains(&PhoneticFeature::Mid) {
1
} else if features.contains(&PhoneticFeature::Low) {
0
} else {
1 }
}
fn vowel_backness(features: &FxHashSet<PhoneticFeature>) -> i32 {
if features.contains(&PhoneticFeature::Back) {
2
} else if features.contains(&PhoneticFeature::Central) {
1
} else if features.contains(&PhoneticFeature::Front) {
0
} else {
1 }
}
pub fn is_free_substitution(c1: char, c2: char) -> bool {
articulatory_distance(c1, c2) < 0.15
}
pub fn articulatory_edit_distance(source: &str, target: &str) -> f64 {
articulatory_edit_distance_weighted(source, target, &FeatureDistanceWeights::default())
}
pub fn articulatory_edit_distance_weighted(
source: &str,
target: &str,
weights: &FeatureDistanceWeights,
) -> f64 {
let source_chars: Vec<char> = source.chars().collect();
let target_chars: Vec<char> = target.chars().collect();
let m = source_chars.len();
let n = target_chars.len();
if m == 0 {
return n as f64;
}
if n == 0 {
return m as f64;
}
let mut prev_row: Vec<f64> = (0..=n).map(|j| j as f64).collect();
let mut curr_row = vec![0.0; n + 1];
for i in 1..=m {
curr_row[0] = i as f64;
for j in 1..=n {
let sub_cost =
articulatory_distance_weighted(source_chars[i - 1], target_chars[j - 1], weights);
curr_row[j] = (prev_row[j] + 1.0) .min(curr_row[j - 1] + 1.0) .min(prev_row[j - 1] + sub_cost); }
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[n]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_same_character() {
assert_eq!(articulatory_distance('p', 'p'), 0.0);
assert_eq!(articulatory_distance('a', 'a'), 0.0);
}
#[test]
fn test_voicing_pairs() {
let dist_pb = articulatory_distance('p', 'b');
let dist_td = articulatory_distance('t', 'd');
let dist_kg = articulatory_distance('k', 'g');
let dist_fv = articulatory_distance('f', 'v');
let dist_sz = articulatory_distance('s', 'z');
assert!(dist_pb > 0.0 && dist_pb < 0.2, "p-b dist: {}", dist_pb);
assert!(dist_td > 0.0 && dist_td < 0.2, "t-d dist: {}", dist_td);
assert!(dist_kg > 0.0 && dist_kg < 0.2, "k-g dist: {}", dist_kg);
assert!(dist_fv > 0.0 && dist_fv < 0.2, "f-v dist: {}", dist_fv);
assert!(dist_sz > 0.0 && dist_sz < 0.2, "s-z dist: {}", dist_sz);
}
#[test]
fn test_different_place() {
let dist_pt = articulatory_distance('p', 't'); let dist_pk = articulatory_distance('p', 'k');
assert!(dist_pt > 0.0, "p-t should have positive distance");
assert!(dist_pk > dist_pt, "p-k should be further than p-t");
}
#[test]
fn test_different_manner() {
let dist_ps = articulatory_distance('p', 'f'); let dist_pm = articulatory_distance('p', 'm');
assert!(dist_ps > 0.0);
assert!(dist_pm > 0.0);
}
#[test]
fn test_vowel_vs_consonant() {
let dist_ap = articulatory_distance('a', 'p');
assert_eq!(dist_ap, 1.0);
}
#[test]
fn test_vowel_distances() {
let dist_ae = articulatory_distance('a', 'e'); let dist_ai = articulatory_distance('a', 'i');
assert!(dist_ae > 0.0);
assert!(dist_ai > dist_ae, "a-i should be further than a-e");
}
#[test]
fn test_unknown_character() {
let dist = articulatory_distance('p', '£');
assert_eq!(dist, 1.0);
}
#[test]
fn test_is_free_substitution() {
assert!(is_free_substitution('p', 'b'));
assert!(is_free_substitution('t', 'd'));
assert!(!is_free_substitution('p', 'h'));
assert!(!is_free_substitution('a', 'u'));
}
#[test]
fn test_articulatory_edit_distance() {
assert_eq!(articulatory_edit_distance("test", "test"), 0.0);
assert_eq!(articulatory_edit_distance("", "test"), 4.0);
assert_eq!(articulatory_edit_distance("test", ""), 4.0);
let dist = articulatory_edit_distance("pat", "bat");
assert!(
dist > 0.0 && dist < 1.0,
"pat-bat should have fractional distance: {}",
dist
);
let dist2 = articulatory_edit_distance("pat", "hat");
assert!(dist2 > dist, "pat-hat should be more than pat-bat");
}
#[test]
fn test_symmetry() {
assert_eq!(
articulatory_distance('p', 'b'),
articulatory_distance('b', 'p')
);
assert_eq!(
articulatory_distance('a', 'i'),
articulatory_distance('i', 'a')
);
}
#[test]
fn test_ipa_characters() {
let dist_sh = articulatory_distance('ʃ', 'ʒ'); assert!(dist_sh > 0.0 && dist_sh < 0.2);
let dist_theta_eth = articulatory_distance('θ', 'ð'); assert!(dist_theta_eth > 0.0 && dist_theta_eth < 0.2);
}
#[test]
fn weighted_default_matches_unweighted() {
let w = FeatureDistanceWeights::default();
for (a, b) in [
('p', 'b'),
('p', 'k'),
('a', 'e'),
('s', 'z'),
('p', 'h'),
('t', 'd'),
('a', 'i'),
] {
assert_eq!(
articulatory_distance(a, b),
articulatory_distance_weighted(a, b, &w),
"default-weighted must match unweighted for {a}-{b}"
);
}
assert_eq!(
articulatory_edit_distance("receive", "recieve"),
articulatory_edit_distance_weighted("receive", "recieve", &w)
);
}
#[test]
fn weighted_voicing_scales_monotonically() {
let low = FeatureDistanceWeights {
voicing: 0.05,
..Default::default()
};
let high = FeatureDistanceWeights {
voicing: 0.5,
..Default::default()
};
let d_low = articulatory_distance_weighted('p', 'b', &low);
let d_high = articulatory_distance_weighted('p', 'b', &high);
assert!(
d_high > d_low,
"higher voicing weight must increase p-b distance: {d_low} vs {d_high}"
);
assert!((d_low - 0.05).abs() < 1e-9);
assert!((d_high - 0.5).abs() < 1e-9);
}
#[test]
fn weighted_voicing_isolated() {
let base = FeatureDistanceWeights::default();
let d = articulatory_distance_weighted('p', 'b', &base);
assert!((d - 0.1).abs() < 1e-9, "p/b expected 0.1, got {d}");
for w in [
FeatureDistanceWeights {
place_step: 0.9,
..base
},
FeatureDistanceWeights {
manner_default: 0.9,
..base
},
FeatureDistanceWeights {
manner_table_scale: 5.0,
..base
},
FeatureDistanceWeights {
vowel_height_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_backness_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_rounding: 0.9,
..base
},
] {
assert_eq!(articulatory_distance_weighted('p', 'b', &w), d);
}
}
#[test]
fn weighted_place_step_monotonic_and_isolated() {
let base = FeatureDistanceWeights::default();
let d = articulatory_distance_weighted('p', 't', &base);
assert!((d - 0.45).abs() < 1e-9, "p/t expected 0.45, got {d}");
let lo = FeatureDistanceWeights {
place_step: 0.10,
..base
};
let hi = FeatureDistanceWeights {
place_step: 0.20,
..base
};
assert!(
articulatory_distance_weighted('p', 't', &hi)
> articulatory_distance_weighted('p', 't', &lo),
"raising place_step must increase p/t distance"
);
for w in [
FeatureDistanceWeights {
voicing: 0.9,
..base
},
FeatureDistanceWeights {
manner_table_scale: 5.0,
..base
},
FeatureDistanceWeights {
vowel_rounding: 0.9,
..base
},
] {
assert_eq!(articulatory_distance_weighted('p', 't', &w), d);
}
}
#[test]
fn weighted_manner_table_scale_monotonic_and_isolated() {
let base = FeatureDistanceWeights::default();
let d = articulatory_distance_weighted('p', 'm', &base);
assert!((d - 0.4).abs() < 1e-9, "p/m expected 0.4, got {d}");
let lo = FeatureDistanceWeights {
manner_table_scale: 0.5,
..base
};
let hi = FeatureDistanceWeights {
manner_table_scale: 2.0,
..base
};
assert!(
articulatory_distance_weighted('p', 'm', &hi)
> articulatory_distance_weighted('p', 'm', &lo),
"raising manner_table_scale must increase p/m distance"
);
for w in [
FeatureDistanceWeights {
vowel_height_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_backness_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_rounding: 0.9,
..base
},
] {
assert_eq!(articulatory_distance_weighted('p', 'm', &w), d);
}
}
#[test]
fn weighted_manner_default_monotonic() {
let mut f1 = FxHashSet::default();
f1.insert(PhoneticFeature::Consonant);
f1.insert(PhoneticFeature::Bilabial);
let mut f2 = FxHashSet::default();
f2.insert(PhoneticFeature::Consonant);
f2.insert(PhoneticFeature::Bilabial);
f2.insert(PhoneticFeature::Stop);
let lo = FeatureDistanceWeights {
manner_default: 0.3,
..Default::default()
};
let hi = FeatureDistanceWeights {
manner_default: 0.7,
..Default::default()
};
let d_lo = feature_set_distance_weighted(&f1, &f2, &lo);
let d_hi = feature_set_distance_weighted(&f1, &f2, &hi);
assert!(
(d_lo - 0.3).abs() < 1e-9,
"expected manner_default 0.3, got {d_lo}"
);
assert!(
(d_hi - 0.7).abs() < 1e-9,
"expected manner_default 0.7, got {d_hi}"
);
assert!(
d_hi > d_lo,
"raising manner_default must increase the distance"
);
}
#[test]
fn weighted_vowel_height_monotonic_and_isolated() {
let base = FeatureDistanceWeights::default();
let d = articulatory_distance_weighted('e', 'i', &base);
assert!((d - 0.15).abs() < 1e-9, "e/i expected 0.15, got {d}");
let lo = FeatureDistanceWeights {
vowel_height_step: 0.10,
..base
};
let hi = FeatureDistanceWeights {
vowel_height_step: 0.30,
..base
};
assert!(
articulatory_distance_weighted('e', 'i', &hi)
> articulatory_distance_weighted('e', 'i', &lo),
"raising vowel_height_step must increase e/i distance"
);
for w in [
FeatureDistanceWeights {
vowel_backness_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_rounding: 0.9,
..base
},
FeatureDistanceWeights {
voicing: 0.9,
..base
},
FeatureDistanceWeights {
place_step: 0.9,
..base
},
] {
assert_eq!(articulatory_distance_weighted('e', 'i', &w), d);
}
}
#[test]
fn weighted_vowel_backness_monotonic_and_isolated() {
let base = FeatureDistanceWeights::default();
let d = articulatory_distance_weighted('i', 'ɨ', &base);
assert!((d - 0.15).abs() < 1e-9, "i/ɨ expected 0.15, got {d}");
let lo = FeatureDistanceWeights {
vowel_backness_step: 0.10,
..base
};
let hi = FeatureDistanceWeights {
vowel_backness_step: 0.30,
..base
};
assert!(
articulatory_distance_weighted('i', 'ɨ', &hi)
> articulatory_distance_weighted('i', 'ɨ', &lo),
"raising vowel_backness_step must increase i/ɨ distance"
);
for w in [
FeatureDistanceWeights {
vowel_height_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_rounding: 0.9,
..base
},
] {
assert_eq!(articulatory_distance_weighted('i', 'ɨ', &w), d);
}
}
#[test]
fn weighted_vowel_rounding_monotonic_and_isolated() {
let base = FeatureDistanceWeights::default();
let d = articulatory_distance_weighted('e', 'ø', &base);
assert!((d - 0.1).abs() < 1e-9, "e/ø expected 0.1, got {d}");
let lo = FeatureDistanceWeights {
vowel_rounding: 0.05,
..base
};
let hi = FeatureDistanceWeights {
vowel_rounding: 0.40,
..base
};
assert!(
articulatory_distance_weighted('e', 'ø', &hi)
> articulatory_distance_weighted('e', 'ø', &lo),
"raising vowel_rounding must increase e/ø distance"
);
for w in [
FeatureDistanceWeights {
vowel_height_step: 0.9,
..base
},
FeatureDistanceWeights {
vowel_backness_step: 0.9,
..base
},
] {
assert_eq!(articulatory_distance_weighted('e', 'ø', &w), d);
}
}
#[test]
fn weighted_standard_equals_default() {
assert_eq!(
FeatureDistanceWeights::standard(),
FeatureDistanceWeights::default()
);
let w = FeatureDistanceWeights::standard();
assert_eq!(w.voicing, VOICING_DISTANCE);
assert_eq!(w.place_step, PLACE_STEP_DISTANCE);
assert_eq!(w.manner_default, DEFAULT_MANNER_DISTANCE);
assert_eq!(w.manner_table_scale, 1.0);
assert_eq!(w.vowel_height_step, VOWEL_HEIGHT_STEP);
assert_eq!(w.vowel_backness_step, VOWEL_BACKNESS_STEP);
assert_eq!(w.vowel_rounding, VOWEL_ROUNDING_DIFF);
}
}