litsea 0.13.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 +7 -0
- data/Cargo.toml +25 -0
- data/README.md +137 -0
- data/README_ja.md +137 -0
- data/extconf.rb +8 -0
- data/lib/litsea/version.rb +7 -0
- data/lib/litsea.rb +19 -0
- data/src/error.rs +120 -0
- data/src/gvl.rs +112 -0
- data/src/language.rs +31 -0
- data/src/lib.rs +84 -0
- data/src/metrics.rs +250 -0
- data/src/segmenter.rs +241 -0
- data/src/token.rs +135 -0
- data/src/trainer.rs +421 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 12e321b2284507d1ac91d3a77c6a5a2fa77440da6f655c3591d6535a1226ddbd
|
|
4
|
+
data.tar.gz: df71d843a1ae5ea741fcbc124d202200ee41f62ef56d340af43c8430cbfb7e0a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 710a2a9a938b22309d001bdbacc36c2035a7c6aaefa364f39fd052ca69c2f3fda7b6b7b8f8d924b723e9dda7b40cb965f981b5625bb02abdd5d4afad0a9b57fa
|
|
7
|
+
data.tar.gz: 884330a6bbab450ed6f51442d5614edb7640dca5b40b3ed5d8588fe219f9fa6fd51f6ab1770564ae368b6b6d42de9c17b673634a9afec74e91bb7e413b634ef6
|
data/Cargo.toml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "litsea-ruby"
|
|
3
|
+
version.workspace = true
|
|
4
|
+
edition.workspace = true
|
|
5
|
+
rust-version.workspace = true
|
|
6
|
+
description = "Ruby binding for Litsea."
|
|
7
|
+
documentation = "https://docs.rs/litsea-ruby"
|
|
8
|
+
homepage.workspace = true
|
|
9
|
+
repository.workspace = true
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
keywords = ["word", "segmentation", "nlp", "ruby", "binding"]
|
|
12
|
+
categories.workspace = true
|
|
13
|
+
license.workspace = true
|
|
14
|
+
|
|
15
|
+
[lib]
|
|
16
|
+
name = "litsea_ruby"
|
|
17
|
+
crate-type = ["cdylib", "lib"]
|
|
18
|
+
|
|
19
|
+
[dependencies]
|
|
20
|
+
litsea.workspace = true
|
|
21
|
+
litsea-binding-core = { workspace = true, features = ["remote_model"] }
|
|
22
|
+
magnus = { version = "0.8.2", features = ["rb-sys"] }
|
|
23
|
+
# Used only to link against libruby; the GVL entry point is declared by
|
|
24
|
+
# hand in `gvl.rs` because neither magnus nor rb-sys exposes it.
|
|
25
|
+
rb-sys = "0.9.130"
|
data/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# litsea-ruby
|
|
2
|
+
|
|
3
|
+
Ruby binding for [Litsea](https://github.com/mosuka/litsea), a compact word segmentation and POS (Part-of-Speech) tagging library for Japanese, Chinese, Korean, and English.
|
|
4
|
+
|
|
5
|
+
[日本語のREADME](README_ja.md)
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install litsea
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The gem compiles the native extension on install, so a Rust toolchain is required. Ruby 3.1 or later.
|
|
14
|
+
|
|
15
|
+
## Models are not bundled
|
|
16
|
+
|
|
17
|
+
The gem ships code only. Download a pre-trained model from the [Litsea repository](https://github.com/mosuka/litsea/tree/main/models) and point the segmenter at it:
|
|
18
|
+
|
|
19
|
+
| Model | Purpose | Size |
|
|
20
|
+
|-------|---------|------|
|
|
21
|
+
| `japanese.model`, `chinese.model`, `korean.model`, `english.model` | Segmentation | 84 KB – 2.0 MB |
|
|
22
|
+
| `japanese_pos.model`, `chinese_pos.model`, `korean_pos.model`, `english_pos.model` | Segmentation + POS | 3.0 – 8.0 MB |
|
|
23
|
+
|
|
24
|
+
You never have to say which kind you have: the model file identifies itself, and `has_pos?` reports what the loaded model can do.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### Segmentation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "litsea"
|
|
32
|
+
|
|
33
|
+
seg = Litsea::Segmenter.open(:japanese, "models/japanese.model")
|
|
34
|
+
|
|
35
|
+
seg.segment("これはテストです。")
|
|
36
|
+
# => ["これ", "は", "テスト", "です", "。"]
|
|
37
|
+
|
|
38
|
+
seg.segment_batch(["これはテストです。", "東京都から神奈川県へ引っ越した"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The language accepts a Symbol or a String, and the ISO 639-1 code works too: `:ja`, `"japanese"`.
|
|
42
|
+
|
|
43
|
+
For space-delimited languages the whitespace comes back as its own token, so the tokens always reconstruct the input:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
Litsea::Segmenter.open(:korean, "models/korean.model").segment("안녕하세요 반갑습니다")
|
|
47
|
+
# => ["안녕하세요", " ", "반갑습니다"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### POS tagging
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
seg = Litsea::Segmenter.open(:japanese, "models/japanese_pos.model")
|
|
54
|
+
|
|
55
|
+
seg.segment_with_pos("これはテストです。").each do |token|
|
|
56
|
+
puts "#{token.surface}\t#{token.pos}\t[#{token.start}..#{token.end}]"
|
|
57
|
+
end
|
|
58
|
+
# これ PRON [0..6]
|
|
59
|
+
# は ADP [6..9]
|
|
60
|
+
# テスト NOUN [9..18]
|
|
61
|
+
# です AUX [18..24]
|
|
62
|
+
# 。 PUNCT [24..27]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`start` and `end` are **byte** offsets, so slice with `byteslice` — Ruby's `String#[]` counts characters:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
text.byteslice(token.start, token.end - token.start) # == token.surface
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Calling `segment_with_pos` on a segmentation-only model raises `Litsea::PosUnavailableError`.
|
|
72
|
+
|
|
73
|
+
### Other model sources
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Litsea::Segmenter.from_bytes(:korean, File.binread("korean.model"))
|
|
77
|
+
Litsea::Segmenter.from_uri(:chinese, "https://example.com/chinese.model")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Training
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Litsea::Extractor.new(:japanese).extract("corpus.txt", "features.txt")
|
|
84
|
+
|
|
85
|
+
metrics = Litsea::Trainer.new(0.01, 10_000, "features.txt").train("japanese.model")
|
|
86
|
+
puts format("accuracy: %.2f%%", metrics.accuracy)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Two-stage (segmentation + POS) training:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Litsea::Extractor.new(:japanese).extract_two_stage("corpus_pos.txt", "features", feature_set: "fast")
|
|
93
|
+
|
|
94
|
+
metrics = Litsea::TwoStageTrainer.new(10, "features").train("japanese_pos.model")
|
|
95
|
+
puts metrics.stage1.accuracy, metrics.stage2.accuracy
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A `TwoStageTrainer` can only be used once — training collapses stage 1 into an AdaBoost model, which consumes it. `available?` reports whether it can still run, and a second `train` raises.
|
|
99
|
+
|
|
100
|
+
### Cancelling a training run
|
|
101
|
+
|
|
102
|
+
Training releases the GVL, so other Ruby threads keep running — which means one of them can stop a run that is already going:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
cancel = Litsea::CancelToken.new
|
|
106
|
+
Thread.new { sleep 60; cancel.cancel }
|
|
107
|
+
|
|
108
|
+
metrics = Litsea::Trainer.new(0.01, 100_000, "features.txt").train("japanese.model", cancel: cancel)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Cancelling is **not** an error: training stops at its next check point, still writes the partially trained model, and returns its metrics. The binding never installs a signal handler.
|
|
112
|
+
|
|
113
|
+
## Errors
|
|
114
|
+
|
|
115
|
+
Every error derives from `Litsea::Error`, so one `rescue` handles them all.
|
|
116
|
+
|
|
117
|
+
| Error | Raised when |
|
|
118
|
+
|-------|-------------|
|
|
119
|
+
| `Litsea::InvalidArgumentError` | Unknown language name, unknown feature set, reused trainer |
|
|
120
|
+
| `Litsea::ModelError` | Download failed, or the file is a legacy joint POS model |
|
|
121
|
+
| `Litsea::IoError` | A file could not be read or written |
|
|
122
|
+
| `Litsea::ParseError` | The model or training data is malformed |
|
|
123
|
+
| `Litsea::UnsupportedError` | The scheme or operation is unavailable in this build |
|
|
124
|
+
| `Litsea::PosUnavailableError` | POS tagging requested from a segmentation-only model |
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
make test-litsea-ruby # cargo test + rake compile + rake test
|
|
130
|
+
make build-litsea-ruby # release build
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The parity tests build the `litsea` CLI and compare the binding's output against it. Note that `bundle` must be available for the active Ruby; with rbenv, `rbenv local 3.4.9` (or any installed 3.1+) is enough.
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT. See [LICENSE](../LICENSE).
|
data/README_ja.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# litsea-ruby
|
|
2
|
+
|
|
3
|
+
[Litsea](https://github.com/mosuka/litsea) の Ruby バインディングです。Litsea は日本語・中国語・韓国語・英語に対応した、コンパクトな単語分割と品詞(POS)タグ付けのライブラリです。
|
|
4
|
+
|
|
5
|
+
[English README](README.md)
|
|
6
|
+
|
|
7
|
+
## インストール
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install litsea
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
インストール時にネイティブ拡張をコンパイルするため、Rust ツールチェーンが必要です。Ruby 3.1 以降に対応しています。
|
|
14
|
+
|
|
15
|
+
## モデルは同梱されません
|
|
16
|
+
|
|
17
|
+
gem にはコードのみが含まれます。事前学習済みモデルは [Litsea リポジトリ](https://github.com/mosuka/litsea/tree/main/models)から取得し、パスを指定して読み込んでください。
|
|
18
|
+
|
|
19
|
+
| モデル | 用途 | サイズ |
|
|
20
|
+
|-------|------|-------|
|
|
21
|
+
| `japanese.model`, `chinese.model`, `korean.model`, `english.model` | 分割 | 84KB〜2.0MB |
|
|
22
|
+
| `japanese_pos.model`, `chinese_pos.model`, `korean_pos.model`, `english_pos.model` | 分割 + POS | 3.0〜8.0MB |
|
|
23
|
+
|
|
24
|
+
どちらの種別かを指定する必要はありません。モデルファイル自身が種別を持っており、読み込んだモデルで何ができるかは `has_pos?` が示します。
|
|
25
|
+
|
|
26
|
+
## 使い方
|
|
27
|
+
|
|
28
|
+
### 分割
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require "litsea"
|
|
32
|
+
|
|
33
|
+
seg = Litsea::Segmenter.open(:japanese, "models/japanese.model")
|
|
34
|
+
|
|
35
|
+
seg.segment("これはテストです。")
|
|
36
|
+
# => ["これ", "は", "テスト", "です", "。"]
|
|
37
|
+
|
|
38
|
+
seg.segment_batch(["これはテストです。", "東京都から神奈川県へ引っ越した"])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
言語は Symbol でも String でも指定でき、ISO 639-1 コードも使えます(`:ja` / `"japanese"`)。
|
|
42
|
+
|
|
43
|
+
空白区切りの言語では空白自体が 1 トークンとして返るため、トークンを連結すると常に入力が復元されます。
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
Litsea::Segmenter.open(:korean, "models/korean.model").segment("안녕하세요 반갑습니다")
|
|
47
|
+
# => ["안녕하세요", " ", "반갑습니다"]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### POS タグ付け
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
seg = Litsea::Segmenter.open(:japanese, "models/japanese_pos.model")
|
|
54
|
+
|
|
55
|
+
seg.segment_with_pos("これはテストです。").each do |token|
|
|
56
|
+
puts "#{token.surface}\t#{token.pos}\t[#{token.start}..#{token.end}]"
|
|
57
|
+
end
|
|
58
|
+
# これ PRON [0..6]
|
|
59
|
+
# は ADP [6..9]
|
|
60
|
+
# テスト NOUN [9..18]
|
|
61
|
+
# です AUX [18..24]
|
|
62
|
+
# 。 PUNCT [24..27]
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`start` と `end` は**バイト**オフセットです。Ruby の `String#[]` は文字単位なので、切り出しには `byteslice` を使ってください。
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
text.byteslice(token.start, token.end - token.start) # == token.surface
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
分割専用モデルに対して `segment_with_pos` を呼ぶと `Litsea::PosUnavailableError` が発生します。
|
|
72
|
+
|
|
73
|
+
### その他のモデル読み込み方法
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Litsea::Segmenter.from_bytes(:korean, File.binread("korean.model"))
|
|
77
|
+
Litsea::Segmenter.from_uri(:chinese, "https://example.com/chinese.model")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### 学習
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Litsea::Extractor.new(:japanese).extract("corpus.txt", "features.txt")
|
|
84
|
+
|
|
85
|
+
metrics = Litsea::Trainer.new(0.01, 10_000, "features.txt").train("japanese.model")
|
|
86
|
+
puts format("accuracy: %.2f%%", metrics.accuracy)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
二段構成(分割 + POS)の学習:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
Litsea::Extractor.new(:japanese).extract_two_stage("corpus_pos.txt", "features", feature_set: "fast")
|
|
93
|
+
|
|
94
|
+
metrics = Litsea::TwoStageTrainer.new(10, "features").train("japanese_pos.model")
|
|
95
|
+
puts metrics.stage1.accuracy, metrics.stage2.accuracy
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`TwoStageTrainer` は 1 度しか使えません。学習時に stage 1 が AdaBoost モデルへ collapse され、トレーナが消費されるためです。再利用可能かどうかは `available?` が示し、2 回目の `train` は例外を発生させます。
|
|
99
|
+
|
|
100
|
+
### 学習のキャンセル
|
|
101
|
+
|
|
102
|
+
学習は GVL を解放するため、他の Ruby スレッドが動き続けます。つまり、実行中の学習を別スレッドから停止できます。
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
cancel = Litsea::CancelToken.new
|
|
106
|
+
Thread.new { sleep 60; cancel.cancel }
|
|
107
|
+
|
|
108
|
+
metrics = Litsea::Trainer.new(0.01, 100_000, "features.txt").train("japanese.model", cancel: cancel)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
キャンセルは**エラーではありません**。次のチェックポイントで停止し、部分的に学習されたモデルを保存してメトリクスを返します。バインディングはシグナルハンドラを登録しません。
|
|
112
|
+
|
|
113
|
+
## エラー
|
|
114
|
+
|
|
115
|
+
すべてのエラーは `Litsea::Error` を継承するため、1 つの `rescue` で捕捉できます。
|
|
116
|
+
|
|
117
|
+
| エラー | 発生条件 |
|
|
118
|
+
|-------|---------|
|
|
119
|
+
| `Litsea::InvalidArgumentError` | 未知の言語名、未知の feature set、使用済みトレーナ |
|
|
120
|
+
| `Litsea::ModelError` | ダウンロード失敗、または旧 joint POS モデル |
|
|
121
|
+
| `Litsea::IoError` | ファイルの読み書き失敗 |
|
|
122
|
+
| `Litsea::ParseError` | モデルまたは学習データの形式不正 |
|
|
123
|
+
| `Litsea::UnsupportedError` | このビルドでは利用できないスキームや操作 |
|
|
124
|
+
| `Litsea::PosUnavailableError` | 分割専用モデルに対する POS タグ付けの要求 |
|
|
125
|
+
|
|
126
|
+
## 開発
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
make test-litsea-ruby # cargo test + rake compile + rake test
|
|
130
|
+
make build-litsea-ruby # リリースビルド
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
パリティテストは `litsea` CLI をビルドし、その出力とバインディングの出力を突き合わせます。なお、有効な Ruby に `bundle` が入っている必要があります(rbenv なら `rbenv local 3.4.9` などで 3.1 以降を選択してください)。
|
|
134
|
+
|
|
135
|
+
## ライセンス
|
|
136
|
+
|
|
137
|
+
MIT。[LICENSE](../LICENSE) を参照してください。
|
data/extconf.rb
ADDED
data/lib/litsea.rb
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'litsea/version'
|
|
4
|
+
|
|
5
|
+
# Loads the compiled extension, preferring a version-specific build when
|
|
6
|
+
# rake-compiler produced one.
|
|
7
|
+
begin
|
|
8
|
+
RUBY_VERSION =~ /(\d+\.\d+)/
|
|
9
|
+
require "litsea/#{Regexp.last_match(1)}/litsea_ruby"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
require 'litsea/litsea_ruby'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Word segmentation and POS tagging.
|
|
15
|
+
#
|
|
16
|
+
# See {Litsea::Segmenter} to get started; every class is defined by the
|
|
17
|
+
# native extension.
|
|
18
|
+
module Litsea
|
|
19
|
+
end
|
data/src/error.rs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
//! The Ruby exception hierarchy.
|
|
2
|
+
//!
|
|
3
|
+
//! Mirrors the Python and PHP bindings: one class per [`ErrorKind`], all
|
|
4
|
+
//! below `Litsea::Error`, so `rescue Litsea::Error` catches everything the
|
|
5
|
+
//! binding raises.
|
|
6
|
+
|
|
7
|
+
use litsea_binding_core::{CoreError, ErrorKind};
|
|
8
|
+
use magnus::{Module, RModule, Ruby, error::Error, exception::ExceptionClass};
|
|
9
|
+
|
|
10
|
+
/// Holds the exception classes, looked up from the `Litsea` module.
|
|
11
|
+
///
|
|
12
|
+
/// Ruby classes are values, not types, so they are resolved on demand rather
|
|
13
|
+
/// than stored in Rust statics (a `Ruby` handle is only valid on a Ruby
|
|
14
|
+
/// thread).
|
|
15
|
+
struct Exceptions {
|
|
16
|
+
/// `Litsea::Error`, the base class.
|
|
17
|
+
base: ExceptionClass,
|
|
18
|
+
/// `Litsea::InvalidArgumentError`.
|
|
19
|
+
invalid_argument: ExceptionClass,
|
|
20
|
+
/// `Litsea::ModelError`.
|
|
21
|
+
model: ExceptionClass,
|
|
22
|
+
/// `Litsea::IoError`.
|
|
23
|
+
io: ExceptionClass,
|
|
24
|
+
/// `Litsea::ParseError`.
|
|
25
|
+
parse: ExceptionClass,
|
|
26
|
+
/// `Litsea::UnsupportedError`.
|
|
27
|
+
unsupported: ExceptionClass,
|
|
28
|
+
/// `Litsea::PosUnavailableError`.
|
|
29
|
+
pos_unavailable: ExceptionClass,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Looks up the exception classes defined by [`define_exceptions`].
|
|
33
|
+
///
|
|
34
|
+
/// # Arguments
|
|
35
|
+
/// * `ruby` - The Ruby handle for the current thread.
|
|
36
|
+
///
|
|
37
|
+
/// # Returns
|
|
38
|
+
/// The exception classes, or `None` if the module has not been defined yet.
|
|
39
|
+
fn exceptions(ruby: &Ruby) -> Option<Exceptions> {
|
|
40
|
+
let module: RModule = ruby.class_object().const_get("Litsea").ok()?;
|
|
41
|
+
Some(Exceptions {
|
|
42
|
+
base: module.const_get("Error").ok()?,
|
|
43
|
+
invalid_argument: module.const_get("InvalidArgumentError").ok()?,
|
|
44
|
+
model: module.const_get("ModelError").ok()?,
|
|
45
|
+
io: module.const_get("IoError").ok()?,
|
|
46
|
+
parse: module.const_get("ParseError").ok()?,
|
|
47
|
+
unsupported: module.const_get("UnsupportedError").ok()?,
|
|
48
|
+
pos_unavailable: module.const_get("PosUnavailableError").ok()?,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Defines `Litsea::Error` and its subclasses on the given module.
|
|
53
|
+
///
|
|
54
|
+
/// # Arguments
|
|
55
|
+
/// * `ruby` - The Ruby handle for the current thread.
|
|
56
|
+
/// * `module` - The `Litsea` module to define the classes on.
|
|
57
|
+
///
|
|
58
|
+
/// # Returns
|
|
59
|
+
/// `()` on success.
|
|
60
|
+
///
|
|
61
|
+
/// # Errors
|
|
62
|
+
/// Returns a Ruby exception if a class cannot be defined.
|
|
63
|
+
pub fn define_exceptions(ruby: &Ruby, module: &RModule) -> Result<(), Error> {
|
|
64
|
+
let base = module.define_error("Error", ruby.exception_standard_error())?;
|
|
65
|
+
module.define_error("InvalidArgumentError", base)?;
|
|
66
|
+
module.define_error("ModelError", base)?;
|
|
67
|
+
module.define_error("IoError", base)?;
|
|
68
|
+
module.define_error("ParseError", base)?;
|
|
69
|
+
module.define_error("UnsupportedError", base)?;
|
|
70
|
+
module.define_error("PosUnavailableError", base)?;
|
|
71
|
+
Ok(())
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Converts a [`CoreError`] into the matching Ruby exception.
|
|
75
|
+
///
|
|
76
|
+
/// # Arguments
|
|
77
|
+
/// * `error` - The error to convert.
|
|
78
|
+
///
|
|
79
|
+
/// # Returns
|
|
80
|
+
/// A magnus [`Error`] carrying the class that matches the error's kind;
|
|
81
|
+
/// [`ErrorKind::Runtime`] uses `Litsea::Error` itself.
|
|
82
|
+
pub fn to_ruby_error(error: CoreError) -> Error {
|
|
83
|
+
let message = error.message().to_string();
|
|
84
|
+
let Ok(ruby) = Ruby::get() else {
|
|
85
|
+
// Not on a Ruby thread, which cannot happen for a call that came
|
|
86
|
+
// from Ruby - but the type system does not know that. The
|
|
87
|
+
// handle-based replacement for this constructor needs the very
|
|
88
|
+
// handle we just failed to get, so the deprecated free function is
|
|
89
|
+
// the only way to build an error here.
|
|
90
|
+
#[allow(deprecated)]
|
|
91
|
+
return Error::new(magnus::exception::fatal(), message);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
let Some(classes) = exceptions(&ruby) else {
|
|
95
|
+
return Error::new(ruby.exception_runtime_error(), message);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let class = match error.kind() {
|
|
99
|
+
ErrorKind::InvalidArgument => classes.invalid_argument,
|
|
100
|
+
ErrorKind::Model => classes.model,
|
|
101
|
+
ErrorKind::Io => classes.io,
|
|
102
|
+
ErrorKind::Parse => classes.parse,
|
|
103
|
+
ErrorKind::Unsupported => classes.unsupported,
|
|
104
|
+
ErrorKind::PosUnavailable => classes.pos_unavailable,
|
|
105
|
+
ErrorKind::Runtime => classes.base,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
Error::new(class, message)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Maps a core result onto a Ruby result.
|
|
112
|
+
///
|
|
113
|
+
/// # Arguments
|
|
114
|
+
/// * `result` - The result to convert.
|
|
115
|
+
///
|
|
116
|
+
/// # Returns
|
|
117
|
+
/// The original value, or the mapped Ruby exception.
|
|
118
|
+
pub fn map_err<T>(result: Result<T, CoreError>) -> Result<T, Error> {
|
|
119
|
+
result.map_err(to_ruby_error)
|
|
120
|
+
}
|
data/src/gvl.rs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
//! Releasing the GVL around long-running work.
|
|
2
|
+
//!
|
|
3
|
+
//! Ruby is genuinely multi-threaded, so a `train()` that holds the Global VM
|
|
4
|
+
//! Lock blocks every other Ruby thread in the process - including the one
|
|
5
|
+
//! that would cancel it. Releasing the lock is what makes
|
|
6
|
+
//! `Litsea::CancelToken` usable while training runs, exactly as
|
|
7
|
+
//! `Python::detach` does for the Python binding.
|
|
8
|
+
//!
|
|
9
|
+
//! Neither magnus nor `rb-sys` exposes `rb_thread_call_without_gvl`: magnus
|
|
10
|
+
//! lists it among the C functions it does not wrap, and it is declared in
|
|
11
|
+
//! `ruby/thread.h`, which is outside the bindings `rb-sys` generates. The
|
|
12
|
+
//! declaration below is therefore written by hand. The symbol resolves
|
|
13
|
+
//! because the extension links against libruby regardless.
|
|
14
|
+
|
|
15
|
+
use std::ffi::c_void;
|
|
16
|
+
use std::panic::{AssertUnwindSafe, catch_unwind};
|
|
17
|
+
|
|
18
|
+
unsafe extern "C" {
|
|
19
|
+
/// Runs `func` with the GVL released.
|
|
20
|
+
///
|
|
21
|
+
/// `ubf` is the "unblocking function" Ruby calls to interrupt the work;
|
|
22
|
+
/// passing null selects Ruby's default, which defers interrupts until
|
|
23
|
+
/// the call returns. That is what we want: the work is pure computation
|
|
24
|
+
/// with no blocking syscall to interrupt, and it stops on its own when
|
|
25
|
+
/// the cancellation flag is cleared.
|
|
26
|
+
fn rb_thread_call_without_gvl(
|
|
27
|
+
func: unsafe extern "C" fn(*mut c_void) -> *mut c_void,
|
|
28
|
+
data1: *mut c_void,
|
|
29
|
+
ubf: *const c_void,
|
|
30
|
+
data2: *mut c_void,
|
|
31
|
+
) -> *mut c_void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// State handed to the trampoline and filled in with the closure's result.
|
|
35
|
+
struct Payload<F, R> {
|
|
36
|
+
/// The closure to run, taken by the trampoline.
|
|
37
|
+
func: Option<F>,
|
|
38
|
+
/// Where the result lands; `None` if the closure panicked.
|
|
39
|
+
result: Option<R>,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// The `extern "C"` entry point Ruby calls with the GVL released.
|
|
43
|
+
///
|
|
44
|
+
/// # Safety
|
|
45
|
+
/// `data` must be a valid `*mut Payload<F, R>` that outlives the call, which
|
|
46
|
+
/// [`without_gvl`] guarantees by keeping the payload on its own stack frame
|
|
47
|
+
/// for the duration of `rb_thread_call_without_gvl`.
|
|
48
|
+
unsafe extern "C" fn trampoline<F, R>(data: *mut c_void) -> *mut c_void
|
|
49
|
+
where
|
|
50
|
+
F: FnOnce() -> R,
|
|
51
|
+
{
|
|
52
|
+
// SAFETY: `without_gvl` passes a pointer to a live `Payload<F, R>` and
|
|
53
|
+
// does not touch it until this function returns.
|
|
54
|
+
let payload = unsafe { &mut *(data as *mut Payload<F, R>) };
|
|
55
|
+
|
|
56
|
+
if let Some(func) = payload.func.take() {
|
|
57
|
+
// A panic must not unwind across the C frame Ruby put on the stack:
|
|
58
|
+
// that is undefined behaviour. Catch it here and report it as a
|
|
59
|
+
// missing result, which `without_gvl` turns back into a panic on the
|
|
60
|
+
// Rust side of the boundary.
|
|
61
|
+
payload.result = catch_unwind(AssertUnwindSafe(func)).ok();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
std::ptr::null_mut()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Runs `func` with the GVL released, so other Ruby threads keep running.
|
|
68
|
+
///
|
|
69
|
+
/// The closure runs on the calling thread, not a new one. It **must not**
|
|
70
|
+
/// touch the Ruby API - doing so without the GVL is undefined behaviour.
|
|
71
|
+
/// Every caller in this crate passes pure Rust work (segmentation or
|
|
72
|
+
/// training) that only reaches `litsea`.
|
|
73
|
+
///
|
|
74
|
+
/// # Arguments
|
|
75
|
+
/// * `func` - The work to run without the GVL.
|
|
76
|
+
///
|
|
77
|
+
/// # Returns
|
|
78
|
+
/// Whatever `func` returns.
|
|
79
|
+
///
|
|
80
|
+
/// # Panics
|
|
81
|
+
/// Re-panics on the Rust side if `func` panicked, after the panic has been
|
|
82
|
+
/// contained inside the C call.
|
|
83
|
+
pub fn without_gvl<F, R>(func: F) -> R
|
|
84
|
+
where
|
|
85
|
+
F: FnOnce() -> R,
|
|
86
|
+
{
|
|
87
|
+
let mut payload = Payload {
|
|
88
|
+
func: Some(func),
|
|
89
|
+
result: None,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// SAFETY: `trampoline::<F, R>` matches the signature Ruby expects, and
|
|
93
|
+
// `&mut payload` stays valid for the whole call because
|
|
94
|
+
// `rb_thread_call_without_gvl` returns before this frame is dropped. A
|
|
95
|
+
// null unblocking function selects Ruby's default deferred-interrupt
|
|
96
|
+
// behaviour, which is correct for non-blocking computation.
|
|
97
|
+
unsafe {
|
|
98
|
+
rb_thread_call_without_gvl(
|
|
99
|
+
trampoline::<F, R>,
|
|
100
|
+
std::ptr::addr_of_mut!(payload) as *mut c_void,
|
|
101
|
+
std::ptr::null(),
|
|
102
|
+
std::ptr::null_mut(),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
match payload.result.take() {
|
|
107
|
+
Some(result) => result,
|
|
108
|
+
// The closure panicked; the panic was contained in the trampoline so
|
|
109
|
+
// it could not unwind through C, and is re-raised here.
|
|
110
|
+
None => panic!("a Litsea operation panicked while the GVL was released"),
|
|
111
|
+
}
|
|
112
|
+
}
|
data/src/language.rs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//! Language arguments.
|
|
2
|
+
//!
|
|
3
|
+
//! Ruby callers reach for a Symbol (`:japanese`) as readily as a String, and
|
|
4
|
+
//! magnus converts neither into the other automatically, so both are
|
|
5
|
+
//! accepted here.
|
|
6
|
+
|
|
7
|
+
use litsea::Language;
|
|
8
|
+
use litsea_binding_core::parse_language;
|
|
9
|
+
use magnus::{Symbol, TryConvert, Value, error::Error};
|
|
10
|
+
|
|
11
|
+
use crate::error::map_err;
|
|
12
|
+
|
|
13
|
+
/// Converts a Ruby String or Symbol into a [`Language`].
|
|
14
|
+
///
|
|
15
|
+
/// # Arguments
|
|
16
|
+
/// * `value` - The language name or ISO 639-1 code, as a String or Symbol.
|
|
17
|
+
///
|
|
18
|
+
/// # Returns
|
|
19
|
+
/// The parsed language.
|
|
20
|
+
///
|
|
21
|
+
/// # Errors
|
|
22
|
+
/// Raises `Litsea::InvalidArgumentError` for an unknown language, or a
|
|
23
|
+
/// `TypeError` if the value is neither a String nor a Symbol.
|
|
24
|
+
pub fn language_from_value(value: Value) -> Result<Language, Error> {
|
|
25
|
+
let name = match Symbol::from_value(value) {
|
|
26
|
+
Some(symbol) => symbol.name()?.to_string(),
|
|
27
|
+
None => String::try_convert(value)?,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
map_err(parse_language(&name))
|
|
31
|
+
}
|