use std::collections::BTreeMap;
use serde::Serialize;
use crate::conlang::phonology::syllable::syllabify;
use crate::conlang::types::Phonology;
use crate::conlang::types::phoneme::PhonemeKind;
use crate::language_entry::DictionaryEntry;
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct PhonemeDistribution {
pub ipa: String,
pub total: usize,
pub as_onset: usize,
pub as_nucleus: usize,
pub as_coda: usize,
pub word_initial: usize,
pub word_final: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub restriction: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct DistributionReport {
pub analyzable_words: usize,
pub onset_inventory: Vec<String>,
pub coda_inventory: Vec<String>,
pub restricted: Vec<PhonemeDistribution>,
pub phonemes: Vec<PhonemeDistribution>,
}
pub fn distribution(phon: &Phonology, entries: &[DictionaryEntry]) -> DistributionReport {
let mut table: BTreeMap<String, PhonemeDistribution> = BTreeMap::new();
let mut words = 0usize;
for e in entries {
let word = e.word.to_lowercase();
let seq = phon.segment(&word);
if seq.is_empty() || !seq.iter().all(|s| phon.phoneme(s).is_some()) {
continue;
}
words += 1;
let first = seq.first().cloned();
let last = seq.last().cloned();
for s in &seq {
let d = table.entry(s.clone()).or_insert_with(|| PhonemeDistribution {
ipa: s.clone(),
..Default::default()
});
d.total += 1;
if first.as_deref() == Some(s.as_str()) {
d.word_initial += 1;
}
if last.as_deref() == Some(s.as_str()) {
d.word_final += 1;
}
}
for syl in syllabify(phon, &seq) {
for s in &syl.onset {
table.entry(s.clone()).or_default().as_onset += 1;
}
for s in &syl.nucleus {
table.entry(s.clone()).or_default().as_nucleus += 1;
}
for s in &syl.coda {
table.entry(s.clone()).or_default().as_coda += 1;
}
}
}
let mut r = DistributionReport { analyzable_words: words, ..Default::default() };
if words == 0 {
return r;
}
for (ipa, d) in table.iter_mut() {
if d.ipa.is_empty() {
d.ipa = ipa.clone();
}
d.restriction = restriction_of(phon, d);
}
let is_consonant =
|ipa: &str| phon.phoneme(ipa).map(|p| p.kind == PhonemeKind::Consonant).unwrap_or(false);
r.onset_inventory =
table.values().filter(|d| d.as_onset > 0 && is_consonant(&d.ipa)).map(|d| d.ipa.clone()).collect();
r.coda_inventory =
table.values().filter(|d| d.as_coda > 0 && is_consonant(&d.ipa)).map(|d| d.ipa.clone()).collect();
r.restricted =
table.values().filter(|d| d.restriction.is_some()).cloned().collect();
r.restricted.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.ipa.cmp(&b.ipa)));
let mut phonemes: Vec<PhonemeDistribution> = table.into_values().collect();
phonemes.sort_by(|a, b| b.total.cmp(&a.total).then_with(|| a.ipa.cmp(&b.ipa)));
r.phonemes = phonemes;
r
}
fn restriction_of(phon: &Phonology, d: &PhonemeDistribution) -> Option<String> {
let is_consonant =
phon.phoneme(&d.ipa).map(|p| p.kind == PhonemeKind::Consonant).unwrap_or(false);
if !is_consonant || d.total < 2 {
return None; }
if d.as_coda == 0 && d.as_onset > 0 {
return Some("onset only".to_string());
}
if d.as_onset == 0 && d.as_coda > 0 {
return Some("coda only".to_string());
}
if d.word_initial == 0 {
return Some("never word-initial".to_string());
}
if d.word_final == 0 {
return Some("never word-final".to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn phon() -> Phonology {
let body = r#"{
phonemes: [
{ ipa: "p", kind: "consonant" }, { ipa: "t", kind: "consonant" },
{ ipa: "k", kind: "consonant" }, { ipa: "n", kind: "consonant" },
{ ipa: "ŋ", kind: "consonant" }, { ipa: "h", kind: "consonant" },
{ ipa: "a", kind: "vowel" }, { ipa: "i", kind: "vowel" }
]
}"#;
Phonology::from_hjson(body).unwrap().unwrap()
}
fn entry(word: &str) -> DictionaryEntry {
DictionaryEntry { word: word.into(), pos: "noun".into(), translation: "x".into(), ..Default::default() }
}
fn find<'a>(r: &'a DistributionReport, ipa: &str) -> &'a PhonemeDistribution {
r.phonemes.iter().find(|d| d.ipa == ipa).unwrap()
}
#[test]
fn counts_onset_coda_and_word_edges() {
let r = distribution(&phon(), &[entry("pat"), entry("tap")]);
assert_eq!(r.analyzable_words, 2);
let p = find(&r, "p");
assert!(p.as_onset > 0 && p.word_initial > 0);
assert!(r.onset_inventory.contains(&"p".to_string()));
}
#[test]
fn a_coda_only_consonant_is_flagged() {
let r = distribution(
&phon(),
&[entry("taŋ"), entry("kaŋ"), entry("haka"), entry("hata")],
);
assert_eq!(find(&r, "ŋ").restriction.as_deref(), Some("coda only"));
assert_eq!(find(&r, "h").restriction.as_deref(), Some("onset only"));
assert!(r.restricted.iter().any(|d| d.ipa == "ŋ"));
}
#[test]
fn an_unrestricted_consonant_has_no_flag() {
let r = distribution(&phon(), &[entry("tat"), entry("atta"), entry("tita")]);
assert_eq!(find(&r, "t").restriction, None);
}
}