madi 0.2.1

madi(마디): pure Rust Korean morphological analyzer — library entry point (Engine, sentence splitting)
Documentation
//! Engine 시험. 사전이 있어야 도는 시험은 `dict/madi.dict`(저장소 기준)가 없으면 건너뛴다.
//! KnLM 은 `dict/knlm.bin` 이 있으면 함께 쓴다.

use madi::{Engine, EngineConfig, Skip};
use std::path::PathBuf;

const DOCS: [&str; 4] = [
    "정부는 내년 경제 성장률이 2.5%에 이를 것으로 내다봤다. 그러나 수출 여건은 여전히 불투명하다.\n김철수 기자 kim@example.com",
    "서울 시내 버스 요금이 다음 달부터 오른다. 시민들은 \"부담이 커졌다\"고 말했다.",
    "그는 어제 도서관에 가서 책을 빌렸다. 날씨가 더워서 일찍 돌아왔다.\n▶ 관련 기사 보기",
    "회의는 오후 3시에 열린다. 참석자는 모두 12명이다. 끝.",
];

fn root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn config() -> Option<EngineConfig> {
    let r = root();
    let dict = r.join("dict/madi.dict");
    if !dict.exists() {
        eprintln!("skip: {} 없음", dict.display());
        return None;
    }
    let knlm = r.join("dict/knlm.bin");
    Some(EngineConfig { knlm: knlm.exists().then_some(knlm), ..EngineConfig::new(dict) })
}

fn flat(e: &Engine, doc: &str) -> Vec<String> {
    e.worker().analyze_doc(doc).iter().map(|s| format!("{}\t{}", s.text, madi::format_tokens(&s.tokens))).collect()
}

#[test]
fn builtin_rules_parse() {
    let r = madi::text::SkipRules::builtin();
    assert!(r.len() > 0);
}

#[test]
fn deterministic_across_threads_and_rule_source() {
    let Some(cfg) = config() else { return };
    let e = Engine::load(&cfg).unwrap();
    assert!(e.version().starts_with(env!("CARGO_PKG_VERSION")));
    let one: Vec<Vec<String>> = DOCS.iter().map(|d| flat(&e, d)).collect();
    assert!(one.iter().all(|v| !v.is_empty()));
    // 4 스레드, 문서 순서를 뒤집어 돌려도 같은 결과
    let many: Vec<Vec<String>> = std::thread::scope(|s| {
        let hs: Vec<_> = DOCS.iter().rev().map(|d| s.spawn(|| flat(&e, d))).collect();
        let mut v: Vec<Vec<String>> = hs.into_iter().map(|h| h.join().unwrap()).collect();
        v.reverse();
        v
    });
    assert_eq!(one, many);
    // 같은 규칙 파일을 File 로 주면 같은 결과·같은 버전
    let cfg2 = EngineConfig { skip: Skip::File(root().join("tools/boilerplate.txt")), ..cfg.clone() };
    let e2 = Engine::load(&cfg2).unwrap();
    assert_eq!(e.version(), e2.version());
    for (d, o) in DOCS.iter().zip(&one) {
        assert_eq!(&flat(&e2, d), o);
    }
    // 같은 규칙을 CRLF 로 바꾼 파일이어도 같은 버전·같은 결과 (Windows git checkout)
    let crlf = std::env::temp_dir().join("madi_boilerplate_crlf.txt");
    std::fs::write(&crlf, madi::text::BUILTIN_SKIP.replace("\r\n", "\n").replace('\n', "\r\n")).unwrap();
    let e4 = Engine::load(&EngineConfig { skip: Skip::File(crlf), ..cfg.clone() }).unwrap();
    assert_eq!(e.version(), e4.version());
    for (d, o) in DOCS.iter().zip(&one) {
        assert_eq!(&flat(&e4, d), o);
    }
    // 규칙을 끄면 버전이 달라진다
    let e3 = Engine::load(&EngineConfig { skip: Skip::Off, ..cfg }).unwrap();
    assert_ne!(e.version(), e3.version());
}

#[test]
fn query_and_eojeol_start() {
    let Some(cfg) = config() else { return };
    let e = Engine::load(&cfg).unwrap();
    let mut w = e.worker();
    let q = w.analyze_query("  경제 성장률이 ");
    assert!(!q.is_empty());
    assert!(!q[0].space_before);
    assert!(q.iter().skip(1).any(|t| t.space_before));
    let nb = w.analyze_query_nbest("경제 성장률이", 3);
    assert!(!nb.is_empty() && nb.len() <= 3);
    assert_eq!(nb[0].1.iter().map(|t| (&t.form, t.tag)).collect::<Vec<_>>(), q.iter().map(|t| (&t.form, t.tag)).collect::<Vec<_>>());
}

/// 메모리 로드(WASM 경로, 10.14): 같은 파일이면 버전 문자열·분석 결과가 경로 로드와 같아야 한다
#[test]
fn from_memory_matches_load() {
    let Some(cfg) = config() else { return };
    let words = |p: &std::path::Path| -> (Vec<u64>, usize) {
        let b = std::fs::read(p).unwrap();
        let mut w = vec![0u64; b.len().div_ceil(8)];
        for (i, c) in b.chunks(8).enumerate() {
            let mut x = [0u8; 8];
            x[..c.len()].copy_from_slice(c);
            w[i] = u64::from_le_bytes(x);
        }
        (w, b.len())
    };
    let (dw, dn) = words(&cfg.dict);
    let km = cfg.knlm.as_ref().map(|p| words(p));
    let e1 = Engine::load(&cfg).unwrap();
    let e2 = Engine::from_memory(dw, dn, km, &cfg).unwrap();
    assert_eq!(e1.version(), e2.version());
    for d in DOCS {
        assert_eq!(flat(&e1, d), flat(&e2, d));
    }
}