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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1311b1fd889d88fc04f4bc695884b6463c64b73fa1c4166fa4bbac07af015e67
4
- data.tar.gz: 8abdf827ccb81820074a6a446fc3ac735bf0931ea90b0858192bf0622510ba89
3
+ metadata.gz: 4a70dc4b3b987df22342c2b87fcb06cafe4aa0c3cd194ecd30fbce80f98b8281
4
+ data.tar.gz: 27b0027a5eddeeb89d6d3bebcd47425db2aeb309c0821c03f3e29af6f1d97b7b
5
5
  SHA512:
6
- metadata.gz: 0a8f9d2d3977a0710dbc184aa2eda9cffb85b29dada72ee6e032dcaa71d875b844593ad0bf3b5786e87a64b8df4c5185efeb6387484ad0e4d8a1f44511630944
7
- data.tar.gz: 41319ca91d7837a116bcf3846d253b6e8f0130f22bdfcfda956138274ce2be8d6ef80043d6c7014dbeff1ea1c0e1be8eb70018499be7935734ae345f9e8ad043
6
+ metadata.gz: cf0d834428557fd1591e6011d79ae1d2cb963d4fe389c8b706c3f3cc86a169a8831ef3a7e50f12ac3d01c49345a2e35ae564ddf9b73d411d039824014bbf5b30
7
+ data.tar.gz: 8bedb4672df3399b4f8074626e3896894fb9fba9b879f1cfd9970377265ffbf74a0aef6fd6e14687829dc945ab35270df76f0856496c36256ad813c5b3c47b90
@@ -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
- #[magnus::wrap(class = "Kabosu::Morpheme")]
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
- pub(crate) fn surface(&self) -> &str {
249
- &self.data.surface
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
- pub(crate) fn part_of_speech(&self) -> Result<RArray, Error> {
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
- pub(crate) fn dictionary_form(&self) -> &str {
292
- &self.data.dictionary_form
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 {
@@ -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
- download(url, zip_path)
54
- extract(zip_path, @dir)
55
- FileUtils.rm_f(zip_path)
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
- target = File.join(dest_dir, entry.name)
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}"
@@ -1,3 +1,3 @@
1
1
  module Kabosu
2
- VERSION = "0.6.11.1".freeze
2
+ VERSION = "0.6.11.2.dev.20260820.2da2749".freeze
3
3
  end
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.1
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-06-30 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys