use std::collections::BTreeMap;
use serde::Serialize;
use crate::conlang::phonology::validator;
use crate::conlang::types::Phonology;
use crate::conlang::types::morphology::Morphology;
use crate::language_entry::DictionaryEntry;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Finding {
pub level: &'static str,
pub message: String,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq)]
pub struct OracleReport {
pub word: String,
pub findings: Vec<Finding>,
}
impl OracleReport {
pub fn ok(&self) -> bool {
self.findings.is_empty()
}
}
pub fn check_word(
phon: &Phonology,
morph: &Morphology,
entries: &[DictionaryEntry],
word: &str,
) -> OracleReport {
let mut r = OracleReport { word: word.to_string(), ..Default::default() };
let seq = phon.segment(&word.to_lowercase());
if seq.is_empty() {
r.findings.push(Finding { level: "phonotactics", message: "empty or unsegmentable".into() });
return r;
}
let unknown: Vec<&str> =
seq.iter().filter(|s| phon.phoneme(s).is_none()).map(|s| s.as_str()).collect();
if !unknown.is_empty() {
r.findings.push(Finding {
level: "phonotactics",
message: format!("segment(s) not in the inventory: {}", unknown.join(" ")),
});
return r;
}
for v in validator::violations(phon, &seq) {
r.findings.push(Finding { level: "phonotactics", message: format!("contains {v}") });
}
let is_listed = entries.iter().any(|e| e.word.eq_ignore_ascii_case(word));
if !is_listed {
let parse = crate::conlang::parse::parse(phon, morph, entries, word);
if parse.parses.is_empty() {
r.findings.push(Finding {
level: "morphology",
message: "no analysis: not a listed root, and no affix stripping reaches one".into(),
});
}
}
r
}
#[derive(Debug, Clone)]
pub struct ClauseInput<'a> {
pub verb: &'a str,
pub verb_root: Option<&'a str>,
pub valence: &'a str,
pub args: &'a [String],
pub subject_features: &'a BTreeMap<String, String>,
}
pub fn check_clause(phon: &Phonology, morph: &Morphology, clause: &ClauseInput) -> OracleReport {
let mut r = OracleReport { word: clause.verb.to_string(), ..Default::default() };
let link = crate::conlang::link::link(clause.verb, clause.valence, clause.args);
for issue in link.issues.into_iter().filter(|i| !i.contains("inferring")) {
r.findings.push(Finding { level: "syntax", message: issue });
}
if let (Some(rule), Some(root)) = (morph.agreement_for("verb"), clause.verb_root) {
if !clause.subject_features.is_empty() {
match crate::conlang::morphology::agreement::agree(
phon,
morph,
rule,
root,
"V",
clause.subject_features,
) {
Some(expected) if !expected.form.eq_ignore_ascii_case(clause.verb) => {
r.findings.push(Finding {
level: "agreement",
message: format!(
"verb `{}` does not agree with the subject — expected `{}` ({})",
clause.verb, expected.form, expected.gloss
),
});
}
None => {
r.findings.push(Finding {
level: "agreement",
message: "cannot check agreement — the verb paradigm has no cell for the subject's features".into(),
});
}
_ => {}
}
}
}
r
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProseFinding {
pub word: String,
pub findings: Vec<Finding>,
}
pub fn scan_prose(
phon: &Phonology,
morph: &Morphology,
entries: &[DictionaryEntry],
words: &[String],
) -> Vec<ProseFinding> {
use std::collections::HashSet;
let known: HashSet<String> =
entries.iter().flat_map(|e| e.surface_forms().into_iter().map(|s| s.to_lowercase())).collect();
if !words.iter().any(|w| known.contains(&w.to_lowercase())) {
return Vec::new();
}
let mut out = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for w in words {
let lc = w.to_lowercase();
if known.contains(&lc) || !seen.insert(lc.clone()) {
continue;
}
let seq = phon.segment(&lc);
if seq.len() < 2 || !seq.iter().all(|s| phon.phoneme(s).is_some()) {
continue;
}
let findings: Vec<Finding> = check_word(phon, morph, entries, &lc)
.findings
.into_iter()
.filter(|f| f.level == "phonotactics")
.collect();
if !findings.is_empty() {
out.push(ProseFinding { word: w.clone(), findings });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn phon() -> Phonology {
let body = r#"{
phonemes: [
{ ipa: "k", kind: "consonant" }, { ipa: "t", kind: "consonant" },
{ ipa: "n", kind: "consonant" }, { ipa: "a", kind: "vowel" }, { ipa: "i", kind: "vowel" }
],
constraints: [ { kind: "no_geminate" } ]
}"#;
Phonology::from_hjson(body).unwrap().unwrap()
}
fn morph() -> Morphology {
Morphology::from_hjson(r#"{ morphemes: [ { id: "pl", gloss: "PL", form: "i", position: "suffix" } ] }"#)
.unwrap()
.unwrap()
}
fn entry(w: &str) -> DictionaryEntry {
DictionaryEntry { word: w.into(), pos: "noun".into(), translation: "x".into(), ..Default::default() }
}
#[test]
fn a_well_formed_listed_word_passes() {
let r = check_word(&phon(), &morph(), &[entry("kata")], "kata");
assert!(r.ok(), "findings: {:?}", r.findings);
}
#[test]
fn a_foreign_segment_is_flagged() {
let r = check_word(&phon(), &morph(), &[entry("kata")], "kabu");
assert!(r.findings.iter().any(|f| f.level == "phonotactics" && f.message.contains("not in the inventory")));
}
#[test]
fn a_constraint_violation_is_flagged() {
let r = check_word(&phon(), &morph(), &[entry("kata")], "katta");
assert!(r.findings.iter().any(|f| f.level == "phonotactics" && f.message.contains("geminate")));
}
#[test]
fn an_unanalyzable_but_legal_word_is_flagged_at_morphology() {
let r = check_word(&phon(), &morph(), &[entry("kata")], "nika");
assert!(r.findings.iter().any(|f| f.level == "morphology"));
}
#[test]
fn a_suffixed_form_of_a_root_passes() {
let r = check_word(&phon(), &morph(), &[entry("kata")], "katai");
assert!(!r.findings.iter().any(|f| f.level == "morphology"), "findings: {:?}", r.findings);
}
fn agreeing_morph() -> Morphology {
let body = r#"{
morphemes: [ { id: "pl", gloss: "PL", form: "i", position: "suffix" } ]
paradigms: [ { name: "vconj", cells: [
{ features: { number: "sg" }, morphemes: [] }
{ features: { number: "pl" }, morphemes: ["pl"] }
] } ]
agreement: [ { dependent: "verb", head: "subject", features: ["number"], paradigm: "vconj" } ]
}"#;
Morphology::from_hjson(body).unwrap().unwrap()
}
fn feats(pairs: &[(&str, &str)]) -> std::collections::BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn a_well_formed_transitive_clause_passes() {
let args = vec!["she".to_string(), "bird".to_string()];
let none = feats(&[]);
let clause = ClauseInput { verb: "kata", verb_root: None, valence: "transitive", args: &args, subject_features: &none };
let r = check_clause(&phon(), &agreeing_morph(), &clause);
assert!(r.ok(), "findings: {:?}", r.findings);
}
#[test]
fn an_arity_mismatch_is_flagged_at_syntax() {
let args = vec!["she".to_string(), "bed".to_string()];
let none = feats(&[]);
let clause = ClauseInput { verb: "kata", verb_root: None, valence: "intransitive", args: &args, subject_features: &none };
let r = check_clause(&phon(), &agreeing_morph(), &clause);
assert!(r.findings.iter().any(|f| f.level == "syntax"), "findings: {:?}", r.findings);
}
#[test]
fn a_verb_agreeing_with_its_subject_passes() {
let args = vec!["she".to_string(), "bird".to_string()];
let pl = feats(&[("number", "pl")]);
let clause = ClauseInput {
verb: "katai",
verb_root: Some("kata"),
valence: "transitive",
args: &args,
subject_features: &pl,
};
let r = check_clause(&phon(), &agreeing_morph(), &clause);
assert!(!r.findings.iter().any(|f| f.level == "agreement"), "findings: {:?}", r.findings);
}
#[test]
fn a_verb_not_agreeing_with_its_subject_is_flagged() {
let args = vec!["she".to_string(), "bird".to_string()];
let pl = feats(&[("number", "pl")]);
let clause = ClauseInput {
verb: "kata",
verb_root: Some("kata"),
valence: "transitive",
args: &args,
subject_features: &pl,
};
let r = check_clause(&phon(), &agreeing_morph(), &clause);
assert!(
r.findings.iter().any(|f| f.level == "agreement" && f.message.contains("expected `katai`")),
"findings: {:?}",
r.findings
);
}
fn words(ws: &[&str]) -> Vec<String> {
ws.iter().map(|s| s.to_string()).collect()
}
#[test]
fn scan_flags_an_illformed_conlang_word_in_context() {
let out = scan_prose(&phon(), &morph(), &[entry("kata")], &words(&["kata", "katta"]));
assert!(
out.iter().any(|p| p.word == "katta" && p.findings.iter().any(|f| f.level == "phonotactics")),
"got: {out:?}"
);
}
#[test]
fn scan_skips_prose_with_no_conlang_word() {
let out = scan_prose(&phon(), &morph(), &[entry("kata")], &words(&["hello", "world"]));
assert!(out.is_empty());
}
#[test]
fn scan_does_not_flag_a_wellformed_undefined_word() {
let out = scan_prose(&phon(), &morph(), &[entry("kata")], &words(&["kata", "nika"]));
assert!(out.is_empty(), "got: {out:?}");
}
#[test]
fn scan_ignores_natural_language_words() {
let out = scan_prose(&phon(), &morph(), &[entry("kata")], &words(&["kata", "sun"]));
assert!(out.is_empty());
}
}