kabosu 0.6.11.1 → 0.6.11.2.dev.20260820.2da2749
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 +4 -4
- data/ext/kabosu/src/morpheme.rs +89 -8
- data/lib/kabosu/dict_manager.rb +83 -8
- data/lib/kabosu/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a70dc4b3b987df22342c2b87fcb06cafe4aa0c3cd194ecd30fbce80f98b8281
|
|
4
|
+
data.tar.gz: 27b0027a5eddeeb89d6d3bebcd47425db2aeb309c0821c03f3e29af6f1d97b7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf0d834428557fd1591e6011d79ae1d2cb963d4fe389c8b706c3f3cc86a169a8831ef3a7e50f12ac3d01c49345a2e35ae564ddf9b73d411d039824014bbf5b30
|
|
7
|
+
data.tar.gz: 8bedb4672df3399b4f8074626e3896894fb9fba9b879f1cfd9970377265ffbf74a0aef6fd6e14687829dc945ab35270df76f0856496c36256ad813c5b3c47b90
|
data/ext/kabosu/src/morpheme.rs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
use magnus::value::ReprValue;
|
|
2
|
-
use magnus::{gc, Error, RArray, RString, Ruby};
|
|
1
|
+
use magnus::value::{Opaque, ReprValue};
|
|
2
|
+
use magnus::{gc, DataTypeFunctions, Error, RArray, RString, Ruby, TypedData};
|
|
3
3
|
use std::cell::OnceCell;
|
|
4
4
|
use std::collections::HashMap;
|
|
5
5
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
@@ -75,9 +75,28 @@ pub(crate) fn rb_morpheme_from_data(
|
|
|
75
75
|
dict,
|
|
76
76
|
debug,
|
|
77
77
|
word_fields: OnceCell::new(),
|
|
78
|
+
surface_cache: OnceCell::new(),
|
|
79
|
+
dictionary_form_cache: OnceCell::new(),
|
|
80
|
+
pos_cache: OnceCell::new(),
|
|
78
81
|
}
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
// One frozen Ruby String per slot, built on first ask.
|
|
85
|
+
//
|
|
86
|
+
// FROZEN ON PURPOSE, and not only for safety: `+str` returns self for a mutable
|
|
87
|
+
// String and a mutable COPY for a frozen one, so a shared mutable value would let
|
|
88
|
+
// an innocent `buf = +m.surface; buf << other` write straight through into the
|
|
89
|
+
// cache and corrupt every later read. Frozen, that idiom keeps working unchanged
|
|
90
|
+
// and anything else fails loudly instead of silently.
|
|
91
|
+
fn frozen_string(ruby: &Ruby, cell: &OnceCell<Opaque<RString>>, source: &str) -> RString {
|
|
92
|
+
let cached = cell.get_or_init(|| {
|
|
93
|
+
let rstr = ruby.str_new(source);
|
|
94
|
+
rstr.as_value().freeze();
|
|
95
|
+
Opaque::from(rstr)
|
|
96
|
+
});
|
|
97
|
+
ruby.get_inner(*cached)
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
fn vec_u32_to_array(ids: &[u32]) -> Result<RArray, Error> {
|
|
82
101
|
let ruby = Ruby::get().unwrap();
|
|
83
102
|
let ary = ruby.ary_new_capa(ids.len());
|
|
@@ -87,12 +106,35 @@ fn vec_u32_to_array(ids: &[u32]) -> Result<RArray, Error> {
|
|
|
87
106
|
Ok(ary)
|
|
88
107
|
}
|
|
89
108
|
|
|
90
|
-
#[
|
|
109
|
+
#[derive(TypedData)]
|
|
110
|
+
#[magnus(class = "Kabosu::Morpheme", mark)]
|
|
91
111
|
pub(crate) struct RbMorpheme {
|
|
92
112
|
data: MorphemeData,
|
|
93
113
|
dict: Arc<JapaneseDictionary>,
|
|
94
114
|
debug: bool,
|
|
95
115
|
word_fields: OnceCell<LazyWordFields>,
|
|
116
|
+
// Ruby objects handed to the same caller over and over: built once per
|
|
117
|
+
// morpheme rather than once per call. See `surface` for the reasoning and
|
|
118
|
+
// for why they are frozen.
|
|
119
|
+
surface_cache: OnceCell<Opaque<RString>>,
|
|
120
|
+
dictionary_form_cache: OnceCell<Opaque<RString>>,
|
|
121
|
+
pos_cache: OnceCell<Opaque<RArray>>,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
impl DataTypeFunctions for RbMorpheme {
|
|
125
|
+
// The cached surface is reachable only from Rust, so the GC cannot see it
|
|
126
|
+
// without being told. Missing this would let a live String be collected.
|
|
127
|
+
fn mark(&self, marker: &gc::Marker) {
|
|
128
|
+
if let Some(cached) = self.surface_cache.get() {
|
|
129
|
+
marker.mark(*cached);
|
|
130
|
+
}
|
|
131
|
+
if let Some(cached) = self.dictionary_form_cache.get() {
|
|
132
|
+
marker.mark(*cached);
|
|
133
|
+
}
|
|
134
|
+
if let Some(cached) = self.pos_cache.get() {
|
|
135
|
+
marker.mark(*cached);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
96
138
|
}
|
|
97
139
|
|
|
98
140
|
struct LazyWordFields {
|
|
@@ -245,11 +287,47 @@ impl RbMorpheme {
|
|
|
245
287
|
Ok(result)
|
|
246
288
|
}
|
|
247
289
|
|
|
248
|
-
|
|
249
|
-
|
|
290
|
+
// One frozen Ruby String per morpheme, not one per call.
|
|
291
|
+
//
|
|
292
|
+
// Callers ask this a lot: measured on a real tokenizer workload, 58,874 calls
|
|
293
|
+
// across 3,569 distinct morphemes, 16.5 each, so 94% of the Strings this used
|
|
294
|
+
// to allocate were identical copies of one already made. Returning `&str` let
|
|
295
|
+
// magnus build a fresh RString every time.
|
|
296
|
+
//
|
|
297
|
+
// FROZEN ON PURPOSE, and not only for safety: `+str` returns self for a
|
|
298
|
+
// mutable String and a mutable COPY for a frozen one, so a shared mutable
|
|
299
|
+
// surface would let an innocent `buf = +m.surface; buf << other` write through
|
|
300
|
+
// into the cache and corrupt every later read. Frozen, that idiom keeps
|
|
301
|
+
// working unchanged and anything else fails loudly. `part_of_speech` freezes
|
|
302
|
+
// what it caches for the same reason.
|
|
303
|
+
// Takes `&Ruby` as its first parameter rather than calling `Ruby::get()`.
|
|
304
|
+
// magnus hands that handle over for free (`Ruby::get_with` is
|
|
305
|
+
// `Self(PhantomData)`, zero instructions), where `Ruby::get()` costs a
|
|
306
|
+
// thread-local access, a RefCell borrow and a Result construction on EVERY
|
|
307
|
+
// call — including the cache-hit path, whose only use for the handle is
|
|
308
|
+
// `get_inner`, which discards it. With the allocations gone that round trip
|
|
309
|
+
// was a real share of what these accessors still cost. `method!(.., 0)` in
|
|
310
|
+
// lib.rs is unchanged: magnus resolves the &Ruby form on the fn pointer.
|
|
311
|
+
pub(crate) fn surface(ruby: &Ruby, rb_self: &Self) -> RString {
|
|
312
|
+
frozen_string(ruby, &rb_self.surface_cache, &rb_self.data.surface)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Asked more than any other method on this class: 35.7 times per morpheme on
|
|
316
|
+
// a real workload. The process-wide POS_CACHE below already makes the ANSWER
|
|
317
|
+
// free of allocation, but every one of those calls still took a global Mutex
|
|
318
|
+
// and hashed a key to reach it. A morpheme's part of speech cannot change, so
|
|
319
|
+
// the first ask is remembered here and the rest never reach the lock.
|
|
320
|
+
pub(crate) fn part_of_speech(ruby: &Ruby, rb_self: &Self) -> Result<RArray, Error> {
|
|
321
|
+
if let Some(cached) = rb_self.pos_cache.get() {
|
|
322
|
+
return Ok(ruby.get_inner(*cached));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let ary = rb_self.pos_from_shared_cache()?;
|
|
326
|
+
let _ = rb_self.pos_cache.set(Opaque::from(ary));
|
|
327
|
+
Ok(ary)
|
|
250
328
|
}
|
|
251
329
|
|
|
252
|
-
|
|
330
|
+
fn pos_from_shared_cache(&self) -> Result<RArray, Error> {
|
|
253
331
|
let dict_ptr = Arc::as_ptr(&self.dict) as usize;
|
|
254
332
|
let pos_id = self.data.pos_id;
|
|
255
333
|
|
|
@@ -288,8 +366,11 @@ impl RbMorpheme {
|
|
|
288
366
|
self.data.pos_id
|
|
289
367
|
}
|
|
290
368
|
|
|
291
|
-
|
|
292
|
-
|
|
369
|
+
// Cached like `surface`, and for the same measured reason: 6.0 calls per
|
|
370
|
+
// morpheme on a real workload, so five of every six Strings this built were
|
|
371
|
+
// a copy of one it had already built.
|
|
372
|
+
pub(crate) fn dictionary_form(ruby: &Ruby, rb_self: &Self) -> RString {
|
|
373
|
+
frozen_string(ruby, &rb_self.dictionary_form_cache, &rb_self.data.dictionary_form)
|
|
293
374
|
}
|
|
294
375
|
|
|
295
376
|
pub(crate) fn normalized_form(&self) -> &str {
|
data/lib/kabosu/dict_manager.rb
CHANGED
|
@@ -46,13 +46,32 @@ module Kabosu
|
|
|
46
46
|
return dic_path
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
url = release_asset_url(version, edition)
|
|
50
|
-
zip_path = File.join(@dir, "sudachi-dictionary-#{version}-#{edition}.zip")
|
|
51
|
-
|
|
52
49
|
FileUtils.mkdir_p(@dir)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
|
|
51
|
+
# SudachiDict switched to Python-only release assets in v20260723:
|
|
52
|
+
# https://github.com/WorksApplications/SudachiDict/releases/tag/v20260723
|
|
53
|
+
# The legacy `sudachi-dictionary-{version}-{edition}.zip` is gone; the
|
|
54
|
+
# new releases ship `sudachidict_{edition}-{version}-py3-none-any.whl`
|
|
55
|
+
# (a PEP 427 wheel). Pick whichever exists, preferring the legacy zip
|
|
56
|
+
# when both are present (older releases kept both formats in flight).
|
|
57
|
+
sources = pick_release_sources(version, edition)
|
|
58
|
+
raise DownloadError, "No downloadable assets for #{version}/#{edition}" if sources.empty?
|
|
59
|
+
|
|
60
|
+
sources.each do |source|
|
|
61
|
+
begin
|
|
62
|
+
download(source.fetch(:url), source.fetch(:archive_path))
|
|
63
|
+
extract(source.fetch(:archive_path), dest_dir, edition: edition)
|
|
64
|
+
FileUtils.rm_f(source.fetch(:archive_path))
|
|
65
|
+
break
|
|
66
|
+
rescue DownloadError => e
|
|
67
|
+
FileUtils.rm_f(source.fetch(:archive_path)) if File.exist?(source.fetch(:archive_path))
|
|
68
|
+
# Try the next candidate (e.g. wheel if the zip 404s). If this was
|
|
69
|
+
# the last one, surface the original error.
|
|
70
|
+
raise if sources.last.equal?(source)
|
|
71
|
+
|
|
72
|
+
warn " falling back: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
56
75
|
|
|
57
76
|
raise DownloadError, "Expected #{dic_path} after extraction, but file not found" unless File.exist?(dic_path)
|
|
58
77
|
|
|
@@ -181,6 +200,51 @@ module Kabosu
|
|
|
181
200
|
edition
|
|
182
201
|
end
|
|
183
202
|
|
|
203
|
+
# Look up the GitHub release for `version` and return an ordered list of
|
|
204
|
+
# candidate download sources. The legacy `sudachi-dictionary-{version}-{edition}.zip`
|
|
205
|
+
# is preferred when present (matches the layout every other consumer
|
|
206
|
+
# assumes); otherwise we fall back to the SudachiDict Python wheel,
|
|
207
|
+
# which is what v20260723+ ship and which carries `system.dic` inside
|
|
208
|
+
# `sudachidict_{edition}/resources/`.
|
|
209
|
+
#
|
|
210
|
+
# On GitHub API failure we degrade to the legacy zip URL alone: an old
|
|
211
|
+
# release that 404s on the zip *also* 404s on the wheel, so falling back
|
|
212
|
+
# to "guessed" candidates costs nothing and keeps the code path simple
|
|
213
|
+
# for offline / no-network callers.
|
|
214
|
+
def pick_release_sources(version, edition)
|
|
215
|
+
candidates = []
|
|
216
|
+
zip = release_asset_url(version, edition)
|
|
217
|
+
candidates << { url: zip, archive_path: File.join(@dir, "sudachi-dictionary-#{version}-#{edition}.zip"), format: :zip }
|
|
218
|
+
|
|
219
|
+
begin
|
|
220
|
+
release = fetch_release(version)
|
|
221
|
+
if release
|
|
222
|
+
wheel_name = "sudachidict_#{edition}-#{version}-py3-none-any.whl"
|
|
223
|
+
wheel = release["assets"].to_a.find { |a| a["name"] == wheel_name }
|
|
224
|
+
if wheel && wheel["browser_download_url"]
|
|
225
|
+
candidates << {
|
|
226
|
+
url: wheel.fetch("browser_download_url"),
|
|
227
|
+
archive_path: File.join(@dir, wheel_name),
|
|
228
|
+
format: :wheel
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
rescue DownloadError
|
|
233
|
+
# API failure is fine: keep the legacy-zip candidate; the user will
|
|
234
|
+
# see a clean 404 if neither asset actually exists on disk.
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
candidates
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def fetch_release(version)
|
|
241
|
+
uri = URI("#{GITHUB_API}/repos/#{GITHUB_REPO}/releases/tags/v#{version}")
|
|
242
|
+
response = http_get(uri, headers: { "Accept" => "application/json" })
|
|
243
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
244
|
+
|
|
245
|
+
JSON.parse(response.body)
|
|
246
|
+
end
|
|
247
|
+
|
|
184
248
|
def release_asset_url(version, edition)
|
|
185
249
|
"https://github.com/#{GITHUB_REPO}/releases/download/v#{version}/sudachi-dictionary-#{version}-#{edition}.zip"
|
|
186
250
|
end
|
|
@@ -249,11 +313,22 @@ module Kabosu
|
|
|
249
313
|
end
|
|
250
314
|
end
|
|
251
315
|
|
|
252
|
-
def extract(zip_path, dest_dir)
|
|
316
|
+
def extract(zip_path, dest_dir, edition: nil)
|
|
253
317
|
warn "Extracting..."
|
|
254
318
|
Zip::File.open(zip_path) do |archive|
|
|
255
319
|
archive.each do |entry|
|
|
256
|
-
|
|
320
|
+
# Wheels (`sudachidict_{edition}/resources/system.dic`) carry the
|
|
321
|
+
# edition in the top-level directory; legacy zips name the file
|
|
322
|
+
# `system_{edition}.dic` already. When the entry is a `system.dic`
|
|
323
|
+
# inside a wheel and `edition:` was passed, land it at
|
|
324
|
+
# `dest_dir/system_{edition}.dic` so `find`/`installed` keep working
|
|
325
|
+
# without a separate code path.
|
|
326
|
+
target_name = entry.name
|
|
327
|
+
if edition && File.basename(entry.name) == "system.dic"
|
|
328
|
+
target_name = "system_#{edition}.dic"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
target = File.join(dest_dir, target_name)
|
|
257
332
|
# Guard against zip-slip — refuse entries that escape dest_dir.
|
|
258
333
|
unless File.expand_path(target).start_with?(File.expand_path(dest_dir) + File::SEPARATOR)
|
|
259
334
|
raise DownloadError, "Refusing to extract entry outside dest_dir: #{entry.name}"
|
data/lib/kabosu/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kabosu
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.11.
|
|
4
|
+
version: 0.6.11.2.dev.20260820.2da2749
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- davafons
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rb_sys
|