madi/lib.rs
1//! # madi(마디) — 순수 Rust 한국어 형태소 분석기
2//!
3//! 이 크레이트는 madi 의 **라이브러리 진입점**입니다. 사전과 언어 모델을 열어 [`Engine`] 을 만들고,
4//! 스레드마다 [`EngineWorker`] 를 하나씩 받아 문장을 분석합니다. 명령줄 도구는 `madi-cli`,
5//! Python 패키지는 `madipy`(`import madi`), 둘 다 이 크레이트 위에 얹혀 있습니다.
6//!
7//! - 품사는 세종 태그 체계([`Tag`]: NNG·VV·JKS …)이고, 분석 관행은 국립국어원 모두의 말뭉치
8//! 형태 분석 지침을 따릅니다.
9//! - 형태소 분석을 이용한 **문장 분리**(종결 부호가 없어도 종결 어미 뒤에서 끊음)를 함께 제공합니다.
10//! - 소개 페이지 <https://p14jeffwest.github.io/madi-release/>, 모델·이용 조건
11//! <https://github.com/p14jeffwest/madi-release>.
12//!
13//! ## 모델 파일
14//!
15//! 사전·언어 모델은 크레이트에 들어 있지 않습니다. 위 저장소의 Release `models-kword5` 에서 받아 풉니다
16//! (CC BY-SA 2.0 KR).
17//!
18//! | 파일 | 무엇 | 필요한 때 |
19//! |---|---|---|
20//! | `madi.dict` | 사전(어휘·활용형·복합어·연접표) | 항상 |
21//! | `knlm.bin` | n-gram 언어 모델(KnLM) | 권장 — 없으면 ko-dic 연접표만으로 분석 |
22//! | `model.nlm` | 신경망 언어 모델(NLM) | 기본 모드(feature `nlm`, 기본 켬) |
23//! | `precise.tlm` | 정밀 모드 교사(작은 트랜스포머) | `precise` 를 줄 때만 |
24//!
25//! ## 빠른 시작
26//!
27//! ```no_run
28//! use madi::{Engine, EngineConfig, NLM_MIX_WEIGHTS};
29//!
30//! // 기본 모드(사전 + KnLM + NLM). Python 패키지·CLI 의 기본값과 같다.
31//! let cfg = EngineConfig {
32//! knlm: Some("models/knlm.bin".into()),
33//! nlm: Some("models/model.nlm".into()),
34//! lm_weight: NLM_MIX_WEIGHTS.0,
35//! nlm_weight: NLM_MIX_WEIGHTS.1,
36//! skip: madi::Skip::Off, // 문서 정제·문장 버리기 없이 (라이브러리 용도)
37//! min_len: 0,
38//! ..EngineConfig::new("models/madi.dict")
39//! };
40//! let engine = Engine::load(&cfg)?; // 1초 안팎, 한 번만 만들어 공유
41//! println!("{}", engine.version()); // 0.2.1+decdb.r3p.f11.k838c.n15e6.s-
42//!
43//! let mut w = engine.worker(); // 스레드마다 하나
44//! for t in w.analyze_sentence("아버지가 방에 들어가신다.") {
45//! print!("{}/{} ", t.form, t.tag.code()); // 아버지/NNG 가/JKS 방/NNG 에/JKB 들어가/VV 시/EP ㄴ다/EF ./SF
46//! }
47//! # Ok::<(), anyhow::Error>(())
48//! ```
49//!
50//! ## 어느 함수를 쓰나
51//!
52//! | 하고 싶은 것 | 함수 | 돌려주는 것 |
53//! |---|---|---|
54//! | 문장 하나 분석 | [`EngineWorker::analyze_sentence`] | `Vec<Token>` (위치는 그 문장 안) |
55//! | 여러 줄을 문장으로 나누고 각 문장 분석 | [`EngineWorker::sentences`] | `Vec<SentSpan>` (입력 전체 기준 위치·어절 번호) |
56//! | 위 결과를 한 줄로 | [`EngineWorker::tokenize`] | `Vec<Morph>` |
57//! | 문장 경계만 | [`EngineWorker::split_sentences`] | `Vec<String>` |
58//! | 상위 n 개 후보와 점수 | [`EngineWorker::analyze_nbest`] | `Vec<(f32, Vec<Token>)>` |
59//! | 특정 구간을 정해진 형태소로 | [`EngineWorker::analyze_pretokenized`] | `(Vec<Token>, Vec<bool>)` |
60//! | 기사·문서를 말뭉치 만들 듯 처리(정제·보일러플레이트 제거) | [`EngineWorker::analyze_doc`] | `Vec<Sentence>` |
61//! | 사전에 없는 말 보태기 | [`Engine::add_user_word`], [`Engine::load_user_dictionary`], [`EngineConfig::user`] | — |
62//!
63//! ## 모드
64//!
65//! | 모드 | 설정 | 특징 |
66//! |---|---|---|
67//! | 기본 | `knlm` + `nlm`, 가중 [`NLM_MIX_WEIGHTS`] | 대부분의 용도 |
68//! | 정밀 | 기본 + `precise: Some("precise.tlm")` | 애매한 어절을 교사가 다시 고름. 약 10배 느림 |
69//! | 가벼운 | `knlm` 만(`nlm: None`, `lm_weight: 1.0`) | 메모리 약 130 MB 적음, 정확도 조금 낮음. `default-features = false` 로 빌드 가능 |
70//!
71//! ## 스레드
72//!
73//! [`Engine`] 은 `Send + Sync` 라 `Arc` 로 여러 스레드가 공유하고, 각 스레드는 [`Engine::worker`] 로
74//! 자기 작업 버퍼를 받습니다. 분석은 문장마다 독립이라 스레드 수와 관계없이 결과가 같습니다.
75//!
76//! ## 위치와 어절
77//!
78//! 모든 위치는 **글자(`char`) 단위** 반열린 구간 `[start, end)` 입니다(바이트가 아님). 축약된 활용형은
79//! 여러 형태소가 같은 구간을 가리킵니다: "신다" → 시/EP(11..13) + ㄴ다/EF(11..13). [`Token::space_before`]
80//! 가 참이면 그 형태소가 새 어절의 시작입니다.
81//!
82//! ## 판본 문자열
83//!
84//! [`Engine::version`] 은 `0.2.1+decdb.r3p.f11.k838c.n15e6.s-[.u…][.w…][.p…]` 꼴로, 크레이트 판본 뒤에
85//! 사전·점수 가중 판·가지치기 문턱·KnLM·NLM·보일러플레이트 규칙의 해시가 붙습니다. 사용자 사전을 쓰면 `.u`,
86//! 기본값과 다른 가중·옵션이 있으면 `.w`, 정밀 모드면 `.p` 가 더 붙습니다. 두 실행의 판본 문자열이 같으면
87//! 같은 입력에 같은 출력을 냅니다.
88
89/// 형태소 분석 결과를 쓰는 문장 분리 규칙 ([`EngineWorker::split_sentences`]·[`EngineWorker::sentences`] 가 쓴다).
90pub mod split;
91/// 문서 전처리: 보일러플레이트 규칙, 규칙 기반 문장 분리([`text::split_sentences`]), 토큰 줄 형식.
92pub mod text;
93
94/// 분석 옵션·품사·토큰 — `madi-core` 의 것을 그대로 내보낸다.
95pub use madi_core::{Options, Tag, TagStyle, Token};
96/// 정밀 모드 설정(τ 문턱·후보 수 등). [`EngineConfig::precise_opt`].
97pub use madi_lm::precise::PreciseOpt;
98/// 신문 기사 수집·정제 도구(`madi-news`). 보통의 분석에는 필요 없다.
99pub use madi_news as news;
100
101use anyhow::{bail, Context, Result};
102use madi_core::{Analyzer, Dict, LangModel, Worker};
103use madi_lm::tlm::TlmState;
104use madi_lm::{KnLm, Tlm};
105#[cfg(feature = "nlm")]
106use madi_lm::{Mixed, Nlm};
107use std::io::Read;
108use std::path::{Path, PathBuf};
109use text::SkipRules;
110
111/// 문서 정제·문장 버리기 규칙(보일러플레이트 규칙)을 어디서 가져올지. [`EngineConfig::skip`].
112///
113/// 이 규칙은 [`EngineWorker::analyze_doc`] 에서만 쓰인다(신문 기사의 "무단 전재 금지" 같은 상투 문장 제거).
114/// 문장을 그대로 분석하는 용도([`EngineWorker::analyze_sentence`]·[`sentences`](EngineWorker::sentences))라면
115/// [`Skip::Off`] 로 두는 것이 판본 문자열도 짧고(`.s-`) 뜻도 분명하다.
116#[derive(Debug, Clone, PartialEq)]
117pub enum Skip {
118 /// 빌드 때 넣은 기본 규칙(`boilerplate.txt`). [`EngineConfig::new`] 의 기본값
119 Builtin,
120 /// 같은 형식의 규칙 파일에서 읽는다
121 File(PathBuf),
122 /// 정제·문장 버리기를 하지 않는다 (라이브러리 용도 권장)
123 Off,
124}
125
126/// [`Engine`] 을 만드는 설정. [`EngineConfig::new`] 로 사전 경로만 주고 나머지는 구조체 갱신 문법으로 바꾼다.
127///
128/// ```no_run
129/// let cfg = madi::EngineConfig {
130/// knlm: Some("models/knlm.bin".into()),
131/// nlm: Some("models/model.nlm".into()),
132/// lm_weight: madi::NLM_MIX_WEIGHTS.0,
133/// nlm_weight: madi::NLM_MIX_WEIGHTS.1,
134/// user: vec!["my_dict.tsv".into()],
135/// ..madi::EngineConfig::new("models/madi.dict")
136/// };
137/// ```
138///
139/// 가중치 조합: KnLM 만 쓰면 `lm_weight` 1.0(기본), NLM 을 함께 쓰면 [`NLM_MIX_WEIGHTS`] (0.7, 0.3).
140/// 그 밖의 값을 주면 판본 문자열에 `.w…` 가 붙어 기본 설정과 구별된다.
141#[derive(Debug, Clone)]
142pub struct EngineConfig {
143 /// 사전 파일 `madi.dict` (필수)
144 pub dict: PathBuf,
145 /// n-gram 언어 모델 `knlm.bin`. 없으면 ko-dic 연접표만으로 분석한다(정확도 낮음, 시험용)
146 pub knlm: Option<PathBuf>,
147 /// 신경망 언어 모델 `model.nlm` (feature `nlm`, 기본 켬). 기본 모드에 필요하다. `madi nlm-pack` 으로 묶은 파일(권장)
148 /// 또는 `model.i8`(어휘는 같은 디렉터리의 `vocab.tsv`). 로드 때 모델·어휘의 짝을 확인하고 어긋나면 오류
149 pub nlm: Option<PathBuf>,
150 /// KnLM 가중치. NLM 과 함께 쓸 때는 [`NLM_MIX_WEIGHTS`]`.0`(0.7), KnLM 만 쓸 때는 1.0([`EngineConfig::new`] 기본)
151 pub lm_weight: f32,
152 /// NLM 가중치. NLM 과 함께 쓸 때 [`NLM_MIX_WEIGHTS`]`.1`(0.3). `nlm` 이 없으면 쓰이지 않는다
153 pub nlm_weight: f32,
154 /// KnLM 미등록어 클래스 강도 (기본 0.0 = 모델 그대로). 보통 건드리지 않는다
155 pub unk_scale: f32,
156 /// 분석·출력 옵션([`Options`]): 복합어 펼치기, 2025년판 출력(`std_2025`), 탐색 설정 등. 기본값이 공개판 운영 설정이다
157 pub options: Options,
158 /// 문서 정제 규칙([`Skip`]). [`analyze_doc`](EngineWorker::analyze_doc) 에만 쓰인다. 기본 [`Skip::Builtin`]
159 pub skip: Skip,
160 /// [`analyze_doc`](EngineWorker::analyze_doc) 에서 이 글자 수보다 짧은 문장은 버린다 (기본 5; 규칙을 끄면 0 을 권장)
161 pub min_len: usize,
162 /// 한자 독음 자료 디렉터리(`hanja_char.csv`·`dueum.csv`·`hanja_word.csv`). 주면 한자 표기를 독음으로 분석하고
163 /// [`Token::hanja`] 에 원 한자를 둔다. 없으면 한자는 SH 로 남는다
164 pub hanja: Option<PathBuf>,
165 /// 사용자 사전 파일들(탭 구분; 형식은 [`Engine::load_user_dictionary`]). 생성 때 읽어 두는 권장 경로
166 pub user: Vec<PathBuf>,
167 /// 정밀 모드 교사 `precise.tlm`. 주면 정밀 모드: 1차 분석에서 점수 차가 τ 안쪽인 어절만 교사가 다시 고른다(약 10배 느림)
168 pub precise: Option<PathBuf>,
169 /// 정밀 모드 설정([`PreciseOpt`]: τ 문턱·대안 수 등). 기본값이 공개판 설정
170 pub precise_opt: PreciseOpt,
171 /// 교사를 분석에는 쓰지 않고 [`EngineWorker::set_teacher_score`] 재채점에만 쓴다 (맞춤법 검사기용; 보통 false)
172 pub teacher_only: bool,
173}
174
175impl EngineConfig {
176 /// 사전 경로만 주고 나머지는 기본값으로: 언어 모델 없음, `lm_weight` 1.0, [`Skip::Builtin`], `min_len` 5,
177 /// 사용자 사전·한자·정밀 모드 없음. 보통 구조체 갱신 문법으로 `knlm`·`nlm` 을 채워 쓴다
178 pub fn new(dict: impl Into<PathBuf>) -> Self {
179 Self {
180 dict: dict.into(),
181 knlm: None,
182 nlm: None,
183 lm_weight: 1.0,
184 nlm_weight: 1.0,
185 unk_scale: 0.0,
186 options: Options::default(),
187 skip: Skip::Builtin,
188 min_len: 5,
189 hanja: None,
190 user: Vec::new(),
191 precise: None,
192 precise_opt: PreciseOpt::default(),
193 teacher_only: false,
194 }
195 }
196}
197
198enum Inner {
199 Conn(Analyzer<madi_core::ConnCost>),
200 Kn(Analyzer<KnLm>),
201 #[cfg(feature = "nlm")]
202 Nl(Analyzer<Nlm>),
203 #[cfg(feature = "nlm")]
204 KnNl(Analyzer<Mixed<KnLm, Nlm>>),
205}
206
207/// 공유 분석기: 사전·언어 모델·옵션을 한 번 메모리에 올린 것. `Send + Sync`.
208///
209/// - [`Engine::load`] 로 만든다(1초 안팎, 기본 모드 약 470 MB). 한 프로세스에 하나를 만들어 `Arc` 로 공유한다.
210/// - 분석은 [`Engine::worker`] 가 돌려주는 [`EngineWorker`] 로 한다 — 스레드마다 하나.
211/// - 사용자 단어를 실행 중에 더하려면 `&mut Engine` 이 필요하다([`Engine::add_user_word`]). 더한 뒤 새로 만든
212/// worker 부터 쓰인다.
213///
214/// ```no_run
215/// use std::sync::Arc;
216/// let engine = Arc::new(madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?);
217/// let handles: Vec<_> = (0..4).map(|i| {
218/// let e = Arc::clone(&engine);
219/// std::thread::spawn(move || {
220/// let mut w = e.worker();
221/// w.analyze_sentence(&format!("{}번째 스레드", i)).len()
222/// })
223/// }).collect();
224/// # Ok::<(), anyhow::Error>(())
225/// ```
226pub struct Engine {
227 inner: Inner,
228 skip: Option<SkipRules>,
229 min_len: usize,
230 options: Options,
231 version: String,
232 /// 정밀 모드 교사 (읽기 전용 — 상태는 worker 마다)
233 precise: Option<(Tlm, PreciseOpt)>,
234 /// 교사는 재채점용으로만 (분석은 보통 경로)
235 teacher_only: bool,
236 /// 사용자 사전·한자 자료의 해시 (판본 문자열 `.u`; 실행 중 사용자 단어를 더하면 갱신, 0.2.0)
237 user_h: Option<u64>,
238}
239
240enum WInner<'a> {
241 Conn(Worker<'a, madi_core::ConnCost>),
242 Kn(Worker<'a, KnLm>),
243 #[cfg(feature = "nlm")]
244 Nl(Worker<'a, Nlm>),
245 #[cfg(feature = "nlm")]
246 KnNl(Worker<'a, Mixed<KnLm, Nlm>>),
247}
248
249/// 스레드 하나의 분석 작업 버퍼. [`Engine::worker`] 로 받는다.
250///
251/// 격자·탐색 버퍼와 정밀 모드 교사 상태를 들고 있어 `Send` 가 아니다 — 스레드를 넘기지 말고 스레드마다 새로 받는다.
252/// 모든 분석 메서드는 `&mut self` 를 받지만 엔진 자체는 바꾸지 않으므로 몇 번을 불러도 결과가 같다.
253///
254/// 주요 메서드: [`analyze_sentence`](Self::analyze_sentence)(문장 하나) · [`sentences`](Self::sentences)(문장 분리 + 위치) ·
255/// [`tokenize`](Self::tokenize) · [`split_sentences`](Self::split_sentences) · [`analyze_nbest`](Self::analyze_nbest)(여러 후보) ·
256/// [`analyze_pretokenized`](Self::analyze_pretokenized)(구간 지정) · [`analyze_doc`](Self::analyze_doc)(문서 정제 포함).
257pub struct EngineWorker<'a> {
258 w: WInner<'a>,
259 e: &'a Engine,
260 /// `analyze_query_nbest` 에만 쓰는 옵션 (`None` 이면 엔진 옵션). 맞춤법 검사기가 재채점 점수차를 예전 눈금(`legacy_score`)으로 재려고 쓴다 (10.13)
261 nbest_opt: Option<Options>,
262 /// 정밀 모드 교사 상태 (엔진에 교사가 있을 때만)
263 tst: Option<TlmState>,
264 /// `analyze_query_nbest` 의 점수를 교사 로그 확률로 (맞춤법 gate Teacher)
265 teacher_score: bool,
266}
267
268/// [`EngineWorker::analyze_doc`] 이 돌려주는 문장 하나: 정제된 문장 글과 그 형태소들.
269/// 토큰 위치는 **이 문장 안** 글자 위치다(입력 전체 기준이 필요하면 [`EngineWorker::sentences`]).
270#[derive(Debug, Clone)]
271pub struct Sentence {
272 /// 정제·분리를 거친 문장 글
273 pub text: String,
274 /// 형태소들(위치는 `text` 안 글자 위치)
275 pub tokens: Vec<Token>,
276}
277
278/// 위치가 붙은 형태소 — [`EngineWorker::sentences`]·[`EngineWorker::tokenize`] 의 결과 단위.
279///
280/// [`Token`] 에 "입력 안 어디에 있는가"를 더한 것이다. Python 패키지의 `Token` 이 이것에 해당한다.
281#[derive(Debug, Clone)]
282pub struct Morph {
283 /// 형태소. `token.start`/`end` 는 **입력 전체** 기준 글자 위치(문장 안이 아님)
284 pub token: Token,
285 /// 문장 안 어절 번호 (0부터)
286 pub word: u32,
287 /// 입력 안 문장 번호 (0부터)
288 pub sent: u32,
289}
290
291/// 문장 하나의 구간과 형태소 — [`EngineWorker::sentences`] 의 결과 단위.
292///
293/// `&input.chars().collect::<Vec<_>>()[start..end]` 가 `text` 와 같다 — 원문에서 그 문장을 그대로 잘라 낼 수 있다.
294#[derive(Debug, Clone)]
295pub struct SentSpan {
296 /// 입력 전체 기준 글자 위치 (시작)
297 pub start: usize,
298 /// 입력 전체 기준 글자 위치 (끝, 포함하지 않음)
299 pub end: usize,
300 /// 문장 글 (원문 그대로)
301 pub text: String,
302 /// 형태소들 (입력 전체 기준 위치, 어절·문장 번호 포함)
303 pub tokens: Vec<Morph>,
304}
305
306/// 미리 정한 구간 — [`EngineWorker::analyze_pretokenized`] 의 입력 (Kiwi 의 `pretokenized` 에 해당).
307///
308/// 입력 글자 구간 `[start, end)` 를 `form`·`tag` 인 형태소 하나로 분석하게 한다.
309/// - `form` 이 `None` 이면 그 구간 원문 그대로.
310/// - `tag` 가 `None` 이면 아무 품사나. 품사 코드는 **접두 일치**: `"NN"` 이면 NNG·NNP·NNB 가운데 하나.
311///
312/// 격자에 큰 보너스를 주는 방식이라 사전에 그런 (형태, 품사) 후보가 전혀 없으면 지키지 못할 수 있다 —
313/// 그때 [`analyze_pretokenized`](EngineWorker::analyze_pretokenized) 가 돌려주는 `bool` 이 `false` 다.
314#[derive(Debug, Clone)]
315pub struct Pretoken {
316 /// 입력 글자 위치 (시작)
317 pub start: usize,
318 /// 입력 글자 위치 (끝, 포함하지 않음)
319 pub end: usize,
320 /// 형태(사전 표기). `None` 이면 구간 원문
321 pub form: Option<String>,
322 /// 품사 코드(접두 일치). `None` 이면 제한 없음
323 pub tag: Option<String>,
324}
325
326/// 원형(lemma): 용언(VV·VA·VX·VCP·VCN)은 어간 + "다"(들어가 → 들어가다), 그 밖은 형태 그대로.
327///
328/// madi 의 `form` 은 이미 사전 표기(활용·축약을 되돌린 꼴)라 원형이 따로 필요한 경우는 용언뿐이다.
329pub fn lemma(t: &Token) -> String {
330 match t.tag {
331 Tag::VV | Tag::VA | Tag::VX | Tag::VCP | Tag::VCN => format!("{}다", t.form),
332 _ => t.form.clone(),
333 }
334}
335
336/// 바이트열의 64비트 해시(FNV 식, 8바이트 단위). 판본 문자열을 만드는 데 쓴다. `h` 는 시작값(이어서 해시할 때 앞 결과).
337pub fn hash_bytes(h: u64, b: &[u8]) -> u64 {
338 let mut h = h;
339 let mut ch = b.chunks_exact(8);
340 for c in &mut ch {
341 h = (h ^ u64::from_le_bytes(c.try_into().unwrap())).wrapping_mul(0x100000001b3).rotate_left(23);
342 }
343 for &x in ch.remainder() {
344 h = (h ^ x as u64).wrapping_mul(0x100000001b3);
345 }
346 h
347}
348
349/// 텍스트의 64비트 해시. BOM 을 떼고 줄끝을 LF 로 맞춘 뒤 재므로 Windows 에서 CRLF 로 풀린 파일도 같은 값이 난다.
350/// 사용자 사전·규칙 파일의 판본 해시(`.u`·`.s`)에 쓴다.
351pub fn hash_text(t: &str) -> u64 {
352 let t = t.strip_prefix('\u{feff}').unwrap_or(t);
353 if t.contains('\r') {
354 hash_bytes(0xcbf29ce484222325, t.replace("\r\n", "\n").as_bytes())
355 } else {
356 hash_bytes(0xcbf29ce484222325, t.as_bytes())
357 }
358}
359
360/// 파일 내용의 64비트 해시([`hash_bytes`] 와 같은 값을 1 MB 씩 읽어 가며). 사전·모델 파일의 판본 해시에 쓴다.
361pub fn hash_file(path: &Path) -> Result<u64> {
362 let mut f = std::fs::File::open(path).with_context(|| path.display().to_string())?;
363 let mut buf = vec![0u8; 1 << 20];
364 let mut h = 0xcbf29ce484222325u64;
365 let mut carry: Vec<u8> = Vec::new();
366 loop {
367 let n = f.read(&mut buf)?;
368 if n == 0 {
369 break;
370 }
371 // 8바이트 정렬을 유지하려고 남은 조각을 다음 읽기와 잇는다
372 carry.extend_from_slice(&buf[..n]);
373 let k = carry.len() / 8 * 8;
374 h = hash_bytes(h, &carry[..k]);
375 carry.drain(..k);
376 }
377 Ok(hash_bytes(h, &carry))
378}
379
380/// [`hash_file`] 과 같은 값을 메모리 버퍼에서 낸다 — `words` 는 8바이트 단위로 담은 내용, `len` 은 실제 바이트 수.
381/// [`Engine::from_memory`](WASM 등 파일이 없는 환경) 가 쓴다. 리틀 엔디언 가정(x86·wasm32).
382pub fn hash_words(words: &[u64], len: usize) -> u64 {
383 let full = len / 8;
384 let mut h = 0xcbf29ce484222325u64;
385 for &w in &words[..full] {
386 h = (h ^ u64::from_le(w)).wrapping_mul(0x100000001b3).rotate_left(23);
387 }
388 if len % 8 != 0 {
389 let b = words[full].to_le_bytes();
390 for &x in &b[..len % 8] {
391 h = (h ^ x as u64).wrapping_mul(0x100000001b3);
392 }
393 }
394 h
395}
396
397fn short(h: u64) -> String {
398 format!("{:04x}", (h ^ (h >> 16) ^ (h >> 32) ^ (h >> 48)) & 0xffff)
399}
400
401#[cfg(feature = "nlm")]
402fn load_nlm(p: &Path, weight: f32, dict: &Dict) -> Result<Nlm> {
403 let mut n = madi_lm::nlm::load_checked(p).with_context(|| format!("load {}", p.display()))?;
404 n.weight = weight;
405 n.attach(dict);
406 Ok(n)
407}
408
409/// `Inner` 의 분석기에 같은 일을 한다 (변형 넷)
410macro_rules! with_an {
411 ($inner:expr, $an:ident => $body:expr) => {
412 match &mut $inner {
413 Inner::Conn($an) => $body,
414 Inner::Kn($an) => $body,
415 #[cfg(feature = "nlm")]
416 Inner::Nl($an) => $body,
417 #[cfg(feature = "nlm")]
418 Inner::KnNl($an) => $body,
419 }
420 };
421}
422
423/// 사용자 사전·한자 자료 파일의 해시 (판본 문자열 `.u`). 둘 다 없으면 None
424fn user_hash(cfg: &EngineConfig) -> Result<Option<u64>> {
425 if cfg.hanja.is_none() && cfg.user.is_empty() {
426 return Ok(None);
427 }
428 let mut h = 0u64;
429 if let Some(d) = &cfg.hanja {
430 for f in ["hanja_char.csv", "dueum.csv", "hanja_word.csv"] {
431 let t = std::fs::read_to_string(d.join(f)).with_context(|| d.join(f).display().to_string())?;
432 h = h.rotate_left(5) ^ hash_text(&t);
433 }
434 }
435 for p in &cfg.user {
436 let t = std::fs::read_to_string(p).with_context(|| p.display().to_string())?;
437 h = h.rotate_left(5) ^ hash_text(&t);
438 }
439 Ok(Some(h))
440}
441
442fn prepare<L: LangModel>(mut an: Analyzer<L>, cfg: &EngineConfig) -> Result<Analyzer<L>> {
443 if let Some(dir) = &cfg.hanja {
444 an.load_hanja(dir).with_context(|| format!("load hanja from {}", dir.display()))?;
445 }
446 for p in &cfg.user {
447 let bad = an.load_user_dict(p).with_context(|| format!("read {}", p.display()))?;
448 if let Some((ln, line)) = bad.first() {
449 bail!("{}:{}: 사용자 사전 줄 형식 오류: {}", p.display(), ln, line);
450 }
451 }
452 Ok(an)
453}
454
455impl Engine {
456 /// 설정대로 사전·언어 모델·(있으면) 정밀 모드 교사·사용자 사전을 열어 엔진을 만든다. 보통의 진입점.
457 ///
458 /// 파일이 없거나 사전과 모델의 짝이 맞지 않거나 사용자 사전 줄 형식이 틀리면 오류를 돌려준다.
459 /// feature `nlm` 없이 빌드했는데 `cfg.nlm` 이 있으면 오류.
460 pub fn load(cfg: &EngineConfig) -> Result<Self> {
461 let dict = Dict::open(&cfg.dict).with_context(|| format!("open {}", cfg.dict.display()))?;
462 Self::from_dict(dict, cfg)
463 }
464
465 /// 이미 연 [`Dict`] 로 엔진을 만든다([`load`](Self::load) 의 뒷부분). 사전을 미리 고쳐 넘길 때(CLI 의 실험용) 쓰며,
466 /// 판본 문자열의 사전 해시는 여전히 `cfg.dict` 파일로 낸다. 보통은 [`load`](Self::load) 를 쓴다.
467 pub fn from_dict(dict: Dict, cfg: &EngineConfig) -> Result<Self> {
468 #[cfg(not(feature = "nlm"))]
469 if cfg.nlm.is_some() {
470 bail!("this madi was built without feature \"nlm\"");
471 }
472 let knlm = match &cfg.knlm {
473 Some(p) => {
474 let mut lm = KnLm::load(p).with_context(|| format!("load {}", p.display()))?;
475 lm.weight = cfg.lm_weight;
476 lm.unk_scale = cfg.unk_scale;
477 lm.attach(&dict);
478 Some(lm)
479 }
480 None => None,
481 };
482 let inner = match (knlm, &cfg.nlm) {
483 (None, None) => Inner::Conn(prepare(Analyzer::with_lm(dict, madi_core::ConnCost), cfg)?),
484 (Some(lm), None) => Inner::Kn(prepare(Analyzer::with_lm(dict, lm), cfg)?),
485 #[cfg(feature = "nlm")]
486 (None, Some(np)) => {
487 let nlm = load_nlm(np, cfg.nlm_weight, &dict)?;
488 Inner::Nl(prepare(Analyzer::with_lm(dict, nlm), cfg)?)
489 }
490 #[cfg(feature = "nlm")]
491 (Some(lm), Some(np)) => {
492 let mut nlm = load_nlm(np, cfg.nlm_weight, &dict)?;
493 nlm.weight *= cfg.options.search.nlm_scale; // 10.12 기본 가중의 NLM 몫 (보간일 때만)
494 Inner::KnNl(prepare(Analyzer::with_lm(dict, Mixed { a: lm, b: nlm }), cfg)?)
495 }
496 #[cfg(not(feature = "nlm"))]
497 (_, Some(_)) => unreachable!(),
498 };
499 let (skip, skip_text) = match &cfg.skip {
500 Skip::Builtin => (Some(SkipRules::builtin()), Some(text::BUILTIN_SKIP.to_string())),
501 Skip::File(p) => {
502 let t = std::fs::read_to_string(p).with_context(|| p.display().to_string())?;
503 (Some(SkipRules::parse(&t, &p.display().to_string())?), Some(t))
504 }
505 Skip::Off => (None, None),
506 };
507 let version = Self::make_version(cfg, skip_text.as_deref())?;
508 let precise = match &cfg.precise {
509 Some(p) => Some((Tlm::load(p).with_context(|| format!("load {} (정밀 모드 교사)", p.display()))?, cfg.precise_opt)),
510 None => None,
511 };
512 Ok(Self { inner, skip, min_len: cfg.min_len, options: cfg.options.clone(), version, precise, teacher_only: cfg.teacher_only, user_h: user_hash(cfg)? })
513 }
514
515 /// 파일 없이 메모리에 있는 사전·KnLM 바이트로 엔진을 만든다 (WASM 처럼 파일 시스템이 없는 환경용).
516 ///
517 /// `dict_words`/`knlm` 은 파일 내용을 8바이트 단위 `Vec<u64>` 에 담은 것과 실제 바이트 수. `cfg` 의 파일 경로 항목
518 /// (`dict`·`knlm`·`nlm`·`precise`·`user`·`hanja`)은 쓰지 않고 가중치·옵션·`skip`·`min_len` 만 읽는다.
519 /// NLM·정밀 모드·사용자 사전·한자 자료·[`Skip::File`] 은 아직 지원하지 않는다(오류).
520 /// 판본 문자열은 같은 파일을 경로로 열 때와 같다.
521 pub fn from_memory(dict_words: Vec<u64>, dict_len: usize, knlm: Option<(Vec<u64>, usize)>, cfg: &EngineConfig) -> Result<Self> {
522 if cfg.nlm.is_some() || cfg.precise.is_some() || !cfg.user.is_empty() || cfg.hanja.is_some() {
523 bail!("from_memory: NLM·정밀 모드·사용자 사전·한자 자료는 아직 지원하지 않는다");
524 }
525 let mut v = format!("{}+d{}", env!("CARGO_PKG_VERSION"), short(hash_words(&dict_words, dict_len)));
526 v += &rev_tag(cfg);
527 let kh = knlm.as_ref().map(|(w, n)| hash_words(w, *n));
528 let dict = Dict::from_words(dict_words, dict_len).context("사전")?;
529 let lm = match knlm {
530 Some((w, n)) => {
531 let mut lm = KnLm::from_raw(madi_core::dict::RawSections::from_words(w, n).context("KnLM")?)?;
532 lm.weight = cfg.lm_weight;
533 lm.unk_scale = cfg.unk_scale;
534 lm.attach(&dict);
535 Some(lm)
536 }
537 None => None,
538 };
539 match kh {
540 Some(h) => v += &format!(".k{}", short(h)),
541 None => v += ".k-",
542 }
543 v += ".n-";
544 let skip = match &cfg.skip {
545 Skip::Builtin => {
546 v += &format!(".s{}", short(hash_text(text::BUILTIN_SKIP)));
547 Some(SkipRules::builtin())
548 }
549 Skip::File(_) => bail!("from_memory: Skip::File 은 쓸 수 없다 (Builtin 또는 Off)"),
550 Skip::Off => {
551 v += ".s-";
552 None
553 }
554 };
555 v += &knob_tag(cfg);
556 let inner = match lm {
557 None => Inner::Conn(Analyzer::with_lm(dict, madi_core::ConnCost)),
558 Some(lm) => Inner::Kn(Analyzer::with_lm(dict, lm)),
559 };
560 Ok(Self { inner, skip, min_len: cfg.min_len, options: cfg.options.clone(), version: v, precise: None, teacher_only: false, user_h: None })
561 }
562
563 /// `0.1.0-kword1+d3f2a.k91c0.n-.s7e1b[.u…][.w…]`: 크레이트 버전 + 사전·KnLM·NLM·보일러플레이트 규칙 해시 앞 4자리.
564 /// 사용자 사전·한자 자료를 쓰면 `u`, 기본이 아닌 가중치·옵션이면 `w` 가 붙는다
565 fn make_version(cfg: &EngineConfig, skip_text: Option<&str>) -> Result<String> {
566 let mut v = format!("{}+d{}", env!("CARGO_PKG_VERSION"), short(hash_file(&cfg.dict)?));
567 // 기본 점수 가중 판본 (10.12): 기본값 자체가 바뀐 것은 아래 `.w` 로 드러나지 않으므로
568 v += &rev_tag(cfg);
569 match &cfg.knlm {
570 Some(p) => v += &format!(".k{}", short(hash_file(p)?)),
571 None => v += ".k-",
572 }
573 match &cfg.nlm {
574 Some(p) => {
575 // 묶음 파일이면 그 파일만, 아니면 같은 디렉터리의 vocab.tsv 도
576 #[allow(unused_mut)]
577 let mut h = hash_file(p)?;
578 #[cfg(feature = "nlm")]
579 if !Nlm::is_packed(p) {
580 h ^= hash_file(&p.parent().unwrap_or(Path::new(".")).join("vocab.tsv"))?.rotate_left(7);
581 }
582 v += &format!(".n{}", short(h));
583 }
584 None => v += ".n-",
585 }
586 match skip_text {
587 Some(t) => v += &format!(".s{}", short(hash_text(t))),
588 None => v += ".s-",
589 }
590 if let Some(h) = user_hash(cfg)? {
591 v += &format!(".u{}", short(h));
592 }
593 // 가중치·옵션이 기본과 다르면 그 차이도 버전에 (같은 파일이라도 결과가 달라지므로)
594 v += &knob_tag(cfg);
595 // 정밀 모드 (없으면 아무것도 붙지 않아 예전 버전 문자열 그대로)
596 if let Some(p) = &cfg.precise {
597 let mut h = hash_file(p)?;
598 let po = cfg.precise_opt.diff_default();
599 if !po.is_empty() {
600 h ^= hash_bytes(11, po.join(" ").as_bytes());
601 }
602 v += &format!(".p{}", short(h));
603 }
604 Ok(v)
605 }
606
607 /// 판본 문자열: `0.2.1+decdb.r3p.f11.k838c.n15e6.s-[.u…][.w…][.p…]`.
608 ///
609 /// 크레이트 판본 + 사전(`d`)·점수 가중 판(`r`)·전후향 가지치기 문턱(`f`)·KnLM(`k`)·NLM(`n`)·정제 규칙(`s`) 해시.
610 /// 사용자 사전이 있으면 `.u`, 기본값과 다른 가중·옵션이 있으면 `.w`, 정밀 모드면 `.p`. 같은 문자열이면 같은 출력을
611 /// 보장하므로 결과를 저장할 때 함께 적어 두면 좋다(없는 모델은 `-`: `.k-`, `.n-`).
612 pub fn version(&self) -> &str {
613 &self.version
614 }
615
616 /// 사용자 단어를 더한다 — 사전 파일의 `형태 ⇥ 품사 [⇥ 보너스]` 줄과 같다. 보너스(`score`)는 로그 확률 눈금의
617 /// 가산점으로 기본 3.0; 문맥이 강하게 다른 쪽을 가리키면 질 수 있으니 그때는 올린다.
618 ///
619 /// 용언은 어간만 넣는다(`달리`, `VV` — `달리다`·`달려` 가 아님). 이미 만든 [`EngineWorker`] 에는 반영되지 않고
620 /// 그 뒤 [`worker`](Self::worker) 로 받은 것부터 쓰인다. 판본 문자열의 `.u` 가 바뀐다.
621 pub fn add_user_word(&mut self, form: &str, tag: Tag, score: Option<f32>) {
622 with_an!(self.inner, an => an.add_user_word(form, tag, score));
623 self.bump_user(&format!("{form}\t{}\t{score:?}", tag.code()));
624 }
625
626 /// 기분석 항목을 더한다 — 표면형 `surface` 가 나오면 `morphs` 열로 분석하는 후보를 추가한다. 사전 파일의
627 /// `표면형 ⇥ = ⇥ 형태/품사 형태/품사 …` 줄과 같다. 보너스 기본 6.0. 역시 다음 worker 부터 쓰인다.
628 ///
629 /// ```no_run
630 /// # let mut engine = madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?;
631 /// engine.add_pre_analyzed_word("달라고", &[("달".into(), madi::Tag::VX), ("라고".into(), madi::Tag::EC)], None);
632 /// # Ok::<(), anyhow::Error>(())
633 /// ```
634 pub fn add_pre_analyzed_word(&mut self, surface: &str, morphs: &[(String, Tag)], score: Option<f32>) {
635 with_an!(self.inner, an => an.add_preanalysis(surface, morphs, score));
636 let ms: Vec<String> = morphs.iter().map(|(f, t)| format!("{f}/{}", t.code())).collect();
637 self.bump_user(&format!("{surface}\t=\t{}\t{score:?}", ms.join(" ")));
638 }
639
640 /// 사용자 사전 파일을 읽어 더한다. 잘못된 줄이 있으면 그 줄 번호와 함께 오류. 다음 worker 부터 쓰인다.
641 ///
642 /// 파일 형식(탭 구분, `#` 뒤는 주석, 품사는 코드·한글 축약 어느 쪽이든):
643 /// ```text
644 /// 케데헌 NNP # 단어 ⇥ 품사 [⇥ 보너스] (기본 3.0)
645 /// 먹깨비 고유명 5
646 /// 달라고 = 달/VX 라고/EC # 표면형 ⇥ = ⇥ 형태/품사 … 기분석 (기본 6.0)
647 /// 운주사 운주사/NNG => 운주사/NNP # 표면형 ⇥ 틀린 분석 ⇥ => ⇥ 맞는 분석 분석 뒤 고치기(문맥 안 봄)
648 /// ```
649 /// 단어·기분석 줄은 후보를 더하는 것이라 문맥에 따라 질 수 있고, `=>` 줄은 결과를 무조건 바꾼다.
650 /// 엔진을 만들 때 읽는 [`EngineConfig::user`] 가 권장 경로다.
651 pub fn load_user_dictionary(&mut self, path: &Path) -> Result<()> {
652 let t = std::fs::read_to_string(path).with_context(|| path.display().to_string())?;
653 let bad = with_an!(self.inner, an => an.load_user_dict(path)).with_context(|| format!("read {}", path.display()))?;
654 if let Some((ln, line)) = bad.first() {
655 bail!("{}:{}: 사용자 사전 줄 형식 오류: {}", path.display(), ln, line);
656 }
657 self.bump_user(&t);
658 Ok(())
659 }
660
661 /// 판본 문자열의 `.u` 를 갱신 (`…s0968[.uXXXX][.w…][.p…]`)
662 fn bump_user(&mut self, text: &str) {
663 let h = self.user_h.unwrap_or(0).rotate_left(5) ^ hash_text(text);
664 self.user_h = Some(h);
665 let (head, rest) = self.version.split_once('+').map(|(a, b)| (a.to_string(), b.to_string())).unwrap_or_default();
666 let mut segs: Vec<String> = rest.split('.').filter(|x| !x.starts_with('u')).map(String::from).collect();
667 let at = segs.iter().position(|x| x.starts_with('s')).map(|i| i + 1).unwrap_or(segs.len());
668 segs.insert(at, format!("u{}", short(h)));
669 self.version = format!("{head}+{}", segs.join("."));
670 }
671
672 /// 엔진이 쓰는 분석 옵션([`EngineConfig::options`] 의 사본)
673 pub fn options(&self) -> &Options {
674 &self.options
675 }
676
677 /// 정밀 모드 교사를 실었는가(`precise` 를 주고 만들었는가)
678 pub fn is_precise(&self) -> bool {
679 self.precise.is_some()
680 }
681
682 /// 언어 모델 구성 이름: `"conn"`(연접표만) · `"knlm"`(가벼운 모드) · `"nlm"` · `"knlm+nlm"`(기본 모드)
683 pub fn lm_name(&self) -> &'static str {
684 match &self.inner {
685 Inner::Conn(_) => "conn",
686 Inner::Kn(_) => "knlm",
687 #[cfg(feature = "nlm")]
688 Inner::Nl(_) => "nlm",
689 #[cfg(feature = "nlm")]
690 Inner::KnNl(_) => "knlm+nlm",
691 }
692 }
693
694 /// 열어 둔 사전(어휘 조회·사전 통계가 필요할 때)
695 pub fn dict(&self) -> &Dict {
696 match &self.inner {
697 Inner::Conn(a) => &a.dict,
698 Inner::Kn(a) => &a.dict,
699 #[cfg(feature = "nlm")]
700 Inner::Nl(a) => &a.dict,
701 #[cfg(feature = "nlm")]
702 Inner::KnNl(a) => &a.dict,
703 }
704 }
705
706 /// 문서 정제 규칙 수 ([`Skip::Off`] 면 0)
707 pub fn n_skip_rules(&self) -> usize {
708 self.skip.as_ref().map(|r| r.len()).unwrap_or(0)
709 }
710
711 /// 분석 작업 버퍼를 하나 만든다. 스레드마다 하나씩 받아 그 스레드 안에서만 쓴다. 만드는 비용은 작다(격자 버퍼 할당 정도).
712 pub fn worker(&self) -> EngineWorker<'_> {
713 let w = match &self.inner {
714 Inner::Conn(a) => WInner::Conn(a.worker()),
715 Inner::Kn(a) => WInner::Kn(a.worker()),
716 #[cfg(feature = "nlm")]
717 Inner::Nl(a) => WInner::Nl(a.worker()),
718 #[cfg(feature = "nlm")]
719 Inner::KnNl(a) => WInner::KnNl(a.worker()),
720 };
721 let tst = self.precise.as_ref().map(|(t, _)| t.new_state());
722 EngineWorker { w, e: self, nbest_opt: None, tst, teacher_score: false }
723 }
724}
725
726/// [`EngineWorker::analyze_doc_full`] 의 결과: 남은 문장들과 버린 문장 수.
727pub struct DocResult {
728 /// 분석된 문장들
729 pub sentences: Vec<Sentence>,
730 /// 정제 규칙([`Skip`])으로 버린 문장 수
731 pub skipped: usize,
732 /// [`EngineConfig::min_len`] 보다 짧아 버린 문장 수
733 pub short: usize,
734}
735
736impl EngineWorker<'_> {
737 fn run(&mut self, text: &str) -> Vec<Token> {
738 let opt = &self.e.options;
739 if let (Some((tlm, po)), Some(st), false) = (&self.e.precise, &mut self.tst, self.e.teacher_only) {
740 // 정밀 모드 (CLI `best_tokens` 와 같음): 국소 대안(τ 문턱) → 교사로 다시 고르기
741 let r = match &mut self.w {
742 WInner::Conn(w) => w.analyze_alts_local_gated(text, opt, po.cand_margin, po.max_alts, Some(po.tau)),
743 WInner::Kn(w) => w.analyze_alts_local_gated(text, opt, po.cand_margin, po.max_alts, Some(po.tau)),
744 #[cfg(feature = "nlm")]
745 WInner::Nl(w) => w.analyze_alts_local_gated(text, opt, po.cand_margin, po.max_alts, Some(po.tau)),
746 #[cfg(feature = "nlm")]
747 WInner::KnNl(w) => w.analyze_alts_local_gated(text, opt, po.cand_margin, po.max_alts, Some(po.tau)),
748 };
749 let mut stats = Default::default();
750 return madi_lm::precise::apply(tlm, st, &r, po, &mut stats);
751 }
752 match &mut self.w {
753 WInner::Conn(w) => w.analyze(text, opt),
754 WInner::Kn(w) => w.analyze(text, opt),
755 #[cfg(feature = "nlm")]
756 WInner::Nl(w) => w.analyze(text, opt),
757 #[cfg(feature = "nlm")]
758 WInner::KnNl(w) => w.analyze(text, opt),
759 }
760 }
761
762 /// 문서(기사 본문 등 여러 줄)를 말뭉치 만들 듯 처리한다: 정제 → 문장 분리 → 상투 문장 버리기 → 짧은 문장 버리기 → 분석.
763 ///
764 /// `madi corpus` 명령이 쓰는 것과 같은 경로다. 정제 규칙은 [`EngineConfig::skip`], 길이 문턱은 [`EngineConfig::min_len`].
765 /// 문장 분리는 규칙 기반([`text::split_sentences`]: 종결 부호 뒤)이며, 돌려주는 토큰 위치는 각 문장 안 위치다.
766 /// 입력을 그대로 두고 위치까지 받고 싶으면 [`sentences`](Self::sentences) 를 쓴다.
767 pub fn analyze_doc(&mut self, text: &str) -> Vec<Sentence> {
768 self.analyze_doc_full(text).sentences
769 }
770
771 /// [`analyze_doc`](Self::analyze_doc) 과 같되 버린 문장 수까지 돌려준다
772 pub fn analyze_doc_full(&mut self, text: &str) -> DocResult {
773 let e = self.e;
774 let cleaned;
775 let text = match &e.skip {
776 Some(r) => {
777 cleaned = r.clean(text);
778 cleaned.as_str()
779 }
780 None => text,
781 };
782 let mut ss = text::split_sentences(text);
783 let mut skipped = 0;
784 if let Some(r) = &e.skip {
785 let before = ss.len();
786 ss.retain(|s| !r.skip(s));
787 skipped = before - ss.len();
788 }
789 let mut short = 0;
790 let mut sentences = Vec::with_capacity(ss.len());
791 for s in ss {
792 if s.chars().count() < e.min_len {
793 short += 1;
794 continue;
795 }
796 let tokens = self.run(&s);
797 sentences.push(Sentence { text: s, tokens });
798 }
799 DocResult { sentences, skipped, short }
800 }
801
802 /// 형태소 분석을 이용한 문장 분리. 문장 글만 돌려준다.
803 ///
804 /// 줄마다 분석해 **종결 부호 뒤**와 **종결 어미(EF)로 끝난 어절 뒤**에서 끊으므로 마침표 없이 이어 쓴 글
805 /// (메신저·댓글·구어)도 나눈다. 괄호·따옴표 안에서는 끊지 않고, 줄바꿈은 늘 경계다. 빈 줄은 건너뛴다.
806 /// 규칙은 [`split`] 모듈에 있다. 모델 없이 종결 부호만 보는 가벼운 분리는 [`text::split_sentences`].
807 ///
808 /// ```no_run
809 /// # let engine = madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?;
810 /// let mut w = engine.worker();
811 /// assert_eq!(w.split_sentences("밥 먹었니 나는 먹었어"), ["밥 먹었니", "나는 먹었어"]);
812 /// # Ok::<(), anyhow::Error>(())
813 /// ```
814 pub fn split_sentences(&mut self, text: &str) -> Vec<String> {
815 let mut out = Vec::new();
816 for line in text.lines() {
817 if line.trim().is_empty() {
818 continue;
819 }
820 let toks = self.run(line);
821 let chars: Vec<char> = line.chars().collect();
822 for (s, e) in split::split_line(line, &toks) {
823 out.push(chars[s..e].iter().collect());
824 }
825 }
826 out
827 }
828
829 /// 입력(여러 줄 가능)을 문장으로 나누고, 문장마다 구간과 위치가 붙은 형태소를 돌려준다. **라이브러리 용도의 기본 진입점.**
830 ///
831 /// - 정제·버리기 없이 입력을 그대로 다룬다. 문장 경계는 [`split_sentences`](Self::split_sentences) 와 같다.
832 /// - [`SentSpan::start`]/`end` 와 각 [`Morph`] 의 `token.start`/`end` 는 **입력 전체** 기준 글자 위치라
833 /// 원문에서 바로 잘라 낼 수 있다. `word` 는 문장 안 어절 번호, `sent` 는 문장 번호.
834 /// - 빈 줄은 건너뛴다(문장 번호에도 들어가지 않는다).
835 ///
836 /// ```no_run
837 /// # let engine = madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?;
838 /// let mut w = engine.worker();
839 /// let input = "비가 온다. 우산을 챙겨라";
840 /// let chars: Vec<char> = input.chars().collect();
841 /// for s in w.sentences(input) {
842 /// assert_eq!(s.text, chars[s.start..s.end].iter().collect::<String>());
843 /// for m in &s.tokens {
844 /// println!("{}/{} 어절{} {}..{}", m.token.form, m.token.tag.ko(), m.word, m.token.start, m.token.end);
845 /// }
846 /// }
847 /// # Ok::<(), anyhow::Error>(())
848 /// ```
849 pub fn sentences(&mut self, text: &str) -> Vec<SentSpan> {
850 let mut out = Vec::new();
851 let mut off = 0usize; // 줄 시작의 char 위치
852 for raw in text.split('\n') {
853 let line = raw.strip_suffix('\r').unwrap_or(raw);
854 let n_line = raw.chars().count() + 1;
855 if !line.trim().is_empty() {
856 let toks = self.run(line);
857 let chars: Vec<char> = line.chars().collect();
858 for (s, e) in split::split_line(line, &toks) {
859 let sent = out.len() as u32;
860 let mut word = 0u32;
861 let mut ms = Vec::new();
862 for t in toks.iter().filter(|t| t.start >= s && t.end <= e) {
863 if !ms.is_empty() && t.space_before {
864 word += 1;
865 }
866 let mut t = t.clone();
867 t.start += off;
868 t.end += off;
869 ms.push(Morph { token: t, word, sent });
870 }
871 out.push(SentSpan { start: off + s, end: off + e, text: chars[s..e].iter().collect(), tokens: ms });
872 }
873 }
874 off += n_line;
875 }
876 out
877 }
878
879 /// [`sentences`](Self::sentences) 의 형태소를 문장 구분 없이 한 줄로 이어 붙인 것. 문장 번호는 `Morph::sent` 에 남아 있다.
880 /// Python 패키지의 `tokenize` 가 이것이다.
881 pub fn tokenize(&mut self, text: &str) -> Vec<Morph> {
882 self.sentences(text).into_iter().flat_map(|s| s.tokens).collect()
883 }
884
885 /// 문장 하나의 상위 `n` 개 분석 후보를 `(점수, 형태소들)` 로, 좋은 것부터. 애매한 문장이 어떻게 갈렸는지 보거나
886 /// 뒤에서 다른 정보로 후보 가운데 고를 때 쓴다.
887 ///
888 /// - 점수는 탐색의 로그 확률 합이라 클수록(0 에 가까울수록) 좋다. 언어 모델 구성마다 눈금이 달라 다른 엔진끼리
889 /// 비교할 값은 아니다.
890 /// - 1번 후보는 늘 [`analyze_sentence`](Self::analyze_sentence) 와 같다(정밀 모드면 교사가 고른 것이고 그때 점수는
891 /// 격자 1위의 값). 후보가 `n` 보다 적을 수 있다.
892 /// - 입력 전체를 한 문장으로 본다(문장을 나누지 않는다). `n > 1` 이면 2단 채점 문턱을 꺼서 1-best 보다 느리다.
893 ///
894 /// ```no_run
895 /// # let engine = madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?;
896 /// let mut w = engine.worker();
897 /// for (score, toks) in w.analyze_nbest("감기는 무서운 병이다", 3) {
898 /// println!("{score:.2} {}", madi::format_tokens(&toks));
899 /// }
900 /// # Ok::<(), anyhow::Error>(())
901 /// ```
902 pub fn analyze_nbest(&mut self, text: &str, n: usize) -> Vec<(f32, Vec<Token>)> {
903 let n = n.max(1);
904 // 2단 채점 문턱(nlm_gate)은 노드마다 1위 근처 경로만 남겨 2위 이하 후보를 지운다 → 후보가 여럿일 때는 끈다.
905 // 1번 후보는 아래에서 늘 `run`(문턱 켬) 결과로 맞추므로 1-best 출력은 바뀌지 않는다
906 let mut o2 = self.e.options.clone();
907 if n > 1 {
908 o2.search.nlm_gate = 0.0;
909 }
910 let opt = &o2;
911 let mut r = match &mut self.w {
912 WInner::Conn(w) => w.analyze_nbest(text, n, opt),
913 WInner::Kn(w) => w.analyze_nbest(text, n, opt),
914 #[cfg(feature = "nlm")]
915 WInner::Nl(w) => w.analyze_nbest(text, n, opt),
916 #[cfg(feature = "nlm")]
917 WInner::KnNl(w) => w.analyze_nbest(text, n, opt),
918 };
919 let best = self.run(text);
920 let same = |a: &[Token], b: &[Token]| a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.form == y.form && x.tag == y.tag && x.start == y.start && x.end == y.end);
921 if r.first().map_or(true, |(_, t)| !same(t, &best)) {
922 let sc = r.first().map_or(0.0, |x| x.0);
923 r.retain(|(_, t)| !same(t, &best));
924 r.insert(0, (sc, best));
925 r.truncate(n);
926 }
927 r
928 }
929
930 /// 미리 정한 구간([`Pretoken`])을 지키며 분석한다. 돌려주는 것은 `(형태소들, 구간마다 지켰는가)`.
931 /// 이미 아는 고유 명사 구간을 고정하거나 분석을 일부러 다른 쪽으로 보내 볼 때 쓴다.
932 ///
933 /// ```no_run
934 /// # let engine = madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?;
935 /// let mut w = engine.worker();
936 /// let pre = [madi::Pretoken { start: 0, end: 1, form: Some("감".into()), tag: Some("VV".into()) }];
937 /// let (toks, ok) = w.analyze_pretokenized("감기는 무서운 병이다", &pre);
938 /// assert!(ok[0]); // 감/VV 기/ETN 는/JX …
939 /// # Ok::<(), anyhow::Error>(())
940 /// ```
941 pub fn analyze_pretokenized(&mut self, text: &str, pre: &[Pretoken]) -> (Vec<Token>, Vec<bool>) {
942 let chars: Vec<char> = text.chars().collect();
943 let cons: Vec<madi_core::lattice::Boost> = pre
944 .iter()
945 .map(|p| {
946 let e = p.end.min(chars.len());
947 let s = p.start.min(e);
948 madi_core::lattice::Boost {
949 c0: s as u32,
950 c1: e as u32,
951 form: p.form.clone().unwrap_or_else(|| chars[s..e].iter().collect()),
952 tag: p.tag.clone().unwrap_or_default(),
953 bonus: 30.0,
954 }
955 })
956 .collect();
957 let opt = &self.e.options;
958 match &mut self.w {
959 WInner::Conn(w) => w.analyze_constrained(text, opt, &cons),
960 WInner::Kn(w) => w.analyze_constrained(text, opt, &cons),
961 #[cfg(feature = "nlm")]
962 WInner::Nl(w) => w.analyze_constrained(text, opt, &cons),
963 #[cfg(feature = "nlm")]
964 WInner::KnNl(w) => w.analyze_constrained(text, opt, &cons),
965 }
966 }
967
968 /// 문장 하나를 그대로 분석한다(정제·문장 분리 없이). 가장 단순한 진입점.
969 ///
970 /// 토큰 위치는 `text` 안 글자 위치. 정밀 모드 엔진이면 교사 재선택까지 거친 결과다.
971 /// 여러 문장이 든 글은 [`sentences`](Self::sentences) 로 나눠 분석하는 편이 정확하다.
972 pub fn analyze_sentence(&mut self, text: &str) -> Vec<Token> {
973 self.run(text)
974 }
975
976 /// 검색어처럼 짧은 입력 하나를 분석한다. 앞뒤 공백을 떼는 것 말고는 [`analyze_sentence`](Self::analyze_sentence) 와 같다.
977 pub fn analyze_query(&mut self, text: &str) -> Vec<Token> {
978 self.run(text.trim())
979 }
980
981 /// [`analyze_query_nbest`](Self::analyze_query_nbest) 가 쓸 옵션을 따로 정한다(`None` 이면 엔진 옵션).
982 /// 점수 눈금만 바꿔 재야 하는 특수 용도(맞춤법 검사기)이며 보통은 쓰지 않는다.
983 pub fn set_nbest_options(&mut self, opt: Option<Options>) {
984 self.nbest_opt = opt;
985 }
986
987 /// 검색어용 n-best — 격자 탐색의 후보를 가공 없이 돌려준다(정밀 모드·2단 문턱 보정 없음). 일반 용도는
988 /// [`analyze_nbest`](Self::analyze_nbest) 를 쓴다. [`set_teacher_score`](Self::set_teacher_score) 가 켜져 있으면 점수가
989 /// 교사 로그 확률로 바뀐다.
990 pub fn analyze_query_nbest(&mut self, text: &str, n: usize) -> Vec<(f32, Vec<Token>)> {
991 let opt = self.nbest_opt.as_ref().unwrap_or(&self.e.options);
992 let text = text.trim();
993 let mut r = match &mut self.w {
994 WInner::Conn(w) => w.analyze_nbest(text, n, opt),
995 WInner::Kn(w) => w.analyze_nbest(text, n, opt),
996 #[cfg(feature = "nlm")]
997 WInner::Nl(w) => w.analyze_nbest(text, n, opt),
998 #[cfg(feature = "nlm")]
999 WInner::KnNl(w) => w.analyze_nbest(text, n, opt),
1000 };
1001 if self.teacher_score {
1002 if let (Some((tlm, _)), Some(st)) = (&self.e.precise, &mut self.tst) {
1003 for (sc, toks) in r.iter_mut() {
1004 *sc = teacher_logprob(tlm, st, toks);
1005 }
1006 }
1007 }
1008 r
1009 }
1010
1011 /// 켜면 [`analyze_query_nbest`](Self::analyze_query_nbest) 의 점수가 정밀 모드 교사의 문장 로그 확률(클수록 좋음)이 된다.
1012 /// 엔진에 교사가 없으면 무시. 특수 용도(맞춤법 검사기).
1013 pub fn set_teacher_score(&mut self, on: bool) {
1014 self.teacher_score = on;
1015 }
1016}
1017
1018/// 교사로 본 형태소열 로그 확률: BOS×3 + 토큰 + EOS. 교사 문맥(ctx)보다 길면 64 토큰씩 앞 문맥을 이어 가며 더한다
1019fn teacher_logprob(tlm: &Tlm, st: &mut TlmState, toks: &[Token]) -> f32 {
1020 use madi_lm::precise::{BOS, EOS};
1021 let mut seq: Vec<u16> = toks.iter().map(|t| tlm.id_of(&t.form, t.tag)).collect();
1022 seq.push(EOS);
1023 let mut prefix: Vec<u16> = vec![BOS, BOS, BOS];
1024 let mut total = 0.0;
1025 for chunk in seq.chunks(64) {
1026 total += tlm.score(st, &prefix, &[chunk])[0];
1027 prefix.extend_from_slice(chunk);
1028 }
1029 total
1030}
1031
1032/// 토큰들을 한 줄 글로: `형태/코드 형태/코드 | 형태/코드 …` (어절 경계는 `|`). `madi corpus --tokens` 출력의 둘째 열과 같은 형식.
1033///
1034/// ```no_run
1035/// # let engine = madi::Engine::load(&madi::EngineConfig::new("models/madi.dict"))?;
1036/// let mut w = engine.worker();
1037/// println!("{}", madi::format_tokens(&w.analyze_sentence("아버지가 방에"))); // 아버지/NNG 가/JKS | 방/NNG 에/JKB
1038/// # Ok::<(), anyhow::Error>(())
1039/// ```
1040pub fn format_tokens(toks: &[Token]) -> String {
1041 text::format_tokens(toks)
1042}
1043
1044/// 판본 문자열의 점수 가중·탐색 판 (10.17 A2): `.r3p`(기본, 논문 판 3p) 또는 `.r3`(옛 운영 가중 `--score-preset rev3`),
1045/// 그 뒤 전후향 가지치기 문턱 `.fN`(기본 `.f11`, 끄면 `.f0`). 가중이 두 프리셋 어느 쪽도 아니면 `.r3p` 로 두고 차이는 `.w` 에 든다
1046fn rev_tag(cfg: &EngineConfig) -> String {
1047 let s = &cfg.options.search;
1048 let r = if s.is_rev3() && !s.is_rev3p() { format!(".r{}", madi_core::lattice::SCORE_REV) } else { format!(".r{}p", madi_core::lattice::SCORE_REV) };
1049 let f = s.fb_cutoff;
1050 let f = if f == f.trunc() { format!("{}", f as i64) } else { format!("{}", f) };
1051 format!("{r}.f{f}")
1052}
1053
1054/// 신경망 LM 을 함께 쓸 때의 운영 가중 `(lm_weight, nlm_weight)` = (0.7, 0.3). 기본 모드의 [`EngineConfig::lm_weight`]·
1055/// [`nlm_weight`](EngineConfig::nlm_weight) 에 이 값을 넣는다. CLI 는 `--nlm` 만 주면 이 값을 쓴다.
1056pub const NLM_MIX_WEIGHTS: (f32, f32) = (0.7, 0.3);
1057
1058/// 판본 문자열의 `.w`: 가중치·옵션 가운데 **기본값과 다른 칸만** `이름=값` 로 모아 해시한다. 옵션 구조에 칸이 늘어도
1059/// 그 칸이 기본값이면 해시가 바뀌지 않는다. 점수 가중 프리셋(`.r3`/`.r3p`)과 `fb_cutoff`(`.fN`)는 앞 태그로 나타내므로 뺀다.
1060/// 아무것도 다르지 않으면 빈 문자열
1061fn knob_tag(cfg: &EngineConfig) -> String {
1062 let d = knob_diff(cfg);
1063 if d.is_empty() { String::new() } else { format!(".w{}", short(hash_bytes(7, d.join(" ").as_bytes()))) }
1064}
1065
1066/// 설정 가운데 기본값과 다른 칸을 `이름=값` 목록으로 — 판본 문자열의 `.w` 해시가 무엇에서 왔는지 확인할 때
1067/// (`madi version --knobs` 가 이것을 찍는다). 비어 있으면 기본 설정이다.
1068pub fn knob_diff(cfg: &EngineConfig) -> Vec<String> {
1069 let def = EngineConfig::new("");
1070 let mut d: Vec<String> = Vec::new();
1071 // 가중 기준: NLM 을 쓰면 0.7 / 0.3, 아니면 KnLM 1.0 (NLM 가중은 쓰이지 않으므로 보지 않는다)
1072 if cfg.nlm.is_some() {
1073 if cfg.lm_weight != NLM_MIX_WEIGHTS.0 { d.push(format!("lm_weight={}", cfg.lm_weight)); }
1074 if cfg.nlm_weight != NLM_MIX_WEIGHTS.1 { d.push(format!("nlm_weight={}", cfg.nlm_weight)); }
1075 } else if cfg.lm_weight != def.lm_weight {
1076 d.push(format!("lm_weight={}", cfg.lm_weight));
1077 }
1078 if cfg.unk_scale != def.unk_scale { d.push(format!("unk_scale={}", cfg.unk_scale)); }
1079 // 짧은 문장 버리기는 `.s` 와 짝으로 본다: 규칙을 쓰면 5, 규칙을 끄면(`.s-`) 0 이 기본 (Python·WASM·맞춤법은 끄고 0)
1080 let min_def = if cfg.skip == Skip::Off { 0 } else { def.min_len };
1081 if cfg.min_len != min_def { d.push(format!("min_len={}", cfg.min_len)); }
1082 let s = &cfg.options.search;
1083 let preset = s.is_rev3() || s.is_rev3p();
1084 d.extend(s.diff_default(!preset, false));
1085 let o = &cfg.options;
1086 let od = &def.options;
1087 madi_core::diff_fields!(d, o, od; split_compound, split_unknown_compound, fine_tags, merge_nouns, std_2025);
1088 d
1089}