use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::conlang::phonology::syllable::syllabify;
use crate::conlang::types::Phonology;
use crate::language_entry::DictionaryEntry;
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct LanguageMetrics {
pub analyzable_words: usize,
pub total_segments: usize,
pub phoneme_entropy: f64,
pub phoneme_entropy_max: f64,
pub phoneme_evenness: f64,
pub phoneme_perplexity: f64,
pub zipf_slope: f64,
pub zipf_r2: f64,
pub attested_syllables: usize,
pub possible_syllables: usize,
pub syllable_saturation: f64,
pub mean_moras: f64,
pub heavy_ratio: f64,
}
pub fn metrics(phon: &Phonology, entries: &[DictionaryEntry]) -> LanguageMetrics {
let mut m = LanguageMetrics::default();
let mut phoneme_count: BTreeMap<String, usize> = BTreeMap::new();
let mut syllable_types: BTreeSet<String> = BTreeSet::new();
let mut onsets: BTreeSet<String> = BTreeSet::new();
let mut nuclei: BTreeSet<String> = BTreeSet::new();
let mut codas: BTreeSet<String> = BTreeSet::new();
let mut total_syllables = 0usize;
let mut heavy_syllables = 0usize;
let mut total_moras = 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; }
m.analyzable_words += 1;
m.total_segments += seq.len();
for s in &seq {
*phoneme_count.entry(s.clone()).or_default() += 1;
}
for syl in syllabify(phon, &seq) {
let onset = syl.onset.join("");
let nucleus = syl.nucleus.join("");
let coda = syl.coda.join("");
syllable_types.insert(format!("{onset}.{nucleus}.{coda}"));
onsets.insert(onset);
nuclei.insert(nucleus.clone());
codas.insert(coda.clone());
total_syllables += 1;
let heavy = !syl.coda.is_empty() || syl.nucleus.len() > 1;
if heavy {
heavy_syllables += 1;
total_moras += 2;
} else {
total_moras += 1;
}
}
}
if m.analyzable_words == 0 {
return m;
}
let total = m.total_segments as f64;
let distinct = phoneme_count.len();
let mut h = 0.0f64;
for &c in phoneme_count.values() {
let p = c as f64 / total;
h -= p * p.log2();
}
m.phoneme_entropy = h;
m.phoneme_entropy_max = if distinct > 1 { (distinct as f64).log2() } else { 0.0 };
m.phoneme_evenness =
if m.phoneme_entropy_max > 0.0 { h / m.phoneme_entropy_max } else { 0.0 };
m.phoneme_perplexity = h.exp2();
let mut freqs: Vec<usize> = phoneme_count.values().copied().collect();
freqs.sort_unstable_by(|a, b| b.cmp(a));
let (slope, r2) = zipf_fit(&freqs);
m.zipf_slope = slope;
m.zipf_r2 = r2;
m.attested_syllables = syllable_types.len();
m.possible_syllables =
onsets.len().max(1) * nuclei.len().max(1) * codas.len().max(1);
m.syllable_saturation = if m.possible_syllables > 0 {
(m.attested_syllables as f64 / m.possible_syllables as f64).min(1.0)
} else {
0.0
};
m.mean_moras = total_moras as f64 / m.analyzable_words as f64;
m.heavy_ratio =
if total_syllables > 0 { heavy_syllables as f64 / total_syllables as f64 } else { 0.0 };
m
}
fn zipf_fit(freqs_desc: &[usize]) -> (f64, f64) {
if freqs_desc.len() < 2 {
return (0.0, 0.0);
}
let xs: Vec<f64> = (1..=freqs_desc.len()).map(|r| (r as f64).ln()).collect();
let ys: Vec<f64> = freqs_desc.iter().map(|&f| (f.max(1) as f64).ln()).collect();
let n = xs.len() as f64;
let mx = xs.iter().sum::<f64>() / n;
let my = ys.iter().sum::<f64>() / n;
let mut sxx = 0.0;
let mut syy = 0.0;
let mut sxy = 0.0;
for i in 0..xs.len() {
let dx = xs[i] - mx;
let dy = ys[i] - my;
sxx += dx * dx;
syy += dy * dy;
sxy += dx * dy;
}
if sxx <= f64::EPSILON || syy <= f64::EPSILON {
return (0.0, 0.0);
}
let slope = sxy / sxx;
let r2 = (sxy * sxy) / (sxx * syy);
(slope, r2)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conlang::types::Phonology;
fn phon() -> Phonology {
let body = r#"{
phonemes: [
{ ipa: "p", kind: "consonant" }, { ipa: "t", kind: "consonant" },
{ ipa: "k", kind: "consonant" }, { ipa: "n", kind: "consonant" },
{ ipa: "a", kind: "vowel" }, { ipa: "i", kind: "vowel" },
{ ipa: "u", kind: "vowel" }
]
}"#;
Phonology::from_hjson(body).unwrap().unwrap()
}
fn entry(word: &str) -> DictionaryEntry {
let raw = serde_json::json!({ "word": word, "pos": "noun", "translation": "x" });
serde_json::from_value(raw).expect("entry")
}
#[test]
fn empty_lexicon_is_all_zero() {
let m = metrics(&phon(), &[]);
assert_eq!(m, LanguageMetrics::default());
assert_eq!(m.analyzable_words, 0);
}
#[test]
fn entropy_evenness_and_saturation_are_in_range() {
let words = ["pata", "kina", "tuku", "nana", "pika"];
let entries: Vec<_> = words.iter().map(|w| entry(w)).collect();
let m = metrics(&phon(), &entries);
assert_eq!(m.analyzable_words, 5);
assert!(m.phoneme_entropy > 0.0);
assert!(m.phoneme_evenness > 0.0 && m.phoneme_evenness <= 1.0);
assert!(m.phoneme_perplexity > 1.0);
assert!(m.syllable_saturation > 0.0 && m.syllable_saturation <= 1.0);
assert!(m.mean_moras >= 1.0);
assert!(m.heavy_ratio >= 0.0 && m.heavy_ratio <= 1.0);
}
#[test]
fn a_single_repeated_phoneme_has_zero_evenness() {
let one = Phonology::from_hjson(
r#"{ phonemes: [ { ipa: "a", kind: "vowel" } ] }"#,
)
.unwrap()
.unwrap();
let m = metrics(&one, &[entry("aaa")]);
assert_eq!(m.phoneme_entropy_max, 0.0);
assert_eq!(m.phoneme_evenness, 0.0);
}
#[test]
fn closed_syllables_are_heavier() {
let open = metrics(&phon(), &[entry("pa"), entry("ta")]);
let closed = metrics(&phon(), &[entry("pat"), entry("tan")]);
assert_eq!(open.heavy_ratio, 0.0);
assert_eq!(closed.heavy_ratio, 1.0);
assert!(
closed.mean_moras > open.mean_moras,
"closed {} vs open {}",
closed.mean_moras,
open.mean_moras
);
}
}