use std::path::Path;
use crate::conlang::distribution::DistributionReport;
use crate::conlang::harmony::HarmonyReport;
use crate::conlang::metrics::LanguageMetrics;
use crate::conlang::naturalness::NaturalnessReport;
use crate::conlang::pairs::PairsReport;
use crate::error::{Error, Result};
use super::*;
pub(crate) fn metrics(project: &Path, language: &str, json: bool) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let entries = load_dictionary(&store, &hierarchy, &lang_book)?;
let m = crate::conlang::metrics::metrics(&phon, &entries);
if json {
println!(
"{}",
serde_json::to_string_pretty(&m)
.map_err(|e| Error::Store(format!("serializing metrics: {e}")))?
);
return Ok(());
}
print_report(language, &m);
Ok(())
}
fn print_report(language: &str, m: &LanguageMetrics) {
println!("language metrics · {language}");
if m.analyzable_words == 0 {
println!(" (no analyzable words — add dictionary entries that parse as the language's phonemes)");
return;
}
println!(
" corpus · {} analyzable word(s), {} segments",
m.analyzable_words, m.total_segments
);
println!(
" entropy · {:.2} bits (max {:.2}) · evenness {:.0}% · perplexity {:.1}",
m.phoneme_entropy,
m.phoneme_entropy_max,
m.phoneme_evenness * 100.0,
m.phoneme_perplexity,
);
println!(
" zipf · slope {:.2} (≈−1 is Zipfian) · fit R² {:.2}",
m.zipf_slope, m.zipf_r2
);
println!(
" syllables · {} attested / {} possible · saturation {:.0}%",
m.attested_syllables,
m.possible_syllables,
m.syllable_saturation * 100.0,
);
println!(
" prosody · {:.2} moras/word · {:.0}% heavy syllables",
m.mean_moras,
m.heavy_ratio * 100.0,
);
}
pub(crate) fn pairs(project: &Path, language: &str, limit: usize, json: bool) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let entries = load_dictionary(&store, &hierarchy, &lang_book)?;
let report = crate::conlang::pairs::minimal_pairs(&phon, &entries, limit.max(1));
if json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("serializing pairs: {e}")))?
);
return Ok(());
}
print_pairs(language, &report);
Ok(())
}
fn print_pairs(language: &str, r: &PairsReport) {
println!("minimal pairs · {language}");
if r.analyzable_words == 0 {
println!(" (no analyzable words — define a phoneme inventory and add dictionary entries)");
return;
}
println!(
" {} minimal pair(s) across {} analyzable words",
r.pair_count, r.analyzable_words
);
if !r.contrast_load.is_empty() {
println!(" functional load (single-feature contrasts):");
let max = r.contrast_load.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1);
for (feat, count) in &r.contrast_load {
let bar = "█".repeat(((*count * 20) / max).max(1));
println!(" {feat:<12} {bar} {count}");
}
}
if r.complex_contrasts > 0 {
println!(" {} pair(s) contrast in more than one feature", r.complex_contrasts);
}
if !r.pairs.is_empty() {
println!(" examples:");
for p in &r.pairs {
let contrast = if p.features.is_empty() {
"(outside the feature matrix)".to_string()
} else {
format!("[{}]", p.features.join(", "))
};
println!(" {} / {} {}~{} {}", p.a, p.b, p.seg_a, p.seg_b, contrast);
}
}
}
pub(crate) fn naturalness(project: &Path, language: &str, json: bool) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let report = crate::conlang::naturalness::naturalness(&phon);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("serializing naturalness: {e}")))?
);
return Ok(());
}
print_naturalness(language, &report);
Ok(())
}
fn print_naturalness(language: &str, r: &NaturalnessReport) {
use crate::conlang::naturalness::SizeClass;
println!("inventory naturalness · {language}");
if r.phoneme_count == 0 {
println!(" (no phoneme inventory — define one in the Phonology chapter)");
return;
}
let size = match r.size_class {
SizeClass::Small => "small",
SizeClass::Typical => "typical",
SizeClass::Large => "large",
};
println!(
" inventory · {} phonemes ({} C / {} V) — {size}",
r.phoneme_count, r.consonants, r.vowels
);
if !r.voicing_pairs.is_empty() {
let pairs =
r.voicing_pairs.iter().map(|(a, b)| format!("{a}/{b}")).collect::<Vec<_>>().join(" ");
println!(" voicing · {pairs}");
}
if !r.voicing_gaps.is_empty() {
println!(" voicing gap· {} (no counterpart)", r.voicing_gaps.join(" "));
}
println!(" places · {}", r.places_present.join(" "));
if r.missing_common.is_empty() {
println!(" common · all near-universal segments present");
} else {
println!(" missing · {} (near-universal)", r.missing_common.join(" "));
}
if !r.unknown_segments.is_empty() {
println!(" outside · {} (not in the feature matrix)", r.unknown_segments.join(" "));
}
println!(" score · {:.2} (0–1; higher = more typologically ordinary)", r.score);
}
pub(crate) fn harmony(project: &Path, language: &str, json: bool) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let entries = load_dictionary(&store, &hierarchy, &lang_book)?;
let report = crate::conlang::harmony::analyze(&phon, &entries);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("serializing harmony: {e}")))?
);
return Ok(());
}
print_harmony(language, &report);
Ok(())
}
fn print_harmony(language: &str, r: &HarmonyReport) {
println!("vowel harmony · {language}");
if r.analyzable_words == 0 {
println!(" (no analyzable words — define a phoneme inventory and add dictionary entries)");
return;
}
println!(" {} analyzable word(s)", r.analyzable_words);
for d in &r.dimensions {
println!(
" {:<9} · {:.0}% harmonic over {} word(s) — {}",
d.name,
d.rate * 100.0,
d.tested_words,
d.verdict,
);
}
let harmonic = r.dimensions.iter().any(|d| d.verdict == "strong" || d.verdict == "tendency");
if !harmonic {
println!(" no vowel-harmony pattern detected.");
}
}
pub(crate) fn suggest_phonemes(project: &Path, language: &str, json: bool) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let report = crate::conlang::suggest::suggest(&phon);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("serializing suggestions: {e}")))?
);
return Ok(());
}
println!("phoneme suggestions · {language}");
if report.phoneme_count == 0 {
println!(" (no phoneme inventory — define one in the Phonology chapter)");
} else if report.suggestions.is_empty() {
println!(" the inventory is well-rounded — nothing obvious to add.");
} else {
for s in &report.suggestions {
println!(" + /{}/ — {}", s.ipa, s.reason);
}
println!(" (advisory — add any you like in the Phonology chapter; nothing changed.)");
}
Ok(())
}
pub(crate) fn sketch(project: &Path, language: &str, out: Option<&str>) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let entries = load_dictionary(&store, &hierarchy, &lang_book)?;
let (spec, _) = load_grammar_spec(&store, &hierarchy, &lang_book)?;
let text = crate::conlang::sketch::sketch(&lang_book.title, &phon, &entries, &spec);
match out {
Some(path) => {
crate::io_atomic::write(std::path::Path::new(path), text.as_bytes())
.map_err(|e| Error::Config(format!("write {path}: {e}")))?;
eprintln!("wrote sketch to {path}");
}
None => print!("{text}"),
}
Ok(())
}
pub(crate) fn distribution(project: &Path, language: &str, json: bool) -> Result<()> {
let (store, hierarchy, lang_book) = open_lang_book(project, language)?;
let phon = load_phonology(&store, &hierarchy, &lang_book)?.unwrap_or_default();
let entries = load_dictionary(&store, &hierarchy, &lang_book)?;
let report = crate::conlang::distribution::distribution(&phon, &entries);
if json {
println!(
"{}",
serde_json::to_string_pretty(&report)
.map_err(|e| Error::Store(format!("serializing distribution: {e}")))?
);
return Ok(());
}
print_distribution(language, &report);
Ok(())
}
fn print_distribution(language: &str, r: &DistributionReport) {
println!("phoneme distribution · {language}");
if r.analyzable_words == 0 {
println!(" (no analyzable words — define a phoneme inventory and add dictionary entries)");
return;
}
println!(" {} analyzable word(s)", r.analyzable_words);
println!(" onsets · {}", r.onset_inventory.join(" "));
println!(" codas · {}", r.coda_inventory.join(" "));
if r.restricted.is_empty() {
println!(" no restricted distributions — every consonant appears freely.");
} else {
println!(" restricted distributions:");
for d in &r.restricted {
println!(
" {:<4} {} (onset {}, coda {}, initial {}, final {})",
d.ipa,
d.restriction.as_deref().unwrap_or(""),
d.as_onset,
d.as_coda,
d.word_initial,
d.word_final,
);
}
}
}