use serde::Serialize;
use crate::conlang::features::{self, Feat};
use crate::conlang::types::Phonology;
const NEAR_UNIVERSAL: &[&str] = &["m", "n", "p", "t", "k", "s", "l", "j", "w", "i", "a", "u"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SizeClass {
Small,
Typical,
Large,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct NaturalnessReport {
pub phoneme_count: usize,
pub consonants: usize,
pub vowels: usize,
pub unknown_segments: Vec<String>,
pub voicing_pairs: Vec<(String, String)>,
pub voicing_gaps: Vec<String>,
pub places_present: Vec<String>,
pub common_present: usize,
pub missing_common: Vec<String>,
pub size_class: SizeClass,
pub score: f64,
}
impl Default for SizeClass {
fn default() -> Self {
SizeClass::Typical
}
}
fn is(ipa: &str, feature: &str, want: Feat) -> bool {
features::value(ipa, feature) == Some(want)
}
fn is_obstruent(ipa: &str) -> bool {
is(ipa, "sonorant", Feat::Minus) && is(ipa, "syllabic", Feat::Minus)
}
pub fn naturalness(phon: &Phonology) -> NaturalnessReport {
use crate::conlang::types::phoneme::PhonemeKind;
let mut r = NaturalnessReport {
phoneme_count: phon.phonemes.len(),
consonants: phon.phonemes.iter().filter(|p| p.kind == PhonemeKind::Consonant).count(),
vowels: phon.phonemes.iter().filter(|p| p.kind == PhonemeKind::Vowel).count(),
..Default::default()
};
let inventory: Vec<&str> = phon.phonemes.iter().map(|p| p.ipa.as_str()).collect();
r.unknown_segments = inventory
.iter()
.filter(|ipa| !features::is_known(ipa))
.map(|s| s.to_string())
.collect();
let obstruents: Vec<&str> = inventory.iter().copied().filter(|ipa| is_obstruent(ipa)).collect();
let voiceless: Vec<&str> =
obstruents.iter().copied().filter(|ipa| is(ipa, "voice", Feat::Minus)).collect();
let voiced: Vec<&str> =
obstruents.iter().copied().filter(|ipa| is(ipa, "voice", Feat::Plus)).collect();
let mut paired_voiced: std::collections::HashSet<&str> = std::collections::HashSet::new();
for &vl in &voiceless {
let mate = voiced.iter().copied().find(|&vd| {
features::differing_features(vl, vd).map(|f| f == ["voice"]).unwrap_or(false)
});
match mate {
Some(vd) => {
r.voicing_pairs.push((vl.to_string(), vd.to_string()));
paired_voiced.insert(vd);
}
None => r.voicing_gaps.push(vl.to_string()),
}
}
for &vd in &voiced {
if !paired_voiced.contains(vd) {
r.voicing_gaps.push(vd.to_string());
}
}
for (feat, label) in [("labial", "labial"), ("coronal", "coronal"), ("dorsal", "dorsal")] {
if obstruents.iter().any(|ipa| is(ipa, feat, Feat::Plus)) {
r.places_present.push(label.to_string());
}
}
for &seg in NEAR_UNIVERSAL {
if inventory.contains(&seg) {
r.common_present += 1;
} else {
r.missing_common.push(seg.to_string());
}
}
let total = r.consonants + r.vowels;
r.size_class = match total {
0..=14 => SizeClass::Small,
15..=45 => SizeClass::Typical,
_ => SizeClass::Large,
};
r.score = score(&r, obstruents.len());
r
}
fn score(r: &NaturalnessReport, obstruent_count: usize) -> f64 {
let common = r.common_present as f64 / NEAR_UNIVERSAL.len() as f64;
let place = r.places_present.len() as f64 / 3.0;
let voicing = if obstruent_count == 0 {
1.0
} else {
1.0 - (r.voicing_gaps.len() as f64 / obstruent_count as f64)
};
let size = match r.size_class {
SizeClass::Typical => 1.0,
SizeClass::Small | SizeClass::Large => 0.7,
};
(0.45 * common + 0.2 * place + 0.15 * voicing + 0.2 * size).clamp(0.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
fn phon(ipas: &[&str]) -> Phonology {
let items: Vec<String> = ipas
.iter()
.map(|ipa| {
let kind = if "aeiouɛɔəæ".contains(*ipa) { "vowel" } else { "consonant" };
format!("{{ ipa: \"{ipa}\", kind: \"{kind}\" }}")
})
.collect();
let body = format!("{{ phonemes: [ {} ] }}", items.join(", "));
Phonology::from_hjson(&body).unwrap().unwrap()
}
#[test]
fn a_balanced_inventory_scores_high_and_symmetric() {
let p =
phon(&["p", "b", "t", "d", "k", "ɡ", "s", "z", "m", "n", "l", "j", "w", "i", "a", "u"]);
let r = naturalness(&p);
assert_eq!(r.voicing_gaps, Vec::<String>::new());
assert_eq!(r.voicing_pairs.len(), 4); assert_eq!(r.places_present.len(), 3); assert!(r.missing_common.is_empty());
assert!(r.score > 0.9, "score was {}", r.score);
}
#[test]
fn a_voicing_gap_is_flagged() {
let p = phon(&["p", "b", "t", "d", "k", "s", "m", "n", "a", "i", "u"]);
let r = naturalness(&p);
assert!(r.voicing_gaps.contains(&"k".to_string()));
assert!(r.voicing_gaps.contains(&"s".to_string()));
assert!(r.voicing_pairs.contains(&("p".to_string(), "b".to_string())));
}
#[test]
fn missing_near_universals_lower_the_score() {
let full = naturalness(&phon(&["p", "t", "k", "m", "n", "s", "a", "i", "u"]));
let gapped = naturalness(&phon(&["p", "t", "k", "n", "s", "u", "e", "o"]));
assert!(gapped.missing_common.contains(&"m".to_string()));
assert!(gapped.missing_common.contains(&"a".to_string()));
assert!(gapped.score < full.score);
}
#[test]
fn exotic_segments_are_listed_not_crashed() {
let r = naturalness(&phon(&["p", "t", "k", "ǃ", "a", "i"]));
assert!(r.unknown_segments.contains(&"ǃ".to_string()));
assert!(r.places_present.contains(&"labial".to_string()));
}
}