litsea 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 849682ce4467eef85788f31a19183ae366bd3c5dd642d3aaa47998f6b1988e1c
4
- data.tar.gz: 2458ce9b638527048aa071f83b0743ae6ede8ae45d64c04b67d2fe8fbf33d7c9
3
+ metadata.gz: 48bde143045210bc8ebc4dacba04155649772c88a97b88d4cbfbbf9d1f4670fa
4
+ data.tar.gz: d45ddd6b6b9875ab645cd5e20dc539974e189953ecd1c4851c5f90a5c6d4d76c
5
5
  SHA512:
6
- metadata.gz: 983194c5e2cc5a25b02827085ae9f532ae4d0a238fc32e10107857a3ec20a25ef417019d33e7ce27151d09b050d626280edc5cf037c6769e53a7e37ef0340cc5
7
- data.tar.gz: 07e311b43e127e758fbf021d54102114e684e287a56858310473a23f12eb3b0de8aa33117fea49b94897bae809c28682aac707d81567b71038a61b37d2e7a8fe
6
+ metadata.gz: 6bf4331b0bd88b94921d492429504761f5ec4a783c020080e9af6012d1e544fb7daae41b38e2580ebe1c3498bf9b49a6c0922c66c1355aef678821b4d2175865
7
+ data.tar.gz: a8b264e872e9e7a04601dd5f4e964893b0cfc3b2af2880b3328d7ad84c7ece3e499a7c615e972a66f78182f0522b62e5f372c5ce101a233b24f433c5085a883d
data/Cargo.toml CHANGED
@@ -13,7 +13,7 @@
13
13
  edition = "2024"
14
14
  rust-version = "1.87"
15
15
  name = "litsea-ruby"
16
- version = "0.14.3"
16
+ version = "0.15.0"
17
17
  build = false
18
18
  autolib = false
19
19
  autobins = false
