lindera_analysis/worker.rs
1use lindera::LinderaResult;
2use lindera::mode::Mode;
3use lindera::space_penalty::SpacePenaltyConfig;
4use lindera::token::Token;
5use lindera::worker::SegmentWorker;
6
7use crate::character_filter::{BoxCharacterFilter, OffsetMapping};
8use crate::token_filter::BoxTokenFilter;
9use crate::tokenizer::{Tokenizer, correct_offsets};
10
11/// A reusable tokenization session over a [`Tokenizer`]'s full analysis
12/// chain (character filters → segmentation → token filters), owning every
13/// per-call buffer: the Viterbi lattice and backtrace scratch (via
14/// [`SegmentWorker`]), the normalized-text buffer the character filters
15/// operate on, and the offset-mapping scratch.
16///
17/// Compared to [`Tokenizer::tokenize`], repeated calls avoid the fresh
18/// lattice allocation, and — when character filters are configured — both
19/// the per-call `String` promotion of the input and the per-token surface
20/// `String` allocations (surfaces borrow the worker's buffer instead).
21///
22/// Create a worker with [`Tokenizer::new_worker`] or
23/// [`Tokenizer::into_worker`]; it stays permanently bound to that
24/// tokenizer's dictionary and filter chain. Returned tokens borrow the
25/// worker, so they must be consumed before the next call — the usual
26/// per-line loop compiles as-is:
27///
28/// ```ignore
29/// let mut worker = tokenizer.new_worker();
30/// for line in lines {
31/// let tokens = worker.tokenize(line)?;
32/// for token in &tokens {
33/// // use token.surface, ...
34/// }
35/// }
36/// ```
37///
38/// The worker is `Send + Sync`; the `&mut self` API means cross-thread
39/// sharing requires external synchronization (one worker per thread, or a
40/// `Mutex` as `lindera-binding-core` does).
41pub struct AnalysisWorker {
42 /// Character filters applied to the text before segmentation.
43 character_filters: Vec<BoxCharacterFilter>,
44 /// Token filters applied to the tokens after segmentation.
45 token_filters: Vec<BoxTokenFilter>,
46 /// The reusable segmentation session (lattice + backtrace scratch).
47 segment_worker: SegmentWorker,
48 /// Persistent buffer holding the character-filtered (normalized) text;
49 /// token surfaces borrow from it when character filters are configured.
50 text_buf: String,
51 /// Scratch for the per-call offset mappings produced by the character
52 /// filters (the `Vec` is reused; mappings themselves are per-call).
53 offset_mappings: Vec<OffsetMapping>,
54}
55
56impl Tokenizer {
57 /// Creates a reusable [`AnalysisWorker`] bound to a deep clone of this
58 /// tokenizer (filters are cloned via `box_clone`; the dictionary clone
59 /// is `Arc`-cheap, but a configured user dictionary is deep-copied —
60 /// prefer [`Tokenizer::into_worker`] when the tokenizer itself is no
61 /// longer needed).
62 ///
63 /// # 戻り値
64 ///
65 /// A fresh worker with empty internal buffers.
66 pub fn new_worker(&self) -> AnalysisWorker {
67 self.clone().into_worker()
68 }
69
70 /// Consumes this tokenizer and creates a reusable [`AnalysisWorker`]
71 /// from it without cloning any of its parts.
72 ///
73 /// # 戻り値
74 ///
75 /// A fresh worker with empty internal buffers.
76 pub fn into_worker(self) -> AnalysisWorker {
77 AnalysisWorker {
78 character_filters: self.character_filters,
79 token_filters: self.token_filters,
80 segment_worker: self.segmenter.into_worker(),
81 text_buf: String::new(),
82 offset_mappings: Vec::new(),
83 }
84 }
85}
86
87impl AnalysisWorker {
88 /// Tokenizes `text` through the full analysis chain, reusing this
89 /// worker's internal buffers.
90 ///
91 /// Produces exactly the same tokens as [`Tokenizer::tokenize`] for the
92 /// same input and configuration. The returned tokens borrow both
93 /// `text` and this worker, so they must be consumed before the next
94 /// call.
95 ///
96 /// # 引数
97 ///
98 /// * `text` - The input text to tokenize.
99 ///
100 /// # 戻り値
101 ///
102 /// The filtered tokens, in reading order, with byte offsets corrected
103 /// back to the original (pre-filter) text.
104 pub fn tokenize<'w>(&'w mut self, text: &'w str) -> LinderaResult<Vec<Token<'w>>> {
105 let Self {
106 character_filters,
107 token_filters,
108 segment_worker,
109 text_buf,
110 offset_mappings,
111 } = self;
112 offset_mappings.clear();
113
114 let (mut tokens, final_text_len) = if character_filters.is_empty() {
115 (segment_worker.segment(text)?, text.len())
116 } else {
117 text_buf.clear();
118 text_buf.push_str(text);
119 for character_filter in character_filters.iter() {
120 let mapping = character_filter.apply(text_buf)?;
121 if !mapping.is_empty() {
122 offset_mappings.push(mapping);
123 }
124 }
125 // Read the length before segmenting: the tokens returned below
126 // hold a shared borrow of `text_buf` for the rest of the call.
127 let final_text_len = text_buf.len();
128 (segment_worker.segment(text_buf.as_str())?, final_text_len)
129 };
130
131 for token_filter in token_filters.iter() {
132 token_filter.apply(&mut tokens)?;
133 }
134
135 correct_offsets(&mut tokens, offset_mappings, final_text_len);
136
137 Ok(tokens)
138 }
139
140 /// Tokenizes `text` and returns the top-N results with costs, through
141 /// the full analysis chain, reusing this worker's internal buffers.
142 ///
143 /// Produces exactly the same results as [`Tokenizer::tokenize_nbest`]
144 /// for the same input and configuration.
145 ///
146 /// # 引数
147 ///
148 /// * `text` - The input text to tokenize.
149 /// * `n` - Maximum number of tokenizations to return.
150 /// * `unique` - Deduplicate results with identical word boundaries.
151 /// * `cost_threshold` - Discard paths costing more than best + threshold.
152 ///
153 /// # 戻り値
154 ///
155 /// Up to `n` `(tokens, cost)` pairs ordered by cost (best first).
156 pub fn tokenize_nbest<'w>(
157 &'w mut self,
158 text: &'w str,
159 n: usize,
160 unique: bool,
161 cost_threshold: Option<i64>,
162 ) -> LinderaResult<Vec<(Vec<Token<'w>>, i64)>> {
163 let Self {
164 character_filters,
165 token_filters,
166 segment_worker,
167 text_buf,
168 offset_mappings,
169 } = self;
170 offset_mappings.clear();
171
172 let (mut all_results, final_text_len) = if character_filters.is_empty() {
173 (
174 segment_worker.segment_nbest(text, n, unique, cost_threshold)?,
175 text.len(),
176 )
177 } else {
178 text_buf.clear();
179 text_buf.push_str(text);
180 for character_filter in character_filters.iter() {
181 let mapping = character_filter.apply(text_buf)?;
182 if !mapping.is_empty() {
183 offset_mappings.push(mapping);
184 }
185 }
186 let final_text_len = text_buf.len();
187 (
188 segment_worker.segment_nbest(text_buf.as_str(), n, unique, cost_threshold)?,
189 final_text_len,
190 )
191 };
192
193 for (tokens, _cost) in &mut all_results {
194 for token_filter in token_filters.iter() {
195 token_filter.apply(tokens)?;
196 }
197 correct_offsets(tokens, offset_mappings, final_text_len);
198 }
199
200 Ok(all_results)
201 }
202
203 /// Sets the segmentation mode for subsequent calls.
204 ///
205 /// # 引数
206 ///
207 /// * `mode` - The mode to use from the next call on.
208 pub fn set_mode(&mut self, mode: Mode) {
209 self.segment_worker.set_mode(mode);
210 }
211
212 /// Sets whether whitespace tokens are kept in the output for
213 /// subsequent calls.
214 ///
215 /// # 引数
216 ///
217 /// * `keep` - `true` to keep whitespace tokens, `false` to drop them.
218 pub fn set_keep_whitespace(&mut self, keep: bool) {
219 self.segment_worker.set_keep_whitespace(keep);
220 }
221
222 /// Sets the unknown-word grouping cap for subsequent calls (MeCab's
223 /// `max-grouping-size` semantics; `None` = unbounded, the default).
224 ///
225 /// # 引数
226 ///
227 /// * `max_grouping_len` - Maximum grouped characters beyond the first.
228 pub fn set_max_grouping_len(&mut self, max_grouping_len: Option<usize>) {
229 self.segment_worker.set_max_grouping_len(max_grouping_len);
230 }
231
232 /// Enables/disables the unknown-word length ladder for subsequent
233 /// calls (see `Segmenter::unknown_word_ladder`; defaults to `true`).
234 ///
235 /// # 引数
236 ///
237 /// * `unknown_word_ladder` - Whether to emit the length ladder.
238 pub fn set_unknown_word_ladder(&mut self, unknown_word_ladder: bool) {
239 self.segment_worker
240 .set_unknown_word_ladder(unknown_word_ladder);
241 }
242
243 /// Sets the left-space penalty rules for subsequent calls (see
244 /// `Segmenter::space_penalty`; `None` disables it, overriding the rules
245 /// the dictionary ships and applies by default).
246 ///
247 /// # 引数
248 ///
249 /// * `space_penalty` - The rules, or `None` to disable the penalty.
250 ///
251 /// # 戻り値
252 ///
253 /// `Ok(())`, or an error when the dictionary schema has no
254 /// part-of-speech field; the previous setting is kept on error.
255 pub fn set_space_penalty(
256 &mut self,
257 space_penalty: Option<SpacePenaltyConfig>,
258 ) -> LinderaResult<()> {
259 self.segment_worker.set_space_penalty(space_penalty)
260 }
261
262 /// Enables the left-space penalty with the rules the dictionary ships in
263 /// its metadata (see `Segmenter::space_penalty_from_dictionary`), e.g.
264 /// to restore the default after `set_space_penalty(None)`.
265 ///
266 /// # 戻り値
267 ///
268 /// `Ok(())`, or an error when the dictionary ships no rules; the
269 /// previous setting is kept on error.
270 pub fn set_space_penalty_from_dictionary(&mut self) -> LinderaResult<()> {
271 self.segment_worker.set_space_penalty_from_dictionary()
272 }
273
274 /// Immediately shrinks the worker's internal buffers to what an input
275 /// of `text_len_hint` bytes needs.
276 ///
277 /// # 引数
278 ///
279 /// * `text_len_hint` - Expected typical input length in bytes.
280 pub fn shrink_to(&mut self, text_len_hint: usize) {
281 self.segment_worker.shrink_to(text_len_hint);
282 self.text_buf.shrink_to(text_len_hint);
283 self.offset_mappings.shrink_to_fit();
284 }
285
286 /// Discards all internal buffers, replacing them with fresh ones.
287 ///
288 /// Intended for recovery paths (e.g. after a panic poisoned a mutex
289 /// holding this worker) where the buffers may be in an inconsistent
290 /// intermediate state; configuration (dictionary, filters, mode) is
291 /// preserved.
292 pub fn reset(&mut self) {
293 self.segment_worker.reset();
294 self.text_buf = String::new();
295 self.offset_mappings = Vec::new();
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 #[cfg(feature = "embed-ipadic")]
302 mod with_ipadic {
303 use std::collections::HashMap;
304
305 use lindera::dictionary::load_dictionary;
306 use lindera::mode::Mode;
307 use lindera::segmenter::Segmenter;
308
309 use crate::character_filter::BoxCharacterFilter;
310 use crate::character_filter::mapping::MappingCharacterFilter;
311 use crate::token_filter::BoxTokenFilter;
312 use crate::token_filter::lowercase::LowercaseTokenFilter;
313 use crate::tokenizer::Tokenizer;
314
315 fn ipadic_tokenizer(with_filters: bool) -> Tokenizer {
316 let dictionary = match load_dictionary("embedded://ipadic") {
317 Ok(dictionary) => dictionary,
318 Err(err) => panic!("failed to load embedded IPADIC: {err}"),
319 };
320 let segmenter = Segmenter::new(Mode::Normal, dictionary, None);
321 let mut tokenizer = Tokenizer::new(segmenter);
322 if with_filters {
323 let mut mapping = HashMap::new();
324 mapping.insert("リンデラ".to_string(), "Lindera".to_string());
325 let filter = match MappingCharacterFilter::new(mapping) {
326 Ok(filter) => filter,
327 Err(err) => panic!("failed to build mapping filter: {err}"),
328 };
329 tokenizer
330 .character_filters
331 .push(BoxCharacterFilter::from(filter));
332 tokenizer
333 .token_filters
334 .push(BoxTokenFilter::from(LowercaseTokenFilter::new()));
335 }
336 tokenizer
337 }
338
339 fn assert_same_tokens(
340 tokenizer: &Tokenizer,
341 worker_tokens: &[(String, usize, usize)],
342 text: &str,
343 ) {
344 let expected = match tokenizer.tokenize(text) {
345 Ok(tokens) => tokens,
346 Err(err) => panic!("tokenize failed: {err}"),
347 };
348 let expected: Vec<(String, usize, usize)> = expected
349 .iter()
350 .map(|t| (t.surface.to_string(), t.byte_start, t.byte_end))
351 .collect();
352 assert_eq!(expected, worker_tokens, "mismatch for {text:?}");
353 }
354
355 #[test]
356 fn test_worker_matches_tokenize_without_filters() {
357 let tokenizer = ipadic_tokenizer(false);
358 let mut worker = tokenizer.new_worker();
359
360 let texts = [
361 "すもももももももものうち",
362 "関西国際空港限定トートバッグ",
363 "",
364 ];
365 for _ in 0..3 {
366 for text in texts {
367 let actual: Vec<(String, usize, usize)> = {
368 let tokens = match worker.tokenize(text) {
369 Ok(tokens) => tokens,
370 Err(err) => panic!("worker.tokenize failed: {err}"),
371 };
372 tokens
373 .iter()
374 .map(|t| (t.surface.to_string(), t.byte_start, t.byte_end))
375 .collect()
376 };
377 assert_same_tokens(&tokenizer, &actual, text);
378 }
379 }
380 }
381
382 #[test]
383 fn test_worker_matches_tokenize_with_filters() {
384 let tokenizer = ipadic_tokenizer(true);
385 let mut worker = tokenizer.new_worker();
386
387 // The mapping filter rewrites リンデラ -> Lindera (changing byte
388 // lengths, so offset corrections are exercised), and the
389 // lowercase token filter forces owned surfaces.
390 let texts = [
391 "リンデラは形態素解析器です。",
392 "TOKYO と リンデラ",
393 "すもももももももものうち",
394 ];
395 for _ in 0..3 {
396 for text in texts {
397 let actual: Vec<(String, usize, usize)> = {
398 let tokens = match worker.tokenize(text) {
399 Ok(tokens) => tokens,
400 Err(err) => panic!("worker.tokenize failed: {err}"),
401 };
402 tokens
403 .iter()
404 .map(|t| (t.surface.to_string(), t.byte_start, t.byte_end))
405 .collect()
406 };
407 assert_same_tokens(&tokenizer, &actual, text);
408 }
409 }
410 }
411
412 #[test]
413 fn test_worker_matches_tokenize_nbest() {
414 let tokenizer = ipadic_tokenizer(true);
415 let mut worker = tokenizer.new_worker();
416 let text = "リンデラとすもももももももものうち";
417
418 let expected = match tokenizer.tokenize_nbest(text, 3, false, None) {
419 Ok(results) => results,
420 Err(err) => panic!("tokenize_nbest failed: {err}"),
421 };
422 for _ in 0..2 {
423 let actual = match worker.tokenize_nbest(text, 3, false, None) {
424 Ok(results) => results,
425 Err(err) => panic!("worker.tokenize_nbest failed: {err}"),
426 };
427 assert_eq!(expected.len(), actual.len());
428 for ((e_tokens, e_cost), (a_tokens, a_cost)) in expected.iter().zip(actual.iter()) {
429 assert_eq!(e_cost, a_cost);
430 let e: Vec<(String, usize, usize)> = e_tokens
431 .iter()
432 .map(|t| (t.surface.to_string(), t.byte_start, t.byte_end))
433 .collect();
434 let a: Vec<(String, usize, usize)> = a_tokens
435 .iter()
436 .map(|t| (t.surface.to_string(), t.byte_start, t.byte_end))
437 .collect();
438 assert_eq!(e, a);
439 }
440 }
441 }
442
443 #[test]
444 fn test_worker_reset_and_shrink_keep_output() {
445 let tokenizer = ipadic_tokenizer(true);
446 let mut worker = tokenizer.new_worker();
447 let text = "リンデラは形態素解析器です。";
448
449 let before: Vec<String> = {
450 let tokens = match worker.tokenize(text) {
451 Ok(tokens) => tokens,
452 Err(err) => panic!("worker.tokenize failed: {err}"),
453 };
454 tokens.iter().map(|t| t.surface.to_string()).collect()
455 };
456
457 worker.reset();
458 let after_reset: Vec<String> = {
459 let tokens = match worker.tokenize(text) {
460 Ok(tokens) => tokens,
461 Err(err) => panic!("worker.tokenize failed: {err}"),
462 };
463 tokens.iter().map(|t| t.surface.to_string()).collect()
464 };
465 assert_eq!(before, after_reset, "output changed after reset()");
466
467 worker.shrink_to(0);
468 let after_shrink: Vec<String> = {
469 let tokens = match worker.tokenize(text) {
470 Ok(tokens) => tokens,
471 Err(err) => panic!("worker.tokenize failed: {err}"),
472 };
473 tokens.iter().map(|t| t.surface.to_string()).collect()
474 };
475 assert_eq!(before, after_shrink, "output changed after shrink_to()");
476 }
477 }
478}