use std::collections::HashMap;
use serde::Serialize;
use crate::conlang::features;
use crate::conlang::types::Phonology;
use crate::language_entry::DictionaryEntry;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct MinimalPair {
pub a: String,
pub b: String,
pub position: usize,
pub seg_a: String,
pub seg_b: String,
pub features: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct PairsReport {
pub analyzable_words: usize,
pub pair_count: usize,
pub pairs: Vec<MinimalPair>,
pub contrast_load: Vec<(String, usize)>,
pub complex_contrasts: usize,
}
pub fn minimal_pairs(phon: &Phonology, entries: &[DictionaryEntry], sample: usize) -> PairsReport {
let mut r = PairsReport::default();
let mut words: Vec<(String, Vec<String>)> = Vec::new();
for e in entries {
let w = e.word.to_lowercase();
let seq = phon.segment(&w);
if seq.is_empty() || !seq.iter().all(|s| phon.phoneme(s).is_some()) {
continue;
}
words.push((w, seq));
}
r.analyzable_words = words.len();
let mut buckets: HashMap<String, Vec<usize>> = HashMap::new();
for (idx, (_, seq)) in words.iter().enumerate() {
for i in 0..seq.len() {
let mut key = String::new();
key.push_str(&i.to_string());
key.push('\u{1}');
for (j, s) in seq.iter().enumerate() {
if j == i {
key.push('\u{2}'); } else {
key.push_str(s);
}
key.push('\u{3}');
}
buckets.entry(key).or_default().push(idx);
}
}
let mut load: HashMap<&'static str, usize> = HashMap::new();
let mut pairs: Vec<MinimalPair> = Vec::new();
let mut seen: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
for (key, members) in &buckets {
if members.len() < 2 {
continue;
}
let position: usize = key.split('\u{1}').next().and_then(|n| n.parse().ok()).unwrap_or(0);
for a in 0..members.len() {
for b in (a + 1)..members.len() {
let (ia, ib) = (members[a], members[b]);
let seg_a = &words[ia].1[position];
let seg_b = &words[ib].1[position];
if seg_a == seg_b {
continue; }
let dedup = if ia < ib { (ia, ib) } else { (ib, ia) };
if !seen.insert((dedup.0, dedup.1 * 1000 + position)) {
continue;
}
r.pair_count += 1;
let feats = features::differing_features(seg_a, seg_b).unwrap_or_default();
if feats.len() == 1 {
*load.entry(feats[0]).or_default() += 1;
} else {
r.complex_contrasts += 1;
}
pairs.push(MinimalPair {
a: words[ia].0.clone(),
b: words[ib].0.clone(),
position,
seg_a: seg_a.clone(),
seg_b: seg_b.clone(),
features: feats.iter().map(|s| s.to_string()).collect(),
});
}
}
}
pairs.sort_by(|x, y| {
x.features.len().cmp(&y.features.len()).then_with(|| x.a.cmp(&y.a)).then_with(|| x.b.cmp(&y.b))
});
pairs.truncate(sample);
r.pairs = pairs;
let mut load: Vec<(String, usize)> = load.into_iter().map(|(k, v)| (k.to_string(), v)).collect();
load.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
r.contrast_load = load;
r
}
#[cfg(test)]
mod tests {
use super::*;
fn phon() -> Phonology {
let body = r#"{
phonemes: [
{ ipa: "p", kind: "consonant" }, { ipa: "b", kind: "consonant" },
{ ipa: "t", kind: "consonant" }, { ipa: "k", kind: "consonant" },
{ ipa: "a", kind: "vowel" }, { ipa: "i", 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 finds_a_voicing_minimal_pair_and_its_contrast() {
let r = minimal_pairs(&phon(), &[entry("pat"), entry("bat")], 10);
assert_eq!(r.analyzable_words, 2);
assert_eq!(r.pair_count, 1);
let p = &r.pairs[0];
assert_eq!((p.seg_a.as_str(), p.seg_b.as_str()), ("p", "b"));
assert_eq!(p.features, vec!["voice"]);
assert_eq!(r.contrast_load, vec![("voice".to_string(), 1)]);
}
#[test]
fn counts_the_functional_load_across_positions() {
let r = minimal_pairs(
&phon(),
&[entry("pat"), entry("bat"), entry("pit"), entry("kat")],
50,
);
assert!(r.pair_count >= 3);
let voice = r.contrast_load.iter().find(|(f, _)| f == "voice").map(|(_, c)| *c);
assert_eq!(voice, Some(1));
}
#[test]
fn no_pairs_in_a_lexicon_with_no_minimal_pairs() {
let r = minimal_pairs(&phon(), &[entry("pat"), entry("kiki")], 10);
assert_eq!(r.pair_count, 0);
assert!(r.pairs.is_empty());
}
}