@@ -44,14 +44,14 @@ crate-type = [
44
44
  path = "src/lib.rs"
45
45
 
46
46
  [dependencies.litsea]
47
- version = "=0.14.3"
47
+ version = "=0.15.0"
48
48
 
49
49
  [dependencies.litsea-binding-core]
50
- version = "=0.14.3"
50
+ version = "=0.15.0"
51
51
  features = ["remote_model"]
52
52
 
53
53
  [dependencies.magnus]
54
- version = "0.8.2"
54
+ version = "0.9.0"
55
55
  features = ["rb-sys"]
56
56
 
57
57
  [dependencies.rb-sys]
data/README.md CHANGED
@@ -24,8 +24,8 @@ The gem ships code only. Download a pre-trained model from the [Litsea repositor
24
24
 
25
25
  | Model | Purpose | Size |
26
26
  |-------|---------|------|
27
- | `japanese.model`, `chinese.model`, `korean.model`, `english.model` | Segmentation | 84 KB – 2.0 MB |
28
- | `japanese_pos.model`, `chinese_pos.model`, `korean_pos.model`, `english_pos.model` | Segmentation + POS | 3.0 – 8.0 MB |
27
+ | `japanese.model`, `chinese.model`, `korean.model`, `english.model` | Segmentation | 86 KB – 2.0 MB |
28
+ | `japanese_pos.model`, `chinese_pos.model`, `korean_pos.model`, `english_pos.model` | Segmentation + POS | 3.1 – 8.3 MB |
29
29
 
30
30
  You never have to say which kind you have: the model file identifies itself, and `has_pos?` reports what the loaded model can do.
31
31
 
@@ -76,6 +76,28 @@ text.byteslice(token.start, token.end - token.start) # == token.surface
76
76
 
77
77
  Calling `segment_with_pos` on a segmentation-only model raises `Litsea::PosUnavailableError`.
78
78
 
79
+ ### User dictionary
80
+
81
+ Words passed as `user_words:` are emitted as single tokens wherever they occur, without retraining; every constructor accepts the keyword.
82
+
83
+ ```ruby
84
+ seg = Litsea::Segmenter.open(:japanese, "models/japanese.model", user_words: ["雪だるま", "新幹線"])
85
+
86
+ seg.segment("雪だるまを作る")
87
+ # => ["雪だるま", "を", "作る"] (without the dictionary: ["雪", "だる", "ま", "を", "作る"])
88
+ ```
89
+
90
+ Matching is context-free, so a short fragment registered as a word breaks every other word it occurs in. `user_words_gate:` (`0.0`-`1.0`) applies a word only where the model's margin against it is small enough: `0.0` only where the model already agrees with it.
91
+
92
+ With a POS model, `user_word_tags:` (a Hash of words and UPOS tags, as Strings) registers further words whose tokens `segment_with_pos` gives the registered tag, ahead of the model's own tagging:
93
+
94
+ ```ruby
95
+ tagged = Litsea::Segmenter.open(:japanese, "models/japanese_pos.model", user_word_tags: { "チャットGPT" => "PROPN" })
96
+
97
+ tagged.segment_with_pos("チャットGPTに聞く").map { |t| "#{t.surface}/#{t.pos}" }
98
+ # => ["チャットGPT/PROPN", "に/ADP", "聞く/VERB"] (in user_words: instead: "チャットGPT/NOUN")
99
+ ```
100
+
79
101
  ### Other model sources
80
102
 
81
103
  ```ruby
@@ -122,7 +144,7 @@ Every error derives from `Litsea::Error`, so one `rescue` handles them all.
122
144
 
123
145
  | Error | Raised when |
124
146
  |-------|-------------|
125
- | `Litsea::InvalidArgumentError` | Unknown language name, unknown feature set, reused trainer |
147
+ | `Litsea::InvalidArgumentError` | Unknown language name, unknown feature set, reused trainer, `user_words_gate:` outside `0.0`-`1.0`, a `user_word_tags:` value that is not a UPOS tag |
126
148
  | `Litsea::ModelError` | Download failed, or the file is a legacy joint POS model |
127
149
  | `Litsea::IoError` | A file could not be read or written |
128
150
  | `Litsea::ParseError` | The model or training data is malformed |
data/README_ja.md CHANGED
@@ -24,8 +24,8 @@ gem にはコードのみが含まれます。事前学習済みモデルは [Li
24
24
 
25
25
  | モデル | 用途 | サイズ |
26
26
  |-------|------|-------|
27
- | `japanese.model`, `chinese.model`, `korean.model`, `english.model` | 分割 | 84KB〜2.0MB |
28
- | `japanese_pos.model`, `chinese_pos.model`, `korean_pos.model`, `english_pos.model` | 分割 + POS | 3.0〜8.0MB |
27
+ | `japanese.model`, `chinese.model`, `korean.model`, `english.model` | 分割 | 86KB〜2.0MB |
28
+ | `japanese_pos.model`, `chinese_pos.model`, `korean_pos.model`, `english_pos.model` | 分割 + POS | 3.1〜8.3MB |
29
29
 
30
30
  どちらの種別かを指定する必要はありません。モデルファイル自身が種別を持っており、読み込んだモデルで何ができるかは `has_pos?` が示します。
31
31
 
@@ -76,6 +76,28 @@ text.byteslice(token.start, token.end - token.start) # == token.surface
76
76
 
77
77
  分割専用モデルに対して `segment_with_pos` を呼ぶと `Litsea::PosUnavailableError` が発生します。
78
78
 
79
+ ### ユーザー辞書
80
+
81
+ `user_words:` に渡した語は、再学習なしで、現れた場所で必ず 1 トークンとして出力されます。すべてのコンストラクタがこのキーワードを受け取ります。
82
+
83
+ ```ruby
84
+ seg = Litsea::Segmenter.open(:japanese, "models/japanese.model", user_words: ["雪だるま", "新幹線"])
85
+
86
+ seg.segment("雪だるまを作る")
87
+ # => ["雪だるま", "を", "作る"] (辞書なしでは ["雪", "だる", "ま", "を", "作る"])
88
+ ```
89
+
90
+ 照合は文脈を見ないため、短い断片を語として登録すると、それを含む他の語をすべて壊します。`user_words_gate:`(`0.0`〜`1.0`)は、モデルの margin が十分小さい場所にだけ語を適用します。`0.0` では、モデルがすでに同意している場所だけに適用します。
91
+
92
+ POS モデルでは、`user_word_tags:`(語と UPOS タグの Hash。キーと値は String)で、さらに語を登録できます。`segment_with_pos` は、その語のトークンに、モデル自身のタグ付けより優先して、登録したタグを付けます:
93
+
94
+ ```ruby
95
+ tagged = Litsea::Segmenter.open(:japanese, "models/japanese_pos.model", user_word_tags: { "チャットGPT" => "PROPN" })
96
+
97
+ tagged.segment_with_pos("チャットGPTに聞く").map { |t| "#{t.surface}/#{t.pos}" }
98
+ # => ["チャットGPT/PROPN", "に/ADP", "聞く/VERB"] (user_words: に入れた場合は "チャットGPT/NOUN")
99
+ ```
100
+
79
101
  ### その他のモデル読み込み方法
80
102
 
81
103
  ```ruby
@@ -122,7 +144,7 @@ metrics = Litsea::Trainer.new(0.01, 100_000, "features.txt").train("japanese.mod
122
144
 
123
145
  | エラー | 発生条件 |
124
146
  |-------|---------|
125
- | `Litsea::InvalidArgumentError` | 未知の言語名、未知の feature set、使用済みトレーナ |
147
+ | `Litsea::InvalidArgumentError` | 未知の言語名、未知の feature set、使用済みトレーナ、`0.0`〜`1.0` の外の `user_words_gate:`、UPOS タグでない `user_word_tags:` の値 |
126
148
  | `Litsea::ModelError` | ダウンロード失敗、または旧 joint POS モデル |
127
149
  | `Litsea::IoError` | ファイルの読み書き失敗 |
128
150
  | `Litsea::ParseError` | モデルまたは学習データの形式不正 |
@@ -3,5 +3,5 @@
3
3
  module Litsea
4
4
  # Kept in lockstep with the `litsea` crate; `Litsea.version` reports the
5
5
  # version the native extension was built from.
6
- VERSION = '0.14.3'
6
+ VERSION = '0.15.0'
7
7
  end
data/src/segmenter.rs CHANGED
@@ -1,21 +1,106 @@
1
1
  //! The `Litsea::Segmenter` class.
2
2
 
3
+ use std::collections::HashMap;
3
4
  use std::path::Path;
4
5
 
5
- use litsea_binding_core::{CoreSegmenter, TokenView};
6
- use magnus::{Module, Object, RArray, RModule, RString, Ruby, Value, error::Error};
6
+ use litsea::UserDictionary;
7
+ use litsea_binding_core::{CoreSegmenter, TokenView, build_user_dictionary_with_tags};
8
+ use magnus::scan_args::{get_kwargs, scan_args};
9
+ use magnus::{Module, Object, RArray, RModule, RString, Ruby, TryConvert, Value, error::Error};
7
10
 
8
11
  use crate::error::map_err;
9
12
  use crate::gvl::without_gvl;
10
13
  use crate::language::language_from_value;
11
14
  use crate::token::Token;
12
15
 
16
+ /// The keyword arguments every constructor accepts (issues #263, #264): the
17
+ /// optional `user_words:`, `user_words_gate:` and `user_word_tags:`, each of
18
+ /// which may also be passed as `nil`.
19
+ struct UserWordsArgs {
20
+ /// The words to emit as single tokens.
21
+ user_words: Option<Vec<String>>,
22
+ /// The flip-ratio gate in `0.0..=1.0`.
23
+ user_words_gate: Option<f64>,
24
+ /// Words to emit as single tokens with the UPOS tag `segment_with_pos`
25
+ /// gives them.
26
+ user_word_tags: Option<HashMap<String, String>>,
27
+ }
28
+
29
+ impl UserWordsArgs {
30
+ /// Builds the user dictionary these arguments describe.
31
+ ///
32
+ /// # Returns
33
+ /// `None` when no words were given, otherwise the dictionary.
34
+ ///
35
+ /// # Errors
36
+ /// Raises `Litsea::InvalidArgumentError` if a tag is not one of the 17
37
+ /// UPOS tags, or if the gate is outside `0.0..=1.0`, also when no words
38
+ /// were given.
39
+ fn dictionary(self) -> Result<Option<UserDictionary>, Error> {
40
+ let word_tags: Vec<(String, String)> =
41
+ self.user_word_tags.unwrap_or_default().into_iter().collect();
42
+ map_err(build_user_dictionary_with_tags(
43
+ self.user_words.as_deref().unwrap_or_default(),
44
+ &word_tags,
45
+ self.user_words_gate,
46
+ ))
47
+ }
48
+ }
49
+
50
+ /// Splits the arguments of `open`, `from_bytes`, and `from_uri`: the
51
+ /// language and the model source as required positionals, then the
52
+ /// `user_words:` / `user_words_gate:` / `user_word_tags:` keywords.
53
+ ///
54
+ /// magnus cannot express optional keyword arguments in a `function!`
55
+ /// arity, so the constructors take their arguments through `scan_args`,
56
+ /// as the trainers do.
57
+ ///
58
+ /// # Arguments
59
+ /// * `args` - The raw Ruby arguments.
60
+ ///
61
+ /// # Returns
62
+ /// The language value, the model source, and the keyword arguments.
63
+ ///
64
+ /// # Errors
65
+ /// Returns a Ruby `ArgumentError` for a wrong argument count or an unknown
66
+ /// keyword, or a `TypeError` if an argument has the wrong type.
67
+ fn scan_constructor_args<T: TryConvert>(
68
+ args: &[Value],
69
+ ) -> Result<(Value, T, UserWordsArgs), Error> {
70
+ let args = scan_args::<(Value, T), (), (), (), _, ()>(args)?;
71
+ let (language, source) = args.required;
72
+ let kwargs = get_kwargs::<
73
+ _,
74
+ (),
75
+ (
76
+ Option<Option<Vec<String>>>,
77
+ Option<Option<f64>>,
78
+ Option<Option<HashMap<String, String>>>,
79
+ ),
80
+ (),
81
+ >(args.keywords, &[], &["user_words", "user_words_gate", "user_word_tags"])?;
82
+ let (user_words, user_words_gate, user_word_tags) = kwargs.optional;
83
+ Ok((
84
+ language,
85
+ source,
86
+ UserWordsArgs {
87
+ user_words: user_words.flatten(),
88
+ user_words_gate: user_words_gate.flatten(),
89
+ user_word_tags: user_word_tags.flatten(),
90
+ },
91
+ ))
92
+ }
93
+
13
94
  /// A word segmenter, optionally with POS tagging.
14
95
  ///
15
96
  /// Build one with `Segmenter.open`, `Segmenter.from_bytes`, or
16
97
  /// `Segmenter.from_uri`. The kind of model is detected from the file itself,
17
98
  /// so `has_pos?` describes what was loaded rather than something the caller
18
- /// declares.
99
+ /// declares. Every constructor accepts the keyword arguments `user_words:`,
100
+ /// an array of words to emit as single tokens; `user_words_gate:`, a flip
101
+ /// ratio in `0.0..=1.0` that applies a word only where the model's margin
102
+ /// against it is small enough; and `user_word_tags:`, a Hash mapping further
103
+ /// words to the UPOS tag `segment_with_pos` gives them.
19
104
  #[magnus::wrap(class = "Litsea::Segmenter", free_immediately, size)]
20
105
  pub struct Segmenter {
21
106
  /// The wrapped core segmenter.
@@ -26,20 +111,25 @@ impl Segmenter {
26
111
  /// Loads a model from a filesystem path.
27
112
  ///
28
113
  /// # Arguments
29
- /// * `language` - A language name or ISO 639-1 code, as a String or Symbol.
30
- /// * `path` - Path to the model file.
114
+ /// * `args` - `language` (a language name or ISO 639-1 code, as a String
115
+ /// or Symbol), `path` (the model file), and the optional
116
+ /// `user_words:` / `user_words_gate:` / `user_word_tags:` keywords.
31
117
  ///
32
118
  /// # Returns
33
119
  /// The new segmenter.
34
120
  ///
35
121
  /// # Errors
36
- /// Raises `Litsea::InvalidArgumentError`, `Litsea::IoError`,
122
+ /// Raises `Litsea::InvalidArgumentError` (also for a tag that is not a
123
+ /// UPOS tag or a gate outside `0.0..=1.0`), `Litsea::IoError`,
37
124
  /// `Litsea::ParseError`, or `Litsea::ModelError`.
38
- fn open(language: Value, path: String) -> Result<Self, Error> {
125
+ fn open(args: &[Value]) -> Result<Self, Error> {
126
+ let (language, path, user_words): (_, String, _) = scan_constructor_args(args)?;
39
127
  let language = language_from_value(language)?;
128
+ let dictionary = user_words.dictionary()?;
40
129
  // Reading and compiling a model is measurable work; let other Ruby
41
130
  // threads run while it happens.
42
- let inner = without_gvl(|| CoreSegmenter::from_path(language, Path::new(&path)));
131
+ let inner =
132
+ without_gvl(|| CoreSegmenter::from_path_with(language, Path::new(&path), dictionary));
43
133
  Ok(Self {
44
134
  inner: map_err(inner)?,
45
135
  })
@@ -51,23 +141,27 @@ impl Segmenter {
51
141
  /// (ASCII-8BIT) works as well as one read as UTF-8.
52
142
  ///
53
143
  /// # Arguments
54
- /// * `language` - A language name or ISO 639-1 code, as a String or Symbol.
55
- /// * `data` - The model file contents.
144
+ /// * `args` - `language` (a language name or ISO 639-1 code, as a String
145
+ /// or Symbol), `data` (the model file contents), and the optional
146
+ /// `user_words:` / `user_words_gate:` / `user_word_tags:` keywords.
56
147
  ///
57
148
  /// # Returns
58
149
  /// The new segmenter.
59
150
  ///
60
151
  /// # Errors
61
- /// Raises `Litsea::InvalidArgumentError`, `Litsea::ParseError`, or
152
+ /// Raises `Litsea::InvalidArgumentError` (also for a tag that is not a
153
+ /// UPOS tag or a gate outside `0.0..=1.0`), `Litsea::ParseError`, or
62
154
  /// `Litsea::ModelError`.
63
- fn from_bytes(language: Value, data: RString) -> Result<Self, Error> {
155
+ fn from_bytes(args: &[Value]) -> Result<Self, Error> {
156
+ let (language, data, user_words): (_, RString, _) = scan_constructor_args(args)?;
64
157
  let language = language_from_value(language)?;
158
+ let dictionary = user_words.dictionary()?;
65
159
  // SAFETY: the slice is used only within this call, and nothing here
66
160
  // runs Ruby code that could move or free the string in the meantime
67
- // (`from_bytes` parses into owned Rust structures).
161
+ // (`from_bytes_with` parses into owned Rust structures).
68
162
  let bytes = unsafe { data.as_slice() };
69
163
  Ok(Self {
70
- inner: map_err(CoreSegmenter::from_bytes(language, bytes))?,
164
+ inner: map_err(CoreSegmenter::from_bytes_with(language, bytes, dictionary))?,
71
165
  })
72
166
  }
73
167
 
@@ -77,8 +171,9 @@ impl Segmenter {
77
171
  /// The GVL is released while the model is fetched.
78
172
  ///
79
173
  /// # Arguments
80
- /// * `language` - A language name or ISO 639-1 code, as a String or Symbol.
81
- /// * `uri` - The model URI.
174
+ /// * `args` - `language` (a language name or ISO 639-1 code, as a String
175
+ /// or Symbol), `uri` (the model URI), and the optional `user_words:` /
176
+ /// `user_words_gate:` / `user_word_tags:` keywords.
82
177
  ///
83
178
  /// # Returns
84
179
  /// The new segmenter.
@@ -86,9 +181,12 @@ impl Segmenter {
86
181
  /// # Errors
87
182
  /// Raises `Litsea::ModelError` if the download fails, plus the same
88
183
  /// errors as `open`.
89
- fn from_uri(language: Value, uri: String) -> Result<Self, Error> {
184
+ fn from_uri(args: &[Value]) -> Result<Self, Error> {
185
+ let (language, uri, user_words): (_, String, _) = scan_constructor_args(args)?;
90
186
  let language = language_from_value(language)?;
91
- let inner = without_gvl(|| CoreSegmenter::from_uri_blocking(language, &uri));
187
+ let dictionary = user_words.dictionary()?;
188
+ let inner =
189
+ without_gvl(|| CoreSegmenter::from_uri_blocking_with(language, &uri, dictionary));
92
190
  Ok(Self {
93
191
  inner: map_err(inner)?,
94
192
  })
@@ -224,9 +322,9 @@ fn tokens_to_array(ruby: &Ruby, tokens: Vec<TokenView>) -> Result<RArray, Error>
224
322
  /// Returns a Ruby exception if the class cannot be defined.
225
323
  pub fn define(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
226
324
  let class = module.define_class("Segmenter", ruby.class_object())?;
227
- class.define_singleton_method("open", magnus::function!(Segmenter::open, 2))?;
228
- class.define_singleton_method("from_bytes", magnus::function!(Segmenter::from_bytes, 2))?;
229
- class.define_singleton_method("from_uri", magnus::function!(Segmenter::from_uri, 2))?;
325
+ class.define_singleton_method("open", magnus::function!(Segmenter::open, -1))?;
326
+ class.define_singleton_method("from_bytes", magnus::function!(Segmenter::from_bytes, -1))?;
327
+ class.define_singleton_method("from_uri", magnus::function!(Segmenter::from_uri, -1))?;
230
328
  class.define_method("language", magnus::method!(Segmenter::language, 0))?;
231
329
  class.define_method("has_pos?", magnus::method!(Segmenter::has_pos, 0))?;
232
330
  class.define_method("segment", magnus::method!(Segmenter::segment, 1))?;
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: litsea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.3
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Minoru Osuka