use crate::conlang::types::contact::LoanPhonology;
use crate::conlang::types::template::TemplateAtom;
use crate::conlang::types::PhonemeKind;
use crate::conlang::Phonology;
#[derive(Debug, Clone)]
pub struct Adaptation {
pub donor: String,
pub adapted: String,
pub ipa: Vec<String>,
pub steps: Vec<String>,
}
pub fn adapt(phon: &Phonology, loan: &LoanPhonology, donor_form: &str) -> Adaptation {
let mut steps = Vec::new();
let perceived = perceive(phon, loan, donor_form, &mut steps);
let ev = epenthetic_vowel(phon, loan);
let repaired = repair(phon, &perceived, &loan.repair, &ev, &mut steps);
let adapted = repaired.iter().map(|ipa| grapheme(phon, ipa)).collect::<String>();
Adaptation {
donor: donor_form.to_string(),
adapted,
ipa: repaired,
steps,
}
}
fn perceive(phon: &Phonology, loan: &LoanPhonology, donor: &str, steps: &mut Vec<String>) -> Vec<String> {
let mut keys: Vec<String> = loan.substitutions.keys().cloned().collect();
for p in &phon.phonemes {
keys.push(p.grapheme().to_string());
keys.push(p.ipa.clone());
}
keys.sort_by(|a, b| b.chars().count().cmp(&a.chars().count()));
keys.dedup();
let chars: Vec<char> = donor.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let rest: String = chars[i..].iter().collect();
let key = keys.iter().find(|k| !k.is_empty() && rest.starts_with(k.as_str()));
let (seg, adv) = match key {
Some(k) => (k.clone(), k.chars().count()),
None => (chars[i].to_string(), 1),
};
i += adv;
if let Some(sub) = loan.substitutions.get(&seg) {
steps.push(format!("{seg} → {sub} (substitution)"));
out.push(sub.clone());
} else if let Some(p) = phon.phonemes.iter().find(|p| p.ipa == seg || p.grapheme() == seg) {
out.push(p.ipa.clone());
} else {
let native = nearest_phoneme(phon, &seg);
match native {
Some(n) => {
steps.push(format!("{seg} → {n} (nearest native)"));
out.push(n);
}
None => steps.push(format!("{seg} dropped (no native equivalent)")),
}
}
}
out
}
fn nearest_phoneme(phon: &Phonology, sound: &str) -> Option<String> {
if phon.phonemes.is_empty() {
return None;
}
let target = crate::conlang::phonology::ipa::sonority_of(phon, sound);
phon.phonemes
.iter()
.min_by_key(|p| {
let s = crate::conlang::phonology::ipa::sonority_of(phon, &p.ipa);
(s as i32 - target as i32).unsigned_abs()
})
.map(|p| p.ipa.clone())
}
fn epenthetic_vowel(phon: &Phonology, loan: &LoanPhonology) -> String {
if !loan.epenthetic_vowel.trim().is_empty() {
return loan.epenthetic_vowel.clone();
}
phon.phonemes
.iter()
.find(|p| p.kind == PhonemeKind::Vowel)
.map(|p| p.ipa.clone())
.unwrap_or_else(|| "a".to_string())
}
fn grapheme(phon: &Phonology, ipa: &str) -> String {
phon.phoneme(ipa).map(|p| p.grapheme().to_string()).unwrap_or_else(|| ipa.to_string())
}
fn is_consonant(phon: &Phonology, ipa: &str) -> bool {
phon.kind_of(ipa) != Some(PhonemeKind::Vowel)
}
fn syllable_shape(phon: &Phonology) -> (usize, usize, usize) {
let is_vowel_class = |cls: &str| -> bool {
phon.class_members(cls)
.iter()
.next()
.and_then(|m| phon.kind_of(m))
.map(|k| k == PhonemeKind::Vowel)
.unwrap_or(false)
};
let (mut mi, mut mf, mut mm, mut any) = (0usize, 0usize, 0usize, false);
for templates in phon.templates.values() {
for t in templates {
any = true;
let vocalic: Vec<bool> = t
.pattern
.iter()
.map(|a| {
let cls = match a {
TemplateAtom::Class(n) | TemplateAtom::OptionalClass(n) => n.as_str(),
};
is_vowel_class(cls)
})
.collect();
let lead = vocalic.iter().take_while(|v| !**v).count();
let tail = vocalic.iter().rev().take_while(|v| !**v).count();
let mut interior = 0usize;
let mut run = 0usize;
let mut seen_vowel = false;
for (k, &v) in vocalic.iter().enumerate() {
if v {
if seen_vowel && k < vocalic.len() {
interior = interior.max(run);
}
seen_vowel = true;
run = 0;
} else if seen_vowel {
run += 1;
}
}
mi = mi.max(lead);
mf = mf.max(tail);
mm = mm.max(interior);
}
}
if !any {
return (1, 0, 1);
}
(mi.max(1), mf, mm.max(1))
}
fn repair(
phon: &Phonology,
perceived: &[String],
strategy: &str,
ev: &str,
steps: &mut Vec<String>,
) -> Vec<String> {
let (max_initial, max_final, max_medial) = syllable_shape(phon);
let onset_chunk = max_initial.max(1);
let deletion = strategy.eq_ignore_ascii_case("deletion");
let mut out: Vec<String> = Vec::new();
let n = perceived.len();
let mut i = 0;
let mut seen_vowel = false;
while i < n {
if !is_consonant(phon, &perceived[i]) {
out.push(perceived[i].clone());
seen_vowel = true;
i += 1;
continue;
}
let start = i;
while i < n && is_consonant(phon, &perceived[i]) {
i += 1;
}
let run: Vec<String> = perceived[start..i].to_vec();
let followed_by_vowel = i < n;
let allowed = if !seen_vowel {
max_initial } else if followed_by_vowel {
max_final + max_medial.max(max_initial) } else {
max_final };
if run.len() <= allowed {
out.extend(run);
continue;
}
if deletion {
for (k, c) in run.iter().enumerate() {
if k < allowed.max(if followed_by_vowel { onset_chunk } else { 0 }) {
out.push(c.clone());
} else {
steps.push(format!("deleted {c}"));
}
}
} else {
let chunks: Vec<&[String]> = run.chunks(onset_chunk).collect();
let last = chunks.len() - 1;
for (ci, chunk) in chunks.iter().enumerate() {
out.extend(chunk.iter().cloned());
let is_last = ci == last;
if !(is_last && followed_by_vowel) {
out.push(ev.to_string());
steps.push(format!("epenthesis: inserted {ev}"));
}
}
}
}
out
}
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArealStatus {
Converged,
Shift,
Adopt,
}
impl ArealStatus {
pub fn as_str(self) -> &'static str {
match self {
ArealStatus::Converged => "converged",
ArealStatus::Shift => "shift",
ArealStatus::Adopt => "adopt",
}
}
}
#[derive(Debug, Clone)]
pub struct Convergence {
pub feature: String,
pub areal_value: String,
pub current: Option<String>,
pub status: ArealStatus,
}
pub fn converge(
typology: &BTreeMap<String, String>,
areal_features: &BTreeMap<String, String>,
) -> Vec<Convergence> {
areal_features
.iter()
.map(|(f, av)| {
let current = typology.get(f).cloned();
let status = match ¤t {
Some(c) if c.eq_ignore_ascii_case(av) => ArealStatus::Converged,
Some(_) => ArealStatus::Shift,
None => ArealStatus::Adopt,
};
Convergence {
feature: f.clone(),
areal_value: av.clone(),
current,
status,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conlang::types::contact::LoanPhonology;
fn recipient() -> Phonology {
let body = r#"{ phonemes: [
{ ipa: "k", kind: "consonant" }, { ipa: "t", kind: "consonant" },
{ ipa: "n", kind: "consonant" }, { ipa: "m", kind: "consonant" },
{ ipa: "s", kind: "consonant" }, { ipa: "l", kind: "consonant" },
{ ipa: "a", kind: "vowel" }, { ipa: "u", kind: "vowel" }
],
classes: { C: ["k","t","n","m","s","l"], V: ["a","u"] },
templates: { root: [ { pattern: "C V" } ] } }"#;
Phonology::from_hjson(body).unwrap().unwrap()
}
fn loan() -> LoanPhonology {
let body = r#"{ loan_phonology: { repair: "epenthesis", epenthetic_vowel: "u",
substitutions: { "θ": "t", "r": "l" } } }"#;
LoanPhonology::from_hjson(body).unwrap().unwrap()
}
#[test]
fn substitution_then_epenthesis_breaks_clusters() {
let a = adapt(&recipient(), &loan(), "tras");
assert_eq!(a.ipa, vec!["t", "u", "l", "a", "s", "u"]);
assert_eq!(a.adapted, "tulasu");
assert!(a.steps.iter().any(|s| s.contains("r → l")));
assert!(a.steps.iter().any(|s| s.contains("epenthesis")));
}
#[test]
fn unknown_sound_maps_to_nearest_and_theta_substitutes() {
let a = adapt(&recipient(), &loan(), "θu");
assert_eq!(a.adapted, "tu");
assert!(a.steps.iter().any(|s| s.contains("θ → t")));
}
#[test]
fn deletion_strategy_drops_excess() {
let mut lp = loan();
lp.repair = "deletion".into();
let a = adapt(&recipient(), &lp, "tras");
assert!(a.steps.iter().any(|s| s.starts_with("deleted")));
assert!(!a.adapted.is_empty());
}
#[test]
fn already_legal_word_is_untouched() {
let a = adapt(&recipient(), &loan(), "kata");
assert_eq!(a.adapted, "kata");
assert!(a.steps.is_empty());
}
#[test]
fn convergence_classifies_each_areal_feature() {
let mut typ = BTreeMap::new();
typ.insert("word_order".to_string(), "sov".to_string()); typ.insert("alignment".to_string(), "nominative_accusative".to_string()); let mut areal = BTreeMap::new();
areal.insert("word_order".to_string(), "sov".to_string());
areal.insert("alignment".to_string(), "ergative_absolutive".to_string());
areal.insert("case".to_string(), "yes".to_string());
let cs = converge(&typ, &areal);
let by = |f: &str| cs.iter().find(|c| c.feature == f).unwrap().status;
assert_eq!(by("word_order"), ArealStatus::Converged);
assert_eq!(by("alignment"), ArealStatus::Shift);
assert_eq!(by("case"), ArealStatus::Adopt);
}
}