use std::fmt;
use super::costs_f64::OperationCostsF64;
#[cfg(feature = "phonetic-rules")]
use crate::phonetic::feature_distance::FeatureDistanceWeights;
const DEFAULT_ARTICULATION_WEIGHT: f64 = 0.6;
const FREE_SUBSTITUTION_THRESHOLD: f64 = 0.15;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ArticulatoryCosts {
pub base: OperationCostsF64,
pub articulation_weight: f64,
pub free_substitution_threshold: f64,
#[cfg(feature = "phonetic-rules")]
pub feature_weights: FeatureDistanceWeights,
}
impl ArticulatoryCosts {
#[inline]
pub const fn new() -> Self {
Self {
base: OperationCostsF64::standard(),
articulation_weight: DEFAULT_ARTICULATION_WEIGHT,
free_substitution_threshold: FREE_SUBSTITUTION_THRESHOLD,
#[cfg(feature = "phonetic-rules")]
feature_weights: FeatureDistanceWeights::standard(),
}
}
pub fn with_weight(weight: f64) -> Self {
assert!(
(0.0..=1.0).contains(&weight),
"Articulation weight must be in [0.0, 1.0], got {}",
weight
);
Self {
base: OperationCostsF64::standard(),
articulation_weight: weight,
free_substitution_threshold: FREE_SUBSTITUTION_THRESHOLD,
#[cfg(feature = "phonetic-rules")]
feature_weights: FeatureDistanceWeights::standard(),
}
}
pub fn with_base(base: OperationCostsF64) -> Self {
Self {
base,
articulation_weight: DEFAULT_ARTICULATION_WEIGHT,
free_substitution_threshold: FREE_SUBSTITUTION_THRESHOLD,
#[cfg(feature = "phonetic-rules")]
feature_weights: FeatureDistanceWeights::standard(),
}
}
pub fn custom(base: OperationCostsF64, articulation_weight: f64, free_threshold: f64) -> Self {
assert!(
(0.0..=1.0).contains(&articulation_weight),
"Articulation weight must be in [0.0, 1.0], got {}",
articulation_weight
);
assert!(
free_threshold >= 0.0,
"Free substitution threshold must be non-negative, got {}",
free_threshold
);
Self {
base,
articulation_weight,
free_substitution_threshold: free_threshold,
#[cfg(feature = "phonetic-rules")]
feature_weights: FeatureDistanceWeights::standard(),
}
}
#[cfg(feature = "phonetic-rules")]
pub fn with_feature_weights(feature_weights: FeatureDistanceWeights) -> Self {
Self {
base: OperationCostsF64::standard(),
articulation_weight: DEFAULT_ARTICULATION_WEIGHT,
free_substitution_threshold: FREE_SUBSTITUTION_THRESHOLD,
feature_weights,
}
}
#[cfg(feature = "phonetic-rules")]
pub fn substitution_cost(&self, from: char, to: char) -> f64 {
if from == to {
return 0.0;
}
let art_dist = crate::phonetic::feature_distance::articulatory_distance_weighted(
from,
to,
&self.feature_weights,
);
if art_dist < self.free_substitution_threshold {
return art_dist * 0.1; }
self.base.substitution * (1.0 - self.articulation_weight)
+ art_dist * self.articulation_weight
}
#[cfg(not(feature = "phonetic-rules"))]
pub fn substitution_cost(&self, from: char, to: char) -> f64 {
if from == to {
0.0
} else {
self.base.substitution
}
}
#[cfg(feature = "phonetic-rules")]
pub fn is_free_substitution(&self, from: char, to: char) -> bool {
if from == to {
return true;
}
crate::phonetic::feature_distance::articulatory_distance_weighted(
from,
to,
&self.feature_weights,
) < self.free_substitution_threshold
}
#[cfg(not(feature = "phonetic-rules"))]
pub fn is_free_substitution(&self, from: char, to: char) -> bool {
from == to
}
#[inline]
pub fn insertion_cost(&self) -> f64 {
self.base.insertion
}
#[inline]
pub fn deletion_cost(&self) -> f64 {
self.base.deletion
}
#[inline]
pub fn transposition_cost(&self) -> f64 {
self.base.transposition
}
#[inline]
pub fn split_cost(&self) -> f64 {
self.base.split
}
#[inline]
pub fn merge_cost(&self) -> f64 {
self.base.merge
}
pub fn is_valid(&self) -> bool {
self.base.is_valid()
&& (0.0..=1.0).contains(&self.articulation_weight)
&& self.free_substitution_threshold >= 0.0
}
pub fn min_nonzero_cost(&self) -> f64 {
let min_sub = self.free_substitution_threshold * 0.1;
let costs = [
min_sub,
self.base.insertion,
self.base.deletion,
self.base.transposition,
self.base.split,
self.base.merge,
];
costs
.iter()
.copied()
.filter(|&c| c > 0.0)
.min_by(|a, b| a.partial_cmp(b).expect("valid f64"))
.unwrap_or(0.01) }
}
impl Default for ArticulatoryCosts {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for ArticulatoryCosts {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"ArticulatoryCosts(weight={:.2}, threshold={:.2}, base={})",
self.articulation_weight, self.free_substitution_threshold, self.base
)
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPSILON: f64 = 1e-10;
#[test]
fn test_default_costs() {
let costs = ArticulatoryCosts::default();
assert!((costs.articulation_weight - 0.6).abs() < EPSILON);
assert!((costs.free_substitution_threshold - 0.15).abs() < EPSILON);
assert!(costs.is_valid());
}
#[test]
fn test_with_weight() {
let costs = ArticulatoryCosts::with_weight(0.8);
assert!((costs.articulation_weight - 0.8).abs() < EPSILON);
assert!(costs.is_valid());
}
#[test]
fn test_with_base() {
let base = OperationCostsF64::typo_friendly();
let costs = ArticulatoryCosts::with_base(base);
assert!((costs.base.transposition - 0.5).abs() < EPSILON);
assert!(costs.is_valid());
}
#[test]
fn test_custom() {
let base = OperationCostsF64::standard();
let costs = ArticulatoryCosts::custom(base, 0.9, 0.2);
assert!((costs.articulation_weight - 0.9).abs() < EPSILON);
assert!((costs.free_substitution_threshold - 0.2).abs() < EPSILON);
assert!(costs.is_valid());
}
#[test]
#[should_panic(expected = "Articulation weight must be in [0.0, 1.0]")]
fn test_invalid_weight_panics() {
ArticulatoryCosts::with_weight(1.5);
}
#[test]
#[should_panic(expected = "Free substitution threshold must be non-negative")]
fn test_negative_threshold_panics() {
let base = OperationCostsF64::standard();
ArticulatoryCosts::custom(base, 0.5, -0.1);
}
#[test]
fn test_same_character_free() {
let costs = ArticulatoryCosts::default();
assert!((costs.substitution_cost('a', 'a') - 0.0).abs() < EPSILON);
assert!((costs.substitution_cost('z', 'z') - 0.0).abs() < EPSILON);
}
#[cfg(feature = "phonetic-rules")]
#[test]
fn test_voicing_pairs_cheap() {
let costs = ArticulatoryCosts::default();
let pb = costs.substitution_cost('p', 'b');
let ph = costs.substitution_cost('p', 'h');
assert!(pb < ph, "p→b ({}) should be cheaper than p→h ({})", pb, ph);
let td = costs.substitution_cost('t', 'd');
let th = costs.substitution_cost('t', 'h');
assert!(td < th, "t→d ({}) should be cheaper than t→h ({})", td, th);
}
#[cfg(feature = "phonetic-rules")]
#[test]
fn test_free_substitution() {
let costs = ArticulatoryCosts::default();
assert!(costs.is_free_substitution('a', 'a'));
assert!(costs.is_free_substitution('p', 'p'));
assert!(!costs.is_free_substitution('a', 'z'));
assert!(!costs.is_free_substitution('p', 'h'));
}
#[test]
fn test_other_costs() {
let costs = ArticulatoryCosts::default();
assert!((costs.insertion_cost() - 1.0).abs() < EPSILON);
assert!((costs.deletion_cost() - 1.0).abs() < EPSILON);
assert!((costs.transposition_cost() - 1.0).abs() < EPSILON);
assert!((costs.split_cost() - 1.0).abs() < EPSILON);
assert!((costs.merge_cost() - 1.0).abs() < EPSILON);
}
#[test]
fn test_min_nonzero_cost() {
let costs = ArticulatoryCosts::default();
let min = costs.min_nonzero_cost();
assert!(min > 0.0);
assert!(min < 1.0); }
#[test]
fn test_display() {
let costs = ArticulatoryCosts::default();
let s = format!("{}", costs);
assert!(s.contains("weight=0.60"));
assert!(s.contains("threshold=0.15"));
}
#[cfg(feature = "phonetic-rules")]
#[test]
fn articulatory_costs_responds_to_feature_weights() {
let default_costs = ArticulatoryCosts::default();
let heavy_place = ArticulatoryCosts::with_feature_weights(FeatureDistanceWeights {
place_step: 0.4,
..Default::default()
});
let base_cost = default_costs.substitution_cost('p', 't');
let heavy_cost = heavy_place.substitution_cost('p', 't');
assert!(
heavy_cost > base_cost,
"heavier place_step must raise p/t substitution cost: {base_cost} vs {heavy_cost}"
);
assert!((heavy_place.articulation_weight - 0.6).abs() < EPSILON);
assert!((heavy_place.free_substitution_threshold - 0.15).abs() < EPSILON);
assert!(heavy_place.is_valid());
}
}