use std::collections::HashMap;
use std::sync::LazyLock;
pub const FEATURE_NAMES: &[&str] = &[
"syllabic", "consonantal", "sonorant", "continuant", "voice", "nasal", "lateral", "labial",
"coronal", "dorsal", "anterior", "strident", "high", "low", "back", "round",
];
const RAW: &str = include_str!("../../assets/linguistic/features.tsv");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Feat {
Plus,
Minus,
Unspec,
}
impl Feat {
fn parse(s: &str) -> Option<Feat> {
match s {
"+" => Some(Feat::Plus),
"-" => Some(Feat::Minus),
"0" => Some(Feat::Unspec),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeatureSet {
pub vals: [Feat; 16],
}
static TABLE: LazyLock<HashMap<String, FeatureSet>> = LazyLock::new(|| parse(RAW));
fn parse(raw: &str) -> HashMap<String, FeatureSet> {
let mut table = HashMap::new();
for line in raw.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut cols = line.split_whitespace();
let Some(ipa) = cols.next() else { continue };
let mut vals = [Feat::Unspec; 16];
let mut ok = true;
for slot in vals.iter_mut() {
match cols.next().and_then(Feat::parse) {
Some(f) => *slot = f,
None => {
ok = false;
break;
}
}
}
if ok && cols.next().is_none() {
table.insert(ipa.to_string(), FeatureSet { vals });
}
}
table
}
pub fn features(ipa: &str) -> Option<&'static FeatureSet> {
TABLE.get(ipa)
}
pub fn value(ipa: &str, feature: &str) -> Option<Feat> {
let fs = features(ipa)?;
let i = FEATURE_NAMES.iter().position(|&n| n == feature)?;
Some(fs.vals[i])
}
#[allow(dead_code)]
pub fn is_known(ipa: &str) -> bool {
TABLE.contains_key(ipa)
}
#[allow(dead_code)]
pub fn len() -> usize {
TABLE.len()
}
#[allow(dead_code)]
pub fn distance(a: &str, b: &str) -> Option<usize> {
let (fa, fb) = (features(a)?, features(b)?);
Some(count_differences(fa, fb).len())
}
pub fn differing_features(a: &str, b: &str) -> Option<Vec<&'static str>> {
let (fa, fb) = (features(a)?, features(b)?);
Some(count_differences(fa, fb))
}
fn count_differences(a: &FeatureSet, b: &FeatureSet) -> Vec<&'static str> {
let mut out = Vec::new();
for i in 0..16 {
let (x, y) = (a.vals[i], b.vals[i]);
let differs = matches!(
(x, y),
(Feat::Plus, Feat::Minus) | (Feat::Minus, Feat::Plus)
);
if differs {
out.push(FEATURE_NAMES[i]);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_matrix_loads_a_reasonable_inventory() {
assert!(len() > 70, "feature matrix only has {} segments", len());
assert!(is_known("a"));
assert!(is_known("t"));
assert!(!is_known("Q")); }
#[test]
fn voicing_pairs_differ_in_exactly_one_feature() {
for (a, b) in [("p", "b"), ("t", "d"), ("k", "ɡ"), ("s", "z")] {
assert_eq!(distance(a, b), Some(1), "{a}/{b} should differ by 1");
assert_eq!(differing_features(a, b).unwrap(), vec!["voice"]);
}
}
#[test]
fn place_and_manner_contrasts_are_captured() {
let pt = differing_features("p", "t").unwrap();
assert!(pt.contains(&"labial") && pt.contains(&"coronal"));
assert!(!pt.contains(&"anterior"), "anterior is unspecified on p, not a difference");
let ts = differing_features("t", "s").unwrap();
assert!(ts.contains(&"continuant") && ts.contains(&"strident"));
}
#[test]
fn distant_segments_and_unknowns() {
assert!(distance("i", "p").unwrap() >= 5);
assert_eq!(distance("§", "a"), None);
assert_eq!(differing_features("a", "§"), None);
}
}