inkhaven 1.7.4

Inkhaven — TUI literary work editor for Typst books
//! LING-1 L-P2 — `inkhaven language metrics <lang>`: deterministic quantitative
//! metrics over a language's lexicon + phonology (entropy, Zipf fit, phonotactic
//! saturation, mora weight). The information-theoretic complement to
//! `language stats` (descriptive counts). Read-only; `--json` for machine use.

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,
    );
}

/// LING-1 Wave-2 — `inkhaven language pairs <lang>`: minimal pairs in the lexicon
/// and the distinctive feature each turns on (the functional load of the
/// language's contrasts).
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);
        }
    }
}

/// LING-1 Wave-2 — `inkhaven language naturalness <lang>`: judge the phoneme
/// inventory against cross-linguistic tendencies (voicing symmetry, place
/// coverage, near-universal segments, size) via the feature matrix.
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);
}

/// LING-1 Wave-2 — `inkhaven language harmony <lang>`: detect vowel harmony
/// (backness, rounding) by measuring how consistently a word's vowels agree.
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.");
    }
}

/// LING-1 Wave-2 — `inkhaven language suggest-phonemes <lang>`: recommend
/// phonemes that would round out the inventory (voiced counterparts, missing
/// near-universals), via the feature matrix. Advisory; changes nothing.
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(())
}

/// LING-1 L-P4 — `inkhaven language sketch <lang>`: a one-page, deterministic
/// overview of the language, assembling the whole analysis suite into prose.
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(())
}

/// LING-1 Wave-2 — `inkhaven language distribution <lang>`: where each phoneme
/// appears (onset / nucleus / coda, word edges) and any restricted distributions.
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,
            );
        }
    }
}