Skip to main content

lindera_binding_core/
tokenizer.rs

1//! Shared tokenizer build-flow orchestration for the bindings.
2//!
3//! Each binding's tokenizer wrapper reimplements the same flow: configure a
4//! [`lindera_analysis::tokenizer::TokenizerBuilder`], build a
5//! [`lindera_analysis::tokenizer::Tokenizer`], and convert the resulting tokens. This
6//! module collects that orchestration into [`CoreTokenizerBuilder`] and
7//! [`CoreTokenizer`], leaving each binding to do only its FFI-value conversion
8//! (`serde_json::Value` ⇔ the host language's argument type) and a thin wrapper.
9
10use std::path::Path;
11use std::str::FromStr;
12use std::sync::{Mutex, MutexGuard};
13
14use serde_json::Value;
15
16use lindera::dictionary::{Dictionary, UserDictionary};
17use lindera::mode::Mode;
18use lindera::segmenter::Segmenter;
19use lindera::space_penalty::SpacePenaltyConfig;
20use lindera_analysis::tokenizer::{Tokenizer, TokenizerBuilder};
21use lindera_analysis::worker::AnalysisWorker;
22
23use crate::error::{CoreError, CoreResult};
24use crate::token::{SurfaceView, TokenView};
25
26/// A left-space penalty setting, parsed from the value the bindings pass
27/// (same meaning as `segmenter.space_penalty` in a YAML config).
28#[derive(Debug, Clone, PartialEq)]
29enum SpacePenaltySetting {
30    /// `null`: keep the default, i.e. the rules the dictionary ships, if any.
31    Default,
32    /// `false`: turn the penalty off.
33    Off,
34    /// `true`: require the rules the dictionary ships.
35    FromDictionary,
36    /// An object with `rules`: apply these rules instead.
37    Rules(SpacePenaltyConfig),
38}
39
40impl SpacePenaltySetting {
41    /// Parses a binding-supplied value.
42    ///
43    /// # Arguments
44    ///
45    /// * `value` - `null`, a boolean, or an object such as
46    ///   `{"rules": [{"pos": ["JKS"], "cost": 6000}]}`.
47    ///
48    /// # Returns
49    ///
50    /// The setting, or an invalid-argument error for any other value.
51    fn from_value(value: &Value) -> CoreResult<Self> {
52        match value {
53            Value::Null => Ok(Self::Default),
54            Value::Bool(false) => Ok(Self::Off),
55            Value::Bool(true) => Ok(Self::FromDictionary),
56            Value::Object(_) => serde_json::from_value(value.clone())
57                .map(Self::Rules)
58                .map_err(|err| {
59                    CoreError::invalid_argument(format!("invalid space_penalty rules: {err}"))
60                }),
61            other => Err(CoreError::invalid_argument(format!(
62                "space_penalty must be null, a boolean or an object with \"rules\", got {other}"
63            ))),
64        }
65    }
66
67    /// Applies the setting to a segmenter built with [`Segmenter::new`],
68    /// which already carries the dictionary's default rules.
69    ///
70    /// # Arguments
71    ///
72    /// * `segmenter` - The segmenter to configure.
73    ///
74    /// # Returns
75    ///
76    /// `Ok(())`, or an error when the dictionary ships no rules for
77    /// [`Self::FromDictionary`] or the rules cannot be applied to it.
78    fn apply(&self, segmenter: &mut Segmenter) -> CoreResult<()> {
79        match self {
80            Self::Default => {}
81            Self::Off => segmenter.set_space_penalty(None)?,
82            Self::FromDictionary => segmenter.set_space_penalty_from_dictionary()?,
83            Self::Rules(config) => segmenter.set_space_penalty(Some(config.clone()))?,
84        }
85        Ok(())
86    }
87}
88
89/// Builder that orchestrates tokenizer configuration on behalf of the bindings.
90///
91/// Wraps [`lindera_analysis::tokenizer::TokenizerBuilder`]; filter arguments are passed
92/// as [`serde_json::Value`] so the FFI-specific value conversion stays in each
93/// binding.
94pub struct CoreTokenizerBuilder {
95    /// The backing lindera builder.
96    inner: TokenizerBuilder,
97}
98
99impl CoreTokenizerBuilder {
100    /// Creates a builder with the default (empty) configuration.
101    pub fn new() -> CoreResult<Self> {
102        Ok(Self {
103            inner: TokenizerBuilder::new()?,
104        })
105    }
106
107    /// Creates a builder from a YAML configuration file.
108    pub fn from_file(file_path: &Path) -> CoreResult<Self> {
109        Ok(Self {
110            inner: TokenizerBuilder::from_file(file_path)?,
111        })
112    }
113
114    /// Sets the segmenter mode from a string (`"normal"` or `"decompose"`).
115    pub fn set_mode(&mut self, mode: &str) -> CoreResult<&mut Self> {
116        let mode = Mode::from_str(mode)?;
117        self.inner.set_segmenter_mode(&mode);
118        Ok(self)
119    }
120
121    /// Sets the segmenter dictionary URI / path.
122    pub fn set_dictionary(&mut self, uri: &str) -> &mut Self {
123        self.inner.set_segmenter_dictionary(uri);
124        self
125    }
126
127    /// Sets the segmenter user-dictionary URI / path.
128    pub fn set_user_dictionary(&mut self, uri: &str) -> &mut Self {
129        self.inner.set_segmenter_user_dictionary(uri);
130        self
131    }
132
133    /// Sets whether whitespace tokens are kept in the output.
134    pub fn set_keep_whitespace(&mut self, keep_whitespace: bool) -> &mut Self {
135        self.inner.set_segmenter_keep_whitespace(keep_whitespace);
136        self
137    }
138
139    /// Sets the left-space penalty (Korean; see `Segmenter::space_penalty`)
140    /// from a value with the same meaning as `segmenter.space_penalty` in a
141    /// YAML config:
142    ///
143    /// - `null` restores the default: the rules the dictionary ships, if any
144    ///   (ko-dic ships mecab-ko-dic's rules, so they apply unless turned off);
145    /// - `false` turns the penalty off;
146    /// - `true` requires the dictionary's rules (building fails if it ships
147    ///   none);
148    /// - an object such as `{"rules": [{"pos": ["JKS"], "cost": 6000}]}`
149    ///   applies those rules instead.
150    ///
151    /// # Arguments
152    ///
153    /// * `value` - The setting, converted from the binding's native value.
154    ///
155    /// # Returns
156    ///
157    /// A mutable reference to `self`, for chaining, or an invalid-argument
158    /// error when `value` is not one of the forms above.
159    pub fn set_space_penalty(&mut self, value: &Value) -> CoreResult<&mut Self> {
160        match SpacePenaltySetting::from_value(value)? {
161            SpacePenaltySetting::Default => {
162                self.inner.reset_segmenter_space_penalty();
163            }
164            SpacePenaltySetting::Off => {
165                self.inner.set_segmenter_space_penalty(None);
166            }
167            SpacePenaltySetting::FromDictionary => {
168                self.inner.set_segmenter_space_penalty_from_dictionary(true);
169            }
170            SpacePenaltySetting::Rules(config) => {
171                self.inner.set_segmenter_space_penalty(Some(&config));
172            }
173        }
174        Ok(self)
175    }
176
177    /// Appends a character filter identified by `kind` with JSON `args`.
178    pub fn append_character_filter(&mut self, kind: &str, args: &Value) -> &mut Self {
179        self.inner.append_character_filter(kind, args);
180        self
181    }
182
183    /// Appends a token filter identified by `kind` with JSON `args`.
184    pub fn append_token_filter(&mut self, kind: &str, args: &Value) -> &mut Self {
185        self.inner.append_token_filter(kind, args);
186        self
187    }
188
189    /// Builds a [`CoreTokenizer`] from the current configuration.
190    pub fn build(&self) -> CoreResult<CoreTokenizer> {
191        Ok(CoreTokenizer::from_tokenizer(self.inner.build()?))
192    }
193}
194
195/// Tokenizer that orchestrates tokenization on behalf of the bindings.
196///
197/// Internally holds a reusable [`AnalysisWorker`] behind a [`Mutex`], so
198/// every call reuses the Viterbi lattice and scratch buffers instead of
199/// reallocating them (bindings keep one long-lived `CoreTokenizer`
200/// instance, which makes this the natural reuse point). `Mutex` — rather
201/// than `RefCell` — keeps `CoreTokenizer: Send + Sync`, which the Python
202/// (pyo3) and Node.js (napi) class wrappers require. All binding runtimes
203/// call tokenize while effectively single-threaded (GIL / one JS thread /
204/// request scope), so the lock is uncontended in practice.
205///
206/// Returns owned [`TokenView`]s so the bindings never handle borrowed
207/// `lindera` tokens directly.
208pub struct CoreTokenizer {
209    /// The reusable analysis session (lattice + normalization buffer +
210    /// scratch), locked per call.
211    worker: Mutex<AnalysisWorker>,
212}
213
214/// Locks the worker mutex, recovering from poisoning.
215///
216/// A panic in a previous call may have left the worker's internal buffers
217/// in an inconsistent intermediate state, so recovery resets them (the
218/// dictionary and filter configuration are unaffected). Capacity built up
219/// so far is lost, which is acceptable on this exceptional path.
220///
221/// # 引数
222///
223/// * `mutex` - The worker mutex to lock.
224///
225/// # 戻り値
226///
227/// A guard for the (possibly reset) worker.
228fn lock_worker(mutex: &Mutex<AnalysisWorker>) -> MutexGuard<'_, AnalysisWorker> {
229    match mutex.lock() {
230        Ok(guard) => guard,
231        Err(poisoned) => {
232            let mut guard = poisoned.into_inner();
233            guard.reset();
234            guard
235        }
236    }
237}
238
239impl CoreTokenizer {
240    /// Builds a tokenizer from segmenter parts, parsing `mode` from a string.
241    pub fn from_segmenter(
242        mode: &str,
243        dictionary: Dictionary,
244        user_dictionary: Option<UserDictionary>,
245    ) -> CoreResult<Self> {
246        let mode = Mode::from_str(mode)?;
247        let segmenter = Segmenter::new(mode, dictionary, user_dictionary);
248        Ok(Self::from_tokenizer(Tokenizer::new(segmenter)))
249    }
250
251    /// Like [`Self::from_segmenter`], with a left-space penalty setting
252    /// applied to the segmenter. This is how a binding that builds from a
253    /// dictionary instance (rather than a URI through
254    /// [`CoreTokenizerBuilder`]) honours
255    /// [`CoreTokenizerBuilder::set_space_penalty`].
256    ///
257    /// # Arguments
258    ///
259    /// * `mode` - `"normal"` or `"decompose"`.
260    /// * `dictionary` - The system dictionary.
261    /// * `user_dictionary` - An optional user dictionary.
262    /// * `space_penalty` - The setting, in the forms
263    ///   [`CoreTokenizerBuilder::set_space_penalty`] accepts (`null` keeps the
264    ///   dictionary's default rules).
265    ///
266    /// # Returns
267    ///
268    /// The tokenizer, or an error for an invalid mode or setting, for `true`
269    /// with a dictionary that ships no rules, or for rules that cannot be
270    /// applied to the dictionary.
271    pub fn from_segmenter_with_space_penalty(
272        mode: &str,
273        dictionary: Dictionary,
274        user_dictionary: Option<UserDictionary>,
275        space_penalty: &Value,
276    ) -> CoreResult<Self> {
277        let setting = SpacePenaltySetting::from_value(space_penalty)?;
278        let mode = Mode::from_str(mode)?;
279        let mut segmenter = Segmenter::new(mode, dictionary, user_dictionary);
280        setting.apply(&mut segmenter)?;
281        Ok(Self::from_tokenizer(Tokenizer::new(segmenter)))
282    }
283
284    /// Wraps an already-built lindera [`Tokenizer`], consuming it into the
285    /// internal reusable worker (no user-dictionary copy).
286    pub fn from_tokenizer(tokenizer: Tokenizer) -> Self {
287        Self {
288            worker: Mutex::new(tokenizer.into_worker()),
289        }
290    }
291
292    /// Tokenizes `text`, returning owned [`TokenView`]s.
293    ///
294    /// Reuses the internal worker's buffers across calls; output is
295    /// identical to tokenizing with a fresh lattice.
296    pub fn tokenize(&self, text: &str) -> CoreResult<Vec<TokenView>> {
297        let mut worker = lock_worker(&self.worker);
298        let tokens = worker.tokenize(text)?;
299        Ok(tokens.into_iter().map(TokenView::from_token).collect())
300    }
301
302    /// Tokenizes `text`, returning surface-only views ([`SurfaceView`])
303    /// without materializing any morphological details.
304    ///
305    /// This is the fast path for wakati-style callers: it skips the
306    /// per-token details loading and string materialization that
307    /// [`CoreTokenizer::tokenize`] pays (~14 allocations per IPADIC token
308    /// down to ~1), while reusing the internal worker's buffers like the
309    /// other methods. Token filters configured on the tokenizer still run
310    /// (some may load details internally); the saving on this path is the
311    /// FFI-side materialization.
312    pub fn tokenize_surfaces(&self, text: &str) -> CoreResult<Vec<SurfaceView>> {
313        let mut worker = lock_worker(&self.worker);
314        let tokens = worker.tokenize(text)?;
315        Ok(tokens.into_iter().map(SurfaceView::from_token).collect())
316    }
317
318    /// Tokenizes `text` and returns the N-best results as `(tokens, cost)` pairs.
319    ///
320    /// Reuses the internal worker's buffers across calls, like
321    /// [`CoreTokenizer::tokenize`].
322    pub fn tokenize_nbest(
323        &self,
324        text: &str,
325        n: usize,
326        unique: bool,
327        cost_threshold: Option<i64>,
328    ) -> CoreResult<Vec<(Vec<TokenView>, i64)>> {
329        let mut worker = lock_worker(&self.worker);
330        let results = worker.tokenize_nbest(text, n, unique, cost_threshold)?;
331        Ok(results
332            .into_iter()
333            .map(|(tokens, cost)| {
334                (
335                    tokens.into_iter().map(TokenView::from_token).collect(),
336                    cost,
337                )
338            })
339            .collect())
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn builder_new_succeeds() {
349        assert!(CoreTokenizerBuilder::new().is_ok());
350    }
351
352    #[test]
353    fn set_mode_accepts_known_modes() {
354        let mut builder = CoreTokenizerBuilder::new().expect("builder");
355        assert!(builder.set_mode("normal").is_ok());
356        assert!(builder.set_mode("decompose").is_ok());
357    }
358
359    #[test]
360    fn set_mode_rejects_unknown_mode() {
361        let mut builder = CoreTokenizerBuilder::new().expect("builder");
362        assert!(builder.set_mode("definitely-not-a-mode").is_err());
363    }
364
365    #[test]
366    fn infallible_setters_chain() {
367        let mut builder = CoreTokenizerBuilder::new().expect("builder");
368        builder
369            .set_dictionary("embedded://ipadic")
370            .set_keep_whitespace(true)
371            .append_token_filter("japanese_compound_word", &Value::Object(Default::default()));
372        // Reaching here means the borrow-returning setters compose.
373    }
374
375    #[test]
376    fn set_space_penalty_accepts_the_config_forms() {
377        let mut builder = CoreTokenizerBuilder::new().expect("builder");
378        for value in [
379            Value::Null,
380            Value::Bool(true),
381            Value::Bool(false),
382            serde_json::json!({ "rules": [{ "pos": ["JKS"], "cost": 6000 }] }),
383            serde_json::json!({ "rules": [] }),
384        ] {
385            assert!(
386                builder.set_space_penalty(&value).is_ok(),
387                "rejected {value}"
388            );
389        }
390    }
391
392    #[test]
393    fn set_space_penalty_rejects_other_values() {
394        let mut builder = CoreTokenizerBuilder::new().expect("builder");
395        for value in [
396            serde_json::json!(1),
397            serde_json::json!("false"),
398            serde_json::json!(["JKS"]),
399            serde_json::json!({ "rules": "JKS" }),
400            serde_json::json!({ "rules": [{ "pos": ["JKS"] }] }),
401        ] {
402            let err = match builder.set_space_penalty(&value) {
403                Ok(_) => panic!("accepted {value}"),
404                Err(err) => err,
405            };
406            assert_eq!(err.kind(), crate::error::ErrorKind::InvalidArgument);
407        }
408    }
409
410    /// The pyo3 (Python) and napi (Node.js) class wrappers require the
411    /// wrapped type to be `Send + Sync`; losing either auto-trait would be
412    /// a de-facto breaking change for every binding. The `Mutex` (rather
413    /// than `RefCell`) around the internal worker exists exactly to
414    /// preserve this.
415    #[test]
416    fn core_tokenizer_is_send_and_sync() {
417        fn assert_send_sync<T: Send + Sync>() {}
418        assert_send_sync::<CoreTokenizer>();
419        assert_send_sync::<CoreTokenizerBuilder>();
420    }
421
422    #[cfg(feature = "embed-ipadic")]
423    mod with_ipadic {
424        use super::super::*;
425
426        fn ipadic_tokenizer() -> CoreTokenizer {
427            let dictionary = match lindera::dictionary::load_dictionary("embedded://ipadic") {
428                Ok(dictionary) => dictionary,
429                Err(err) => panic!("failed to load embedded IPADIC: {err}"),
430            };
431            match CoreTokenizer::from_segmenter("normal", dictionary, None) {
432                Ok(tokenizer) => tokenizer,
433                Err(err) => panic!("failed to build CoreTokenizer: {err}"),
434            }
435        }
436
437        /// Repeated calls through the internal reused worker must keep
438        /// producing identical output (lattice-reuse regression gate).
439        #[test]
440        fn tokenize_repeated_calls_are_stable() {
441            let tokenizer = ipadic_tokenizer();
442            let first = match tokenizer.tokenize("すもももももももものうち") {
443                Ok(tokens) => tokens,
444                Err(err) => panic!("tokenize failed: {err}"),
445            };
446            assert!(!first.is_empty());
447            for _ in 0..100 {
448                let again = match tokenizer.tokenize("すもももももももものうち") {
449                    Ok(tokens) => tokens,
450                    Err(err) => panic!("tokenize failed: {err}"),
451                };
452                assert_eq!(first.len(), again.len());
453                for (a, b) in first.iter().zip(again.iter()) {
454                    assert_eq!(a.surface, b.surface);
455                    assert_eq!(a.byte_start, b.byte_start);
456                    assert_eq!(a.byte_end, b.byte_end);
457                    assert_eq!(a.details, b.details);
458                }
459            }
460        }
461
462        /// N-best calls reuse the same worker and must stay stable too.
463        #[test]
464        fn tokenize_nbest_repeated_calls_are_stable() {
465            let tokenizer = ipadic_tokenizer();
466            let first = match tokenizer.tokenize_nbest("すもももももももものうち", 3, false, None)
467            {
468                Ok(results) => results,
469                Err(err) => panic!("tokenize_nbest failed: {err}"),
470            };
471            assert!(!first.is_empty());
472            for _ in 0..10 {
473                let again =
474                    match tokenizer.tokenize_nbest("すもももももももものうち", 3, false, None)
475                    {
476                        Ok(results) => results,
477                        Err(err) => panic!("tokenize_nbest failed: {err}"),
478                    };
479                assert_eq!(first.len(), again.len());
480                for ((a_tokens, a_cost), (b_tokens, b_cost)) in first.iter().zip(again.iter()) {
481                    assert_eq!(a_cost, b_cost);
482                    assert_eq!(a_tokens.len(), b_tokens.len());
483                }
484            }
485        }
486
487        /// The surface-only fast path must yield exactly the surfaces and
488        /// byte offsets of the full tokenize path, for repeated calls.
489        #[test]
490        fn tokenize_surfaces_matches_tokenize() {
491            let tokenizer = ipadic_tokenizer();
492            let texts = [
493                "すもももももももものうち",
494                "関西国際空港限定トートバッグ",
495                "",
496            ];
497            for _ in 0..3 {
498                for text in texts {
499                    let full = match tokenizer.tokenize(text) {
500                        Ok(tokens) => tokens,
501                        Err(err) => panic!("tokenize failed: {err}"),
502                    };
503                    let surfaces = match tokenizer.tokenize_surfaces(text) {
504                        Ok(views) => views,
505                        Err(err) => panic!("tokenize_surfaces failed: {err}"),
506                    };
507                    assert_eq!(full.len(), surfaces.len(), "count for {text:?}");
508                    for (a, b) in full.iter().zip(surfaces.iter()) {
509                        assert_eq!(a.surface, b.surface);
510                        assert_eq!(a.byte_start, b.byte_start);
511                        assert_eq!(a.byte_end, b.byte_end);
512                    }
513                }
514            }
515        }
516
517        /// A panic while the worker lock is held must not wedge the
518        /// tokenizer: the next call recovers from the poisoned mutex with a
519        /// reset worker and produces correct output.
520        #[test]
521        fn tokenize_recovers_from_poisoned_lock() {
522            use std::sync::Arc;
523
524            let tokenizer = Arc::new(ipadic_tokenizer());
525            let expected = match tokenizer.tokenize("すもももももももものうち") {
526                Ok(tokens) => tokens,
527                Err(err) => panic!("tokenize failed: {err}"),
528            };
529
530            // Poison the mutex by panicking while holding the guard.
531            let poisoner = Arc::clone(&tokenizer);
532            let result = std::thread::spawn(move || {
533                let _guard = lock_worker(&poisoner.worker);
534                panic!("intentional panic to poison the worker lock");
535            })
536            .join();
537            assert!(result.is_err(), "poisoning thread must have panicked");
538            assert!(tokenizer.worker.is_poisoned(), "lock must be poisoned");
539
540            // The next call must recover and produce identical output.
541            let after = match tokenizer.tokenize("すもももももももものうち") {
542                Ok(tokens) => tokens,
543                Err(err) => panic!("tokenize after poison failed: {err}"),
544            };
545            assert_eq!(expected.len(), after.len());
546            for (a, b) in expected.iter().zip(after.iter()) {
547                assert_eq!(a.surface, b.surface);
548            }
549        }
550
551        /// IPADIC ships no left-space penalty rules: requiring them (`true`)
552        /// makes the build fail, while the default and `false` build fine.
553        #[test]
554        fn space_penalty_true_fails_on_a_dictionary_without_rules() {
555            for (value, should_build) in [
556                (Value::Null, true),
557                (Value::Bool(false), true),
558                (Value::Bool(true), false),
559            ] {
560                let mut builder = CoreTokenizerBuilder::new().expect("builder");
561                builder
562                    .set_dictionary("embedded://ipadic")
563                    .set_space_penalty(&value)
564                    .expect("valid value");
565                assert_eq!(builder.build().is_ok(), should_build, "value {value}");
566            }
567        }
568
569        /// The dictionary-instance path behaves like the builder path.
570        #[test]
571        fn from_segmenter_with_space_penalty_on_ipadic() {
572            for (value, should_build) in [
573                (Value::Null, true),
574                (Value::Bool(false), true),
575                (Value::Bool(true), false),
576                (
577                    serde_json::json!({ "rules": [{ "pos": ["助詞"], "cost": 1000 }] }),
578                    true,
579                ),
580            ] {
581                let dictionary = match lindera::dictionary::load_dictionary("embedded://ipadic") {
582                    Ok(dictionary) => dictionary,
583                    Err(err) => panic!("failed to load embedded IPADIC: {err}"),
584                };
585                let result = CoreTokenizer::from_segmenter_with_space_penalty(
586                    "normal", dictionary, None, &value,
587                );
588                assert_eq!(result.is_ok(), should_build, "value {value}");
589            }
590        }
591    }
592
593    #[cfg(feature = "embed-ko-dic")]
594    mod with_ko_dic {
595        use super::super::*;
596
597        /// First part-of-speech tag of each token for `text`, built with the
598        /// given space-penalty setting.
599        fn tags(space_penalty: &Value, text: &str) -> Vec<(String, String)> {
600            let mut builder = CoreTokenizerBuilder::new().expect("builder");
601            builder
602                .set_dictionary("embedded://ko-dic")
603                .set_space_penalty(space_penalty)
604                .expect("valid value");
605            let tokenizer = match builder.build() {
606                Ok(tokenizer) => tokenizer,
607                Err(err) => panic!("build failed: {err}"),
608            };
609            match tokenizer.tokenize(text) {
610                Ok(tokens) => tokens
611                    .into_iter()
612                    .map(|t| (t.surface, t.details.first().cloned().unwrap_or_default()))
613                    .collect(),
614                Err(err) => panic!("tokenize failed: {err}"),
615            }
616        }
617
618        /// ko-dic ships mecab-ko-dic's rules, so the penalty applies by
619        /// default; `false` turns it off, `true` and the shipped rules given
620        /// explicitly behave like the default (#1052).
621        #[test]
622        fn space_penalty_setting_changes_ko_dic_output() {
623            let text = "서울 시 에서 출발";
624            let default = tags(&Value::Null, text);
625            let off = tags(&Value::Bool(false), text);
626            let required = tags(&Value::Bool(true), text);
627            let shipped = serde_json::json!({ "rules": [
628                { "pos": ["EC", "EF", "EP", "ETM", "ETN", "VCP", "XSA", "XSN", "XSV"], "cost": 3000 },
629                { "pos": ["JC", "JKB", "JKC", "JKG", "JKO", "JKQ", "JKS", "JKV", "JX"], "cost": 6000 }
630            ] });
631            let explicit = tags(&shipped, text);
632
633            assert_ne!(
634                default, off,
635                "turning the penalty off must change the output"
636            );
637            assert_eq!(default, required);
638            assert_eq!(default, explicit);
639            // With the penalty, `시` after a space is no longer the
640            // pre-final ending EP.
641            assert!(
642                off.iter().any(|(s, tag)| s == "시" && tag == "EP"),
643                "{off:?}"
644            );
645            assert!(
646                !default.iter().any(|(s, tag)| s == "시" && tag == "EP"),
647                "{default:?}"
648            );
649        }
650
651        /// Building from a dictionary instance (as lindera-wasm does after
652        /// `loadDictionaryFromBytes`) honours the same setting.
653        #[test]
654        fn from_segmenter_with_space_penalty_matches_the_builder() {
655            let text = "서울 시 에서 출발";
656            for value in [Value::Null, Value::Bool(false), Value::Bool(true)] {
657                let dictionary = match lindera::dictionary::load_dictionary("embedded://ko-dic") {
658                    Ok(dictionary) => dictionary,
659                    Err(err) => panic!("failed to load embedded ko-dic: {err}"),
660                };
661                let tokenizer = match CoreTokenizer::from_segmenter_with_space_penalty(
662                    "normal", dictionary, None, &value,
663                ) {
664                    Ok(tokenizer) => tokenizer,
665                    Err(err) => panic!("build failed for {value}: {err}"),
666                };
667                let from_instance: Vec<(String, String)> = match tokenizer.tokenize(text) {
668                    Ok(tokens) => tokens
669                        .into_iter()
670                        .map(|t| (t.surface, t.details.first().cloned().unwrap_or_default()))
671                        .collect(),
672                    Err(err) => panic!("tokenize failed: {err}"),
673                };
674                assert_eq!(from_instance, tags(&value, text), "value {value}");
675            }
676        }
677    }
678}